# 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_.jsonl),跨片并行 # 写同一文件互踩内容,断言日志纯净序的套件(cross_system_flow)必读错行。新增日志会话套件必须 # 同步登记 SERIAL_PATTERNS,漏登记=并行期日志互踩假红。空片合法(--only 小集 × 多片):空片 # 直接 quit(0) 放行,判定职责由其余片承担。各片独立退出码,由闸机聚合;片内三硬闸与 # 快速失败语义不变,逐套件日志名加 _s 段防跨片覆写。 # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(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_.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) for fp in failed_paths: push_error("[run_all] 失败套件: %s" % fp) for tp in timed_out_paths: push_error("[run_all] 超时套件: %s" % tp) 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/settings_store_test.gd", "expect": 0}, # 减动效开关存储契约(持久化回读/缺省值/引擎动效档应用)〔断言补齐批〕 {"path": "res://tests/unit/battle_settlement/boss_gate_test.gd", "expect": 0}, # Boss 节点开战必选 Boss 行集(行类路由+端到端)〔断言补齐批〕 {"path": "res://tests/unit/data_config/effect_kind_retired_test.gd", "expect": 1, "negative": true}, # 甲案词表退役——overflow/attack_segments 误写加载致命〔架构整改〕 {"path": "res://tests/unit/core/playtest_logger_guard_test.gd", "expect": 0}, # R5 必填注入守卫——未注入拒绝开会话〔架构整改〕 {"path": "res://tests/unit/core/battle_driver_drive_test.gd", "expect": 0}, # R12 驱动循环契约——缺省确定性/provider/rejects/合步〔架构整改〕 {"path": "res://tests/unit/core/auto_player_deduce_test.gd", "expect": 0}, # R15 0 格连锁算子——远端格经真实闭包入 safe〔架构整改〕 {"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/effect_port_test.gd", "expect": 0}, # 效果端口件(效果端口与掷格分件批 V1–V3——端口单实例/业务类零引擎直连源码锚/事实面闭集两对照) {"path": "res://tests/unit/battle_settlement/roll_multiplier_test.gd", "expect": 0}, # 掷点与乘区宿主(敌攻击类型轴/护甲掷点行为面/乘区接线/伤害类型克制与标错/fresh 池攻击链——批 B 自 smoke_all 迁入) {"path": "res://tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd", "expect": 0}, # 结算接线同种子逐项 golden 对拍主证〔封装5.1〕(架构审查 5.1 封装修复——段类束注入改造零行为变更基线) {"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/七件数据与牌库/无专长基线;原经卡片申报走关联档选跑,卡片退役后补登记由单元与集成测试全套件承接) {"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:束构造/编排器接线/真实一轮走段序) {"path": "res://tests/unit/core/encapsulation_sweep_test.gd", "expect": 0}, # 五处结构性零命中扫描〔封装5.1 V2–V7〕(架构审查 5.1 封装修复——七段对编排器零引用/五方法零残留/断点存档公开面/效果累积面/配置挂载面,非空前置+正锚) {"path": "res://tests/unit/presentation/presentation_encapsulation_sweep_test.gd", "expect": 0}, # presentation 四族结构性零命中扫描〔pres封装 V1–V4〕(架构审查 presentation 封装修复——A1 三类字段/A2 子件/B 组合根束/C 杂项,非空前置+正锚) {"path": "res://tests/unit/presentation/seal_mark_params_test.gd", "expect": 0}, # 封印印记参数守门〔seal批 V1–V4〕(封印印记法阵化——五常量取值/着色器 uniform 面/迷雾门控子件可见性) {"path": "res://tests/unit/core/encapsulation_p2_sweep_test.gd", "expect": 0}, # NODE_TYPES 词表单点化结构性扫描〔架构P2 V1/V2〕(架构审查 P2 代码面——生成器本地副本归零+schema 唯一定点,非空前置+正锚) {"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 pid: int = -1 var out := PackedStringArray() if OS.get_name() == "Windows": # Windows 形态(原实现逐位保留):/V:ON 延迟展开,!ERRORLEVEL! 到 echo 时(套件已退出)才取值; # 延迟展开会吞字面 !,命令串各段(引擎路径/工程路径/套件路径/日志路径)不得含 !。 var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1 & echo !ERRORLEVEL! > "%s"' % [bin, proj, path, log, codefile] pid = OS.create_process("cmd.exe", PackedStringArray(["/V:ON", "/C", cmd])) else: # POSIX 形态(mac/Linux runner):/bin/sh -c 同步串行——sh 前台等引擎退出再落 $?, # 无延迟展开问题;create_process 拿 sh 的 pid(存活轮询=sh 前台等待期),截止杀走 kill 树。 var sh_cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1; echo $? > "%s"' % [bin, proj, path, log, codefile] pid = OS.create_process("/bin/sh", PackedStringArray(["-c", sh_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() if OS.get_name() == "Windows": OS.execute("taskkill", PackedStringArray(["/F", "/T", "/PID", str(pid)]), kill_out, true) else: # POSIX:先杀 sh 的直接子进程(引擎),再杀 sh 自身(顺序要紧——先杀父会让子成孤儿续跑) OS.execute("pkill", PackedStringArray(["-9", "-P", str(pid)]), kill_out, true) OS.execute("kill", PackedStringArray(["-9", 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 ""