feat(win): T-WIN-004 239 字节关键点帧编解码 + 无头测试框架
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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)
|
||||
@@ -0,0 +1,55 @@
|
||||
class_name TestCase
|
||||
extends RefCounted
|
||||
|
||||
## 测试断言基类。由 tests/run_tests.gd 注入 _runner 后调用。
|
||||
|
||||
var _runner: Object = null
|
||||
|
||||
|
||||
func ok(condition: bool, label: String, detail: String = "") -> void:
|
||||
_runner.report_assert(condition, label, detail)
|
||||
|
||||
|
||||
func is_true(condition: bool, label: String, detail: String = "") -> void:
|
||||
ok(condition, label, detail if detail != "" else "期望 true,实际 false")
|
||||
|
||||
|
||||
func is_false(condition: bool, label: String, detail: String = "") -> void:
|
||||
ok(not condition, label, detail if detail != "" else "期望 false,实际 true")
|
||||
|
||||
|
||||
func eq(actual: Variant, expected: Variant, label: String) -> void:
|
||||
ok(actual == expected, label, "期望 %s,实际 %s" % [str(expected), str(actual)])
|
||||
|
||||
|
||||
func ne(actual: Variant, unexpected: Variant, label: String) -> void:
|
||||
ok(actual != unexpected, label, "不应等于 %s" % str(unexpected))
|
||||
|
||||
|
||||
func almost(actual: float, expected: float, tolerance: float, label: String) -> void:
|
||||
ok(
|
||||
absf(actual - expected) <= tolerance,
|
||||
label,
|
||||
"期望 %.6f ± %.6f,实际 %.6f(差 %.6f)" % [expected, tolerance, actual, absf(actual - expected)]
|
||||
)
|
||||
|
||||
|
||||
func between(actual: float, low: float, high: float, label: String) -> void:
|
||||
ok(actual >= low and actual <= high, label, "期望落在 [%.4f, %.4f],实际 %.4f" % [low, high, actual])
|
||||
|
||||
|
||||
func is_null(value: Variant, label: String) -> void:
|
||||
ok(value == null, label, "期望 null,实际 %s" % str(value))
|
||||
|
||||
|
||||
func not_null(value: Variant, label: String) -> void:
|
||||
ok(value != null, label, "期望非 null")
|
||||
|
||||
|
||||
## 跳过当前测试(依赖外部服务不可达等)。不计入通过,也不计入失败。
|
||||
func skip(label: String, reason: String) -> void:
|
||||
_runner.report_skip(label, reason)
|
||||
|
||||
|
||||
func contains(haystack: String, needle: String, label: String) -> void:
|
||||
ok(haystack.contains(needle), label, "「%s」中未包含「%s」" % [haystack, needle])
|
||||
@@ -0,0 +1,161 @@
|
||||
extends TestCase
|
||||
|
||||
## T-WIN-004:239 字节帧反序列化 —— 契约 contracts/websocket-keypoints.md §2
|
||||
##
|
||||
## 测试向量与 opendance-web 的 TypeScript 参考实现
|
||||
## (web-server/src/preprocess/keypoint-codec.ts)保持一致,
|
||||
## 保证两端对同一字节流的解释完全相同。
|
||||
|
||||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||||
|
||||
|
||||
func _sample_points() -> PackedVector3Array:
|
||||
var points := PackedVector3Array()
|
||||
points.resize(33)
|
||||
for i in 33:
|
||||
points[i] = Vector3(
|
||||
float(i % 33) / 32.0,
|
||||
1.0 - float(i % 33) / 32.0,
|
||||
float((i % 7) - 3) / 10.0
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
func _sample_confidence() -> PackedFloat32Array:
|
||||
var conf := PackedFloat32Array()
|
||||
conf.resize(33)
|
||||
for i in 33:
|
||||
conf[i] = 1.0 if i % 2 == 0 else 0.5
|
||||
return conf
|
||||
|
||||
|
||||
func test_constants_match_contract() -> void:
|
||||
eq(KeypointFrame.BINARY_SIZE, 239, "帧长度常量为 239")
|
||||
eq(KeypointFrame.KEYPOINT_COUNT, 33, "关键点数量为 33")
|
||||
eq(KeypointFrame.HEADER_SIZE + KeypointFrame.KEYPOINT_COUNT * KeypointFrame.POINT_BYTES, 239, "8 + 33×7 = 239")
|
||||
eq(KeypointFrame.MAGIC, 0x4F44, "magic 为 'OD'(0x4F44)")
|
||||
eq(KeypointFrame.PROTOCOL_VERSION, 1, "协议版本为 1")
|
||||
|
||||
|
||||
func test_encode_produces_239_bytes_with_correct_header() -> void:
|
||||
var buf := KeypointFrame.encode(42, _sample_points(), _sample_confidence(), 1)
|
||||
eq(buf.size(), 239, "编码结果恰好 239 字节")
|
||||
eq(buf.decode_u16(0), 0x4F44, "magic 正确")
|
||||
eq(buf.decode_u8(2), 1, "version 正确")
|
||||
eq(buf.decode_u8(3), 1, "flags 正确")
|
||||
eq(buf.decode_u32(4), 42, "frame_id 正确")
|
||||
|
||||
|
||||
func test_roundtrip_within_fixed_point_precision() -> void:
|
||||
var points := _sample_points()
|
||||
var conf := _sample_confidence()
|
||||
var decoded := KeypointFrame.decode(KeypointFrame.encode(7, points, conf, 0))
|
||||
|
||||
not_null(decoded, "解码成功")
|
||||
eq(decoded.frame_id, 7, "frame_id 往返正确")
|
||||
eq(decoded.points.size(), 33, "解码出 33 个点")
|
||||
|
||||
var max_delta := 0.0
|
||||
for i in 33:
|
||||
max_delta = maxf(max_delta, absf(decoded.points[i].x - points[i].x))
|
||||
max_delta = maxf(max_delta, absf(decoded.points[i].y - points[i].y))
|
||||
max_delta = maxf(max_delta, absf(decoded.points[i].z - points[i].z))
|
||||
# 定点精度:1/32767 ≈ 3.05e-5,留一倍余量
|
||||
ok(max_delta < 1e-4, "坐标往返误差小于定点精度", "最大误差 %.8f" % max_delta)
|
||||
|
||||
var max_conf_delta := 0.0
|
||||
for i in 33:
|
||||
max_conf_delta = maxf(max_conf_delta, absf(decoded.confidence[i] - conf[i]))
|
||||
ok(max_conf_delta < 1.0 / 255.0 + 1e-6, "置信度往返误差在一个量化步长内", "最大误差 %.8f" % max_conf_delta)
|
||||
|
||||
|
||||
func test_rejects_wrong_length() -> void:
|
||||
var short := PackedByteArray()
|
||||
short.resize(238)
|
||||
ne(KeypointFrame.validate(short), "", "238 字节被拒绝")
|
||||
contains(KeypointFrame.validate(short), "239", "错误信息说明期望长度")
|
||||
|
||||
var long := PackedByteArray()
|
||||
long.resize(240)
|
||||
ne(KeypointFrame.validate(long), "", "240 字节被拒绝")
|
||||
|
||||
is_null(KeypointFrame.decode(short), "解码失败返回 null")
|
||||
|
||||
|
||||
func test_rejects_bad_magic() -> void:
|
||||
var buf := PackedByteArray()
|
||||
buf.resize(239)
|
||||
ne(KeypointFrame.validate(buf), "", "全零帧因 magic 错误被拒绝")
|
||||
contains(KeypointFrame.validate(buf), "magic", "错误信息指出 magic")
|
||||
|
||||
|
||||
func test_rejects_unsupported_version() -> void:
|
||||
var buf := KeypointFrame.encode(0, _sample_points(), _sample_confidence())
|
||||
buf.encode_u8(2, 2)
|
||||
contains(KeypointFrame.validate(buf), "版本", "版本不符被拒绝")
|
||||
|
||||
|
||||
func test_extreme_values_do_not_overflow() -> void:
|
||||
var points := _sample_points()
|
||||
points[0] = Vector3(1.0, 1.0, -1.0)
|
||||
points[1] = Vector3(0.0, 0.0, 1.0)
|
||||
var conf := _sample_confidence()
|
||||
conf[0] = 1.0
|
||||
conf[1] = 0.0
|
||||
|
||||
var decoded := KeypointFrame.decode(KeypointFrame.encode(0, points, conf))
|
||||
almost(decoded.points[0].x, 1.0, 1e-4, "x = 1.0 不溢出 int16")
|
||||
almost(decoded.points[0].z, -1.0, 1e-4, "z = -1.0 不溢出 int16")
|
||||
almost(decoded.points[1].z, 1.0, 1e-4, "z = 1.0 不溢出 int16")
|
||||
almost(decoded.confidence[1], 0.0, 1e-6, "置信度 0 往返正确")
|
||||
|
||||
|
||||
func test_flags_helpers() -> void:
|
||||
var frame := KeypointFrame.decode(
|
||||
KeypointFrame.encode(0, _sample_points(), _sample_confidence(), KeypointFrame.FLAG_MIRRORED | KeypointFrame.FLAG_UPPER_BODY_ONLY)
|
||||
)
|
||||
is_true(frame.is_mirrored(), "镜像标志解析正确")
|
||||
is_true(frame.is_upper_body_only(), "上半身模式标志解析正确")
|
||||
is_false(frame.has_low_confidence(), "未设置低置信度标志")
|
||||
|
||||
|
||||
func test_mirrored_frame_x_is_flipped() -> void:
|
||||
var points := _sample_points()
|
||||
var frame := KeypointFrame.decode(
|
||||
KeypointFrame.encode(0, points, _sample_confidence(), KeypointFrame.FLAG_MIRRORED)
|
||||
)
|
||||
var flipped := frame.unmirrored_points()
|
||||
almost(flipped[5].x, 1.0 - points[5].x, 1e-4, "镜像帧翻转 x")
|
||||
# y 未经翻转,但仍经过 int16 定点往返,容差取定点精度
|
||||
almost(flipped[5].y, points[5].y, 1e-4, "翻转不影响 y")
|
||||
|
||||
var normal := KeypointFrame.decode(KeypointFrame.encode(0, points, _sample_confidence(), 0))
|
||||
almost(normal.unmirrored_points()[5].x, points[5].x, 1e-4, "非镜像帧不翻转 x")
|
||||
|
||||
|
||||
func test_confidence_threshold_marks_unreliable() -> void:
|
||||
var conf := _sample_confidence()
|
||||
conf[11] = 0.49
|
||||
conf[12] = 0.5
|
||||
var frame := KeypointFrame.decode(KeypointFrame.encode(0, _sample_points(), conf))
|
||||
is_false(frame.is_reliable(11), "0.49 判为不可信")
|
||||
is_true(frame.is_reliable(12), "0.5 判为可信(阈值含端点)")
|
||||
is_true(KeypointFrame.compute_low_confidence(_sample_points(), conf), "存在低置信度点时置位")
|
||||
|
||||
|
||||
func test_json_debug_frame() -> void:
|
||||
var raw_points: Array = []
|
||||
for i in 33:
|
||||
raw_points.append([0.25, 0.75, -0.1, 0.9])
|
||||
var frame := KeypointFrame.from_json_dict({
|
||||
"frameId": 9,
|
||||
"flags": {"mirrored": true, "upperBodyOnly": false},
|
||||
"points": raw_points,
|
||||
})
|
||||
not_null(frame, "JSON 调试帧解析成功")
|
||||
eq(frame.frame_id, 9, "frameId 解析正确")
|
||||
is_true(frame.is_mirrored(), "mirrored 标志解析正确")
|
||||
almost(frame.points[0].x, 0.25, 1e-6, "坐标解析正确")
|
||||
|
||||
var bad := KeypointFrame.from_json_dict({"frameId": 1, "points": []})
|
||||
is_null(bad, "点数不足的 JSON 帧被拒绝")
|
||||
Reference in New Issue
Block a user