用户口径:差异放在 Java 层,Python 相当于路由。 下移到 Java(新增 cmd=setblocks): Python 只发 RLE([[stateId,count],...]),Java 用官方 API 完成 调色板组装 / 位打包 / 官方序列化 / 入 cache / 渲染。 ★ 意外收益:走 ClientboundLevelChunkPacketData(chunk).getReadBuffer() + replaceWithPacketData(...) 这条官方路径,两代签名完全一致 ⇒ 原先的 Storage.chunks 反射注入**整段删掉**。 新增 cmd=capacity:世界尺寸(sectionsCount/minSection/defaultBiome) 由运行时回答,Python 不再硬编码 24/-4(1.16 时代是 16/0)。 Python 侧删除(防回归,见 test_import_surface): render_bridge.py(旧桥,无世代概念) render._pack/_long_array/_encode_paletted/_encode_chunk/_varint anvil.encode_block_section/encode_chunk_payload/save_chunk_to_payload 合计 454→374 行(render.py)、300→177 行(anvil.py) 保留在 Python(都是「不随版本变」的知识): RLE 压缩(rle_encode/rle_decode,具名可测) 存档读取(region/NBT) 方块名→id 的路由(查询在 Java) NDJSON 协议 测试:62 项单元 + 6 项渲染联测全绿 新测试反映新架构:RLE 边界、运行时容量、世代分派、新路径渲染 test_anvil 里原有 8 项协议位打包测试已删(那已下移到 Java, 由两代后端各自的编译验证覆盖) 新增 scaffold/ARCHITECTURE.md(分层原则 + 加新世代步骤 + 防回归清单)
244 lines
9.0 KiB
Python
244 lines
9.0 KiB
Python
"""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)
|