From 13d6dc915d7cd3422fa9d7c090894523b5c90f02 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 20:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(win):=20T-WIN-008/009/010=20=E4=B8=BB?= =?UTF-8?q?=E7=95=8C=E9=9D=A2/=E6=B8=B8=E6=88=8F/=E7=BB=93=E7=AE=97/?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E5=B1=95=E7=A4=BA/=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/about_screen.gd | 56 ++++++ src/ui/credits.gd | 63 ++++++ src/ui/game_screen.gd | 355 ++++++++++++++++++++++++++++++++++ src/ui/main_menu.gd | 301 ++++++++++++++++++++++++++++ src/ui/result_screen.gd | 115 +++++++++++ src/ui/source_panel.gd | 91 +++++++++ tests/test_api_integration.gd | 161 +++++++++++++++ tests/test_credits.gd | 75 +++++++ 8 files changed, 1217 insertions(+) create mode 100644 src/ui/about_screen.gd create mode 100644 src/ui/credits.gd create mode 100644 src/ui/game_screen.gd create mode 100644 src/ui/main_menu.gd create mode 100644 src/ui/result_screen.gd create mode 100644 src/ui/source_panel.gd create mode 100644 tests/test_api_integration.gd create mode 100644 tests/test_credits.gd diff --git a/src/ui/about_screen.gd b/src/ui/about_screen.gd new file mode 100644 index 0000000..4ffe3a3 --- /dev/null +++ b/src/ui/about_screen.gd @@ -0,0 +1,56 @@ +class_name AboutScreen +extends Control + +## T-WIN-010:关于页面 + 开源声明(可滚动) +## 文档 §8.3 要求 Windows 客户端与 Android App 的「关于」页展示同款声明。 + +const UiKit = preload("res://src/ui/ui_kit.gd") +const Credits = preload("res://src/ui/credits.gd") + +var _on_back: Callable = Callable() + + +func _init(on_back: Callable = Callable()) -> void: + _on_back = on_back + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + + var root := UiKit.vbox(10) + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.offset_left = 24 + root.offset_top = 18 + root.offset_right = -24 + root.offset_bottom = -18 + add_child(root) + + var header := UiKit.hbox(12) + header.add_child(UiKit.title("关于 OpenDance Studio")) + header.add_child(UiKit.expander()) + var back := UiKit.button("返回主菜单", func(): if _on_back.is_valid(): _on_back.call()) + header.add_child(back) + root.add_child(header) + + var info := UiKit.panel() + var info_box := UiKit.vbox(2) + info_box.add_child(UiKit.label("Windows 客户端 v%s" % ProjectSettings.get_setting("application/config/version", "0.1.0"), UiKit.FONT_HEADING)) + info_box.add_child(UiKit.muted("摄像头跳舞评分系统:接收 Android 辅助 App 通过 WebSocket 推送的 33 点骨骼关键点,驱动 VRM 角色并实时 DTW 评分。")) + info_box.add_child(UiKit.spacer(4)) + info_box.add_child(UiKit.muted("契约版本:rest-api / data-models / skeleton-topology / websocket-keypoints / model-formats = v%s" % Credits.CONTRACT_VERSION)) + info_box.add_child(UiKit.muted("本客户端仅接收关键点,不接收、不存储任何视频画面。")) + info.add_child(info_box) + root.add_child(info) + + var credits_panel := UiKit.panel() + var credits_box := UiKit.vbox(4) + credits_box.add_child(UiKit.heading("开源项目声明")) + credits_box.add_child(UiKit.muted("共 %d 项;与 contracts/open-source-credits.md 及 THIRD_PARTY.md 保持一致。" % Credits.entry_count())) + var text := UiKit.rich_text() + text.bbcode_enabled = true + text.text = Credits.to_bbcode() + text.custom_minimum_size = Vector2(0, 320) + credits_box.add_child(text) + credits_panel.add_child(credits_box) + credits_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + root.add_child(credits_panel) diff --git a/src/ui/credits.gd b/src/ui/credits.gd new file mode 100644 index 0000000..b06ec25 --- /dev/null +++ b/src/ui/credits.gd @@ -0,0 +1,63 @@ +class_name Credits +extends RefCounted + +## T-WIN-010:开源声明数据 +## +## 与 contracts/open-source-credits.md(v0.1.0)逐条对应; +## 该表是**唯一来源**,任一端新增依赖须先回到 opendance-web 更新契约, +## 再同步本文件与 THIRD_PARTY.md。 +## +## 关于页面必须展示完整列表并可滚动查看(文档 §8.3)。 + +const ENTRIES := [ + {"name": "MediaPipe", "license": "Apache License 2.0", "url": "https://github.com/google-ai-edge/mediapipe", "copyright": "Copyright 2023 Google LLC", "usage": "33 点骨骼拓扑定义(契约来源,本端不含其代码)"}, + {"name": "Three.js", "license": "MIT", "url": "https://github.com/mrdoob/three.js", "copyright": "", "usage": "Web 管理端 3D 预览"}, + {"name": "@pixiv/three-vrm", "license": "MIT", "url": "https://github.com/pixiv/three-vrm", "copyright": "", "usage": "Web 管理端 VRM 加载"}, + {"name": "Assimp", "license": "BSD-3-Clause", "url": "https://github.com/assimp/assimp", "copyright": "", "usage": "FBX/glTF 动作转换(可选)"}, + {"name": "Godot Engine", "license": "MIT", "url": "https://github.com/godotengine/godot", "copyright": "Copyright Godot Engine contributors", "usage": "本客户端运行引擎与内置 glTF/VRM 导入器"}, + {"name": "godot-vrm", "license": "MIT", "url": "https://github.com/V-Sekai/godot-vrm", "copyright": "", "usage": "VRM humanoid 扩展(本端改为按名映射,未使用其二进制)"}, + {"name": "NestJS", "license": "MIT", "url": "https://github.com/nestjs/nest", "copyright": "", "usage": "Web 服务端"}, + {"name": "TypeORM", "license": "MIT", "url": "https://github.com/typeorm/typeorm", "copyright": "", "usage": "Web 服务端 ORM"}, + {"name": "OkHttp", "license": "Apache License 2.0", "url": "https://github.com/square/okhttp", "copyright": "", "usage": "Android 辅助 App 通信"}, + {"name": "bvh", "license": "MIT", "url": "https://github.com/aimoonchen/bvh", "copyright": "", "usage": "BVH 解析参考实现"}, + {"name": "React", "license": "MIT", "url": "https://github.com/facebook/react", "copyright": "", "usage": "Web 管理端界面"}, + {"name": "Vite", "license": "MIT", "url": "https://github.com/vitejs/vite", "copyright": "", "usage": "Web 管理端构建"}, + {"name": "CameraX", "license": "Apache License 2.0", "url": "https://developer.android.com/jetpack/androidx/releases/camera", "copyright": "", "usage": "Android 相机采集"}, + {"name": "Jetpack Compose", "license": "Apache License 2.0", "url": "https://developer.android.com/jetpack/compose", "copyright": "", "usage": "Android 界面"}, + {"name": "mysql2", "license": "MIT", "url": "https://github.com/sidorares/node-mysql2", "copyright": "", "usage": "Web 服务端数据库驱动"}, +] + +## 契约版本(与 contracts/VERSION 对齐) +const CONTRACT_VERSION := "0.1.0" + + +## 渲染为 BBCode(用于 RichTextLabel) +static func to_bbcode() -> String: + var lines := PackedStringArray() + lines.append("[b][font_size=20]第三方开源项目声明[/font_size][/b]") + lines.append("") + lines.append("本软件使用了以下开源项目,特此声明。契约版本 v%s。" % CONTRACT_VERSION) + lines.append("") + for i in ENTRIES.size(): + var entry: Dictionary = ENTRIES[i] + lines.append("[b]%d. %s[/b]" % [i + 1, entry["name"]]) + if str(entry["copyright"]) != "": + lines.append("  版权:%s" % entry["copyright"]) + lines.append("  许可证:%s" % entry["license"]) + lines.append("  用途:%s" % entry["usage"]) + lines.append("  源链接:[url=%s]%s[/url]" % [entry["url"], entry["url"]]) + lines.append("") + lines.append("[i]未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑,以规避非商用许可限制。[/i]") + return "\n".join(lines) + + +## 导出为纯文本(用于 THIRD_PARTY.md 一致性自检) +static func to_plain_text() -> String: + var lines := PackedStringArray() + for entry in ENTRIES: + lines.append("%s | %s | %s" % [entry["name"], entry["license"], entry["url"]]) + return "\n".join(lines) + + +static func entry_count() -> int: + return ENTRIES.size() diff --git a/src/ui/game_screen.gd b/src/ui/game_screen.gd new file mode 100644 index 0000000..3da46a3 --- /dev/null +++ b/src/ui/game_screen.gd @@ -0,0 +1,355 @@ +class_name GameScreen +extends Control + +## T-WIN-008:游戏界面(3D 角色 + 实时评分 HUD) +## T-WIN-009:来源信息展示 + +const UiKit = preload("res://src/ui/ui_kit.gd") +const Scoring = preload("res://src/score/scoring.gd") +const VrmLoader = preload("res://src/model/vrm_loader.gd") +const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd") +const SourcePanel = preload("res://src/ui/source_panel.gd") +const CatalogTypes = preload("res://src/net/catalog_types.gd") + +enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED } + +var state: State = State.IDLE +var vrm: VrmLoader.VrmModel = null +var retarget: SkeletonRetarget = null + +var _viewport: SubViewport +var _camera: Camera3D +var _model_host: Node3D +var _score_label: Label +var _grade_label: Label +var _combo_label: Label +var _progress: ProgressBar +var _status_label: Label +var _hint_label: Label +var _source_panel: SourcePanel +var _start_button: Button +var _stop_button: Button + +var _motion: CatalogTypes.Motion = null +var _song: CatalogTypes.Song = null +var _model: CatalogTypes.Model = null +var _good_frames: int = 0 +var _total_frames: int = 0 + +var _on_exit: Callable = Callable() +var _on_finished: Callable = Callable() + + +func _init(on_exit: Callable = Callable(), on_finished: Callable = Callable()) -> void: + _on_exit = on_exit + _on_finished = on_finished + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + name = "GameScreen" + + var root := UiKit.hbox(14) + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.offset_left = 20 + root.offset_top = 16 + root.offset_right = -20 + root.offset_bottom = -16 + add_child(root) + + # ---- 左侧:3D 视图 ---- + var view_container := SubViewportContainer.new() + view_container.stretch = true + view_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + view_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + + _viewport = SubViewport.new() + _viewport.size = Vector2i(960, 720) + _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + _viewport.transparent_bg = false + view_container.add_child(_viewport) + root.add_child(view_container) + + _env_setup() + + # ---- 右侧:HUD ---- + var side := UiKit.vbox(10) + side.custom_minimum_size = Vector2(340, 0) + root.add_child(side) + + side.add_child(UiKit.title("评分")) + + var score_card := UiKit.panel() + var score_box := UiKit.vbox(4) + _score_label = UiKit.label("—", 52, UiKit.COLOR_ACCENT) + _score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + score_box.add_child(_score_label) + _grade_label = UiKit.label("等待开始", UiKit.FONT_BODY, UiKit.COLOR_MUTED) + _grade_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + score_box.add_child(_grade_label) + _combo_label = UiKit.label("连击 0", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + _combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + score_box.add_child(_combo_label) + score_card.add_child(score_box) + side.add_child(score_card) + + side.add_child(UiKit.label("动作进度", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)) + _progress = UiKit.progress_bar(0.0) + side.add_child(_progress) + + _status_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + side.add_child(_status_label) + + _hint_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + _hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + side.add_child(_hint_label) + + _source_panel = SourcePanel.new("动作来源") + side.add_child(_source_panel) + + side.add_child(UiKit.expander()) + + var actions := UiKit.hbox(8) + _start_button = UiKit.button("开始跟练", _on_start_pressed) + _stop_button = UiKit.button("结束本轮", _on_stop_pressed) + _stop_button.disabled = true + actions.add_child(_start_button) + actions.add_child(_stop_button) + side.add_child(actions) + side.add_child(UiKit.button("返回主菜单", func(): if _on_exit.is_valid(): _on_exit.call())) + + PoseReceiver.frame_received.connect(_on_frame_received) + ScoreService.score_updated.connect(_on_score_updated) + PoseReceiver.client_connected.connect(func(_d): _refresh_connection_hint()) + PoseReceiver.client_disconnected.connect(func(_r): _refresh_connection_hint()) + + _refresh_connection_hint() + + +func _env_setup() -> void: + var world := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = UiKit.COLOR_BG + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(1, 1, 1) + env.ambient_light_energy = 0.9 + world.environment = env + _viewport.add_child(world) + + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-35, 40, 0) + light.light_energy = 1.2 + _viewport.add_child(light) + + _camera = Camera3D.new() + _camera.position = Vector3(0, 1.35, 2.4) + _camera.rotation_degrees = Vector3(-6, 0, 0) + _camera.fov = 40.0 + _viewport.add_child(_camera) + + _model_host = Node3D.new() + _viewport.add_child(_model_host) + + +## 设置本轮要跟练的曲目与动作,并加载模型 +func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model) -> void: + _song = song + _motion = motion + _model = model + _good_frames = 0 + _total_frames = 0 + state = State.PREPARING + _progress.value = 0.0 + _score_label.text = "—" + _grade_label.text = "正在准备…" + _start_button.disabled = true + _stop_button.disabled = true + + _source_panel.show_source(motion.source if motion != null else null, motion.name if motion != null else "") + _set_status("正在下载并加载模型…") + + _clear_model() + + if model == null or model.file_url == "": + _fail("目录中没有可用模型(未发布任何模型)") + return + + var cached: Dictionary = await CatalogStore.ensure_model_cached(model) + if not cached["ok"]: + _fail("模型下载失败:%s" % str(cached["error"])) + return + + var path := str(cached["path"]) + vrm = VrmLoader.load_from_file(path) + if not vrm.available or vrm.skeleton == null: + _fail("模型加载失败:%s" % (vrm.error if vrm.error != "" else "缺少骨骼")) + return + + vrm.root.visible = true + _model_host.add_child(vrm.root) + _frame_camera_on(vrm.root) + + retarget = SkeletonRetarget.new(vrm.skeleton, vrm.bones) + retarget.capture_rest() + if not retarget.is_bound(): + _fail("骨骼绑定失败,模型可能不受支持") + return + + var coverage := vrm.coverage() * 100.0 + _set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)" % [ + model.name, vrm.vrm_version if vrm.vrm_version != "" else "—", vrm.bone_count, coverage, + ]) + if coverage < 60.0: + _grade_label.text = "骨骼覆盖偏低,动作可能不完整" + + state = State.IDLE + _start_button.disabled = false + _refresh_connection_hint() + + +## 让相机框住模型 +func _frame_camera_on(root: Node3D) -> void: + var aabb := _compute_aabb(root) + if aabb.size == Vector3.ZERO: + return + var height := aabb.size.y + var center := aabb.get_center() + _camera.position = center + Vector3(0, 0, maxf(height * 1.5, 2.0)) + _camera.rotation_degrees = Vector3(0, 0, 0) + # 略微俯视,看向模型上半身 + _camera.position.y = center.y + height * 0.15 + _camera.look_at(center + Vector3(0, height * 0.02, 0), Vector3.UP) + + +func _compute_aabb(node: Node) -> AABB: + var result := AABB() + var first := true + if node is MeshInstance3D: + var mesh := (node as MeshInstance3D).get_aabb() + var world := (node as MeshInstance3D).global_transform + # 用节点自身变换近似(骨架绑定姿态下足够用于定机位) + result = world * mesh + first = false + for child in node.get_children(): + var child_aabb := _compute_aabb(child) + if child_aabb.size == Vector3.ZERO: + continue + if first: + result = child_aabb + first = false + else: + result = result.merge(child_aabb) + return result + + +func _on_start_pressed() -> void: + if _motion == null: + _fail("未选择动作") + return + if not PoseReceiver.is_listening(): + PoseReceiver.start() + if PoseReceiver.connected_client_count() == 0: + _set_status("尚未连接 Android 辅助 App,已开始计时但暂无可评分数据。") + + _set_status("正在拉取参考动作序列…") + _start_button.disabled = true + + var fetched: Dictionary = await CatalogStore.fetch_sequence(_motion.id) + if not fetched["ok"]: + _fail("参考序列拉取失败:%s" % str(fetched["error"])) + _start_button.disabled = false + return + + var sequence = fetched["sequence"] + ScoreService.start_session(sequence.features, _motion.id, AppConfig.score_tolerance) + state = State.RUNNING + _stop_button.disabled = false + _progress.value = 0.0 + _set_status("跟练进行中:参考 %d 帧(约 %.1f 秒)" % [ + sequence.frame_count(), sequence.duration_ms() / 1000.0, + ]) + + +func _on_stop_pressed() -> void: + if state != State.RUNNING: + return + _finish_session("已手动结束") + + +func _finish_session(reason: String) -> void: + var summary := ScoreService.finish() + state = State.FINISHED + _stop_button.disabled = true + _start_button.disabled = false + _set_status("本轮结束(%s)" % reason) + if retarget != null: + retarget.reset() + if _on_finished.is_valid(): + _on_finished.call(summary, _motion.source if _motion != null else null, _motion.name if _motion != null else "") + + +func _on_frame_received(frame: Object) -> void: + if state != State.RUNNING or retarget == null: + return + retarget.apply(frame, 4.0 / 3.0) + _total_frames += 1 + + +func _on_score_updated(result: Dictionary) -> void: + var score := float(result.get("score", 0.0)) + _score_label.text = "%d" % int(round(score)) + _score_label.add_theme_color_override("font_color", Scoring.grade_color(score)) + _grade_label.text = "%s | %s" % [Scoring.grade(score), Scoring.grade_label(score)] + _combo_label.text = "连击 %d | 最高连击 %d" % [int(result.get("combo", 0)), int(result.get("max_combo", 0))] + _progress.value = float(result.get("progress", 0.0)) * 100.0 + if score >= Scoring.COMBO_THRESHOLD: + _good_frames += 1 + + # 参考序列走完自动结算 + if bool(result.get("progress", 0.0) >= 0.999): + _finish_session("动作已完成") + + +func _refresh_connection_hint() -> void: + var count := PoseReceiver.connected_client_count() + if count > 0: + _hint_label.text = "● 已连接 %d 台设备:%s" % [count, AppConfig.listen_url()] + _hint_label.add_theme_color_override("font_color", UiKit.COLOR_GOOD) + elif PoseReceiver.is_listening(): + _hint_label.text = "○ 等待 Android 辅助 App 连接 %s" % AppConfig.listen_url() + _hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED) + else: + _hint_label.text = "○ 接收服务未启动(将在开始跟练时自动启动 %s)" % AppConfig.listen_url() + _hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED) + + +func _set_status(text: String) -> void: + _status_label.text = text + + +func _fail(message: String) -> void: + state = State.FAILED + _set_status(message) + _grade_label.text = "无法开始" + _start_button.disabled = true + _stop_button.disabled = true + push_warning("[GameScreen] %s" % message) + + +func _clear_model() -> void: + if vrm != null: + if vrm.root != null and is_instance_valid(vrm.root) and vrm.root.get_parent() == _model_host: + _model_host.remove_child(vrm.root) + vrm.dispose() + vrm = null + retarget = null + + +func _exit_tree() -> void: + if PoseReceiver.frame_received.is_connected(_on_frame_received): + PoseReceiver.frame_received.disconnect(_on_frame_received) + if ScoreService.score_updated.is_connected(_on_score_updated): + ScoreService.score_updated.disconnect(_on_score_updated) + _clear_model() diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd new file mode 100644 index 0000000..fd76980 --- /dev/null +++ b/src/ui/main_menu.gd @@ -0,0 +1,301 @@ +class_name MainMenuScreen +extends Control + +## T-WIN-008:主界面(曲目/动作选择 + 连接状态 + 设置) +## T-WIN-009:来源信息展示 + +const UiKit = preload("res://src/ui/ui_kit.gd") +const CatalogTypes = preload("res://src/net/catalog_types.gd") +const SourcePanel = preload("res://src/ui/source_panel.gd") + +var _song_list: ItemList +var _motion_list: ItemList +var _model_list: ItemList +var _source_panel: SourcePanel +var _status: Label +var _health: Label +var _api_input: LineEdit +var _start_button: Button + +var _songs: Array = [] +var _motions: Array = [] +var _models: Array = [] +var _selected_motion: CatalogTypes.Motion = null +var _selected_model: CatalogTypes.Model = null + +var _on_start: Callable = Callable() +var _on_about: Callable = Callable() +var _on_open_settings: Callable = Callable() + + +func _init(on_start: Callable = Callable(), on_about: Callable = Callable()) -> void: + _on_start = on_start + _on_about = on_about + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + name = "MainMenu" + + var root := UiKit.vbox(12) + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.offset_left = 24 + root.offset_top = 18 + root.offset_right = -24 + root.offset_bottom = -18 + add_child(root) + + # ---- 顶栏 ---- + var header := UiKit.hbox(12) + header.add_child(UiKit.title("OpenDance Studio")) + header.add_child(UiKit.expander()) + _health = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + header.add_child(_health) + header.add_child(UiKit.button("刷新目录", _refresh_catalog)) + header.add_child(UiKit.button("开源声明", func(): if _on_about.is_valid(): _on_about.call())) + root.add_child(header) + + # ---- API 地址 ---- + var api_row := UiKit.hbox(10) + api_row.add_child(UiKit.label("服务端地址", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)) + _api_input = UiKit.line_edit("http://127.0.0.1:3000", AppConfig.api_base_url) + api_row.add_child(_api_input) + api_row.add_child(UiKit.button("保存并测试", _apply_api_base)) + api_row.add_child(UiKit.expander()) + var listen := UiKit.label("接收地址 %s" % AppConfig.listen_url(), UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + api_row.add_child(listen) + root.add_child(api_row) + + # ---- 三栏选择 ---- + var columns := UiKit.hbox(12) + columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + + var song_column := _make_list_column("1. 选择曲目") + _song_list = song_column["list"] + columns.add_child(_wrap_in_panel(song_column["box"])) + + var motion_column := _make_list_column("2. 选择动作(已完成预处理)") + _motion_list = motion_column["list"] + columns.add_child(_wrap_in_panel(motion_column["box"])) + + var model_column := _make_list_column("3. 选择模型(VRM 优先)") + _model_list = model_column["list"] + columns.add_child(_wrap_in_panel(model_column["box"])) + + root.add_child(columns) + + # ---- 来源 + 操作 ---- + var bottom := UiKit.hbox(14) + _source_panel = SourcePanel.new("动作来源") + _source_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bottom.add_child(_source_panel) + + var actions := UiKit.vbox(8) + _start_button = UiKit.button("开始跟练", _on_start_pressed) + _start_button.custom_minimum_size = Vector2(180, 46) + _start_button.disabled = true + actions.add_child(_start_button) + actions.add_child(UiKit.button("重新连接接收服务", _restart_receiver)) + actions.add_child(UiKit.button("查看接收状态", _report_receiver)) + bottom.add_child(actions) + root.add_child(bottom) + + _status = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + root.add_child(_status) + + _song_list.item_selected.connect(_on_song_selected) + _motion_list.item_selected.connect(_on_motion_selected) + _model_list.item_selected.connect(_on_model_selected) + + CatalogStore.catalog_loaded.connect(_rebuild_lists) + CatalogStore.catalog_failed.connect(_on_catalog_failed) + + _refresh_catalog() + + +## 把任意 Control 包进带背景的面板 +func _wrap_in_panel(content: Control) -> PanelContainer: + var panel := UiKit.panel() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_child(content) + return panel + + +## 生成「标题 + 列表」的一列,返回 { list, box } +func _make_list_column(title: String) -> Dictionary: + var box := UiKit.vbox(6) + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + box.size_flags_vertical = Control.SIZE_EXPAND_FILL + box.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT)) + var list := ItemList.new() + list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list.size_flags_vertical = Control.SIZE_EXPAND_FILL + list.custom_minimum_size = Vector2(260, 260) + list.allow_reselect = true + box.add_child(list) + return {"list": list, "box": box} + + +func _refresh_catalog() -> void: + _set_status("正在从 %s 拉取目录…" % AppConfig.api_base_url) + _start_button.disabled = true + var health_result: Dictionary = await CatalogStore.check_health() + if health_result["ok"]: + var db: Dictionary = health_result["data"].get("database", {}) + _health.text = "服务端正常 | DB %s" % str(db.get("server", "?")) + _health.add_theme_color_override("font_color", UiKit.COLOR_GOOD) + else: + _health.text = "服务端不可达" + _health.add_theme_color_override("font_color", UiKit.COLOR_BAD) + + var result: Dictionary = await CatalogStore.refresh() + if not result["ok"]: + _on_catalog_failed(str(result["error"])) + + +func _on_catalog_failed(message: String) -> void: + _set_status("目录拉取失败:%s" % message) + _health.text = "目录拉取失败" + _health.add_theme_color_override("font_color", UiKit.COLOR_BAD) + + +func _rebuild_lists() -> void: + _songs = CatalogStore.songs() + _motions = CatalogStore.motions() + _models = CatalogStore.models() + + _song_list.clear() + for song in _songs: + _song_list.add_item("%s — %s" % [song.title, song.artist]) + + _rebuild_motion_list(-1) + + _model_list.clear() + var default_model := CatalogStore.default_model() + for model in _models: + var tag := " ★默认" if model.is_primary else "" + if default_model != null and model.id == default_model.id and not model.is_primary: + tag = " ★服务端推荐" + _model_list.add_item("%s [%s%s]%s" % [ + model.name, model.format.to_upper(), (" v" + model.vrm_version) if model.vrm_version != "" else "", tag, + ]) + if _models.size() > 0: + var index := 0 + if default_model != null: + for i in _models.size(): + if _models[i].id == default_model.id: + index = i + break + _model_list.select(index) + _selected_model = _models[index] + + _set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型(契约 v%s)" % [ + _songs.size(), _motions.size(), _models.size(), + str(CatalogStore.contract_version.get("data-models", "?")), + ]) + _update_start_state() + + +func _rebuild_motion_list(song_index: int) -> void: + _motion_list.clear() + var filtered: Array = [] + if song_index >= 0 and song_index < _songs.size(): + var song = _songs[song_index] + for motion in CatalogStore.scorable_motions_for_song(song.id): + filtered.append(motion) + if filtered.is_empty(): + # 该曲目没有可评分动作时,退回展示全部可评分动作 + filtered = _all_scorable() + else: + filtered = _all_scorable() + + for motion in filtered: + _motion_list.add_item("%s(%d 帧)" % [motion.name, motion.keypoint_frames]) + _motions = filtered + + if filtered.size() > 0: + _motion_list.select(0) + _selected_motion = filtered[0] + _source_panel.show_source(_selected_motion.source, _selected_motion.name) + else: + _selected_motion = null + _source_panel.clear() + _update_start_state() + + +func _all_scorable() -> Array: + var out: Array = [] + for motion in CatalogStore.motions(): + if motion.is_scorable(): + out.append(motion) + return out + + +func _on_song_selected(index: int) -> void: + _rebuild_motion_list(index) + + +func _on_motion_selected(index: int) -> void: + if index >= 0 and index < _motions.size(): + _selected_motion = _motions[index] + _source_panel.show_source(_selected_motion.source, _selected_motion.name) + _update_start_state() + + +func _on_model_selected(index: int) -> void: + if index >= 0 and index < _models.size(): + _selected_model = _models[index] + _update_start_state() + + +func _update_start_state() -> void: + _start_button.disabled = _selected_motion == null + + +func _on_start_pressed() -> void: + if _selected_motion == null or not _on_start.is_valid(): + return + var song = null + if _selected_motion.song_id >= 0: + song = CatalogStore.bundle.find_song(_selected_motion.song_id) if CatalogStore.bundle != null else null + _on_start.call(song, _selected_motion, _selected_model) + + +func _apply_api_base() -> void: + var value := _api_input.text.strip_edges() + if value == "": + _set_status("服务端地址不能为空") + return + AppConfig.apply_settings({"api_base_url": value}) + _api_input.text = AppConfig.api_base_url + _set_status("已保存服务端地址:%s" % AppConfig.api_base_url) + await _refresh_catalog() + + +func _restart_receiver() -> void: + PoseReceiver.stop() + var err := PoseReceiver.start() + if err == OK: + _set_status("接收服务已启动:%s" % AppConfig.listen_url()) + else: + _set_status("接收服务启动失败(端口 %d 可能被占用)" % AppConfig.listen_port) + + +func _report_receiver() -> void: + var snap := PoseReceiver.snapshot() + if snap.is_empty(): + _set_status("接收服务未初始化") + return + _set_status("接收服务:%s,端口 %d,连接 %d,累计帧 %d,丢帧 %d,畸形帧 %d" % [ + "运行中" if bool(snap.get("running", false)) else "已停止", + int(snap.get("port", 0)), + int(snap.get("connected", 0)), + int(snap.get("frames_total", 0)), + int(snap.get("frames_dropped", 0)), + int(snap.get("malformed_frames", 0)), + ]) + + +func _set_status(text: String) -> void: + _status.text = text diff --git a/src/ui/result_screen.gd b/src/ui/result_screen.gd new file mode 100644 index 0000000..ae853a5 --- /dev/null +++ b/src/ui/result_screen.gd @@ -0,0 +1,115 @@ +class_name ResultScreen +extends Control + +## T-WIN-008:结算界面 + +const UiKit = preload("res://src/ui/ui_kit.gd") +const Scoring = preload("res://src/score/scoring.gd") +const SourcePanel = preload("res://src/ui/source_panel.gd") + +var _summary: Dictionary = {} +var _source: Object = null +var _asset_name: String = "" + +var _grade_label: Label +var _score_label: Label +var _stats_box: VBoxContainer +var _source_panel: SourcePanel + +var _on_retry: Callable = Callable() +var _on_back: Callable = Callable() +var _on_about: Callable = Callable() + + +func _init(on_retry: Callable = Callable(), on_back: Callable = Callable(), on_about: Callable = Callable()) -> void: + _on_retry = on_retry + _on_back = on_back + _on_about = on_about + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + + var root := UiKit.vbox(12) + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.offset_left = 40 + root.offset_top = 30 + root.offset_right = -40 + root.offset_bottom = -30 + add_child(root) + + root.add_child(UiKit.title("本轮结算")) + + var card := UiKit.panel() + var card_box := UiKit.vbox(6) + _grade_label = UiKit.label("—", 64, UiKit.COLOR_ACCENT) + _grade_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card_box.add_child(_grade_label) + + _score_label = UiKit.label("", UiKit.FONT_HEADING) + _score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card_box.add_child(_score_label) + + _stats_box = UiKit.vbox(2) + card_box.add_child(_stats_box) + card.add_child(card_box) + root.add_child(card) + + _source_panel = SourcePanel.new("动作来源") + root.add_child(_source_panel) + + root.add_child(UiKit.expander()) + + var actions := UiKit.hbox(12) + actions.alignment = BoxContainer.ALIGNMENT_CENTER + actions.add_child(UiKit.button("再来一次", func(): if _on_retry.is_valid(): _on_retry.call())) + actions.add_child(UiKit.button("返回主菜单", func(): if _on_back.is_valid(): _on_back.call())) + actions.add_child(UiKit.button("开源声明", func(): if _on_about.is_valid(): _on_about.call())) + root.add_child(actions) + + +func show_summary(summary: Dictionary, source: Object = null, asset_name: String = "") -> void: + _summary = summary + _source = source + _asset_name = asset_name + refresh() + + +func refresh() -> void: + if _grade_label == null: + return + var final_score := float(_summary.get("final_score", 0.0)) + var average := float(_summary.get("average_score", 0.0)) + + _grade_label.text = str(_summary.get("grade", "D")) + _grade_label.add_theme_color_override("font_color", Scoring.grade_color(average)) + _score_label.text = "%s | 平均 %.0f 分 | 最终 %.0f 分" % [ + Scoring.grade_label(average), average, final_score, + ] + + for child in _stats_box.get_children(): + child.queue_free() + + var completion := float(_summary.get("completion", 0.0)) * 100.0 + var rows := [ + ["有效评分帧数", "%d 帧" % int(_summary.get("frames_scored", 0))], + ["参考动作长度", "%d 帧" % int(_summary.get("reference_frames", 0))], + ["动作完成度", "%.0f%%" % completion], + ["最高单帧得分", "%.0f" % float(_summary.get("peak_score", 0.0))], + ["最长连击", "%d 帧" % int(_summary.get("max_combo", 0))], + ] + for row in rows: + var line := UiKit.hbox(10) + var key := UiKit.label(str(row[0]), UiKit.FONT_BODY, UiKit.COLOR_MUTED) + key.custom_minimum_size = Vector2(160, 0) + line.add_child(key) + line.add_child(UiKit.label(str(row[1]), UiKit.FONT_BODY)) + _stats_box.add_child(line) + + _stats_box.add_child(UiKit.spacer(6)) + _stats_box.add_child(UiKit.muted(Scoring.summary_comment(average))) + + if _source != null: + _source_panel.show_source(_source, _asset_name) + else: + _source_panel.clear() diff --git a/src/ui/source_panel.gd b/src/ui/source_panel.gd new file mode 100644 index 0000000..261be23 --- /dev/null +++ b/src/ui/source_panel.gd @@ -0,0 +1,91 @@ +class_name SourcePanel +extends PanelContainer + +## T-WIN-009:模型 / 动作来源信息展示 +## +## 契约 data-models.md §1 要求来源五元组在任何资产上都必须齐备; +## 合规要求(文档 §11.5)审核界面与客户端都要展示。 + +const UiKit = preload("res://src/ui/ui_kit.gd") +const CatalogTypes = preload("res://src/net/catalog_types.gd") + +var _type_label: Label +var _name_label: Label +var _author_label: Label +var _license_label: LinkButton +var _warning: Label + + +func _init(title: String = "来源信息") -> void: + var style := StyleBoxFlat.new() + style.bg_color = UiKit.COLOR_BG + style.corner_radius_top_left = 8 + style.corner_radius_top_right = 8 + style.corner_radius_bottom_left = 8 + style.corner_radius_bottom_right = 8 + style.content_margin_left = 12 + style.content_margin_right = 12 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + add_theme_stylebox_override("panel", style) + + var box := UiKit.vbox(4) + add_child(box) + + var header := UiKit.hbox(8) + header.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT)) + _type_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + header.add_child(_type_label) + box.add_child(header) + + _name_label = UiKit.label("—", UiKit.FONT_BODY) + box.add_child(_name_label) + + var meta := UiKit.hbox(14) + _author_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED) + meta.add_child(_author_label) + _license_label = LinkButton.new() + _license_label.add_theme_font_size_override("font_size", UiKit.FONT_SMALL) + meta.add_child(_license_label) + box.add_child(meta) + + _warning = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_BAD) + _warning.visible = false + box.add_child(_warning) + + clear() + + +func clear() -> void: + _name_label.text = "—" + _type_label.text = "" + _author_label.text = "" + _license_label.text = "" + _license_label.uri = "" + _license_label.visible = false + _warning.visible = false + + +func show_source(source: CatalogTypes.SourceInfo, asset_name: String = "") -> void: + if source == null: + clear() + return + + if asset_name != "": + _name_label.text = "%s | %s" % [asset_name, source.source_name] + else: + _name_label.text = source.source_name + _type_label.text = "[%s]" % source.type_label() + _author_label.text = "作者:%s" % source.author + + if source.license != "": + _license_label.text = "许可:%s ↗" % source.license + _license_label.uri = source.source_url + _license_label.visible = true + else: + _license_label.visible = false + + # 合规提示:五元组不齐备时必须显式告警,而不是静默展示 + _warning.visible = not source.is_complete() + if _warning.visible: + _warning.text = "⚠ 来源信息不完整,请联系管理员补全后再使用" diff --git a/tests/test_api_integration.gd b/tests/test_api_integration.gd new file mode 100644 index 0000000..f63c189 --- /dev/null +++ b/tests/test_api_integration.gd @@ -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"])) diff --git a/tests/test_credits.gd b/tests/test_credits.gd new file mode 100644 index 0000000..315740c --- /dev/null +++ b/tests/test_credits.gd @@ -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 版本一致")