| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- # 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"]))
|