Files
agentmc/tests/test_anvil.py
JianFeeeee 16f2e04fff 架构重构:差异全部下移到 Java,Python 只做路由
用户口径:差异放在 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(分层原则 + 加新世代步骤 + 防回归清单)
2026-09-25 19:21:45 +08:00

244 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)