diff --git a/game/tests/smoke_boot.gd b/game/tests/smoke_boot.gd new file mode 100644 index 00000000..d3d5172c --- /dev/null +++ b/game/tests/smoke_boot.gd @@ -0,0 +1,77 @@ +# tests/smoke_boot.gd — 冒烟测试:极小启动场景(这版活着吗——零逻辑判定) +# 以项目运行目标拉起=真实开机路径:autoload 四件注册在位、配置表全载(fail-fast 通道)、 +# 全局字体与主题可加载、主屏场景实例化、启动态菜单可见。 +# 断言只查存在性与可见性,不做任何玩法逻辑判定;断言完打印摘要并以退出码收口, +# 判定三闸(退出码+摘要失败数+脚本错误扫描)由外部运行器 tools/run_smoke.py 承担。 +# 运行:python tools/run_smoke.py(本地闸 00 冒烟测试) +extends Node + +const MAIN_SCENE_PATH: String = "res://presentation/main.tscn" +const FONT_PATH: String = "res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf" +const THEME_PATH: String = "res://assets/theme/main_theme.tres" +const BOOT_FRAMES: int = 4 # 主屏装配是逐帧推进的挂树过程,等满再断言(探针族同惯例) + +var _failures: int = 0 +var _checks: int = 0 + + +func check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" [PASS] " + label) + else: + _failures += 1 + print(" [FAIL] " + label) + + +func _ready() -> void: + _check_autoloads() + var main: Node = await _boot_main_scene() + _check_main_scene(main) + print("[smoke] 摘要:%d 项检查,%d 项失败" % [_checks, _failures]) + get_tree().quit(1 if _failures > 0 else 0) + + +# 开机前置面:autoload 注册与资源可加载(挂主屏之前先查,失败时主屏断言整段跳过) +func _check_autoloads() -> void: + for autoload_name in ["ConfigDB", "RngService", "SaveManager", "MetaProgression"]: + var node: Node = get_node_or_null(NodePath("/root/" + autoload_name)) + check(node != null, "autoload %s 在位" % autoload_name) + var db: Node = get_node_or_null(NodePath("/root/ConfigDB")) + check(db != null and bool(db.get("is_ready")) and db.get("hero") != null, + "ConfigDB 就绪且配置表全载(is_ready+hero 表在座,校验失败会致命中止)") + check(load(MAIN_SCENE_PATH) != null, "主屏场景资源可加载(%s)" % MAIN_SCENE_PATH) + check(load(FONT_PATH) != null, "全局字体资源可加载(%s)" % FONT_PATH) + check(load(THEME_PATH) != null, "全局主题资源可加载(%s)" % THEME_PATH) + + +# 以项目运行目标同款方式实例化主屏(真实开机路径——区别于 --script 模式的无 autoload 裸跑) +func _boot_main_scene() -> Node: + await get_tree().process_frame + var packed: PackedScene = load(MAIN_SCENE_PATH) as PackedScene + if packed == null: + return null + var main: Node = packed.instantiate() + if main == null: + return null + add_child(main) + for i in range(BOOT_FRAMES): + await get_tree().process_frame + return main + + +# 主屏存活面:组合根装配句柄在位(main.X 一级句柄契约,探针挂载假设的启动侧证明) +func _check_main_scene(main: Node) -> void: + check(main != null and main.is_inside_tree(), "主屏场景已实例化并在树") + if main == null: + return + var run_scope: Node = main.get("run_scope") + check(run_scope != null, "闲时会话 run_scope 非空(永非 null 契约)") + check(run_scope != null and run_scope.is_inside_tree(), "闲时会话 run_scope 在树") + var start_overlay: Control = main.get("_start_overlay") + check(start_overlay != null, "开始菜单层已装配(_start_overlay)") + check(start_overlay != null and start_overlay.visible, "启动态菜单可见(boot 后玩家见菜单)") + check(main.get("settings_store") != null, "系统设置存储已装配(settings_store)") + check(main.get("debug_badge") != null, "调试入口角标已装配(debug_badge)") + check(main.get("legend_overlay") != null, "图例覆盖层已装配(legend_overlay)") + check(main.get("status_panel") != null, "局内状态面板已装配(status_panel)") diff --git a/game/tests/smoke_boot.gd.uid b/game/tests/smoke_boot.gd.uid new file mode 100644 index 00000000..b33e90d4 --- /dev/null +++ b/game/tests/smoke_boot.gd.uid @@ -0,0 +1 @@ +uid://m7p3g1baece4 diff --git a/game/tests/smoke_boot.tscn b/game/tests/smoke_boot.tscn new file mode 100644 index 00000000..aceb75a9 --- /dev/null +++ b/game/tests/smoke_boot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://csmokeboot01"] + +[ext_resource type="Script" path="res://tests/smoke_boot.gd" id="1"] + +[node name="SmokeBoot" type="Node"] +script = ExtResource("1") diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd new file mode 100644 index 00000000..8846a55c --- /dev/null +++ b/game/tests/system_test.gd @@ -0,0 +1,27 @@ +# tests/system_test.gd — 系统测试宿主薄壳(SceneTree+构造注入) +# 系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一,闸 01)。 +# 本件现宿主零断言——smoke_all.gd 的断言按「只剪切禁改写」纪律迁入(断言迁移批执行, +# 本批只落宿主薄壳,跑通即绿)。迁移完成后 tools/run_system_test.py 缺省 --script 改指本件。 +# 协议照单元侧惯例:extends SceneTree 获得 quit(code);--script 模式不加载 autoload, +# 断言夹具显式实例化 ConfigDB/RngService 构造注入;摘要行由 tools/run_system_test.py 三闸判定 +# (退出码+摘要失败数+脚本错误扫描)。 +# 运行:python tools/run_system_test.py --script res://tests/system_test.gd +extends SceneTree + +var _failures: int = 0 +var _checks: int = 0 + + +func check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" [PASS] " + label) + else: + _failures += 1 + print(" [FAIL] " + label) + + +func _initialize() -> void: + print("=== Dungeon Sweeper 系统测试(宿主薄壳——断言面待迁移批自 smoke_all.gd 迁入) ===") + print("[system_test] 摘要:%d 项检查,%d 项失败" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/game/tests/system_test.gd.uid b/game/tests/system_test.gd.uid new file mode 100644 index 00000000..bbc94c2c --- /dev/null +++ b/game/tests/system_test.gd.uid @@ -0,0 +1 @@ +uid://dtoupwbm65jtw diff --git a/tools/run_smoke.py b/tools/run_smoke.py index 15a1e8e1..f6b756cd 100644 --- a/tools/run_smoke.py +++ b/tools/run_smoke.py @@ -1,18 +1,21 @@ #!/usr/bin/env python3 -r"""run_smoke.py — 冒烟运行器(脚本错误外部门禁) +r"""run_smoke.py — 冒烟测试运行器(脚本错误外部门禁) 用法: - python tools/run_smoke.py # 默认跑切片全量冒烟(本机 Godot 固定路径) - python tools/run_smoke.py --script res://smoke/smoke_all.gd - python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 180 + python tools/run_smoke.py # 跑冒烟测试(game/tests/smoke_boot.tscn,本机 Godot 固定路径) + python tools/run_smoke.py --scene res://tests/smoke_boot.tscn + python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 120 -背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、 -不计入冒烟失败数,且会静默中断所在冒烟函数的剩余断言——摘要零失败也可能断言未跑全。 +冒烟测试 = 极小启动场景(game/tests/smoke_boot.tscn/.gd):以项目运行目标拉起=真实开机路径 +(autoload 注册在位、配置表加载、主屏场景实例化、启动态菜单可见),断言只查存活,零逻辑判定。 + +背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、不计入失败数, +且会静默中断所在断言函数的剩余断言——摘要零失败也可能断言未跑全。 进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, 另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 -三闸: ① 进程退出码 = 0;② 摘要行「冒烟完成:N 项检查,0 项失败」在案且失败数为 0; - ③ 输出无「SCRIPT ERROR」「Parse Error」标记行。 +三闸: ① 进程退出码 = 0;② 摘要行「[smoke] 摘要:N 项检查,0 项失败」在案且失败数为 0; + ③ 输出无「SCRIPT ERROR」「Parse Error」标记行(标记清单供 tools/error_scan.py 复用,禁另写一份)。 """ import argparse import re @@ -23,22 +26,22 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" DEFAULT_SLICE = ROOT / "game" -DEFAULT_SCRIPT = "res://smoke/smoke_all.gd" -SUMMARY_RE = re.compile(r"冒烟完成:(\d+) 项检查,(\d+) 项失败") +DEFAULT_SCENE = "res://tests/smoke_boot.tscn" +SUMMARY_RE = re.compile(r"\[smoke\] 摘要:(\d+) 项检查,(\d+) 项失败") ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") - ap = argparse.ArgumentParser(description="冒烟运行器(脚本错误外部门禁)") + ap = argparse.ArgumentParser(description="冒烟测试运行器(脚本错误外部门禁)") ap.add_argument("--godot", default=DEFAULT_GODOT) ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") - ap.add_argument("--script", default=DEFAULT_SCRIPT) - ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(硬性要求)") + ap.add_argument("--scene", default=DEFAULT_SCENE, help="冒烟场景(项目运行目标)") + ap.add_argument("--timeout", type=int, default=120, help="外部超时秒数(硬性要求)") args = ap.parse_args() - cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] + cmd = [args.godot, "--headless", "--path", args.path, args.scene] try: proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=args.timeout) @@ -55,12 +58,12 @@ def main(): gates.append(f"退出码 = {proc.returncode}(≠0)") m = SUMMARY_RE.search(out) if m is None: - gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——入口跑完前中断?)") + gates.append("摘要行缺失(未检出「[smoke] 摘要:N 项检查,M 项失败」——场景跑完前中断?)") elif int(m.group(2)) != 0: gates.append(f"冒烟失败 {m.group(2)} 项 / 共 {m.group(1)} 项") hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] if hits: - gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计冒烟失败数," + gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计失败数," "且中断所在函数的剩余断言,必须人工排查)") if m is not None: diff --git a/tools/run_system_test.py b/tools/run_system_test.py new file mode 100644 index 00000000..9bf91461 --- /dev/null +++ b/tools/run_system_test.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +r"""run_system_test.py — 系统测试运行器(脚本错误外部门禁) + +用法: + python tools/run_system_test.py # 跑系统测试(过渡期宿主 res://smoke/smoke_all.gd) + python tools/run_system_test.py --script res://tests/system_test.gd + python tools/run_system_test.py --godot <引擎路径> --path <切片目录> --timeout 300 + +系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一)。 +宿主过渡态:断言现全部住在 res://smoke/smoke_all.gd,按「只剪切禁改写」纪律迁往 +res://tests/system_test.gd(宿主薄壳已在位)——迁移完成后本件缺省 --script 改指新宿主, +闸 01 系统测试随迁。 + +背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、不计入失败数, +且会静默中断所在断言函数的剩余断言——摘要零失败也可能断言未跑全。 +进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, +另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 + +三闸: ① 进程退出码 = 0;② 摘要行(「冒烟完成:N 项检查,0 项失败」或 + 「[system_test] 摘要:N 项检查,0 项失败」)在案且失败数为 0; + ③ 输出无「SCRIPT ERROR」「Parse Error」标记行(标记清单经 tools/error_scan.py 复用,禁另写一份)。 +""" +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" +DEFAULT_SLICE = ROOT / "game" +DEFAULT_SCRIPT = "res://smoke/smoke_all.gd" # 过渡期宿主;断言迁移批后改 res://tests/system_test.gd +SUMMARY_RE = re.compile(r"(?:冒烟完成|\[system_test\] 摘要):(\d+) 项检查,(\d+) 项失败") +ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") + + +def main(): + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + ap = argparse.ArgumentParser(description="系统测试运行器(脚本错误外部门禁)") + ap.add_argument("--godot", default=DEFAULT_GODOT) + ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") + ap.add_argument("--script", default=DEFAULT_SCRIPT, help="系统测试宿主脚本") + ap.add_argument("--timeout", type=int, default=300, help="外部超时秒数(硬性要求)") + args = ap.parse_args() + + cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=args.timeout) + except subprocess.TimeoutExpired: + print(f"[run_system_test] FAIL:外部超时(>{args.timeout}s——硬性要求)") + return 1 + out = (proc.stdout or "") + (proc.stderr or "") + sys.stdout.write(out) + if out and not out.endswith("\n"): + print() + + gates = [] + if proc.returncode != 0: + gates.append(f"退出码 = {proc.returncode}(≠0)") + m = SUMMARY_RE.search(out) + if m is None: + gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——宿主跑完前中断?)") + elif int(m.group(2)) != 0: + gates.append(f"系统测试失败 {m.group(2)} 项 / 共 {m.group(1)} 项") + hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] + if hits: + gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计失败数," + "且中断所在函数的剩余断言,必须人工排查)") + + if m is not None: + print(f"[run_system_test] 摘要: {m.group(0)}") + if gates: + print("[run_system_test] FAIL——") + for g in gates: + print(f" - {g}") + return 1 + print("[run_system_test] PASS(退出码 / 摘要失败数 / 脚本错误扫描 三闸全过)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())