"""M8/M9 判据:fine.py(细渲染接真状态)+ photo.py(拍照用例)。 ## 判据分两层 **可离线判的**(不需要 JVM,默认套件里跑): - `chunks_to_sections` 的索引顺序(错一位地形就乱) - 机位候选的**依据**是否成立(不能凭空给坐标) - 失败必须可读(不抛异常) - 空世界/未加载必须明确失败,不返回空图 **需要渲染环境的**(`--with-render`): - 真出图并检查 distinctColors 等 ## 为什么索引顺序要单独判 Java 侧按 `(y<<8)|(z<<4)|x` 遍历 4096 格。 铺平顺序错一位,**不报错**,只是地形整体错位(画出来是乱的)。 这类"静默错位"必须用构造数据判:给某坐标一个独特值,看它落在哪个下标。 """ import sys, os, math sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from mcfold.chunk import Chunk, Section, BitArray from mcfold.fine import chunks_to_sections, inject_world, render_from_state from mcfold.photo import propose_shots, _facing_yaw from mcfold.state import WorldState, load_vanilla_registry DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/' 'minecraft-data/minecraft-data/data') _REG = load_vanilla_registry('1.21.1', DATA_ROOT) GRASS = _REG.by_name["grass_block"]["minStateId"] STONE = _REG.by_name["stone"]["minStateId"] AIR = _REG.by_name["air"]["minStateId"] class _ChunkLike: def __init__(self, fn): self.fn = fn def state_id_at(self, x, y, z): return self.fn(x, y, z) class _FakeBridge: """记录 set_blocks 调用的假桥(不启 JVM)。""" def __init__(self, fail_on=None): self.calls: list[tuple[int, int, dict]] = [] self.fail_on = fail_on or set() def set_blocks(self, cx, cz, sections): if (cx, cz) in self.fail_on: raise RuntimeError("模拟写入失败") self.calls.append((cx, cz, sections)) return {"ok": True} class _FakeRenderer: def __init__(self, fail_on=None): self.bridge = _FakeBridge(fail_on) self.chunks: dict = {} self._loaded: set = set() def _real_chunk(fn, y_index=0): """造一个**真 Chunk**(inject_world 需要 .sections/state_id_at)。 ★ 用假区块(只有 state_id_at)测不了 inject_world —— 它按 section 遍历,假对象没有 .sections ⇒ 会被记进 skipped, 于是测的是「假对象不支持」而不是「注入逻辑对错」。 这是判据 fixture 的坑:fixture 形状必须匹配被测代码的真实输入。 """ return Chunk(x=0, z=0, sections=[Section(y_index=y_index, single_value=fn(0, -1, 0))]) def _scene(fn, chunks=((0, 0),)): ws = WorldState(registry=_REG, num_sections=24) for cx, cz in chunks: ch = _real_chunk(fn) ch.x, ch.z = cx, cz ws.world.chunks[(cx, cz)] = ch ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5 return ws # ---------- ① 索引顺序(静默错位是最危险的) ---------- def test_section_linear_index_matches_java_layout(): """★★ 铺平顺序必须是 `idx = (y<<8)|(z<<4)|x`(与 Java 的 PalettedContainer 一致)。 错一位**不报错**,只是地形整体错位(画出来是乱的)—— 属于"静默错位",必须用构造数据判。 """ # 单值 section:全 4096 格都是 42 s = Section(y_index=0, single_value=42) secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s])) assert list(secs.keys()) == [0], secs.keys() assert len(secs[0]) == 4096, len(secs[0]) assert all(v == 42 for v in secs[0]), "单值 section 应全为该值" # 位数组 section:只把 (x=1,y=0,z=0) 设为 1 ⇒ 它必须落在下标 1 bits = BitArray([0b10], capacity=4096, bits_per_value=1) s2 = Section(y_index=1, bits_per_block=1, palette=[7, 9], bits=bits) secs2 = chunks_to_sections(Chunk(x=0, z=0, sections=[s2])) c = secs2[1] assert c[1] == 9, f"(x=1,y=0,z=0) 应落在下标 1,实得 {c[1]}" assert c[0] == 7, f"(x=0,y=0,z=0) 应是 7,实得 {c[0]}" # 多点验证:(x=2,y=0,z=1) ⇒ idx = (0<<8)|(1<<4)|2 = 18 # BitArray 没有 set(),直接构造 data:1 bit/value ⇒ 每 long 64 格, # 偏移 = (index % 64) * 1 bits2 = BitArray([0] * 8, capacity=4096, bits_per_value=1) bits2.data[18 // 64] |= 1 << ((18 % 64) * 1) assert bits2.get(18) == 1, "先确认构造正确" s3 = Section(y_index=2, bits_per_block=1, palette=[7, 9], bits=bits2) c3 = chunks_to_sections(Chunk(x=0, z=0, sections=[s3]))[2] assert c3[18] == 9, f"(x=2,z=1) 应落在下标 18,实得 {c3[18]}" assert sum(1 for v in c3 if v == 9) == 1, "只应有一个格子被置位" print(" ★★ 铺平索引 = (y<<8)|(z<<4)|x(与 Java 一致)") def test_chunks_to_sections_uses_section_index(): """★ section 的 y_index 决定它落在哪个下标(不能顺序错位)。""" s_a = Section(y_index=0, single_value=GRASS) s_b = Section(y_index=5, single_value=STONE) secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s_a, s_b])) assert sorted(secs.keys()) == [0, 5], secs.keys() assert secs[0][0] == GRASS and secs[5][0] == STONE print(" ★ section 按下标归位(0 与 5)") def test_missing_sections_are_omitted_not_zeroed(): """★★ 缺失的 section **不产出**,不能填 0。 0 是空气 ⇒ 把「没数据」渲染成「空气」是撒谎 (本该是实心的地方变成空洞,看起来像被挖空了)。 """ s = Section(y_index=3, single_value=STONE) secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s])) assert list(secs.keys()) == [3], "只该有 y_index=3 这一个 section" assert 0 not in secs and 1 not in secs, "缺失的 section 不该被填 0" print(" ★★ 缺失 section 被省略(不填 0 冒充空气)") # ---------- ② inject_world:不抛异常、如实报告 ---------- def test_inject_world_reports_counts(): ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = _FakeRenderer() out = inject_world(r, ws) assert out["ok"] is True assert out["injected"] == 1, out assert out["skipped"] == [] assert r.bridge.calls and r.bridge.calls[0][0] == 0 print(f" ✓ inject_world 报告注入数({out['injected']})") def test_inject_world_survives_write_failure(): """★ 写入失败要记进 skipped 并继续(不是整批崩掉)。""" ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=((0, 0), (1, 0))) r = _FakeRenderer(fail_on={(1, 0)}) out = inject_world(r, ws) assert out["injected"] == 1, out assert len(out["skipped"]) == 1, out assert out["skipped"][0]["column"] == [1, 0] assert "模拟写入失败" in out["skipped"][0]["why"] print(" ★ 单列写入失败:记入 skipped 且其余继续") def test_inject_world_empty_is_honest(): """★ 没有区块时 **ok=False 且说明原因**,不假装成功。""" ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=()) r = _FakeRenderer() out = inject_world(r, ws) assert out["injected"] == 0 assert out["ok"] is False, out assert "走动" in out["note"] or "加载" in out["note"], out["note"] print(" ✓ 无区块:ok=False + 可执行建议") # ---------- ③ render_from_state:失败必须可读(不抛异常) ---------- def test_render_from_state_empty_world_is_readable_failure(): """★ 空世界出图 ⇒ 结构化失败 + how_to(不是 traceback)。""" ws = _scene(lambda x, y, z: AIR, chunks=()) r = render_from_state(ws, store="/nonexistent-store") assert r["ok"] is False, r assert r.get("why"), r assert r.get("how_to"), r assert "空图" in r["why"] or "区块" in r["why"], r["why"] print(f" ✓ 空世界:可读失败({r['why'][:40]}…)") def test_render_from_state_bad_store_is_readable_failure(): """★ store 缺产物 ⇒ 结构化失败(不抛异常)。""" ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = render_from_state(ws, store="/tmp/no-such-store-xyz") assert r["ok"] is False, r assert r.get("why") and r.get("how_to"), r print(f" ✓ 坏 store:可读失败({r['why'][:44]}…)") # ---------- ④ 机位候选:依据必须成立 ---------- def test_propose_shots_gives_reasons(): """★ 每个机位都要说明**为什么选它**(不能凭空给坐标)。""" ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = propose_shots(ws, (5.0, 0.0, 0.0)) assert r["ok"] is True assert r["count"] >= 3, r["count"] for s in r["shots"]: assert s["why"], f"机位 {s['name']} 没给依据" assert len(s["camera"]) == 5, s # x,y,z,yaw,pitch assert s["distance"] >= 0 assert "未出图" in r["source"] or "粗渲染" in r["source"] print(f" ★ {r['count']} 个机位都带依据") def test_propose_shots_yaw_points_at_target(): """★★ 机位朝向必须**真的看向目标**(算错的症状是拍到别处)。 ★ 判据要用**完整 3D 方向**,不能只看水平分量: 俯视机位在目标正上方 ⇒ 水平方向退化(dx,dz≈0), 只比水平的话 dot 恒为 0/无意义 ⇒ 会把正确的实现判红 (我第一版就犯了这个错)。 正确做法:把 yaw/pitch 还原成 3D 单位向量,与「机位→目标」向量比点积。 """ ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) target = (5.0, 0.0, 0.0) r = propose_shots(ws, target) for s in r["shots"]: # ★ 「当前位置」是**故意**用玩家当前朝向的(它代表「就用你现在的视角」), # 不保证对准给定目标 ⇒ 排除它,其余必须严格对准。 if s["name"] == "当前位置": continue fx, fy, fz, yaw, pitch = s["camera"] # yaw/pitch → 3D 单位向量(MC 约定:yaw 0=+Z,pitch 负=向下) y = math.radians(yaw) p = math.radians(pitch) vx = -math.sin(y) * math.cos(p) vy = -math.sin(p) vz = math.cos(y) * math.cos(p) # 机位 → 目标 tx, ty, tz = target[0] - fx, target[1] - fy, target[2] - fz n = math.sqrt(tx * tx + ty * ty + tz * tz) if n < 1e-6: continue # 与目标重合,无需朝向 dot = (vx * tx + vy * ty + vz * tz) / n assert dot > 0.99, \ f"机位 {s['name']} 朝向没对准目标(3D dot={dot:.3f},camera={s['camera']})" print(" ★★ 所有机位的 3D 朝向都指向目标(dot > 0.99)") def test_facing_yaw_convention(): """★ yaw 约定:0=+Z(南) 90=-X(西) 180=-Z(北) 270=+X(东)。""" # 站在原点看向 +Z ⇒ yaw 应为 0 assert abs(_facing_yaw(0.0, 1.0, 0.0, 0.0) - 0.0) < 1e-6 # 看向 +X ⇒ 270 assert abs(_facing_yaw(1.0, 0.0, 0.0, 0.0) - 270.0) < 1e-6 # 看向 -Z ⇒ 180 assert abs(_facing_yaw(0.0, -1.0, 0.0, 0.0) - 180.0) < 1e-6 print(" ✓ 朝向计算符合 MC 约定") def test_propose_shots_empty_world_still_returns_options(): """★ 没有区块时也要给候选(但要如实说明脚下没支撑)。""" ws = _scene(lambda x, y, z: AIR, chunks=()) r = propose_shots(ws, (5.0, 0.0, 0.0)) assert r["ok"] is True # 至少要给「当前位置」这个零成本机位 assert any(s["name"] == "当前位置" for s in r["shots"]), r["shots"] print(" ✓ 空世界仍给候选(含当前机位)") # ---------- ⑤ 两个子系统的分工(不互相预测) ---------- def test_photo_uses_rough_for_candidates_not_rendering(): """★ 机位候选来自**粗渲染观察**(M6),不是细渲染出图 —— 省时省钱。 plan §4.5:两者不做像素级互相预测。 """ ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = propose_shots(ws, (5.0, 0.0, 0.0)) # 候选阶段没有 image 字段(说明没出图) assert all("image" not in s for s in r["shots"]), "候选阶段不该出图" assert "未出图" in r["source"], r["source"] print(" ★ 机位候选阶段不出图(纯粗渲染观察)") def test_capture_photo_bad_shot_index_is_readable(): """★ shot_index 越界 ⇒ 可读失败 + 列出可用范围。""" from mcfold.photo import capture_photo ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = capture_photo(ws, (5.0, 0.0, 0.0), store="/nonexistent", shot_index=99) assert r["ok"] is False, r assert "越界" in r["why"], r["why"] assert r.get("how_to"), r assert r.get("shot_options"), "应列出可用机位" print(f" ✓ 机位越界:可读失败({r['why']})") # ---------- ⑥ HUD 隐藏是硬要求(plan: 截图必须隐藏 HUD) ---------- def test_world_renderer_exposes_set_view(): """★★ 桥层有能力 ≠ 上层有入口。 回归:桥层 `RenderBackend.set_view` 早就实现了完整 `view` 命令 (含 hide_gui → 官方 options.hideGui),但 `WorldRenderer` **没有** 这个入口。我在 fine.py 里调 `renderer.set_view(hide_gui=True)`, 撞上 AttributeError,又被 `except: pass` 吞掉 ⇒ **出图带着 HUD**(血量/热键栏/教学提示), 而返回值里还写着 `hide_gui: True` —— 自相矛盾且无人发现。 判据:WorldRenderer 必须暴露 set_view,且能透传到桥层。 """ from mcfold.render import WorldRenderer assert hasattr(WorldRenderer, "set_view"), \ "WorldRenderer 缺 set_view(桥层有但上层没入口)" # 透传验证:用假桥看请求内容 calls = [] class _B: def set_view(self, **kw): calls.append(kw) return {"ok": True} r = WorldRenderer.__new__(WorldRenderer) r.bridge = _B() out = r.set_view(hide_gui=True) assert out == {"ok": True} assert calls == [{"hide_gui": True}], calls print(" ★★ WorldRenderer.set_view 存在且透传 hide_gui") def test_hide_gui_failure_is_not_swallowed(): """★★ hide_gui 设不上必须**显式失败**,不能被 except: pass 吞掉。 静默吞掉的后果:出图带 HUD,而调用方以为已隐藏 —— 「以为设了」与「实际没设」无法区分。 """ import mcfold.render as render_mod import mcfold.fine as fine class _R: def __init__(self): self.bridge = _FakeBridge() self.chunks = {} # inject_world 会写 self._loaded = set() # inject_world 会写 def set_blocks(self, *a, **k): return self.bridge.set_blocks(*a, **k) def set_render_distance(self, *a): return {} def set_view(self, **kw): raise AttributeError("set_view") # 模拟「后端不支持」 def set_viewport(self, *a, **k): return {} def camera(self, *a): return None def screenshot(self, **k): raise AssertionError("不该走到截图:hide_gui 已失败") def close(self): return None # ★ 要 patch **render 模块**里的类(fine 是函数内延迟导入的) orig_open = render_mod.WorldRenderer.open render_mod.WorldRenderer.open = classmethod(lambda cls: _R()) try: ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = fine.render_from_state(ws, store="/tmp/x", hide_gui=True) assert r["ok"] is False, f"hide_gui 失败应报错,实际 {r}" assert "HUD" in r["why"], r assert r.get("how_to"), r finally: render_mod.WorldRenderer.open = orig_open print(" ★★ hide_gui 设不上:显式失败(不静默带 HUD 出图)") def test_hide_gui_false_is_allowed(): """★ 明确要求带 HUD 时(hide_gui=False)不该报错。""" import mcfold.render as render_mod import mcfold.fine as fine shots = [] class _R: def __init__(self): self.bridge = _FakeBridge() self.chunks = {} self._loaded = set() def set_blocks(self, *a, **k): return self.bridge.set_blocks(*a, **k) def set_render_distance(self, *a): return {} def set_view(self, **kw): raise AssertionError("hide_gui=False 时不该调 set_view") def set_viewport(self, *a, **k): return {} def camera(self, *a): return None def screenshot(self, **k): shots.append(k) return type("R", (), {"path": "/tmp/ok.png"})() def close(self): return None orig = render_mod.WorldRenderer.open render_mod.WorldRenderer.open = classmethod(lambda cls: _R()) try: ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR) r = fine.render_from_state(ws, store="/tmp/x", hide_gui=False) assert r["ok"] is True, r assert shots, "应走到截图" finally: render_mod.WorldRenderer.open = orig print(" ✓ hide_gui=False:允许带 HUD 出图") if __name__ == "__main__": for fn in [test_section_linear_index_matches_java_layout, test_chunks_to_sections_uses_section_index, test_missing_sections_are_omitted_not_zeroed, test_inject_world_reports_counts, test_inject_world_survives_write_failure, test_inject_world_empty_is_honest, test_render_from_state_empty_world_is_readable_failure, test_render_from_state_bad_store_is_readable_failure, test_propose_shots_gives_reasons, test_propose_shots_yaw_points_at_target, test_facing_yaw_convention, test_propose_shots_empty_world_still_returns_options, test_photo_uses_rough_for_candidates_not_rendering, test_capture_photo_bad_shot_index_is_readable, test_world_renderer_exposes_set_view, test_hide_gui_failure_is_not_swallowed, test_hide_gui_false_is_allowed]: fn() print("\n细渲染+拍照:全部通过")