"""Python 侧职责的单元测试。 ★ 架构口径(用户 2026-09-25 定向) Python 负责:路由、方块名→id 查询、RLE 压缩、存档读取 Java 负责: 协议格式、调色板组装、位打包、入 cache、渲染 所以这里**不再**测协议位打包(那已下移到 Java,由两代后端各自的编译验证覆盖); 改测 Python 真正负责的部分,尤其是 RLE 的边界与存档读取的真实数据。 """ import struct import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from mcfold.anvil import ( NbtReader, RegionFile, list_region_files, nbt_read, read_level_dat, region_path, ) from mcfold.backends.base import rle_decode, rle_encode from mcfold.render import ( BIOME_VOLUME, SECTION_VOLUME, SaveChunk, _props_to_spec, parse_save_chunk, ) SAVE = Path("/home/mc-test/mc-test") # ---------------------------------------------------------------- RLE(Python 的职责) def test_rle_basic(): assert rle_encode([]) == [] assert rle_encode([5]) == [[5, 1]] assert rle_encode([5, 5, 5]) == [[5, 3]] assert rle_encode([1, 2, 1]) == [[1, 1], [2, 1], [1, 1]] print(" ✓ RLE 基本情形") def test_rle_roundtrip(): cases = [ [0] * 4096, [1] * 4096, [0] * 4090 + [9] * 6, list(range(256)) * 16, [7 if i % 3 else 0 for i in range(4096)], ] for v in cases: assert rle_decode(rle_encode(v)) == v print(" ✓ RLE 往返一致(含全同/交替/随机模式)") def test_rle_is_lossless_on_long_runs(): """长游程必须无损 —— 一个 section 常有整片空气/石头。""" v = [0] * 4000 + [1] * 96 enc = rle_encode(v) assert len(enc) == 2, f"应压成 2 段,得到 {len(enc)}" assert rle_decode(enc, expected=SECTION_VOLUME) == v print(" ✓ 长游程无损压缩(4096 格 → 2 段)") def test_rle_decode_length_check(): """长度不符要报错,而不是静默给出错长度的列表(防下游越界)。""" try: rle_decode([[1, 3]], expected=4096) except ValueError as e: assert "4096" in str(e) print(" ✓ RLE 长度校验") return raise AssertionError("长度不符时应抛 ValueError") def test_rle_handles_bool_like_and_negative(): """palette 索引可能是任意 int;不假设范围。""" v = [-1, -1, 0, 0, 0] assert rle_decode(rle_encode(v)) == v print(" ✓ RLE 接受任意 int(含负值)") # ---------------------------------------------------------------- NBT / 存档读取 def test_nbt_reader_primitives(): body = bytearray() body += bytes([10]) + struct.pack(">H", 1) + b"R" body += bytes([3]) + struct.pack(">H", 1) + b"a" + struct.pack(">i", 7) body += bytes([8]) + struct.pack(">H", 1) + b"b" + struct.pack(">H", 2) + b"hi" body += bytes([0]) assert nbt_read(bytes(body)) == {"a": 7, "b": "hi"} print(" ✓ NBT 基本类型(int/string)") def test_nbt_nested_list(): """嵌套 List[Compound] —— 存档 sections 就是这个形状。""" body = bytearray() body += bytes([10]) + struct.pack(">H", 0) body += bytes([9]) + struct.pack(">H", 4) + b"list" + bytes([10]) + struct.pack(">i", 2) for y in (0, 1): body += bytes([3]) + struct.pack(">H", 1) + b"Y" + struct.pack(">i", y) body += bytes([0]) body += bytes([0]) assert nbt_read(bytes(body)) == {"list": [{"Y": 0}, {"Y": 1}]} print(" ✓ NBT 嵌套 List[Compound]") def test_nbt_long_array(): """区块高度图与 block_states.data 都是 LongArray。""" body = bytearray() body += bytes([10]) + struct.pack(">H", 0) body += bytes([12]) + struct.pack(">H", 1) + b"d" + struct.pack(">i", 2) body += struct.pack(">qq", -1, 1234567890123) body += bytes([0]) d = nbt_read(bytes(body)) assert d == {"d": [-1, 1234567890123]}, d print(" ✓ NBT LongArray(含负值)") def test_props_to_spec(): assert _props_to_spec("minecraft:stone", None) == "minecraft:stone" assert _props_to_spec("minecraft:oak_log", {"axis": "y"}) == "minecraft:oak_log[axis=y]" # 属性顺序必须稳定(否则 id 缓存命中率下降) a = _props_to_spec("minecraft:oak_stairs", {"facing": "north", "half": "top"}) b = _props_to_spec("minecraft:oak_stairs", {"half": "top", "facing": "north"}) assert a == b == "minecraft:oak_stairs[facing=north,half=top]" print(" ✓ 方块名规格生成(属性排序稳定)") def test_real_save_level_dat(): if not (SAVE / "level.dat").is_file(): print(" - 无测试存档,跳过") return lv = read_level_dat(SAVE) data = lv["Data"] assert "LevelName" in data and isinstance(data.get("DataVersion"), int) print(f" ✓ 真实 level.dat({data['LevelName']}, DataVersion={data['DataVersion']})") def test_real_region_chunk(): if not (SAVE / "region").is_dir(): print(" - 无测试存档,跳过") return regs = list_region_files(SAVE) assert regs, "应有 region 文件" names = set() for rf in regs[:4]: for cx, cz in [(-1, -1), (0, 0), (8, -7)]: try: raw = RegionFile(rf).chunk_nbt(cx, cz) except Exception: continue if raw: n = nbt_read(raw) for s in n.get("sections", []): for p in (s.get("block_states") or {}).get("palette") or []: names.add(p.get("Name")) if names: break assert len(names) > 1, f"完整区块应含多种方块,得到 {names}" print(f" ✓ 真实区块 NBT({len(names)} 种方块)") def test_parse_save_chunk_shape(): """parse_save_chunk 的产物必须可直接喂 RLE(长度=4096,值是名字规格)。""" if not (SAVE / "region").is_dir(): print(" - 无测试存档,跳过") return nbt = None for r in list_region_files(SAVE): for cx, cz in [(-1, -1), (0, 0)]: try: raw = RegionFile(r).chunk_nbt(cx, cz) except Exception: continue if raw: n = nbt_read(raw) if n.get("Status") == "minecraft:full": nbt = n break if nbt: break if not nbt: print(" - 无 full 区块,跳过") return ch = parse_save_chunk(-1, -1, nbt) assert ch.sections, "应有 sections" for sy, sec in ch.sections.items(): assert len(sec["states"]) == SECTION_VOLUME, f"Y={sy} states 长度 {len(sec['states'])}" assert all(isinstance(s, str) and s.startswith("minecraft:") for s in sec["states"][:8]) assert ch.palette_specs == sorted(ch.palette_specs), "规格应去重且排序" print(f" ✓ parse_save_chunk 产物可直接喂 RLE({len(ch.sections)} sections, " f"{len(ch.palette_specs)} 规格)") def test_section_y_mapping_needs_runtime_value(): """★ 回归:section 索引 ↔ 绝对 Y 的偏移必须是运行时值,不是硬编码。 1.20.x 是 minSection=-4;更早的版本不是。Python 只从后端取。 """ from mcfold.render import FALLBACK_MIN_SECTION_Y, WorldRenderer assert FALLBACK_MIN_SECTION_Y == -4, "降级默认值(真实值应来自后端 capacity)" # WorldRenderer 的 min_section_y 优先用后端值 class FakeCap: _capacity = {"minSection": -4, "sectionsCount": 24} _biome_id = None @property def min_section_y(self): return WorldRenderer.min_section_y.fget(self) @property def sections_count(self): return WorldRenderer.sections_count.fget(self) f = FakeCap() assert f.min_section_y == -4 assert f.sections_count == 24 # 后端给不同值时跟随 class FakeCap2(FakeCap): _capacity = {"minSection": 0, "sectionsCount": 16} f2 = FakeCap2() assert f2.min_section_y == 0, "应跟随后端值(1.16 时代 minSection=0)" assert f2.sections_count == 16 print(" ✓ section 偏移取自运行时(不硬编码版本)") def test_import_surface(): """已下移到 Java 的协议编码函数不应再存在于 Python(防回归)。""" import mcfold.render as R for gone in ("_pack", "_long_array", "_encode_paletted", "_encode_chunk", "_varint"): assert not hasattr(R, gone), f"{gone} 应已下移到 Java 侧" # 读路径必须保留 assert hasattr(R, "_decode_storage") assert hasattr(R, "parse_save_chunk") print(" ✓ 模块面:写路径已下移,读路径保留") if __name__ == "__main__": tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] failed = 0 for t in tests: try: t() except Exception as e: failed += 1 import traceback print(f" ✗ {t.__name__}: {e}") traceback.print_exc() print(f"\nanvil/render 单元测试: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过")) sys.exit(1 if failed else 0)