接 sim-business 设计指导到生成 agent(cheap_roles+prompt.mjs 双源,先设计后写码+MVP-first);新建纯 LLM 丰富度验证 agent cheap_verify(非阻塞·不进 verdict·零 code-presence 断言,红线落地);bake_off 加 richness 列;cheap_roles 三级回落配置热取(C2a 加载器);cheap_studio trace 接线(C1b)。富游戏 M1 三品类各 5/5=100% 过九门达标。测试 test_roles 12 / test_cheap_verify 11 / test_bake_off 17 / test_cheap_trace 4 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
"""
|
||
test_roles.py — U3 cheap_roles 静态断言(关键约束串在场;prompt 是静态内容,非行为门)。
|
||
|
||
不 import agentscope,系统/venv python 均可跑:
|
||
cheap-worker/.venv/bin/python cheap-worker/tests/test_roles.py
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
|
||
from cheap_roles import build_system_prompt, SKILL_PATH
|
||
|
||
|
||
def test_entry_contract_present():
|
||
"""入口契约五法 + _forensicsView 在场。"""
|
||
p = build_system_prompt("t1")
|
||
assert "createGame({ plugins, bundle, viewport })" in p
|
||
assert "_forensicsView" in p
|
||
assert "init(boot)/update(dt)/render(g)/destroy()" in p
|
||
|
||
|
||
def test_two_footguns_present():
|
||
"""收口的关键 footgun 正反例串在场(阶段一B #5 后 drawButton 改为回吐命中矩形、sceneFsm.define 逐场景调)。"""
|
||
p = build_system_prompt("t1")
|
||
# ①:drawButton 回吐命中矩形,命中用 pointInRect(改签名后 const btn = drawButton(...) 直接判命中是直觉写法)
|
||
assert "drawButton" in p and "pointInRect" in p and "命中矩形" in p
|
||
# ②:sceneFsm.define 逐场景调,不要单对象批量
|
||
assert "sceneFsm.define('menu'" in p and "单对象批量" in p
|
||
|
||
|
||
def test_input_contract_present():
|
||
"""输入契约 handleTap/handleKey + 不调 getInput。"""
|
||
p = build_system_prompt("t1")
|
||
assert "handleTap(x, y)" in p
|
||
assert "handleKey(key)" in p
|
||
assert "不写 ctx.getInput" in p
|
||
|
||
|
||
def test_redline_present():
|
||
"""核心红线串:bundle.tick、ctx.time.nowMs()、ctx.random.next()。"""
|
||
p = build_system_prompt("t1")
|
||
assert "bundle.tick(dt)" in p
|
||
assert "ctx.time.nowMs()" in p
|
||
assert "ctx.random.next()" in p
|
||
|
||
|
||
def test_skill_pointer():
|
||
"""prompt 明确要求先 read 真 skill(不手抄)。"""
|
||
p = build_system_prompt("t1")
|
||
assert SKILL_PATH in p
|
||
assert "read_file('.agents/skills/littlejs-game-dev.md')" in p
|
||
|
||
|
||
def test_finish_not_done():
|
||
"""完成工具名已统一为 finish(KTD5),不残留 Node 的 done。"""
|
||
p = build_system_prompt("t1")
|
||
assert "调 finish" in p
|
||
assert "调 done" not in p
|
||
|
||
|
||
def test_no_tool_json_schema():
|
||
"""Python 侧工具 schema 由 FunctionTool 自动抽,prompt 不手写工具 JSON。"""
|
||
p = build_system_prompt("t1")
|
||
assert '"parameters"' not in p
|
||
assert "type: 'function'" not in p
|
||
|
||
|
||
def test_game_id_injected():
|
||
"""占位符 ⟦G⟧ 已据 game_id 全部替换,无残留。"""
|
||
p = build_system_prompt("cheap-abc")
|
||
assert "game-runtime/games/amgen-cheap-abc" in p
|
||
assert "⟦G⟧" not in p
|
||
|
||
|
||
import os
|
||
import tempfile
|
||
|
||
|
||
# ── C2a:热取加载器不变量 + 回落测试 ──────────────────────────────────────────────
|
||
|
||
def test_hotload_invariant_equals_inline():
|
||
"""不变量:热取路输出 == 内置回落路输出(同 game_id/scaffold_desc),逐字节一致。
|
||
|
||
没人改 .md、没设 CHEAP_PROMPTS_DIR 时,build_system_prompt 输出必须逐字节等于
|
||
改造前(直接用 _SYSTEM_PROMPT.replace)的结果——本相红线。
|
||
"""
|
||
import cheap_roles as _r
|
||
game_id = "inv-test-abc"
|
||
scaffold_desc = "测试脚手架描述"
|
||
|
||
# 热取路(默认:读 contracts/prompts/04-config/cheap-system.md)
|
||
_r._cr_reload_cache()
|
||
hot = build_system_prompt(game_id, scaffold_desc)
|
||
|
||
# 内置回落路:直接用 _SYSTEM_PROMPT
|
||
g = f"game-runtime/games/amgen-{game_id}"
|
||
inline = (
|
||
_r._SYSTEM_PROMPT
|
||
.replace("⟦SCAFFOLD_DESC⟧", scaffold_desc)
|
||
.replace("⟦G⟧", g)
|
||
)
|
||
|
||
assert hot == inline, (
|
||
f"热取路与内置路输出不一致(前 100 字符 diff):\n"
|
||
f" hot : {hot[:100]!r}\n"
|
||
f" inline: {inline[:100]!r}"
|
||
)
|
||
|
||
|
||
def test_hotload_default_scaffold_desc():
|
||
"""scaffold_desc=None 时,热取路与内置路同样使用 _DEFAULT_SCAFFOLD_DESC。"""
|
||
import cheap_roles as _r
|
||
_r._cr_reload_cache()
|
||
hot = build_system_prompt("inv-default")
|
||
g = "game-runtime/games/amgen-inv-default"
|
||
inline = (
|
||
_r._SYSTEM_PROMPT
|
||
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
|
||
.replace("⟦G⟧", g)
|
||
)
|
||
assert hot == inline, "scaffold_desc=None 时热取路与内置路输出不一致"
|
||
|
||
|
||
def test_fallback_on_empty_prompts_dir():
|
||
"""CHEAP_PROMPTS_DIR 指向空目录(无 registry)→ 回落内置 _SYSTEM_PROMPT,不抛。"""
|
||
import cheap_roles as _r
|
||
|
||
old_env = os.environ.get("CHEAP_PROMPTS_DIR")
|
||
with tempfile.TemporaryDirectory() as d:
|
||
os.environ["CHEAP_PROMPTS_DIR"] = d
|
||
_r._cr_reload_cache()
|
||
try:
|
||
result = build_system_prompt("fallback-empty")
|
||
# 应与内置替换结果一致
|
||
g = "game-runtime/games/amgen-fallback-empty"
|
||
expected = (
|
||
_r._SYSTEM_PROMPT
|
||
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
|
||
.replace("⟦G⟧", g)
|
||
)
|
||
assert result == expected, "空目录回落后输出与内置不一致"
|
||
finally:
|
||
if old_env is None:
|
||
os.environ.pop("CHEAP_PROMPTS_DIR", None)
|
||
else:
|
||
os.environ["CHEAP_PROMPTS_DIR"] = old_env
|
||
_r._cr_reload_cache() # 恢复默认路径缓存
|
||
|
||
|
||
def test_fallback_on_missing_md_file():
|
||
"""registry 有该 id 但 .md 文件不存在 → 回落内置 _SYSTEM_PROMPT,不抛。"""
|
||
import cheap_roles as _r
|
||
|
||
old_env = os.environ.get("CHEAP_PROMPTS_DIR")
|
||
with tempfile.TemporaryDirectory() as d:
|
||
# 在临时目录里建一个 registry.yaml,指向不存在的 .md
|
||
import pathlib
|
||
reg = pathlib.Path(d) / "registry.yaml"
|
||
reg.write_text(
|
||
"version: 1\nprompts:\n"
|
||
" - id: config.cheap-system\n"
|
||
" version: 1.0.0\n"
|
||
" file: 04-config/cheap-system.md\n",
|
||
encoding="utf-8",
|
||
)
|
||
# .md 文件不创建 → _cr_read_body 返回 None → 回落内置
|
||
|
||
os.environ["CHEAP_PROMPTS_DIR"] = d
|
||
_r._cr_reload_cache()
|
||
try:
|
||
result = build_system_prompt("fallback-no-md")
|
||
g = "game-runtime/games/amgen-fallback-no-md"
|
||
expected = (
|
||
_r._SYSTEM_PROMPT
|
||
.replace("⟦SCAFFOLD_DESC⟧", _r._DEFAULT_SCAFFOLD_DESC)
|
||
.replace("⟦G⟧", g)
|
||
)
|
||
assert result == expected, ".md 不存在时回落输出与内置不一致"
|
||
finally:
|
||
if old_env is None:
|
||
os.environ.pop("CHEAP_PROMPTS_DIR", None)
|
||
else:
|
||
os.environ["CHEAP_PROMPTS_DIR"] = old_env
|
||
_r._cr_reload_cache()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||
_failed = 0
|
||
for _fn in _fns:
|
||
try:
|
||
_fn()
|
||
print(f" PASS {_fn.__name__}")
|
||
except AssertionError as e:
|
||
_failed += 1
|
||
print(f" FAIL {_fn.__name__}: {e}")
|
||
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
|
||
sys.exit(1 if _failed else 0)
|