Files
gplan-2/game/tests/run_all.gd
T

409 lines
30 KiB
GDScript
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.
# tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
# 用法:godot --headless --path game --script res://tests/run_all.gd
# 选跑态:追加 `-- --only=<词[,词…]>` ——对套件 res:// 路径做子串匹配,只跑命中套件;
# 零命中/空值=用法错 quit(2)。两个来源分开定性——开发期直跑(默认)仅供最窄确认,不产闸回执、
# 不作验收凭据;闸机关联档选跑(--only-source=gates,由 tools/run_all_gates.sh 在 02 闸传入)
# 是收口验收跑批本体,回执 scope=related。无任何选跑参数=全量态,行为与全量口径完全一致。
# 分片态:追加 `-- --shard=k --shard-count=n`(须成对,1≤k≤n,n≥2 才有意义)——把选套集
# (--only 过滤后)切成 n 片互斥子集,本进程只跑第 k 片;闸机据此多进程并行缩短 02 闸墙钟。
# 分片分配走「串行道优先」:开 RNG 事件日志会话的套件(SERIAL_PATTERNS,new_adventure 调用方)
# 全部钉第 1 片——事件日志按种子命名同文件追加(user://logs/rng_log_<seed>.jsonl),跨片并行
# 写同一文件互踩内容,断言日志纯净序的套件(cross_system_flow)必读错行。新增日志会话套件必须
# 同步登记 SERIAL_PATTERNS,漏登记=并行期日志互踩假红。空片合法(--only 小集 × 多片):空片
# 直接 quit(0) 放行,判定职责由其余片承担。各片独立退出码,由闸机聚合;片内三硬闸与
# 快速失败语义不变,逐套件日志名加 _s<k> 段防跨片覆写。
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
# 执行形态:逐套件起独立 Godot 子进程实跑
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
# 挂死守护:每套件子进程带截止时间(默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖)。
# 套件挂死(_init 在 quit() 前中断,子进程永不退出)=超时先 taskkill /T 整树回收
#(连 cmd wrapper 带引擎孙进程一起杀,只杀 wrapper 会留孤儿殃及后续全部 Godot 启动)
# 再判该套件 FAIL 继续下一套件——单套件挂死不拖死整批;正常退出路径判定形态不变。
# 快速失败:连续 N 个套件超时=红树正在整批挂死(如公共件解析错让每个套件都起不来),
# 继续逐个回收只剩烧钟——第 N 个超时即中止整轮,汇总已见失败与超时清单后非 0 退出;
# 不连续的超时仍逐个回收照旧。
# 场景探针(.tscn 载体)由本地闸(probe_gate.sh)单独步骤调用,不经本入口。
extends SceneTree
# 脚本错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(GDScript 无脚本错误陷阱 API,
# 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。
const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"]
# 每套件截止时间(秒):默认与 probe_gate 每段同额;环境变量可覆盖——服务挂死守护的
# 机制验证与日后调参,非法值/非正值一律回落默认。
const SUITE_TIMEOUT_ENV := "RUN_ALL_SUITE_TIMEOUT_SEC"
const SUITE_TIMEOUT_DEFAULT_SEC := 120
# 快速失败阈值(可调常量,初值 3):连续 N 个套件超时即中止整轮。
const CONSECUTIVE_TIMEOUT_ABORT := 3
# 串行道套件(路径子串匹配):开 RNG 事件日志会话(new_adventure)的调用方——事件日志按种子
# 命名同文件追加(user://logs/rng_log_<seed>.jsonl),跨分片并行写同一文件互踩;断言日志内容
# 的套件(cross_system_flow)必须读到本会话纯净追加序。分片分配时全部钉第 1 片(见头注分片态)。
# 新增日志会话套件时同步登记此处,漏登记=并行期假红。
const SERIAL_PATTERNS: Array[String] = [
"board_generator_test",
"node_map_generator_test",
"minesweeper_rules_test",
"rng_stream_boot_test",
"cross_system_flow_test",
"node_map_determinism_test",
]
# 分片态成员:0=未分片(单进程原路径);>0=第 _shard_k/_shard_n 片(_run_suite 取名用)。
var _shard_k := 0
var _shard_n := 0
func _init() -> void:
# 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配;
# --only-source=gates=来源标记:由闸脚本传入的关联档选跑(验收跑批本体),缺省=开发期直跑。
# 空值/零命中=用法错 quit(2)(fail-closed:禁把零套件当绿)。
var only_terms := PackedStringArray()
var has_only := false
var only_from_gates := false
for a in OS.get_cmdline_user_args():
if a.begins_with("--only="):
has_only = true
for part in a.trim_prefix("--only=").split(","):
var term := part.strip_edges()
if term != "":
only_terms.append(term)
elif a == "--only-source=gates":
only_from_gates = true
elif a.begins_with("--shard="):
var sv := a.trim_prefix("--shard=").strip_edges()
if sv.is_valid_int():
_shard_k = int(sv)
elif a.begins_with("--shard-count="):
var sn := a.trim_prefix("--shard-count=").strip_edges()
if sn.is_valid_int():
_shard_n = int(sn)
if (_shard_k == 0) != (_shard_n == 0):
push_error("[run_all] --shard 与 --shard-count 必须成对出现")
quit(2)
return
if _shard_n < 0 or (_shard_n > 0 and (_shard_k < 1 or _shard_k > _shard_n)):
push_error("[run_all] 分片参数非法(须 1≤k≤n,n≥1):k=%d n=%d" % [_shard_k, _shard_n])
quit(2)
return
if has_only and only_terms.is_empty():
push_error("[run_all] --only= 空值——用法错(选跑禁把零套件当全量)")
quit(2)
return
var manifest := _suites()
var suites := manifest
var filtered := false
if has_only:
suites = _filter_suites(manifest, only_terms)
if suites.is_empty():
push_error("[run_all] FILTERED RUN — --only=「%s」零命中(manifest 共 %d 套件)——用法错" % [",".join(only_terms), manifest.size()])
quit(2)
return
filtered = true
if only_from_gates:
print("[run_all] GATE FILTERED RUN — 闸机关联档选跑 %d/%d 套件(--only=%s)" % [suites.size(), manifest.size(), ",".join(only_terms)])
else:
print("[run_all] FILTERED RUN — 选跑 %d/%d 套件(--only=%s)——仅供开发期最窄确认,不产闸回执、不作验收凭据" % [suites.size(), manifest.size(), ",".join(only_terms)])
var sharded := _shard_n > 1
if sharded:
suites = _shard_suites(suites, _shard_k, _shard_n)
if suites.is_empty():
print("[run_all] SHARD %d/%d — 本片零套件,空片放行(判定职责由其余片承担)" % [_shard_k, _shard_n])
quit(0)
return
print("[run_all] SHARD %d/%d — 本片 %d 套件" % [_shard_k, _shard_n, suites.size()])
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
var consecutive_timeouts := 0
var failed_paths := PackedStringArray()
var timed_out_paths := PackedStringArray()
for suite in suites:
var outcome := _run_suite(suite, ran, filtered)
var suite_fail: int = outcome["fail"]
var suite_timed_out: bool = outcome["timed_out"]
failures += suite_fail
ran += 1
var spath: String = suite.get("path", "")
if suite_timed_out:
consecutive_timeouts += 1
timed_out_paths.append(spath)
if consecutive_timeouts >= CONSECUTIVE_TIMEOUT_ABORT:
push_error("[run_all] ABORT — 连续 %d 个套件超时(阈值 %d),中止整轮" % [consecutive_timeouts, CONSECUTIVE_TIMEOUT_ABORT])
_report_abort(failed_paths, timed_out_paths, ran, suites.size())
quit(1)
return
else:
consecutive_timeouts = 0
if suite_fail > 0:
failed_paths.append(spath)
# 实跑套件数校验:清单有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。
# 选跑态对过滤后清单校验;全量态与原 manifest 条目数同一。
if ran != suites.size():
push_error("[run_all] 实跑套件数 %d ≠ 清单条目数 %d——有套件未跑" % [ran, suites.size()])
quit(1)
return
if failures > 0:
push_error("[run_all] %d 个套件失败" % failures)
quit(1)
return
var tag := ",filtered" if filtered else ""
if sharded:
tag += ",shard %d/%d" % [_shard_k, _shard_n]
print("[run_all] all green (%d suites%s)" % [suites.size(), tag])
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}, # 六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边)
{"path": "res://tests/unit/data_config/level_gen_params_test.gd", "expect": 0}, # 参数档 schema 正反+访问器读回(节点图生成器核心批 卡① V2〔fv2〕)
{"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}, # 真视窄域护栏
# negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error,
# 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。
{"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 秘宝双表校验
{"path": "res://tests/unit/data_config/counter_prefix_fatal_test.gd", "expect": 1, "negative": true}, # 卡② V9 前缀合法性+引用闭合加载致命
{"path": "res://tests/unit/core/run_time_fatal_test.gd", "expect": 1, "negative": true}, # 卡② wr5/wr6/wr7 运行期违约通道(fatal 封印传导/未知标签/未播种)
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
{"path": "res://tests/unit/data_config/table_structure_guard_test.gd", "expect": 0}, # 表结构守卫宿主(adventure 配额与同调目录/enemy 带键行序/AC 单轨与掷点参数/variation 门控互锁/difficulty 修饰/reward 九型/meta 行族)
{"path": "res://tests/unit/data_config/rng_stream_boot_test.gd", "expect": 0}, # 启动初始化与统一 RNG 流宿主(data/tables 加载链/统一流黄金向量 m=42/derive_at 恒等/open_stream 取号序/new_adventure 计数器重置——批 B 自 smoke_all 迁入;源 :70 事件日志落盘条按裁决一转办集成宿主)
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面
{"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10)
{"path": "res://tests/unit/core/node_map_growth_test.gd", "expect": 0}, # 生成图生长递增+参数无解 fail-fast 子进程通道(卡① V8/V4)
{"path": "res://tests/unit/core/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕)
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点
{"path": "res://tests/unit/core/hero_ledger_test.gd", "expect": 0}, # 主角账公式逐值/三通道日记/断点片段往返与旧档回退(拆件批 V1:hr1/hr1-s/hr2)
{"path": "res://tests/unit/battle_settlement/trigger_cond_test.gd", "expect": 0}, # 触发条件机制 V1/V2/V4/V6/V7/V8/V9/V10(触发器批——出域拒绝/双面过滤/闩语义/事实面/池纪律/两示范/零扰动)
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
{"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值)
{"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # AC⑤/T9——排空零分叉/零丢行/延迟双态
{"path": "res://tests/unit/presentation/standee_fx_sweep_test.gd", "expect": 0}, # 呈现域缺陷扫掠(d2 蓄力档/d4 庆祝句柄/d5+d8 互斥/d12 站桩按 id 解析)
{"path": "res://tests/unit/presentation/tune_sim_batch_parity_test.gd", "expect": 0}, # 测速循环批双实现对拍(帧索引/运行态/进度串/stats/报告五面——信号步进 vs await 基线)
{"path": "res://tests/unit/presentation/standee_present_values_test.gd", "expect": 0}, # 战斗一屏样张批 V5–V12(血条危险红/名字行素底不压地台与提档 FS_HINT/徽标/护甲 PNG 绑定/素材入包/地台色合并退役)
{"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 奖励节点 UI 直验(盲区补漏)
{"path": "res://tests/unit/presentation/level_map_info_fog_test.gd", "expect": 0}, # 信息迷雾——呈现显隐/Boss 豁免/详情零显示/大图拟合
{"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 工作间两区六路线 UI 直验(V13 自动化前置面)
{"path": "res://tests/unit/presentation/camp_scene_layout_test.gd", "expect": 0}, # 营地场景布局数据件校验(V14——fail-fast 判据+七命中区几何红线)
{"path": "res://tests/unit/presentation/stage_background_test.gd", "expect": 0}, # B 区舞台背景三关变体与连续地面层(V1–V5/V8——ground_h 表单源/board_type 选图/缺图回退/地面几何/光柱退役)
{"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/roll_multiplier_test.gd", "expect": 0}, # 掷点与乘区宿主(敌攻击类型轴/护甲掷点行为面/乘区接线/伤害类型克制与标错/fresh 池攻击链——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/battle_settlement/seal_polarity_test.gd", "expect": 0}, # 封印与极性宿主(末点 AP 封印/封印不可逆四情况/清图奖励双份与弱形式/刷新 provenance 守卫/极性路由与封印溢价/负向字典/格面资源资格——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/battle_settlement/round_flow_skill_pool_test.gd", "expect": 0}, # 轮内流与技能池宿主(全战斗回放/轮内刷新与 V5 快照单源/遭遇区间骰/结算五段全序/技能池数据面·回放独立·EV 锚·死亡打断·校验拒绝/妨害附加/四桶计数——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/battle_settlement/refresh_feats_test.gd", "expect": 0}, # 换图系专长七件宿主(迭代 46 卡A——基座产 RAP 与折价同源/成功事实恰一次/全局受伤四路径恰一次/石层缓冲/崩壁延迟/蓄力层/坑道小憩/矿道红利 GP/七件数据与牌库/无专长基线;原经卡片申报走 02 关联档选跑,卡片退役后补登记由 02 全套件承接)
{"path": "res://tests/unit/battle_settlement/variant_placement_test.gd", "expect": 0}, # 变异布置宿主(战内布置段门控与数量公式/全局变异兜底回落与资源面偏斜——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/battle_settlement/skill_unlock_test.gd", "expect": 0}, # Boss 半血解锁技·容器机制(门谓词/闩不可逆/双面过滤/保底不逼锁定/零扰动)
{"path": "res://tests/unit/battle_settlement/extended_row_violation_test.gd", "expect": 1, "negative": true}, # 卡④ wr2-np 扩展行必载 effect_id 负路径(行协议运行期违约面首个负面套件——独立进程豁免错误扫描)
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
{"path": "res://tests/unit/contracts/source_guard_test.gd", "expect": 0}, # 源码契约宿主(project.godot 警告配置与类型闸/产品码卫生死代码·影子账·层序单口——批 B 自 smoke_all 迁入;被测对象=仓库自身源文件,裁决五新设)
{"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0},
{"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0},
# 提灯载体行不在册(呈现桥断言归阔视野带参数面)
{"path": "res://tests/unit/core/effect_words_t1_test.gd", "expect": 0},
{"path": "res://tests/unit/core/treasure_effects_test.gd", "expect": 0}, # 新目录 12 件逐件行为(V7 附属表正常/边界/反例)
{"path": "res://tests/unit/core/tree_rules_v11_test.gd", "expect": 0}, # V11 六键合成行闭集校验 # V1 词表扩签封闭性(新词在册/出域四路被拒/adventure 计数器往返/mod_eq 预评估口径)
{"path": "res://tests/unit/core/tree_requires_level_test.gd", "expect": 0}, # R1 requires_level 等级阈值前置(schema 四拒一过+判定三态+投影两态)
{"path": "res://tests/unit/core/pool_case_d_test.gd", "expect": 0}, # 池结构案D 行为(V6 令牌三态/V7 曝光槽 2+1 回退/V8 概率上限解耦提前退役)
{"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴)
{"path": "res://tests/unit/core/settle_segments_test.gd", "expect": 0}, # 结算五段 Segment(V4:直接 new/编排器接线/真实一轮走段序)
{"path": "res://tests/unit/core/save_slot_isolation_test.gd", "expect": 0}, # 卡① 三槽位间隔离/进档失败恢复/宿主链引用同一(sv1/sv5/sv9/sv10)
{"path": "res://tests/unit/core/save_boot_no_activate_test.gd", "expect": 0}, # 卡① 启动态类级契约:无激活态/拒写盘面零触碰/越界拒绝(sv2/sv3)
{"path": "res://tests/unit/core/save_slot_summary_test.gd", "expect": 0}, # 卡① 摘要三项按槽窥视:异值/空槽/坏槽三态/零副作用(sv7/sv8)
{"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗)
# ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)----
{"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性)
{"path": "res://tests/integration/node_map_determinism_test.gd", "expect": 0}, # 关卡节点图生成·确定性承载(生成图复现×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆)
{"path": "res://tests/integration/cross_system_flow_test.gd", "expect": 0}, # 跨系统真实流×落盘(RNG 事件日志落盘/专长断点往返/存档摘要集成/试玩记录仪真实结算流——批 B 转办块宿主,裁决一/二/三/七)
# ---- core 四件与呈现层两件的直接单测 ----
{"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽)
{"path": "res://tests/unit/core/board_gen_reward_test.gd", "expect": 0}, # 棋盘生成与地图战斗资源宿主(格边长公式/资源身份面/生成器三分出口与 r0 闸/伤害量值区间——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/core/adventure_feats_test.gd", "expect": 0}, # 冒险结构与专长宿主(状态机受理面/三关序列与账本封闭/败北线/feat_table 四级/专长凭证保底/V3 订阅面逐件——批 B 自 smoke_all 迁入;断点往返三条按裁决二转办集成宿主)
{"path": "res://tests/unit/core/level_shop_economy_test.gd", "expect": 0}, # 关卡图与 GP 商店宿主(level 图校验与推进/enemy 行集定点/钱包到账/escrow 钳制/商店三收口/共享池抽买——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/core/meta_progression_forge_test.gd", "expect": 0}, # 元进度与锻造宿主(锻造两轴/树购买受理面/hero_override 注入/收束入账与工坊解锁/树六键合成——批 B 自 smoke_all 迁入)
{"path": "res://tests/unit/core/save_manager_domain_test.gd", "expect": 0}, # 存档域宿主(读写往返/迁移链/损坏兜底/域界干净/摘要 cap/断点域七字段/三槽位承载——批 B 自 smoke_all 迁入;摘要集成块按裁决三转办集成宿主)
{"path": "res://tests/unit/core/minesweeper_rules_test.gd", "expect": 0}, # 排雷规则(装板不变量/挖的 face 规则/揭示推进/封印/目录外拒)
{"path": "res://tests/unit/core/config_db_resolver_test.gd", "expect": 0}, # 配置解析(ceil 偏难语义/行集行序/难度 clamp)
{"path": "res://tests/unit/core/config_db_cross_validator_test.gd", "expect": 0}, # 跨表校验(正向零错/悬空 skill_id/side 反向)
{"path": "res://tests/unit/presentation/hud_presenter_test.gd", "expect": 0}, # HUD 投影文案(逐拒绝原因/兜底不空)
{"path": "res://tests/unit/presentation/battle_narrator_test.gd", "expect": 0}, # 战斗叙述(数值带入/胜负互异/收束态恒定)
{"path": "res://tests/unit/presentation/type_glow_test.gd", "expect": 0}, # 未开格类型微光数据面(九型归组封闭/清单外显式失败/冻结色相只准动明度/α170·带宽cell÷8锚定/合成空间阶梯单调+模型CR≥1.20/probe API 形状)
{"path": "res://tests/unit/presentation/theme_projection_test.gd", "expect": 0}, # 呈现投影与主题宿主(微光数据面/战斗日志行协议/全局主题与按钮五态/纹理过滤白名单/对比度墨色锚/元素细节源码锚/诊断钮退役源码契约/状态面板与生涯页只读投影——批 B 自 smoke_all 迁入;试玩记录仪整套按裁决七转办集成宿主)
{"path": "res://tests/unit/presentation/player_copy_sweep_test.gd", "expect": 0}, # 玩家面文案清扫宿主(迭代36 卡A——钉值表换代断言①–⑧⑩+源 token 扫描两处;直建断言族,禁字「雷」族与旧术语清零钉值)
]
# 返回 {"fail": int(该套件失败计 1/过 0), "timed_out": bool(是否超时挂死)}——
# timed_out 供编排层做连续超时快速失败判定,fail 照旧累计。
func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> Dictionary:
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 {"fail": 1, "timed_out": false}
var bin := _engine_bin()
if bin == "":
push_error("[run_all] 未找到 console 版引擎二进制(设 GODOT_BIN 指向 *_console.exe)——"
+ "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地")
return {"fail": 1, "timed_out": false}
# 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空
#(实测 0 行),故整条命令交给 cmd 并重定向到日志。退出码同样走文件通道——
# 阻塞式 execute 无超时能力(套件挂死时整批陪葬),换 create_process 非阻塞起进程
# 后又没有退出码读取 API,故由 cmd /V:ON 在套件退出后把 !ERRORLEVEL! 回显落盘,
# 值与阻塞式 execute 经 cmd 回传的同一。
var proj := ProjectSettings.globalize_path("res://")
var shard_tag := "" if _shard_n == 0 else "_s%d" % _shard_k
var log := proj + "/tests/.gates/run_all%s%s_%02d.log" % ["_filter" if filtered else "", shard_tag, idx]
var codefile := log + ".code"
if FileAccess.file_exists(codefile):
DirAccess.remove_absolute(codefile) # 陈值防护:上轮临界残件不许冒充本轮退出码
DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates")
# /V:ON=延迟展开:!ERRORLEVEL! 到 echo 执行时(套件已退出)才取值;
# 延迟展开会吞字面 !,命令串各段(引擎路径/工程路径/套件路径/日志路径)不得含 !。
var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1 & echo !ERRORLEVEL! > "%s"' % [bin, proj, path, log, codefile]
var out := PackedStringArray()
var pid: int = OS.create_process("cmd.exe", PackedStringArray(["/V:ON", "/C", cmd]))
if pid < 0:
push_error("[run_all] FAIL %s — cmd 子进程启动失败(pid=%d)" % [path, pid])
return {"fail": 1, "timed_out": false}
# 轮询先查存活再查截止:进程在轮询间隙自然退出时按正常路径判,截止临界不误杀。
var timed_out := false
var deadline_ms := Time.get_ticks_msec() + _suite_timeout_sec() * 1000
while true:
if not OS.is_process_running(pid):
break
if Time.get_ticks_msec() >= deadline_ms:
timed_out = true
break
OS.delay_msec(200)
var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else ""
var negative := bool(suite.get("negative", false))
if timed_out:
var kill_out := PackedStringArray()
OS.execute("taskkill", PackedStringArray(["/F", "/T", "/PID", str(pid)]), kill_out, true)
# taskkill 落刀到进程对象消失有间隔:等死确认再放行下一套件(10s 宽限),
# 确认不了也如实报红留 PID 供手工清杀,不静默放行。
var reap_deadline := Time.get_ticks_msec() + 10000
while OS.is_process_running(pid) and Time.get_ticks_msec() < reap_deadline:
OS.delay_msec(200)
var reaped := not OS.is_process_running(pid)
# 临界窗口:截止到落刀之间套件可能恰好跑完 echo——同删退出码文件,.gates 不积残件。
if FileAccess.file_exists(codefile):
DirAccess.remove_absolute(codefile)
if negative:
print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path)
else:
print(text)
if reaped:
push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树已整树回收(pid=%d)" % [path, _suite_timeout_sec(), pid])
else:
push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树回收未确认(pid=%d,需手工清杀残留)" % [path, _suite_timeout_sec(), pid])
return {"fail": 1, "timed_out": true}
# 负面套件(坏表/违规输入是它的测试目标)全文折叠,只回显结论行——
# 它输出里的 Parse Error 是预期产物,回显出去会让编排层的日志扫描误判成真实脚本错误。
if negative:
print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path)
else:
print(text)
var code_txt := FileAccess.get_file_as_string(codefile).strip_edges() if FileAccess.file_exists(codefile) else ""
if code_txt == "" or not code_txt.is_valid_int():
push_error("[run_all] FAIL %s — 退出码文件缺失或非数字(%s)——子进程未走到退出码落盘即死,判红" % [path, codefile])
return {"fail": 1, "timed_out": false}
var code: int = int(code_txt)
DirAccess.remove_absolute(codefile) # 用毕即删:退出码经文件通道但不落 residuum(.gates 只留 *.log)
if not negative and _has_script_error(text):
push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)])
return {"fail": 1, "timed_out": false}
if code != expect:
push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect])
return {"fail": 1, "timed_out": false}
print("[run_all] PASS %s (exit=%d)" % [path, code])
return {"fail": 0, "timed_out": false}
# 快速失败中止时的汇总:已见失败与超时清单+中止时进度——红树几分钟出结论,不磨完整轮。
func _report_abort(failed: PackedStringArray, timed_out: PackedStringArray, ran: int, total: int) -> void:
var timed_out_txt := "、".join(timed_out) if timed_out.size() > 0 else "(无)"
var failed_txt := "、".join(failed) if failed.size() > 0 else "(无)"
push_error("[run_all] 已见超时 %d 件:%s" % [timed_out.size(), timed_out_txt])
push_error("[run_all] 已见失败(非超时)%d 件:%s" % [failed.size(), failed_txt])
push_error("[run_all] 中止时进度:%d/%d 套件(余下未跑)" % [ran, total])
# 选跑过滤:对套件 res:// 路径做子串匹配,任一词命中即选入,词序无关。
# 只在选跑态调用——全量态走原 manifest,条目与顺序零变化。
func _filter_suites(manifest: Array, terms: PackedStringArray) -> Array:
var out: Array = []
for suite in manifest:
var path: String = suite.get("path", "")
for t in terms:
if path.contains(t):
out.append(suite)
break
return out
# 分片分配:串行道套件(SERIAL_PATTERNS 命中)全钉第 1 片;其余按片轮转(片内相对序 j%n+1)。
# 分配对每个 k 确定性、互斥且完备——各片并起来=传入全集零重复零遗漏。
func _shard_suites(suites: Array, k: int, n: int) -> Array:
var out: Array = []
var j := 0
for suite in suites:
var path: String = suite.get("path", "")
var is_serial := false
for pat in SERIAL_PATTERNS:
if path.contains(pat):
is_serial = true
break
if is_serial:
if k == 1:
out.append(suite)
else:
if j % n + 1 == k:
out.append(suite)
j += 1
return out
# 引擎二进制必须取 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 _suite_timeout_sec() -> int:
var env := OS.get_environment(SUITE_TIMEOUT_ENV)
if env != "" and env.is_valid_int() and int(env) > 0:
return int(env)
return SUITE_TIMEOUT_DEFAULT_SEC
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 ""