feat(win): T-WIN-008/009/010 主界面/游戏/结算/来源展示/关于页

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 7776f68f0b
commit 13d6dc915d
8 changed files with 1217 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
extends TestCase
## T-WIN-002 集成测试:直接调用**真实运行中**的 Web 服务端。
##
## 若服务端不可达则整组跳过(不伪装成通过)。
## 启动服务端:cd opendance-web/web-server && npm run start:prod
##
## 覆盖:
## - GET /health、/api/catalog/version、/api/catalog/export 的真实响应
## - NDJSON 关键点序列解析
## - **跨实现交叉校验**:本端 PoseFeatures 计算的 9 个关节角
## 与服务端 TypeScript 实现算出的 angles 必须一致
const ApiClient = preload("res://src/net/api_client.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
const PoseFeatures = preload("res://src/score/pose_features.gd")
var _client: ApiClient = null
var _available := false
var _unavailable_reason := ""
func _ensure_client() -> bool:
if _client != null:
# 每次都要显式 skip:否则后续测试会静默 return,
# 被运行器判为「未执行任何断言」
if not _available:
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
var tree := Engine.get_main_loop() as SceneTree
_client = ApiClient.new("http://127.0.0.1:3000")
_client.timeout_seconds = 8.0
tree.root.add_child(_client)
var result: Dictionary = await _client.health()
_available = result["ok"] and int(result["status"]) == 200
if _available:
var data: Dictionary = result["data"]
ok(bool(data.get("database", {}).get("connected", false)), "服务端报告数据库已连接")
else:
_unavailable_reason = str(result.get("error", ""))
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
func test_health_reports_contract_version() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.health()
is_true(result["ok"], "GET /health 成功")
var version: Dictionary = result["data"].get("contractVersion", {})
eq(str(version.get("rest-api", "")), "0.1.0", "契约 rest-api 版本为 0.1.0")
eq(str(version.get("websocket-keypoints", "")), "0.1.0", "契约 websocket-keypoints 版本为 0.1.0")
eq(str(version.get("skeleton-topology", "")), "0.1.0", "契约 skeleton-topology 版本为 0.1.0")
func test_catalog_version_returns_revision() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_version()
is_true(result["ok"], "GET /api/catalog/version 成功")
var revision := str(result["data"].get("revision", ""))
eq(revision.length(), 64, "revision 为 64 位十六进制指纹")
func test_catalog_export_parses_into_typed_objects() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_export()
is_true(result["ok"], "GET /api/catalog/export 成功")
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(result["data"])
not_null(bundle, "目录解析成功")
eq(bundle.songs.size(), result["data"].get("songs", []).size(), "曲目数量一致")
eq(bundle.motions.size(), result["data"].get("motions", []).size(), "动作数量一致")
eq(bundle.models.size(), result["data"].get("models", []).size(), "模型数量一致")
# 契约 §4.1:defaultModelId 由服务端解析,客户端直接用
var resolved := bundle.default_model()
if bundle.models.size() > 0:
not_null(resolved, "存在已发布模型时可解析出默认模型")
if resolved != null:
eq(resolved.status, "published", "默认模型状态为 published")
else:
is_null(resolved, "无已发布模型时默认模型为 null")
# 契约要求来源五元组齐备
for song in bundle.songs:
is_true(song.source.is_complete(), "曲目「%s」来源五元组齐备" % song.title)
for model in bundle.models:
is_true(model.source.is_complete(), "模型「%s」来源五元组齐备" % model.name)
func test_keypoint_sequence_matches_contract_and_local_math() -> void:
if not await _ensure_client():
return
var export_result: Dictionary = await _client.catalog_export()
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(export_result["data"])
var target: CatalogTypes.Motion = null
for motion in bundle.motions:
if motion.is_scorable():
target = motion
break
if target == null:
skip("关键点序列", "服务端暂无已完成预处理的动作(先上传并预处理一个 BVH)")
return
eq(target.keypoint_format, KeypointSequence.EXPECTED_FORMAT, "keypointFormat 与契约一致")
var sequence_result: Dictionary = await _client.motion_keypoints(target.id)
is_true(sequence_result["ok"], "GET /api/motions/%d/keypoints 成功" % target.id)
var sequence: KeypointSequence.ParsedSequence = KeypointSequence.parse_ndjson(str(sequence_result["data"]))
eq(sequence.skipped_lines, 0, "NDJSON 无跳过行")
eq(sequence.frame_count(), target.keypoint_frames, "帧数与 motion.keypointFrames 一致")
ok(sequence.frame_count() > 0, "序列非空")
# 每帧 33 点(一次性校验,避免刷屏式断言)
var shape_ok := true
for frame in sequence.frames:
if frame.points.size() != 33 or frame.confidence.size() != 33:
shape_ok = false
break
is_true(shape_ok, "每帧均为 33 个关键点 + 33 个置信度")
# 坐标落在 MediaPipe 归一化范围
var first: KeypointFrame.PoseFrame = sequence.frames[0]
var in_range := true
for p in first.points:
if p.x < -0.001 or p.x > 1.001 or p.y < -0.001 or p.y > 1.001:
in_range = false
break
is_true(in_range, "关键点坐标落在归一化范围")
# 帧率推导合理
var fps := sequence.estimated_fps()
ok(fps > 1.0 and fps < 240.0, "由时间戳推导的帧率合理", "实际 %.2f" % fps)
# ★ 跨实现交叉校验:服务端 TypeScript 与本端 GDScript 的关节角必须一致。
# GDScript 的 % 运算符不支持 %e,故用 str() 输出浮点。
var comparison := KeypointSequence.compare_angles(sequence, 1e-3)
ok(int(comparison["checked"]) > 0, "存在可比对的服务端关节角", "比对帧数 %d" % int(comparison["checked"]))
ok(
int(comparison["mismatched_frames"]) == 0,
"所有帧的 9 个关节角与服务端实现一致",
"不一致帧数 %d,最大偏差 %s" % [int(comparison["mismatched_frames"]), str(comparison["max_delta"])]
)
ok(float(comparison["max_delta"]) < 1e-3, "关节角最大偏差小于 1e-3", "实际 %s" % str(comparison["max_delta"]))
func test_unknown_route_returns_structured_error() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.get_json("/api/songs/99999999")
is_false(result["ok"], "不存在的资源返回失败")
eq(int(result["status"]), 404, "状态码为 404")
ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"]))
+75
View File
@@ -0,0 +1,75 @@
extends TestCase
## T-WIN-010:开源声明一致性与完整性
const Credits = preload("res://src/ui/credits.gd")
## 文档 §8.2 模板中的最低 10 项
const REQUIRED_NAMES := [
"MediaPipe", "Three.js", "@pixiv/three-vrm", "Assimp", "Godot Engine",
"godot-vrm", "NestJS", "TypeORM", "OkHttp", "bvh",
]
func test_credits_cover_documented_minimum_set() -> void:
var names: Array = []
for entry in Credits.ENTRIES:
names.append(str(entry["name"]))
for required in REQUIRED_NAMES:
ok(names.has(required), "文档要求的开源项目「%s」已声明" % required)
ok(Credits.entry_count() >= 10, "声明条目不少于文档要求的 10 项", "实际 %d" % Credits.entry_count())
func test_every_entry_has_license_and_source_link() -> void:
for entry in Credits.ENTRIES:
var name := str(entry["name"])
ne(str(entry["license"]), "", "%s 有许可证" % name)
var url := str(entry["url"])
ok(url.begins_with("https://"), "%s 的源链接为 https" % name, url)
ne(str(entry["usage"]), "", "%s 注明了用途" % name)
func test_known_licenses_are_correct() -> void:
var expected := {
"MediaPipe": "Apache License 2.0",
"Godot Engine": "MIT",
"Assimp": "BSD-3-Clause",
"OkHttp": "Apache License 2.0",
"bvh": "MIT",
}
for entry in Credits.ENTRIES:
var name := str(entry["name"])
if expected.has(name):
eq(str(entry["license"]), expected[name], "%s 的许可证正确" % name)
func test_bbcode_contains_all_entries_and_no_smpl() -> void:
var text := Credits.to_bbcode()
for entry in Credits.ENTRIES:
contains(text, str(entry["name"]), "BBCode 含 %s" % str(entry["name"]))
contains(text, str(entry["url"]), "BBCode 含 %s 的链接" % str(entry["name"]))
contains(text, "SMPL", "BBCode 明确声明未使用 SMPL")
contains(text, "第三方开源项目声明", "BBCode 含声明标题")
contains(text, Credits.CONTRACT_VERSION, "BBCode 含契约版本")
func test_plain_text_export_is_parseable() -> void:
var text := Credits.to_plain_text()
var lines := text.split("\n")
eq(lines.size(), Credits.entry_count(), "纯文本导出行数等于条目数")
for line in lines:
var parts := line.split(" | ")
eq(parts.size(), 3, "每行三段:名称 | 许可证 | 链接")
func test_contract_version_matches_snapshot() -> void:
# contracts/VERSION 是唯一来源;本端 README/关于页/THIRD_PARTY 都必须与之一致
var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ)
if file == null:
ok(false, "无法读取 contracts/VERSION 快照")
return
var content := file.get_as_text()
file.close()
contains(content, "rest-api: %s" % Credits.CONTRACT_VERSION, "契约快照 rest-api 版本一致")
contains(content, "websocket-keypoints: %s" % Credits.CONTRACT_VERSION, "契约快照 websocket-keypoints 版本一致")
contains(content, "skeleton-topology: %s" % Credits.CONTRACT_VERSION, "契约快照 skeleton-topology 版本一致")