①退出码为唯一权威判据:probe_gate.sh 八件探针(轻闸 4 + full 4)判定统一走 verdict 函数, 退出码≠0 即红,PASS 串降级为附加判据(ui_click_probe 本就走退出码语义,签名标记传它自己的 click_probe 串,不强加 PASS)。 ②脚本错误扫描闸件 tools/error_scan.py(标记 import 自 run_smoke.py,禁第二份清单): probe_gate 逐探针段扫日志;run_all 侧因 --script 子进程的 stdout 管道恒空(OS.execute 在本机 Godot/Windows 组合下拿不到输出,且 GUI 子系统 exe 的 stdout/stderr 句柄无效),改为整条命令 交给系统命令解释器重定向落盘再读,退出码原样回传——引擎二进制取 *_console 版(GODOT_BIN 优先)。 ③断言数下限:Kit.finish 在 checks==0 时返回专用退出码 2(不是 1——致命类套件期望 1,返回 1 会被 expect 协议放过);run_all 汇总校验实跑套件数 == manifest 条目数。 ④example_test.gd 退出生产 manifest(协议活样板,文件保留;同类先例 fog_lantern 摘除)。 变异验证(三反例 T0 判绿、T1 后全部判红):零断言套件 exit=2 判红/脚本错误套件命中 SCRIPT ERROR 判红/探针打印 PASS 后 quit(1) 判红「PASS 串不抵」。 本卡附带裁决落地(均已用户裁准): · fx_grant_gp 补进行来源断言豁免表(grant_segment 的专长 GP 入账行无棋盘来源格,每局触发 7 次 断言失败却因脚本错误不改退出码长期漏过;迭代 21 同款修过 fx_reveal)。 · manifest 增 negative 字段,仅 reward_dangling_treasure 一件(坏表是其测试目标,错误扫描误伤)。 · config_db_test 的 rows 定型数组赋值改 rows.assign——原写法触发 Invalid assignment 中断整个函数, 敌人表行集三条纪律 10 条断言从未执行过(31 处 check 实跑 21 条);修复后 26 条全跑,其中两条正向 断言见红,根因是夹具缺 depth-0 锚行(validate 的 anchor 规则),补锚行后全绿。 · system_contracts_test 的 RngStub/UnitContainerStub 空桩换真实实例(定型成员赋值报 type 空)。
131 lines
8.7 KiB
GDScript
131 lines
8.7 KiB
GDScript
# tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
|
||
# 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
|
||
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
|
||
# 执行形态:逐套件起独立 Godot 子进程实跑
|
||
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
|
||
# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
|
||
# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。
|
||
extends SceneTree
|
||
|
||
# 脚本错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(GDScript 无脚本错误陷阱 API,
|
||
# 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。
|
||
const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"]
|
||
|
||
|
||
func _init() -> void:
|
||
print("[run_all] unit/contract suite entry — %d suites" % _suites().size())
|
||
if DisplayServer.get_name() != "headless":
|
||
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
|
||
var failures := 0
|
||
var ran := 0
|
||
for suite in _suites():
|
||
failures += _run_suite(suite, ran)
|
||
ran += 1
|
||
# 实跑套件数校验:manifest 有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。
|
||
if ran != _suites().size():
|
||
push_error("[run_all] 实跑套件数 %d ≠ manifest 条目数 %d——有套件未跑" % [ran, _suites().size()])
|
||
quit(1)
|
||
return
|
||
if failures > 0:
|
||
push_error("[run_all] %d 个套件失败" % failures)
|
||
quit(1)
|
||
return
|
||
print("[run_all] all green (%d suites)" % _suites().size())
|
||
quit(0)
|
||
|
||
|
||
# 套件清单:{ "path": String, "expect": int, "negative"?: bool };致命类(期望非 0)独立进程断言退出码。
|
||
func _suites() -> Array:
|
||
return [
|
||
# example_test.gd 是协议活样板(tests/unit/README 明示),不进生产 manifest——
|
||
# 样板件占位断言跑绿没有信息量,且占据「27 套件」计数。文件保留作模板(同类先例见下 fog_lantern 摘除行)。
|
||
{"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/data_config/forge_table_test.gd", "expect": 0}, # 迭代 23 锻造工坊批 T1——六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边)
|
||
{"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1},
|
||
{"path": "res://tests/unit/data_config/treasure_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏
|
||
# negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error,
|
||
# 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。
|
||
{"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 迭代 7 T1——秘宝双表校验
|
||
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 迭代 7 T7——秘宝结构冻结断言面
|
||
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 迭代 8 卡①/T9——断点往返/续玩确定性/幂等/退出清断点
|
||
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 2026-10-06——三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
|
||
{"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # 迭代 8 卡⑦ AC⑤/T9——排空零分叉/零丢行/延迟双态
|
||
{"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 迭代 7 hotfix——奖励节点 UI 直验(盲区补漏)
|
||
{"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 迭代 23 锻造工坊批 T6——工作间两区六路线 UI 直验(V13 自动化前置面)
|
||
{"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/battle_settlement/skill_unlock_test.gd", "expect": 0}, # 迭代14④ T6——Boss 半血解锁技·容器机制(门谓词/闩不可逆/双面过滤/保底不逼锁定/零扰动)
|
||
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0},
|
||
# fog_lantern_wiring_test 随提灯载体行退役摘除(迭代 22 T1——V13 旧验证约束同生死;呈现桥换代断言归 T4 阔视野带参数面)
|
||
{"path": "res://tests/unit/core/effect_words_t1_test.gd", "expect": 0},
|
||
{"path": "res://tests/unit/core/treasure_effects_test.gd", "expect": 0}, # 迭代 22 秘宝批 T3——新目录 12 件逐件行为(V7 附属表正常/边界/反例)
|
||
{"path": "res://tests/unit/core/tree_rules_v11_test.gd", "expect": 0}, # 迭代 21 天赋树批 V11——六键合成行闭集校验 # 迭代 20 T1——词表扩签封闭性(V1:新词在册/出域四路被拒/adventure 计数器往返/mod_eq 预评估口径)
|
||
{"path": "res://tests/unit/core/tree_requires_level_test.gd", "expect": 0}, # R1 机制位批 2026-10-13——requires_level 等级阈值前置(schema 四拒一过+判定三态+投影两态)
|
||
{"path": "res://tests/unit/core/pool_case_d_test.gd", "expect": 0}, # 迭代 20 T2——池结构案D 行为(V6 令牌三态/V7 曝光槽 2+1 回退/V8 概率上限解耦提前退役)
|
||
{"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 迭代 20 T4——凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴)
|
||
{"path": "res://tests/unit/core/settle_segments_test.gd", "expect": 0}, # 迭代 27 T2——结算五段 Segment(V4:直接 new/编排器接线/真实一轮走段序)
|
||
{"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 迭代 27 T4——模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗)
|
||
]
|
||
|
||
|
||
func _run_suite(suite: Dictionary, idx: int) -> int:
|
||
var path: String = suite.get("path", "")
|
||
var expect: int = int(suite.get("expect", 0))
|
||
if path.is_empty():
|
||
push_error("[run_all] suite missing path")
|
||
return 1
|
||
var bin := _engine_bin()
|
||
if bin == "":
|
||
push_error("[run_all] 未找到 console 版引擎二进制(设 GODOT_BIN 指向 *_console.exe)——"
|
||
+ "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地")
|
||
return 1
|
||
# 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空
|
||
#(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。
|
||
var proj := ProjectSettings.globalize_path("res://")
|
||
var log := proj + "/tests/.gates/run_all_%02d.log" % idx
|
||
DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates")
|
||
var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log]
|
||
var out := PackedStringArray()
|
||
var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false)
|
||
var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else ""
|
||
print(text)
|
||
if not bool(suite.get("negative", false)) and _has_script_error(text):
|
||
push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)])
|
||
return 1
|
||
if code != expect:
|
||
push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect])
|
||
return 1
|
||
print("[run_all] PASS %s (exit=%d)" % [path, code])
|
||
return 0
|
||
|
||
|
||
# 引擎二进制必须取 console 版:OS.get_executable_path() 返回的是 GUI 子系统 exe
|
||
#(Godot_v4.7.2-stable_win64.exe),它起的子进程 stdout/stderr 句柄无效——输出全丢,
|
||
# 脚本错误扫描就成了扫空气。GODOT_BIN 优先,其次按命名推导 *_console.exe。
|
||
func _engine_bin() -> String:
|
||
var env := OS.get_environment("GODOT_BIN")
|
||
if env != "" and FileAccess.file_exists(env):
|
||
return env
|
||
var derived := OS.get_executable_path().replace(".exe", "_console.exe")
|
||
if FileAccess.file_exists(derived):
|
||
return derived
|
||
return ""
|
||
|
||
|
||
func _has_script_error(text: String) -> bool:
|
||
return _script_error_line(text) != ""
|
||
|
||
|
||
func _script_error_line(text: String) -> String:
|
||
for ln in text.split("\n"):
|
||
var s := String(ln).strip_edges()
|
||
if s.is_empty():
|
||
continue
|
||
for marker in ERROR_MARKERS:
|
||
if s.contains(marker):
|
||
return s.substr(0, mini(s.length(), 160))
|
||
return ""
|