165 lines
4.9 KiB
GDScript
165 lines
4.9 KiB
GDScript
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)
|