新增 mcfold/fine.py:把 M3 状态层的区块灌进细渲染器 - chunks_to_sections:铺平顺序 = (y<<8)|(z<<4)|x(与 Java PalettedContainer 一致) ★ 错一位不报错、只是地形静默错位 ⇒ 判据用构造数据打这个点 - 缺失的 section **省略**而非填 0(0=空气,把「没数据」渲染成空洞) - render_from_state:单次调用,失败全部返回 ok:false + why + how_to 新增 mcfold/photo.py:capture_photo - propose_shots 用**粗渲染观察**生成机位候选(正面/斜侧/俯视/远景/当前位置), 每个都带「为什么选它」的依据;候选阶段不出图(省时省钱) - 构图好坏是审美判断=决策,工具不替 agent 选 ★ 关键发现:桥接比预想的干净 原以为要把 state id 反算成 name[props],但 Java 侧 set_blocks 收的 **本来就是 state id**(走官方 LevelChunk API),而 state id 本身含属性 ⇒ 无损、无需转换。 修的真 bug: 1. ★★ HUD 没隐藏:桥层 RenderBackend.set_view 早有 hide_gui (→ 官方 options.hideGui),但 WorldRenderer **没有这个入口** ⇒ AttributeError 被 except: pass 吞掉 ⇒ 出图带生命条/饥饿/物品栏, 而返回值仍写 hide_gui: True(自相矛盾) 教训:桥层有能力 ≠ 上层有入口;except: pass 让「以为设了」与 「实际没设」无法区分。现在 hide_gui 设不上就显式失败。 2. 俯视机位朝向不自洽(水平 yaw 退化却配写死的 pitch)→ 3D dot=0.82。 改为由位置向量反算 yaw/pitch;正面/斜侧/远景同样修正。 3. inject_world 的 ok 自相矛盾(无区块时 ok=True 却说「没有可渲染的区块」) 4. 缺失 section 被填 0 冒充空气 实测出图:854×480、1902 种颜色,地形/天空/月亮/草地纹理正确。 判据 17 项;2 项变异全被抓。 ★ 「机位朝向」判据我第一版只比水平分量 ⇒ 俯视机位恒为 0 会判红, 改为完整 3D 点积(dot > 0.99)。 200 项全通过。
459 lines
18 KiB
Python
459 lines
18 KiB
Python
"""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细渲染+拍照:全部通过") |