# tests/test_map_parser.py """ cpp_reversed/map_parser.py 的 pytest 测试套件 覆盖: header 解析、cell 12B 解析、可走性判断、迭代、越界、roundtrip """ import os import struct import sys import pytest CPP_REV = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "cpp_reversed")) if CPP_REV not in sys.path: sys.path.insert(0, CPP_REV) import map_parser SAMPLE_DIR = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "996传奇盒子资源包", "03-游戏客户端", "jycq_206923", "scene", "map") ) # ============== Fixtures: 加载所有样本 ============== @pytest.fixture(scope="module") def all_maps(): """加载所有 7 个样本, key 是文件名""" files = ["0139.map", "0.map", "3.map", "d011.map", "d022.map", "d401.map", "d601.map"] return {f: map_parser.load(os.path.join(SAMPLE_DIR, f)) for f in files} @pytest.fixture(scope="module") def m_0139(all_maps): return all_maps["0139.map"] @pytest.fixture(scope="module") def m_0(all_maps): return all_maps["0.map"] # 扩展格式 # ============== Header 解析 ============== class TestHeader: """测试 52B header 解析""" def test_0139_header_basic(self, m_0139): h = m_0139.header assert h.width == 46 assert h.height == 50 assert h.title == "Legend of mir" assert h.title_len == 13 assert h.is_extended is False def test_0_header_extended(self, m_0): h = m_0.header assert h.width == 700 assert h.height == 700 assert h.title == "Legend of mir\r\n" assert h.title_len == 15 assert h.is_extended is True assert len(m_0.extra_data) == 980000 # 700*700*2 def test_header_raw_tail_preserved(self, m_0139): """18-51 字节必须原样保留, 用于 roundtrip""" assert len(m_0139.header.raw_tail) == 34 # 52-18 def test_music_id_varies(self, all_maps): """不同地图的 music_id 不同""" assert all_maps["0139.map"].header.music_id == 36 assert all_maps["0.map"].header.music_id == 514 # 3.map 仍是 36 assert all_maps["3.map"].header.music_id == 36 def test_to_dict(self, m_0139): d = m_0139.header.to_dict() assert "width" in d assert "raw_tail" not in d # 不应暴露内部字段 assert d["width"] == 46 def test_parse_header_too_short(self): with pytest.raises(ValueError, match="header 至少需要"): map_parser.parse_header(b"\x00" * 10) # ============== Header 语义字段 (7 样本统计实测) ============== class TestHeaderSemantic: """基于 7 样本统计推断的 header 字段语义校验""" def test_format_id_constant_on_standard_maps(self, all_maps): """6 个标准 map 的 format_id 恒为 0x447E28, 0.map 是 0""" for fname, m in all_maps.items(): if fname == "0.map": assert m.header.format_id == 0 else: assert m.header.format_id == 0x447E28, \ f"{fname}: format_id=0x{m.header.format_id:x} != 0x447e28" def test_format_id_byte_layout(self, m_0139): """format_id 字节布局: raw_tail[26:30] = '28 7e 44 00' (LE uint32)""" rt = m_0139.header.raw_tail assert rt[26:30] == bytes.fromhex("287e4400") assert m_0139.header.format_id == 0x447E28 def test_music_id_values(self, all_maps): """0.map music=514, 其他=36 (标准 0x24)""" assert all_maps["0.map"].header.music_id == 514 for fname, m in all_maps.items(): if fname != "0.map": assert m.header.music_id == 36, f"{fname}: music_id={m.header.music_id}" def test_ambient_id_values(self, all_maps): """0.map ambient=1, 其他=136 (0x88)""" assert all_maps["0.map"].header.ambient_id == 1 for fname, m in all_maps.items(): if fname != "0.map": assert m.header.ambient_id == 136, f"{fname}: ambient_id={m.header.ambient_id}" def test_update_timestamps_standard(self, m_0139): """6 个标准 map 的 update 时间戳相同: 0x3CE9 / 0x0040 ×2""" h = m_0139.header assert h.update_date1 == 0x3CE9 assert h.update_time1 == 0x0040 assert h.update_date2 == 0x3CE9 assert h.update_time2 == 0x0040 def test_update_timestamps_zero_map_differs(self, m_0): """0.map 的 update 时间戳与其他不同 (不同编辑批次)""" h = m_0.header assert (h.update_date1, h.update_time1) != (0x3CE9, 0x0040) def test_light_dims_zero_map(self, m_0): """0.map 无光照贴图 (light_w = light_h = 0)""" assert m_0.header.light_w == 0 assert m_0.header.light_h == 0 def test_light_dims_positive_others(self, all_maps): """6 个标准 map 都有非零光照尺寸""" for fname, m in all_maps.items(): if fname != "0.map": assert m.header.light_w > 0 assert m.header.light_h > 0 def test_editor_build_varies(self, all_maps): """editor_build 在 0.map 是 0, 其他 map 各有值 (推测编辑器 build 号)""" assert all_maps["0.map"].header.editor_build == 0 # 至少一个标准 map 有非零 editor_build vals = {m.header.editor_build for f, m in all_maps.items() if f != "0.map"} assert len(vals) >= 1 assert 0 not in vals or len(vals) > 1 def test_reserved_fields_zero(self, m_0139): """reserved_40 / reserved_48 恒为 0""" assert m_0139.header.reserved_40 == 0 assert m_0139.header.reserved_48 == 0 # ============== Extra Areas (0.map 扩展区) ============== class TestExtraAreas: """测试 0.map 额外区 (2B/格 稀疏索引表) 的解析""" def test_extended_only(self, all_maps): """只有 0.map 是扩展格式, 其他 6 个 extra_areas 应为空""" for fname, m in all_maps.items(): if fname == "0.map": assert len(m.extra_areas) == 700 * 700 # 490000 else: assert m.extra_areas == [], f"{fname} extra_areas 应为空" def test_extra_areas_count(self, m_0): assert len(m_0.extra_areas) == 490000 assert all(isinstance(v, int) for v in m_0.extra_areas[:100]) def test_extra_areas_value_range(self, m_0): """所有 extra_areas 值都在 uint16 范围 [0, 65535]""" for v in m_0.extra_areas: assert 0 <= v <= 0xFFFF def test_extra_areas_matches_raw_bytes(self, m_0): """解析后的 extra_areas 必须与 extra_data (uint16 LE) 完全一致""" import struct expected = list(struct.unpack(f"<{len(m_0.cells)}H", m_0.extra_data)) assert m_0.extra_areas == expected def test_extra_areas_zero_dominates(self, m_0): """87% 的 extra_areas 应为 0 (无图槽)""" nz = sum(1 for v in m_0.extra_areas if v != 0) pct = nz * 100 // len(m_0.extra_areas) assert 10 <= pct <= 20, f"non-zero 占比 {pct}% 偏离预期 10-20%" def test_extra_areas_flag_0x8000(self, m_0): """约 5% 应是 0x8000 标志 (无图/默认图)""" from collections import Counter c = Counter(m_0.extra_areas) flag8_pct = c[0x8000] * 100 // len(m_0.extra_areas) assert 3 <= flag8_pct <= 8, f"0x8000 占比 {flag8_pct}% 偏离预期 3-8%" def test_extra_areas_top_values_consistent(self, m_0): """top-3 (排除 0x0000 和 0x8000 两个结构值) 占比应符合预期分布 (各 < 1%)""" from collections import Counter c = Counter(m_0.extra_areas) # 跳过 0x0000 (87% 空槽) 和 0x8000 (5% 标志位) real_values = [(v, cnt) for v, cnt in c.most_common() if v not in (0, 0x8000)][:3] assert len(real_values) == 3, f"应至少 3 个非零非标志值, 实际 {len(real_values)}" for v, cnt in real_values: pct = cnt * 100 // len(m_0.extra_areas) assert pct < 2, f"value 0x{v:04x} 占比 {pct}% 过高, 可能解析有误" def test_extra_areas_roundtrip(self, m_0): """修改 extra_areas 后序列化必须仍然与原文件字节级一致""" import struct # 用本地副本 (module-scope fixture 不可变, 防止串扰) saved = m_0.extra_areas[:] try: # 翻转一个非零值 (cell index 7 已知 extra=32768) old_val = saved[7] saved[7] = (old_val + 1) & 0xFFFF # 用临时 MapData 测 roundtrip (不污染 m_0) tmp = map_parser.MapData( header=m_0.header, cells=m_0.cells, extra_data=b"", extra_areas=saved, ) out = tmp.to_bytes() orig = open( "996传奇盒子资源包/03-游戏客户端/jycq_206923/scene/map/0.map", "rb" ).read() # 修改了应不等于原文件 assert out != orig # 恢复后应再次匹配 saved[7] = old_val assert tmp.to_bytes() == orig finally: m_0.extra_areas = saved # 双保险: 确保 m_0 状态干净 # ============== Cell 解析 ============== class TestCell: """测试 12B cell 解析""" def test_0139_cell_0(self, m_0139): c = m_0139.get_cell(0, 0) # 来自数据: bg_img=32768, mid=0, obj=0, ani=0, light=0 assert c.bg_img == 32768 assert c.mid_img == 0 assert c.obj_img == 0 assert c.ani_idx == 0 assert c.light == 0 assert c.is_walkable is True def test_0_cell_0(self, m_0): c = m_0.get_cell(0, 0) assert c.bg_img == 34619 def test_cell_pack_roundtrip(self, m_0139): c = m_0139.get_cell(0, 0) b = c.pack() assert len(b) == 12 c2 = map_parser.parse_cell(b, 0) assert c.bg_img == c2.bg_img assert c.mid_img == c2.mid_img assert c.obj_img == c2.obj_img assert c.ani_idx == c2.ani_idx assert c.door_flag == c2.door_flag assert c.light == c2.light assert c.area == c2.area assert c.ani_tick == c2.ani_tick def test_cell_to_dict(self, m_0139): c = m_0139.get_cell(0, 0) d = c.to_dict() assert set(d.keys()) == {"bg_img", "mid_img", "obj_img", "ani_idx", "door_flag", "light", "area", "ani_tick"} def test_walkable_logic(self, m_0139): """有 obj 的格不可走, 无 obj 的可走""" walkable_count = sum(1 for c in m_0139.cells if c.is_walkable) no_obj_count = sum(1 for c in m_0139.cells if c.obj_img == 0) assert walkable_count == no_obj_count def test_is_obstacle(self, m_0139): c = m_0139.get_cell(5, 6) # 已知 (5,6) 位置是 obj # 至少要测一个非空 obj 的 cell for c in m_0139.cells: if c.obj_img != 0: # 大多数有 obj 的格都是障碍 # 但 door_flag bit0 可能让某些 obj 可走 (门) assert hasattr(c, 'is_obstacle') break # ============== get_cell / set_cell 越界 ============== class TestBounds: """测试坐标越界处理""" def test_get_cell_valid(self, m_0139): c = m_0139.get_cell(0, 0) c = m_0139.get_cell(45, 49) # 边界 assert c is not None def test_get_cell_x_out_of_bounds(self, m_0139): with pytest.raises(IndexError, match="越界"): m_0139.get_cell(46, 0) # x=46, 范围 0..45 def test_get_cell_y_out_of_bounds(self, m_0139): with pytest.raises(IndexError, match="越界"): m_0139.get_cell(0, 50) # y=50, 范围 0..49 def test_get_cell_negative(self, m_0139): with pytest.raises(IndexError, match="越界"): m_0139.get_cell(-1, 0) def test_set_cell(self, m_0139): c = m_0139.get_cell(10, 10) new = map_parser.MapCell(bg_img=999, mid_img=0, obj_img=0, ani_idx=0, door_flag=0, light=0, area=0, ani_tick=0) m_0139.set_cell(10, 10, new) assert m_0139.get_cell(10, 10).bg_img == 999 def test_set_cell_oob(self, m_0139): new = map_parser.MapCell() with pytest.raises(IndexError, match="越界"): m_0139.set_cell(100, 100, new) # ============== iter_walkable ============== class TestIteration: """测试格遍历""" def test_iter_walkable_count_matches_stats(self, all_maps): for f, m in all_maps.items(): walked = sum(1 for _ in m.iter_walkable()) stats = m.stats() assert walked == stats["walkable"], f"{f}: walked={walked} != stats={stats['walkable']}" def test_iter_walkable_only_walkable(self, m_0139): for x, y, c in m_0139.iter_walkable(): assert c.is_walkable is True assert c.obj_img == 0 def test_iter_returns_tuples(self, m_0139): items = list(m_0139.iter_walkable()) assert all(isinstance(it, tuple) and len(it) == 3 for it in items) x, y, c = items[0] assert isinstance(x, int) and isinstance(y, int) assert isinstance(c, map_parser.MapCell) # ============== stats ============== class TestStats: """测试统计功能""" def test_stats_total_matches(self, m_0139): s = m_0139.stats() assert s["total"] == 46 * 50 # 2300 def test_stats_keys(self, m_0139): s = m_0139.stats() expected = {"total", "walkable", "walkable_pct", "has_bg", "has_mid", "has_obj", "has_ani", "doors"} assert set(s.keys()) == expected def test_walkable_pct_in_range(self, all_maps): for f, m in all_maps.items(): s = m.stats() assert 0 <= s["walkable_pct"] <= 100, f"{f}: {s['walkable_pct']}" # ============== Roundtrip ============== class TestRoundtrip: """字节级 roundtrip 验证""" @pytest.mark.parametrize("filename", [ "0139.map", "0.map", "3.map", "d011.map", "d022.map", "d401.map", "d601.map", ]) def test_byte_level_roundtrip(self, filename): path = os.path.join(SAMPLE_DIR, filename) m = map_parser.load(path) out = m.to_bytes() with open(path, "rb") as f: orig = f.read() assert out == orig, f"{filename}: roundtrip 不一致 (out={len(out)}B, orig={len(orig)}B)" def test_modified_roundtrip(self, m_0139): """修改后序列化, 修改内容保留, 未修改字节一致""" c_orig = m_0139.get_cell(10, 10) c_orig.bg_img = 12345 out = m_0139.to_bytes() # 重新加载 m2 = map_parser.MapData(header=m_0139.header, cells=m_0139.cells, extra_data=b"") out2 = m2.to_bytes() assert out == out2 # 验证修改生效 assert m_0139.get_cell(10, 10).bg_img == 12345 # ============== 错误处理 ============== class TestErrors: """测试异常处理""" def test_load_nonexistent_file(self): with pytest.raises(FileNotFoundError): map_parser.load("/nonexistent/file.map") def test_load_too_small(self, tmp_path): small = tmp_path / "tiny.map" small.write_bytes(b"\x00" * 10) with pytest.raises(ValueError, match="文件过小"): map_parser.load(str(small)) def test_load_truncated_cells(self, tmp_path): """header 完整, cell 数据不完整""" bad = tmp_path / "bad.map" # 46x50 地图需要 52 + 46*50*12 = 27652B, 只写 100B bad.write_bytes(b"\x2e\x00\x32\x00" + b"Legend of mir" + b"\x00" * 38) with pytest.raises(ValueError, match="文件不完整|文件过小"): map_parser.load(str(bad)) # ============== 集成: 与二进制调用对应 ============== class TestBinaryCorrespondence: """测试与 binary 中 tolua 函数的对应关系""" def test_isObstacle_logic(self, m_0139): """对应 mapData2D:isObstacle""" for c in m_0139.cells: if c.obj_img != 0: assert c.is_walkable is False break def test_getMapDataRows_Cols(self, m_0139): """对应 mapData2D:getMapDataRows/Cols""" assert m_0139.width == 46 assert m_0139.height == 50 def test_markCanWalk_effect(self, m_0139): """模拟 tileSceneManager:MarkCanWalk 修改可走状态""" c = m_0139.get_cell(20, 20) c.door_flag = 0xFF # 设为门 assert c.door_flag == 0xFF # 改动生效 if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v", "--tb=short"]))