extends SceneTree ## 无头测试入口。 ## ## 用法: ## godot --headless --path . --script res://tests/run_tests.gd ## ## 约定:tests/test_*.gd 继承 TestCase,其中 test_ 开头的方法会被执行。 ## 不依赖 autoload —— 测试直接 preload 被测类,确保可在 --script 模式下运行。 const TEST_DIR := "res://tests" const TEST_PREFIX := "test_" const TEST_SUFFIX := ".gd" ## 断言基类所在文件,不作为测试用例收集 const BASE_CASE_FILE := "test_case.gd" var _passed := 0 var _failed := 0 var _skipped := 0 var _assertions := 0 var _failures: Array[String] = [] var _current := "" var _started := false ## 注意:不能在 _initialize() 里跑测试。 ## 那时 root Window 尚未真正进入场景树,HTTPRequest.request() 会返回 ## ERR_UNCONFIGURED(3),导致所有网络相关测试假失败。改在首帧 _process 中启动。 func _initialize() -> void: pass func _process(_delta: float) -> bool: # 必须始终返回 false:只要测试里有 await(例如真实 HTTP 请求), # 协程就会跨帧挂起;此时若返回 true,主循环会立刻退出, # 测试被拦腰截断且悄无声息(同步测试因为不跨帧,一直掩盖了这个缺陷)。 # 结束由 _run_all() 内的 quit() 负责。 if not _started: _started = true _run_all() return false func _run_all() -> void: var files := _discover_tests() if files.is_empty(): print("未发现任何测试文件") quit(1) return print("") print("== OpenDance Studio Windows 客户端 —— 单元测试 ==") print("") for path in files: await _run_file(path) print("") var skip_note := (",%d 跳过" % _skipped) if _skipped > 0 else "" print("== 结果:%d 通过 / %d 失败%s ==" % [_passed, _failed, skip_note]) if _failed > 0: print("") print("失败明细:") for line in _failures: print(" ✗ %s" % line) print("") quit(1) else: print("全部通过") print("") quit(0) func _discover_tests() -> Array[String]: var out: Array[String] = [] var dir := DirAccess.open(TEST_DIR) if dir == null: push_error("无法打开 %s" % TEST_DIR) return out dir.list_dir_begin() var name := dir.get_next() while name != "": if not dir.current_is_dir() and name.begins_with(TEST_PREFIX) and name.ends_with(TEST_SUFFIX) and name != BASE_CASE_FILE: out.append("%s/%s" % [TEST_DIR, name]) name = dir.get_next() dir.list_dir_end() out.sort() return out func _run_file(path: String) -> void: var case_name := path.get_file().get_basename() _current = case_name print("[%s]" % case_name) var script: GDScript = load(path) # 脚本编译失败时 load() 仍返回对象,但 can_instantiate() 为 false。 # 不做这层检查的话,整个文件会静默贡献 0 个测试而不被察觉。 if script == null or not script.can_instantiate(): _record(false, case_name, "测试脚本无法编译/实例化(查看导入日志中的 Parse Error)") return var instance: Object = script.new() if instance == null: _record(false, case_name, "测试脚本实例化返回 null") return # 注入断言上下文 instance.set("_runner", self) var methods: Array[String] = [] for m in instance.get_method_list(): var mname: String = m["name"] if mname.begins_with("test_") and not methods.has(mname): methods.append(mname) methods.sort() for mname in methods: var before_failed := _failed var before_skipped := _skipped var before_assertions := _assertions # call() 对协程返回 GDScriptFunctionState,await 会等到其真正结束; # 对普通方法返回值则立即返回。因此同步与异步测试共用一条路径。 await instance.call(mname) if _failed != before_failed or _skipped != before_skipped: continue # 关键防线:一个测试方法若一条断言都没执行,说明它静默失败了 # (脚本编译失败、preload 失败、方法内报错提前返回等),一律计为失败, # 绝不允许「什么都没做」被记成通过。 if _assertions == before_assertions: _record(false, case_name, "%s 未执行任何断言(疑似脚本编译/加载失败)" % mname) else: passed("%s.%s" % [case_name, mname], true) func report_assert(ok: bool, label: String, detail: String) -> void: _record(ok, _current, label, detail) ## 跳过当前测试(例如依赖外部服务而该服务不可达) func report_skip(label: String, reason: String) -> void: _skipped += 1 print(" ⊘ %s — %s" % [label, reason]) func _record(ok: bool, group: String, label: String, detail: String = "") -> void: _assertions += 1 if ok: _passed += 1 print(" ✓ %s" % label) else: _failed += 1 var line := "%s > %s" % [group, label] if detail != "": line += " — %s" % detail _failures.append(line) print(" ✗ %s%s" % [label, (" — " + detail) if detail != "" else ""]) func passed(label: String, silent: bool = false) -> void: if not silent: _passed += 1 print(" ✓ %s" % label)