From a78a536476c8bac4466b54c9c5c4394e0eb27175 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 05:18:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(integration):=20=E8=B7=A8=E7=AB=AF?= =?UTF-8?q?=E9=9B=86=E6=88=90=E9=AA=8C=E6=94=B6=EF=BC=88=E5=8C=85=E5=86=85?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20=C3=97=20=E7=9C=9F=E5=AE=9E=20Web?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=EF=BC=89+=20=E4=BF=AE=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=E5=AE=9E=E7=BC=BA=E9=99=B7=EF=BC=88v0.2.3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单测打的是假 HTTP 夹具,Web 端测试只验自己那一侧 —— 两端各自绿灯不代表契约字段 真的对得上。本版加了跨端集成验收,并在首次运行就抓到两个真实缺陷。 一、跨端集成验收(T-WIN-019) * build/integration/probe-integration.gd:只加载**包内**代码 (`godot --main-pack --script <探针>` + res://),打真实服务端并逐项断言: /health 六个契约版本、catalog/version 指纹、catalog/export 解析出的四类资源与 defaultModelId;**场景物理参数逐项比对**(重力/基础风/阵风/风坐标系/地面摩擦坡度/ 碰撞体/出生点/相机/环境)+ Wind.sample(0) + 场景 GLB 可下载; 动作 NDJSON → 客户端解析帧数 == 服务端帧数、33 点、每帧服务端关节角、 推导帧率 30 fps、时长 96.67 s;缩略图 PNG;模型 VRM 可下载。 * build/integration/seed-catalog.mjs:用**真实素材**播种目录 (示例文件/ 下的真 VMD → 内部标准 33 点;真 PMX → VRM;场景 GLB + 物理参数), 并把期望值(含从 /health 取的契约版本)写成 JSON。 * build/accept-integration.sh:编排「服务端可达 → 播种 → 包内探针 → 汇报」。 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL):全部通过。 二、探针抓到的两个真实缺陷 1. `CatalogTypes.Motion` 缺 `thumbnail_url`:服务端 catalog/export 从 data-models 0.6.0 起一直在发 thumbnailUrl(Model/Scene 都有),客户端默默丢掉。 已补字段与解析(缺失为 ""),新增 tests/test_catalog_types.gd(4 项 20 断言) 用**服务端真实报文形状**锁住三个资源类型的覆盖,并显式记录一处刻意不消费的差异 (Model.usable/unusableReason —— 目录只下发可用模型,恒为 true)。 2. `ApiClient._request()` 对**所有**响应无条件 `raw.get_string_from_utf8()`: 数据本身没错(二进制路径返回原始字节),但每下载一个文件都会打印 「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声,并把整个文件 (模型可达数十 MB)解码成 String 拷贝后丢弃。改为只在需要文本时解码。 三、顺带修掉测试里的写死契约版本 tests/test_api_integration.gd 断言 rest-api/websocket-keypoints/skeleton-topology 均为 "0.1.0"。该用例只在服务端可达时才跑,平时一直 skip,所以契约升到 0.5.0 后 没有任何地方发现它。改为从仓库 contracts/VERSION 读期望值并与 /health 比对 (顺带成为「客户端契约快照 vs 服务端」的跨端校验)。 证据:verify.sh 全绿(820 通过 / 0 失败,比上版 +46,因为服务端在线时 API 集成组 真的跑了);包内 pck 跨端集成探针全部通过;check-version/PE 版本资源 = 0.2.3。 --- README.md | 40 ++- README.txt | 21 +- build/accept-integration.sh | 135 ++++++++++ build/integration/probe-integration.gd | 275 +++++++++++++++++++++ build/integration/probe-integration.gd.uid | 1 + build/integration/seed-catalog.mjs | 142 +++++++++++ export_presets.cfg | 4 +- project.godot | 2 +- project.yaml | 8 +- src/net/api_client.gd | 12 +- src/net/catalog_types.gd | 3 + tasks/019-cross-end-integration.md | 89 +++++++ tests/test_api_integration.gd | 81 +++++- tests/test_catalog_types.gd | 98 ++++++++ tests/test_catalog_types.gd.uid | 1 + 15 files changed, 889 insertions(+), 23 deletions(-) create mode 100755 build/accept-integration.sh create mode 100644 build/integration/probe-integration.gd create mode 100644 build/integration/probe-integration.gd.uid create mode 100644 build/integration/seed-catalog.mjs create mode 100644 tasks/019-cross-end-integration.md create mode 100644 tests/test_catalog_types.gd create mode 100644 tests/test_catalog_types.gd.uid diff --git a/README.md b/README.md index f9fdfcd..4470869 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,36 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。 | 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** | | 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 | | 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 | -| 发布整包 | OK 单个 `opendance-windows-v0.2.2.zip` + `SHA256SUMS`(不拆分卷) | +| 发布整包 | OK 单个 `opendance-windows-v0.2.3.zip` + `SHA256SUMS`(不拆分卷) | | 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 | | 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) | +## v0.2.3 修订(跨端集成验收 + 两处真实缺陷) + +**缺陷 1(契约字段缺失)**:`CatalogTypes.Motion` 没有 `thumbnail_url` 字段, +而服务端 `catalog/export` 从 `data-models 0.6.0` 起一直在发 `thumbnailUrl` +(同一文件的 `Model` / `Scene` 都有)。客户端默默丢掉该字段。 +**修复**:补字段与解析(缺失时为 `""`,兼容旧服务端),并新增 +`tests/test_catalog_types.gd`(4 项)用**服务端真实报文形状**锁住三个资源类型的 +`thumbnailUrl` 覆盖;同时显式记录一处**刻意不消费**的差异 +(`Model.usable`/`unusableReason` —— 目录只下发可用模型,恒为 true)。 + +**缺陷 2(二进制响应白做 UTF-8 解码)**:`ApiClient._request()` 对**所有**响应 +无条件执行 `raw.get_string_from_utf8()`。数据本身没错(二进制路径返回的是原始字节), +但每下载一个文件都会打印一条 +`Unicode parsing error … Invalid UTF-8 leading byte (N)` 噪声, +并把整个文件(模型可达数十 MB)解码成一份 String 拷贝后丢弃。 +**修复**:只在需要文本时解码。跨端集成探针第一次注意到这条噪声。 + +**新增跨端集成验收**(`build/accept-integration.sh`):用**发布包里的客户端** +(`--main-pack` 加载包内 pck)打**真实 Web 服务端**,逐项比对契约与物理参数, +并跑通「真 VMD → 内部标准 33 点 → 客户端解析 2901 帧」的完整闭环。 +详见 [`tasks/019-cross-end-integration.md`](tasks/019-cross-end-integration.md)。 + +此外修掉 `tests/test_api_integration.gd` 里一处**写死的契约版本**(`0.1.0`): +该用例只在服务端可达时才跑,平时一直 skip,于是契约升到 0.5.0 后 +没有任何地方发现它。现在改为从仓库 `contracts/VERSION` 读期望值并与服务端 `/health` 比对。 + ## v0.2.2 修订(发布包 exe 的版本元数据) **缺陷**:v0.2.1 的包在 Windows 上「右键 → 属性 → 详细信息」显示 @@ -83,10 +109,10 @@ GODOT_BIN=/path/to/godot bash build/verify.sh GODOT_BIN=/path/to/godot bash build/export-windows.sh release # 打包发布产物(单个完整 zip + SHA256SUMS) -bash build/package-release.sh 0.2.2 +bash build/package-release.sh 0.2.3 # 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5) -GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip +GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip ``` `accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→ @@ -97,7 +123,7 @@ exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** → 已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与 「期望版本写错」两个负例反向验证过。 -当前版本:**v0.2.2**(`project.godot` 的 `application/config/version`, +当前版本:**v0.2.3**(`project.godot` 的 `application/config/version`, 与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、 以及 exe 的 PE 版本资源全部一致)。 @@ -180,7 +206,7 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release powershell -ExecutionPolicy Bypass -File build\export-windows.ps1 # 打成可分发的 zip(单个完整包 + SHA256SUMS) -bash build/package-release.sh 0.2.2 +bash build/package-release.sh 0.2.3 ``` | 产物 | 位置 | 说明 | @@ -188,7 +214,7 @@ bash build/package-release.sh 0.2.2 | `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) | | `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** | | `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 | -| `opendance-windows-v0.2.2.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` | +| `opendance-windows-v0.2.3.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` | | `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 | 导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。 @@ -292,7 +318,7 @@ src/ 等格式,但本项目未在 Windows 上实测)。 若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`, 不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包: - `ZIP_METHOD=deflate bash build/package-release.sh 0.2.2`(约 38 MB)。 + `ZIP_METHOD=deflate bash build/package-release.sh 0.2.3`(约 38 MB)。 7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows 「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的 `application/file_version` / `application/product_version` 被硬编码且无人校验。 diff --git a/README.txt b/README.txt index 737e7f8..0bc57c8 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Windows 客户端 v0.2.2 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压) +OpenDance Studio -- Windows 客户端 v0.2.3 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压) ================================================================ 摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket @@ -8,6 +8,17 @@ OpenDance Studio -- Windows 客户端 v0.2.2 使用说明(zip 为 LZMA 压缩 本客户端只接收关键点坐标,不接收、不存储任何视频画面。 +v0.2.3 修订 + 1. 补 Motion.thumbnail_url: 服务端 catalog/export 从 data-models 0.6.0 起一直 + 在发 thumbnailUrl,客户端类型里却缺这个字段(Model/Scene 都有),等于默默丢掉。 + 2. ApiClient 不再对所有响应无条件做 UTF-8 解码: 原来下载每个文件(模型 / GLB / + 缩略图)都会打印一条 "Unicode parsing error ... Invalid UTF-8 leading byte" + 噪声,并白做一份 String 拷贝;二进制路径现在直接返回原始字节。 + 3. 新增跨端集成验收 build/accept-integration.sh: 用发布包里的客户端打真实 Web + 服务端,逐项比对契约与场景物理参数,并跑通真实 VMD → 内部标准 33 点 → 客户端 + 解析 2901 帧的闭环。 + 4. 测试规模 820 项断言(含真实服务端在线的 API 集成组)。 + v0.2.2 修订 1. 修复发布包 exe 的版本元数据: v0.2.1 的包在 Windows「右键 -> 属性 -> 详细信息」里显示的是 文件版本 0.1.0 / 产品版本 0.1.0, 而产物名与 Git tag @@ -129,11 +140,11 @@ OpenDance.exe 的用户看第一节即可。 打成发布包: - bash build/package-release.sh 0.2.2 + bash build/package-release.sh 0.2.3 得到 dist-release/ 下两个资产: - opendance-windows-v0.2.2.zip <- 单个完整包,禁止 .partN 分卷 + opendance-windows-v0.2.3.zip <- 单个完整包,禁止 .partN 分卷 SHA256SUMS <- 裸文件名,可直接 sha256sum -c 包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、 @@ -152,14 +163,14 @@ OpenDance.exe 的用户看第一节即可。 如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个 上限约束),建议改用资源管理器可直接双击解压的 deflate 包: - ZIP_METHOD=deflate bash build/package-release.sh 0.2.2 # 约 38 MB + ZIP_METHOD=deflate bash build/package-release.sh 0.2.3 # 约 38 MB 上限一旦放开,Release 资产也应换回 deflate 包。 7. 自检清单 0) 发布包级验收(对**发布包**而不是源码;文档 §7.5) - GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip + GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip 预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、 读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时 探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 / diff --git a/build/accept-integration.sh b/build/accept-integration.sh new file mode 100755 index 0000000..1a7d538 --- /dev/null +++ b/build/accept-integration.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# ============================================================ +# OpenDance Studio Windows 客户端 —— **跨端集成验收**(T-WIN-019) +# +# Windows 发布包里的客户端(包内 pck) × 真实 Web 服务端 +# +# 为什么需要它:本仓单测打的是**假 HTTP 夹具**,Web 端测试只验自己那一侧。 +# 两端各自绿灯 ≠ 契约字段真的对得上(字段名/嵌套/null/单位/范围都可能一侧对一侧错)。 +# 契约 0.5.0 新增的 `scenes[]` 与重力/风向参数尤其如此。 +# +# 本脚本: +# 1. 要求一个**已部署好的** Web 服务端(不做部署,与项目边界一致); +# 2. 用真实素材播种一份目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数), +# 并写出期望值 JSON(`--skip-seed` 可跳过,复用已有目录); +# 3. 用 Godot 加载**发布包里的 pck**(`--main-pack`)跑 +# `build/integration/probe-integration.gd`,逐项断言并给出退出码。 +# +# 用法: +# GODOT_BIN=/path/to/godot API_BASE=http://127.0.0.1:3000 \ +# bash build/accept-integration.sh dist-release/opendance-windows-v0.2.3.zip +# +# # 已播种过、只重跑探针: +# GODOT_BIN=... API_BASE=... bash build/accept-integration.sh --skip-seed +# +# 环境变量: +# API_BASE Web 服务端基地址(默认 http://127.0.0.1:3000) +# OPENDANCE_SAMPLES_DIR 真实素材目录(默认 仓库根/示例文件) +# EXPECT_PATH 期望值 JSON 输出路径(默认 $WORK_DIR/catalog-expect.json) +# WORK_DIR 解包目录(默认 mktemp -d) +# ============================================================ +set -uo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +TARGET="${1:-}" +SKIP_SEED=0 +for arg in "$@"; do [ "$arg" = "--skip-seed" ] && SKIP_SEED=1; done + +API_BASE="${API_BASE:-http://127.0.0.1:3000}" +SAMPLES_DIR="${OPENDANCE_SAMPLES_DIR:-$PROJECT_DIR/../示例文件}" +WORK_DIR="${WORK_DIR:-$(mktemp -d)}" +EXPECT_PATH="${EXPECT_PATH:-$WORK_DIR/catalog-expect.json}" + +FAILED=0 +log() { printf '\033[1;34m[accept-int]\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m[accept-int] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; } + +if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then + echo "用法:GODOT_BIN=... API_BASE=... bash build/accept-integration.sh <发布包 zip 或已解压目录> [--skip-seed]" >&2 + exit 2 +fi +# 必须**先**建目录:期望值 JSON 默认就落在 $WORK_DIR 下,播种阶段就要写它 +mkdir -p "$WORK_DIR" + +# ---- 0. 服务端可达性 ---- +log "服务端:$API_BASE" +HEALTH="$(curl -sf -m 10 "$API_BASE/health" || true)" +if [ -z "$HEALTH" ]; then + fail "GET $API_BASE/health 不可达 —— 请先部署 Web 服务端(本项目不部署它)" + exit 1 +fi +echo "$HEALTH" | node -e ' +let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { + const j = JSON.parse(s); + console.log(` ✓ 服务端版本 ${j.version}|数据库 ${j.database?.connected ? "已连接" : "未连接"}|scenes.available=${j.scenes?.available}`); +});' + +# ---- 1. 播种目录 ---- +if [ "$SKIP_SEED" = "1" ]; then + log "跳过播种(--skip-seed);期望值文件:$EXPECT_PATH" +else + log "播种真实目录(真 VMD / 真 PMX / 场景 GLB + 物理参数)…" + if [ ! -d "$SAMPLES_DIR" ]; then + fail "找不到素材目录 $SAMPLES_DIR(用 OPENDANCE_SAMPLES_DIR 指定)" + exit 1 + fi + if ! command -v node >/dev/null 2>&1; then + fail "播种需要 node(Web 端已依赖 node,本机应已安装)" + exit 1 + fi + API_BASE="$API_BASE" OPENDANCE_SAMPLES_DIR="$SAMPLES_DIR" EXPECT_PATH="$EXPECT_PATH" \ + node "$PROJECT_DIR/build/integration/seed-catalog.mjs" || { fail "播种失败"; exit 1; } +fi +if [ ! -f "$EXPECT_PATH" ]; then + fail "缺少期望值文件 $EXPECT_PATH(首次运行不要加 --skip-seed)" + exit 1 +fi + +# ---- 2. 取包内 pck ---- +if [ -f "$TARGET" ]; then + log "解压发布包…" + if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then + command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; } + fi + PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" +else + PACKAGE_DIR="$TARGET" +fi +PCK="$PACKAGE_DIR/OpenDance.pck" +[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; } +log "包内 pck:$PCK($(stat -c%s "$PCK") 字节)" + +# ---- 3. Godot ---- +GODOT_BIN="${GODOT_BIN:-}" +if [ -z "$GODOT_BIN" ]; then + for candidate in godot godot4 Godot; do + command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; } + done +fi +[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需与本项目同版本 4.7.x)"; exit 1; } + +# ---- 4. 跨端探针 ---- +log "跨端集成探针(包内客户端 × $API_BASE)…" +PROBE_LOG="$WORK_DIR/probe-integration.log" +if timeout 600 "$GODOT_BIN" --headless --main-pack "$PCK" \ + --script "$PROJECT_DIR/build/integration/probe-integration.gd" \ + -- --api-base "$API_BASE" --expect "$EXPECT_PATH" >"$PROBE_LOG" 2>&1; then + PROBE_OK=1 +else + PROBE_OK=0 +fi +grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /' +[ "$PROBE_OK" = 1 ] || fail "跨端集成探针未通过" + +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "============================================================" + echo " 跨端集成验收失败(工作目录:$WORK_DIR)" + echo "============================================================" + exit 1 +fi +echo "============================================================" +echo " 跨端集成验收全部通过(工作目录:$WORK_DIR)" +echo "============================================================" diff --git a/build/integration/probe-integration.gd b/build/integration/probe-integration.gd new file mode 100644 index 0000000..2bb6b58 --- /dev/null +++ b/build/integration/probe-integration.gd @@ -0,0 +1,275 @@ +extends SceneTree + +## 跨端集成探针(T-WIN-019):**发布包里的 Windows 客户端** × **真实 Web 服务端** +## +## 用法(由 build/accept-integration.sh 调用,也可手工执行): +## godot --headless --main-pack <包内 OpenDance.pck> \ +## --script /绝对路径/build/integration/probe-integration.gd \ +## -- --api-base http://127.0.0.1:3100 --expect /绝对路径/catalog-expect.json +## +## 为什么需要它:本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd` +## 打的是本地起的小服务),Web 端的测试又只验自己那一侧。两端各自绿灯, +## 不代表**契约字段真的对得上**:字段名大小写、嵌套层级、null 与缺字段、 +## 单位(米/度/秒)、归一化坐标范围……都可能在一侧对、另一侧错。 +## 契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。 +## +## 本探针只加载**包内**代码(`--main-pack` + `res://`),打真实服务端: +## 1. `/health`:连通性 + 契约版本; +## 2. `/api/catalog/version`:目录指纹; +## 3. `/api/catalog/export` → `CatalogStore` 解析出的**类型化**目录: +## 曲目 / 动作 / 模型 / 场景 + 默认模型; +## 4. 场景物理参数逐项比对(重力 / 风 / 地面 / 碰撞体 / 出生点 / 相机 / 环境) +## —— ⑤「场景选择与物理参数落地」的跨端证据; +## 5. 动作关键点序列(NDJSON)→ `KeypointSequence.parse_ndjson`: +## 帧数、33 点、时间轴、服务端关节角 —— ②「动作内部标准」跨端闭环; +## 6. 模型 / 场景 / 缩略图资源**可下载**且魔数正确(① ③ 的消费侧)。 + +const CatalogTypes = preload("res://src/net/catalog_types.gd") +const KeypointSequence = preload("res://src/net/keypoint_sequence.gd") +const KeypointFrame = preload("res://src/net/keypoint_frame.gd") + +var _api_base := "" +var _expect_path := "" +var _expect: Dictionary = {} +var _failed := 0 +var _started := false +var _done := false +var _exit_code := 0 +## 看门狗:协程里一旦出现运行时错误,`_finish()` 就不会被调用,进程会永远不退出 +## (调试时表现为「无输出卡住」,很难看出问题)。超过时限就自己判失败退出。 +const WATCHDOG_MS := 120_000 +var _start_ms := 0 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var i := 0 + while i < args.size(): + if args[i] == "--api-base" and i + 1 < args.size(): + _api_base = args[i + 1] + i += 2 + elif args[i] == "--expect" and i + 1 < args.size(): + _expect_path = args[i + 1] + i += 2 + else: + i += 1 + + +## 必须在 _process 里跑:HTTPRequest 需要跨帧等待(同 tests/run_tests.gd) +func _process(_delta: float) -> bool: + if not _started: + _started = true + _start_ms = Time.get_ticks_msec() + _run() + if not _done and Time.get_ticks_msec() - _start_ms > WATCHDOG_MS: + print(" ✗ 看门狗超时(%d ms):协程可能因运行时错误中断" % WATCHDOG_MS) + _exit_code = 1 + _done = true + quit(_exit_code) + return _done + + +func _check(ok: bool, text: String) -> void: + print(" %s %s" % ["✓" if ok else "✗", text]) + if not ok: + _failed += 1 + + +func _near(a: float, b: float, tol := 0.0001) -> bool: + return absf(a - b) <= tol + + +func _load_expect() -> bool: + if _expect_path.is_empty() or not FileAccess.file_exists(_expect_path): + print(" ✗ 找不到期望值文件:%s" % _expect_path) + return false + var file := FileAccess.open(_expect_path, FileAccess.READ) + if file == null: + print(" ✗ 无法读取期望值文件:%s" % _expect_path) + return false + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if typeof(parsed) != TYPE_DICTIONARY: + print(" ✗ 期望值文件不是 JSON 对象") + return false + _expect = parsed + return true + + +func _fail(text: String) -> void: + print(" ✗ %s" % text) + _failed += 1 + + +func _run() -> void: + print("[integration] Windows 包内客户端 × 真实 Web 服务端:%s" % _api_base) + if _api_base.is_empty() or not _load_expect(): + _finish() + return + + # autoload 单例在 `--script` 模式下不能当全局标识符用(编译期找不到 AppConfig / + # CatalogStore 这两个名字),要从场景树里取节点,再用动态调用访问。 + var app_config = root.get_node_or_null("AppConfig") + var catalog_store = root.get_node_or_null("CatalogStore") + if app_config == null or catalog_store == null: + _fail("场景树里没有 AppConfig / CatalogStore(autoload 未加载)") + _finish() + return + app_config.api_base_url = _api_base + var client = catalog_store.client() + client.set_base_url(_api_base) + + # ---------- 1. /health ---------- + var health: Dictionary = await client.health() + if not health["ok"]: + _fail("GET /health 失败:%s" % health["error"]) + _finish() + return + var health_data: Dictionary = health["data"] + _check(health["status"] == 200, "GET /health 200(实际 %d)" % health["status"]) + _check(health_data.get("version", "") != "", "服务端版本 = %s" % health_data.get("version", "")) + var contract: Dictionary = health_data.get("contractVersion", {}) + # 期望值文件缺键时给可读错误,而不是在运行时抛 "Invalid access to property" + var want_contract: Dictionary = _expect.get("contractVersions", {}) + if want_contract.is_empty(): + _fail("期望值文件缺少 contractVersions(请重新播种,或用本仓 seed-catalog.mjs 生成)") + for key in want_contract.keys(): + _check(contract.get(key, "") == want_contract[key], + "契约 %s = %s(服务端 %s)" % [key, want_contract[key], contract.get(key, "缺失")]) + + # ---------- 2/3. 目录 ---------- + var refresh: Dictionary = await catalog_store.refresh() + if not refresh["ok"]: + _fail("GET /api/catalog/export 失败:%s" % refresh.get("error", "")) + _finish() + return + var bundle: CatalogTypes.CatalogBundle = catalog_store.bundle + _check(catalog_store.revision != "", "目录指纹非空:%s" % catalog_store.revision) + if not want_contract.is_empty(): + _check(catalog_store.contract_version.get("rest-api", "") == want_contract.get("rest-api", ""), + "目录内嵌契约版本与 /health 一致") + + var songs: Array = catalog_store.songs() + var motions: Array = catalog_store.motions() + var models: Array = catalog_store.models() + var scenes: Array = catalog_store.scenes() + _check(songs.size() >= 1 and motions.size() >= 1 and models.size() >= 1 and scenes.size() >= 1, + "目录四类资源齐备:songs=%d motions=%d models=%d scenes=%d" % [ + songs.size(), motions.size(), models.size(), scenes.size()]) + if not _expect.has("modelId") or not _expect.has("sceneId") or not _expect.has("motionId"): + _fail("期望值文件缺少 modelId / sceneId / motionId") + _finish() + return + _check(bundle.default_model_id == _expect["modelId"], + "defaultModelId 解析为 %d(期望 %d)" % [bundle.default_model_id, _expect["modelId"]]) + + # ---------- 4. ⑤ 场景选择 + 物理参数落地 ---------- + var scene: CatalogTypes.Scene = bundle.find_scene(_expect["sceneId"]) + if scene == null: + _fail("目录里找不到场景 #%d" % _expect["sceneId"]) + else: + var cfg: CatalogTypes.SceneConfig = scene.config + var want: Dictionary = _expect["sceneConfig"] + var w_phys: Dictionary = want["physics"] + _check(scene.glb_url.begins_with("/static/"), "场景 glbUrl = %s" % scene.glb_url) + _check(scene.file_size > 0, "场景 fileSize = %d" % scene.file_size) + _check(_near(cfg.physics.gravity.y, w_phys["gravity"]["y"]), + "重力 y = %.2f(期望 %.2f)" % [cfg.physics.gravity.y, w_phys["gravity"]["y"]]) + _check(_near(cfg.physics.wind.base.x, w_phys["wind"]["base"]["x"]) + and _near(cfg.physics.wind.base.z, w_phys["wind"]["base"]["z"]), + "基础风 = (%.1f, %.1f, %.1f)" % [cfg.physics.wind.base.x, cfg.physics.wind.base.y, cfg.physics.wind.base.z]) + _check(_near(cfg.physics.wind.gust_amplitude, w_phys["wind"]["gustAmplitude"]) + and _near(cfg.physics.wind.gust_frequency, w_phys["wind"]["gustFrequency"]), + "阵风幅度/频率 = %.1f m/s @ %.2f Hz" % [cfg.physics.wind.gust_amplitude, cfg.physics.wind.gust_frequency]) + _check(cfg.physics.wind.space == w_phys["wind"]["space"], + "风坐标系 = %s" % cfg.physics.wind.space) + _check(_near(cfg.physics.ground.friction, w_phys["ground"]["friction"]) + and _near(cfg.physics.ground.slope_limit, w_phys["ground"]["slopeLimit"]), + "地面摩擦/坡度 = %.2f / %.0f°" % [cfg.physics.ground.friction, cfg.physics.ground.slope_limit]) + _check(cfg.physics.colliders == w_phys["colliders"], + "碰撞体模式 = %s" % cfg.physics.colliders) + _check(cfg.spawn_points.size() >= 1 and _near(cfg.spawn_points[0].yaw, want["spawnPoints"][0]["yaw"]), + "出生点 yaw = %.0f°" % (cfg.spawn_points[0].yaw if cfg.spawn_points.size() > 0 else -999.0)) + _check(cfg.camera.mode == want["camera"]["mode"] + and _near(cfg.camera.distance, want["camera"]["distance"]) + and _near(cfg.camera.height, want["camera"]["height"]), + "相机 %s / 距离 %.1f / 高度 %.1f" % [cfg.camera.mode, cfg.camera.distance, cfg.camera.height]) + _check(_near(cfg.environment.ambient_intensity, want["environment"]["ambientIntensity"]) + and _near(cfg.environment.fog_density, want["environment"]["fogDensity"]), + "环境光强度 %.1f / 雾 %.2f" % [cfg.environment.ambient_intensity, cfg.environment.fog_density]) + # 阵风采样:t=0 应等于基础风,t=1/(4f) 应叠加一个完整峰值 + if not cfg.physics.wind.is_calm(): + var s0: Vector3 = cfg.physics.wind.sample(0.0) + _check(s0.is_equal_approx(cfg.physics.wind.base), "风采样 t=0 等于基础风") + # 场景 GLB 真的能下载 + var glb: Dictionary = await client.get_binary(scene.glb_url) + if glb["ok"]: + var bytes: PackedByteArray = glb["data"] + var magic := bytes.slice(0, 4).get_string_from_ascii() + _check(magic == "glTF" and bytes.size() == scene.file_size, + "场景 GLB 可下载且魔数正确(%s,%d 字节)" % [magic, bytes.size()]) + else: + _fail("场景 GLB 下载失败:%s" % glb["error"]) + + # ---------- 5. ② 动作内部标准跨端闭环 ---------- + var motion: CatalogTypes.Motion = bundle.find_motion(_expect["motionId"]) + if motion == null: + _fail("目录里找不到动作 #%d" % _expect["motionId"]) + else: + _check(motion.keypoint_format == _expect["keypointFormat"], + "动作内部标准 = %s" % motion.keypoint_format) + _check(motion.keypoint_frames == _expect["motionFrames"], + "服务端帧数 = %d(期望 %d)" % [motion.keypoint_frames, _expect["motionFrames"]]) + _check(motion.is_scorable(), "动作可评分(预处理已完成)") + var seq_result: Dictionary = await catalog_store.fetch_sequence(_expect["motionId"]) + if not seq_result["ok"]: + _fail("关键点序列拉取/解析失败:%s" % seq_result.get("error", "")) + else: + var seq: KeypointSequence.ParsedSequence = seq_result["sequence"] + _check(seq.frame_count() == _expect["motionFrames"], + "客户端解析帧数 = %d(期望 %d)" % [seq.frame_count(), _expect["motionFrames"]]) + _check(seq.skipped_lines == 0, "无解析失败行(skipped=%d)" % seq.skipped_lines) + _check(seq.frames.size() > 0 and seq.frames[0].points.size() == KeypointFrame.KEYPOINT_COUNT, + "首帧点数 = %d" % (seq.frames[0].points.size() if seq.frames.size() > 0 else 0)) + _check(seq.server_angles.size() == seq.frame_count(), "每帧都带服务端关节角") + _check(_near(seq.estimated_fps(), 30.0, 0.5), + "时间轴推导帧率 = %.2f fps" % seq.estimated_fps()) + var duration_s := (seq.times_ms[seq.times_ms.size() - 1] - seq.times_ms[0]) / 1000.0 + _check(duration_s > 90.0 and duration_s < 100.0, "动作时长 = %.2f s" % duration_s) + # 缩略图(③ 预览资产)可下载且为 PNG + var thumb: Dictionary = await client.get_binary(motion.thumbnail_url) + if thumb["ok"]: + var png: PackedByteArray = thumb["data"] + _check(png.slice(0, 8).hex_encode() == "89504e470d0a1a0a", + "动作缩略图可下载且为 PNG(%d 字节)" % png.size()) + else: + _fail("动作缩略图下载失败:%s" % thumb["error"]) + + # ---------- 6. ① 模型资源可下载 ---------- + var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"]) + if model == null: + _fail("目录里找不到模型 #%d" % _expect["modelId"]) + else: + _check(model.format == "vrm", "模型格式 = %s" % model.format) + _check(model.is_primary, "模型为默认模型") + var model_bytes: Dictionary = await client.get_binary(model.file_url) + if model_bytes["ok"]: + var payload: PackedByteArray = model_bytes["data"] + _check(payload.slice(0, 4).get_string_from_ascii() == "glTF" + and payload.size() == model.file_size, + "模型文件可下载且为 glTF 容器(%d 字节)" % payload.size()) + else: + _fail("模型下载失败:%s" % model_bytes["error"]) + + _finish() + + +func _finish() -> void: + if _failed > 0: + print("[integration] ✗ 跨端集成探针发现 %d 项不符" % _failed) + _exit_code = 1 + else: + print("[integration] ✓ 跨端集成探针全部通过") + _exit_code = 0 + _done = true + quit(_exit_code) diff --git a/build/integration/probe-integration.gd.uid b/build/integration/probe-integration.gd.uid new file mode 100644 index 0000000..ee480a8 --- /dev/null +++ b/build/integration/probe-integration.gd.uid @@ -0,0 +1 @@ +uid://dexb1tstsyjuh diff --git a/build/integration/seed-catalog.mjs b/build/integration/seed-catalog.mjs new file mode 100644 index 0000000..65c720e --- /dev/null +++ b/build/integration/seed-catalog.mjs @@ -0,0 +1,142 @@ +/** + * 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录: + * 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM; + * 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。 + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const API = process.env.API_BASE ?? 'http://127.0.0.1:3100'; +// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/) +const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR + ?? new URL('../../../示例文件', import.meta.url).pathname; +const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json'; +const SOURCE = { + sourceType: 'original', + sourceName: '跨端集成验收素材', + sourceUrl: 'https://example.com/opendance', + license: 'MIT', + author: 'OpenDance QA', +}; + +async function request(method, path, { json, form } = {}) { + const init = { method }; + if (json !== undefined) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(json); } + if (form !== undefined) init.body = form; + const res = await fetch(`${API}${path}`, init); + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = text; } + return { status: res.status, body }; +} + +function fileForm(fields, filename, content, mime = 'application/octet-stream') { + const form = new FormData(); + for (const [k, v] of Object.entries(fields)) form.append(k, String(v)); + form.append('file', new Blob([content], { type: mime }), filename); + return form; +} + +const must = (cond, what, extra = '') => { + if (!cond) { console.error(`✗ ${what} ${extra}`); process.exit(1); } + console.log(` ✓ ${what}`); +}; + +const run = async () => { + // ---------- 歌曲 ---------- + const song = await request('POST', '/api/songs', { + json: { title: '霜雪千年', artist: '乐正绫 / 洛天依', album: '跨端验收', language: 'zh', durationMs: 96700, bpm: 120, difficulty: 3, ...SOURCE }, + }); + must(song.status === 201, '创建曲目', `${song.status} ${JSON.stringify(song.body)}`); + const songId = song.body.id; + + // ---------- 动作:真实 VMD ---------- + const vmd = readFileSync(`${SAMPLES}/【动作】霜雪千年.vmd`); + const motion = await request('POST', '/api/motions', { + form: fileForm({ name: '霜雪千年(真实 VMD)', songId, autoPreprocess: false, ...SOURCE }, '霜雪千年.vmd', vmd, 'application/octet-stream'), + }); + must(motion.status === 201, '上传真实 VMD 动作', `${motion.status} ${JSON.stringify(motion.body)}`); + const motionId = motion.body.id; + must(motion.body.format === 'vmd', `识别格式 vmd(实际 ${motion.body.format})`); + const pre = await request('POST', `/api/motions/${motionId}/preprocess`); + must(pre.status === 200, '预处理为内部标准 33 点', `${pre.status} ${JSON.stringify(pre.body)}`); + console.log(` 预处理结果:${JSON.stringify(pre.body)}`); + + // ---------- 模型:真实 PMX 压缩包 → VRM ---------- + const pmxZip = readFileSync(`${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`); + const model = await request('POST', '/api/models', { + form: fileForm({ name: '初音未来(真实 PMX → VRM)', ...SOURCE }, 'miku.zip', pmxZip, 'application/zip'), + }); + must(model.status === 201, '上传真实 PMX 压缩包', `${model.status} ${JSON.stringify(model.body)}`); + const modelId = model.body.id; + must(model.body.format === 'vrm', `转换产物格式 vrm(实际 ${model.body.format})`); + must(model.body.usable === true, '模型可用(humanoid 达标)', JSON.stringify(model.body.unusableReason)); + console.log(` 转换说明:${model.body.conversionNote}`); + console.log(` humanoid:${model.body.stats?.humanoidMapped}/${model.body.stats?.humanoidTotal},可驱动=${model.body.stats?.drivable}`); + + // ---------- 场景:真实 GLB(复用刚转换出的 VRM 几何)+ 物理参数 ---------- + const glbRes = await fetch(`${API}${model.body.fileUrl}`); + const glbBytes = Buffer.from(await glbRes.arrayBuffer()); + must(glbBytes.subarray(0, 4).toString('ascii') === 'glTF', `取回模型 GLB(${glbBytes.length} 字节)`); + // 作为场景上传时补一个按客户端 colliders:auto 约定命名的碰撞节点不方便,直接用 mesh 模式 + const sceneConfig = { + physics: { + gravity: { x: 0, y: -3.5, z: 0 }, + wind: { + base: { x: 2, y: 0, z: -1 }, + gustAmplitude: 4, + gustFrequency: 0.5, + gustDirection: { x: 1, y: 0, z: 0 }, + space: 'world', + }, + ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 }, + colliders: 'mesh', + }, + environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 }, + spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }], + camera: { mode: 'orbit', distance: 6, height: 2 }, + }; + const scene = await request('POST', '/api/scenes', { + form: fileForm({ name: '跨端验收场景(真实 GLB)', config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', glbBytes, 'model/gltf-binary'), + }); + must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`); + const sceneId = scene.body.id; + + // ---------- 审核发布 ---------- + for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) { + // 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的 + const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } }); + must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status)); + const approve = await request('POST', `/api/reviews/${kind}/${id}/approve`, { + json: kind === 'model' ? { reviewer: 'qa', comment: '通过', setPrimary: true } : { reviewer: 'qa', comment: '通过' }, + }); + must(approve.status === 200 || approve.status === 201, `审核通过 ${kind}#${id}`, `${approve.status} ${JSON.stringify(approve.body)}`); + } + + // ---------- 目录导出 ---------- + const health = await request('GET', '/health'); + must(health.status === 200, 'GET /health(取契约版本写进期望值)', String(health.status)); + + const catalog = await request('GET', '/api/catalog/export'); + must(catalog.status === 200, 'GET /api/catalog/export', String(catalog.status)); + const data = catalog.body; + console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`); + must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])'); + const version = await request('GET', '/api/catalog/version'); + console.log(` 目录指纹:${JSON.stringify(version.body)}`); + + mkdirSync(dirname(EXPECT_PATH), { recursive: true }); + writeFileSync(EXPECT_PATH, JSON.stringify({ + songId, motionId, modelId, sceneId, + sceneConfig, + motionFrames: pre.body?.frameCount ?? null, + // 注意:**上传响应**里的 keypointFormat 还是 null(预处理后才写入), + // 必须取**预处理响应**的值 —— 这里踩过一次,期望值写成了 null。 + keypointFormat: pre.body?.keypointFormat ?? null, + catalogRevision: version.body?.revision ?? null, + contractVersions: health.body?.contractVersion ?? {}, + }, null, 2)); + console.log(`\n期望值已写入 ${EXPECT_PATH}`); +}; + +run().catch((e) => { console.error('ERR', e); process.exit(1); }); diff --git a/export_presets.cfg b/export_presets.cfg index 64d4edd..65174ec 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -37,8 +37,8 @@ application/modify_resources=true application/icon="" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.2.2" -application/product_version="0.2.2" +application/file_version="0.2.3" +application/product_version="0.2.3" application/company_name="OpenDance Studio" application/product_name="OpenDance Studio" application/file_description="OpenDance Studio 摄像头跳舞评分客户端" diff --git a/project.godot b/project.godot index a87af9a..e6e3b77 100644 --- a/project.godot +++ b/project.godot @@ -9,7 +9,7 @@ config_version=5 config/name="OpenDance Studio" config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。" -config/version="0.2.2" +config/version="0.2.3" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" diff --git a/project.yaml b/project.yaml index 107e3f2..ad4a5fb 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-windows-client -version: 0.2.2 +version: 0.2.3 repository: opendance-windows default_branch: main engine: Godot 4.7.2 @@ -98,9 +98,13 @@ tasks: file: tasks/018-release-acceptance.md scope: 发布包级端到端验收(读包内 pck:版本号 / 界面显示值 / 契约表 / 资源可加载) status: done + - id: T-WIN-019 + file: tasks/019-cross-end-integration.md + scope: 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 补 Motion.thumbnailUrl 契约字段 + status: done deliverable: - artifact: opendance-windows-v0.2.2.zip + artifact: opendance-windows-v0.2.3.zip packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底) release_rules: # 文档 §5.7 硬性要求 - 单个完整包,禁止 .partN 分卷 diff --git a/src/net/api_client.gd b/src/net/api_client.gd index ed8ebbf..de14201 100644 --- a/src/net/api_client.gd +++ b/src/net/api_client.gd @@ -107,9 +107,16 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool = request_completed.emit(path, false, status) return outcome - var text := raw.get_string_from_utf8() + # 只有**需要文本**时才做 UTF-8 解码。 + # 原来这里是无条件 `raw.get_string_from_utf8()`,对二进制响应(模型 VRM、场景 GLB、 + # 缩略图 PNG)有两个后果: + # 1. 每下载一个文件都会打印一条 + # 「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声 —— + # 跨端集成探针里第一次注意到它; + # 2. 白白把整个文件(模型可达数十 MB)解码成一份 String 拷贝后才丢弃。 + # 二进制路径返回的一直是 `raw`,所以数据本身没错,但代价与噪声都是白付的。 if status < 200 or status >= 300: - outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(text)] + outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(raw.get_string_from_utf8())] request_completed.emit(path, false, status) return outcome @@ -119,6 +126,7 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool = request_completed.emit(path, true, status) return outcome + var text := raw.get_string_from_utf8() if as_text: outcome["ok"] = true outcome["data"] = text diff --git a/src/net/catalog_types.gd b/src/net/catalog_types.gd index 2f9e4de..886d3d3 100644 --- a/src/net/catalog_types.gd +++ b/src/net/catalog_types.gd @@ -94,6 +94,8 @@ class Motion extends RefCounted: var keypoint_frames: int = 0 var preprocess_state: String = "none" var preprocess_error: String = "" + ## 首帧骨架缩略图(契约 data-models 0.6.0 起 Motion 也有该字段) + var thumbnail_url: String = "" var source: SourceInfo = SourceInfo.new() var status: String = "" @@ -113,6 +115,7 @@ class Motion extends RefCounted: out.keypoint_frames = int(d.get("keypointFrames", 0) if d.get("keypointFrames") != null else 0) out.preprocess_state = str(d.get("preprocessState", "none")) out.preprocess_error = CatalogTypes._nullable_string(d.get("preprocessError")) + out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl")) out.source = SourceInfo.from_dict(d) out.status = str(d.get("status", "")) return out diff --git a/tasks/019-cross-end-integration.md b/tasks/019-cross-end-integration.md new file mode 100644 index 0000000..dcb8634 --- /dev/null +++ b/tasks/019-cross-end-integration.md @@ -0,0 +1,89 @@ +# T-WIN-019:跨端集成验收(包内客户端 × 真实 Web 服务端) + +- **状态**: done +- **依赖**: T-WIN-013(场景选择)/ T-WIN-018(发布包级验收)/ T-WEB-015(场景管理) +- **文档**: v1.6 §5.6 / §7.5 +- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`skeleton-topology 0.2.0` + +## 为什么需要 + +本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd` 打本地小服务), +Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真的对得上**: +字段名、嵌套层级、null 与缺字段、单位(米/度/秒)、归一化范围, +都可能在一侧对、另一侧错。契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。 + +所以本任务是:**拿发布包里的客户端(包内 pck)打真实服务端**。 + +## 发现并修复的缺陷 + +探针首次运行就抓到一个真实缺口: + +``` +服务端 GET /api/catalog/export 的 motion 条目(契约 data-models 0.6.0 起): + ...,"keypointUrl":"...","thumbnailUrl":"/static/motion-thumbnail/.../x.png","keypointFrames":2901,... +客户端的 CatalogTypes.Motion: + ✗ 没有 thumbnail_url 字段(服务端一直在发,客户端默默丢掉) +而同一文件的 Model / Scene 都有 thumbnail_url。 +``` + +**修复**:`src/net/catalog_types.gd` 的 `Motion` 增加 `thumbnail_url` 并解析 +`thumbnailUrl`(缺失时为 `""`,兼容旧服务端);新增 +`tests/test_catalog_types.gd`(4 项 / 20 条断言)用**服务端真实报文形状**锁住 +「三个资源类型都消费 `thumbnailUrl`」,并显式记录一处**刻意不消费**的差异: +`Model.usable` / `unusableReason` —— 它们是 Web 端审核用的, +而 `catalog/export` 只下发**可用**模型(目录里恒为 true), +不可用模型的可见性由 Web 管理端负责。 + +## 实现 + +| 文件 | 作用 | +|--|--| +| `build/integration/probe-integration.gd` | 跨端集成探针:只加载**包内**代码(`--main-pack` + `res://`),打真实服务端并逐项断言 | +| `build/integration/seed-catalog.mjs` | 通过真实 Web API 播种一份**真实**目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数),并写出期望值 JSON | +| `build/accept-integration.sh` | 编排:检查服务端可达 → 播种(可跳过)→ 对包内 pck 跑探针 → 汇报 | + +### 探针断言(对应需求编号) + +| 需求 | 断言 | +|--|--| +| ⑤ API 升级 | `/health` 连通 + 六个契约版本与 Web 一致;`/api/catalog/version` 指纹非空;`catalog/export` 四类资源齐备;`defaultModelId` 解析为默认模型 id | +| ⑤ 场景选择 + 物理参数落地 | **逐项**比对类型化后的场景参数:重力 y、基础风 x/z、阵风幅度/频率、风坐标系、地面摩擦/坡度、碰撞体模式、出生点 yaw、相机 mode/distance/height、环境光与雾;另外校验 `Wind.sample(0)` 等于基础风;场景 GLB **可下载**且魔数与 `fileSize` 一致 | +| ② 动作内部标准 | `keypointFormat == mediapipe33@0.1.0`、服务端帧数、`is_scorable()`;拉取 NDJSON 后**客户端解析帧数 == 服务端帧数**、`skipped_lines == 0`、首帧 33 点、每帧带服务端关节角、时间轴推导帧率 ≈ 30 fps、时长 ≈ 96.7 s | +| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG | +| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 | + +## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL) + +``` +[integration] Windows 包内客户端 × 真实 Web 服务端:http://127.0.0.1:3100 + ✓ GET /health 200(服务端版本 0.7.4);契约 6 项全部一致 + ✓ 目录指纹非空;目录内嵌契约版本与 /health 一致 + ✓ 目录四类资源齐备:songs=1 motions=1 models=1 scenes=1 + ✓ defaultModelId 解析为 1 + ✓ 场景 glbUrl/fileSize;重力 y=-3.50;基础风 (2.0,0.0,-1.0);阵风 4.0 m/s @ 0.50 Hz + ✓ 风坐标系 world;地面摩擦/坡度 0.80/30°;碰撞体 mesh;出生点 yaw 90° + ✓ 相机 orbit/6.0/2.0;环境光 2.0/雾 0.20;风采样 t=0 等于基础风 + ✓ 场景 GLB 可下载且魔数正确(glTF,1153244 字节) + ✓ 动作内部标准 mediapipe33@0.1.0;客户端解析帧数 2901 == 服务端 2901 + ✓ 无解析失败行;首帧 33 点;每帧带服务端关节角;30.00 fps;96.67 s + ✓ 动作缩略图可下载且为 PNG + ✓ 模型格式 vrm、默认模型、文件可下载且 glTF 容器 +[integration] ✓ 跨端集成探针全部通过 +``` + +### 真实素材 + +播种用的都是仓库根 `示例文件/` 下的真实素材(不是编造的): +`【动作】霜雪千年.vmd`(827,973 B → 2901 帧内部标准)、 +`模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`(1.0 MB → VRM,humanoid 19/22、`drivable`)。 + +## 踩坑记录 + +1. **autoload 单例在 `--script` 模式下不是全局标识符**: + `AppConfig` / `CatalogStore` 直接当名字用会 `Compile Error: Identifier not found` + (脚本来自项目外,编译期不认这两个 autoload 名)。改为 + `root.get_node_or_null("AppConfig")` 取节点后动态调用。 +2. **协程里一处运行时错误会让进程永不退出**:`_finish()` 不会被调用, + 表现为「无输出卡住」,很难定位。已加 120 秒**看门狗**:超时判失败并退出。 +3. **输出缓冲**:`print()` 管道输出在进程被 SIGTERM 杀死时会丢, + 调试这类脚本要重定向到文件而不是 `| head`。 diff --git a/tests/test_api_integration.gd b/tests/test_api_integration.gd index f63c189..d1f654d 100644 --- a/tests/test_api_integration.gd +++ b/tests/test_api_integration.gd @@ -21,6 +21,12 @@ var _available := false var _unavailable_reason := "" +## 服务端基地址:默认契约里的 127.0.0.1:3000,可用 OPENDANCE_API_BASE 覆盖 +func _api_base() -> String: + var from_env := OS.get_environment("OPENDANCE_API_BASE").strip_edges() + return from_env if from_env != "" else "http://127.0.0.1:3000" + + func _ensure_client() -> bool: if _client != null: # 每次都要显式 skip:否则后续测试会静默 return, @@ -30,7 +36,7 @@ func _ensure_client() -> bool: return _available var tree := Engine.get_main_loop() as SceneTree - _client = ApiClient.new("http://127.0.0.1:3000") + _client = ApiClient.new(_api_base()) _client.timeout_seconds = 8.0 tree.root.add_child(_client) @@ -45,15 +51,37 @@ func _ensure_client() -> bool: return _available +## 契约版本**不能写死在测试里**:写死过一次(0.1.0),而这条用例只在 +## 「服务端可达」时才跑,平时一直是 skip,于是契约升到 0.5.0 后没有任何地方发现它。 +## 现在改成从仓库的 `contracts/VERSION`(三端逐字节一致的单一来源)读期望值, +## 并与服务端 `/health` 报的逐一比对 —— 顺带成为「客户端契约快照 vs 服务端」的跨端校验。 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") + var expected := _read_contract_snapshot() + ok(expected.size() > 0, "可读取仓库 contracts/VERSION(%d 项)" % expected.size()) + for key in expected.keys(): + eq(str(version.get(key, "")), str(expected[key]), "契约 %s 与仓库快照一致" % key) + + +## 解析 res://contracts/VERSION(`key: value`,`#` 注释) +func _read_contract_snapshot() -> Dictionary: + var out := {} + if not FileAccess.file_exists("res://contracts/VERSION"): + return out + var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ) + if file == null: + return out + while not file.eof_reached(): + var line := file.get_line().strip_edges() + if not line.is_empty() and not line.begins_with("#") and line.contains(":"): + var idx := line.find(":") + out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges() + file.close() + return out func test_catalog_version_returns_revision() -> void: @@ -159,3 +187,48 @@ func test_unknown_route_returns_structured_error() -> void: is_false(result["ok"], "不存在的资源返回失败") eq(int(result["status"]), 404, "状态码为 404") ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"])) + + +## T-WIN-019 回归:**二进制响应不得经过 UTF-8 字符串往返** +## +## 发现经过:跨端集成探针拉动作缩略图(PNG)时,控制台出现 +## 「Unicode parsing error … Invalid UTF-8 leading byte (89)」。 +## 根因是 `ApiClient._request()` 对所有响应都无条件执行 +## `raw.get_string_from_utf8()`:数据本身仍返回原始字节(所以之前没出错), +## 但每个文件下载都白做一份 String 拷贝并刷一条噪声日志。 +## 本用例把「必须原样返回字节」钉住:对含非法 UTF-8 的载荷, +## 字符串往返会改变长度,而我们的返回值必须与服务端声明的大小完全一致。 +func test_binary_download_returns_exact_bytes() -> void: + if not await _ensure_client(): + return + + var export_result: Dictionary = await _client.catalog_export() + if not export_result["ok"]: + skip("二进制下载", "catalog/export 不可用") + return + var bundle := CatalogTypes.CatalogBundle.from_dict(export_result["data"]) + + var url := "" + var expected_size := 0 + if bundle.scenes.size() > 0: + url = bundle.scenes[0].glb_url + expected_size = bundle.scenes[0].file_size + elif bundle.models.size() > 0: + url = bundle.models[0].file_url + expected_size = bundle.models[0].file_size + if url == "": + skip("二进制下载", "目录里没有可下载的模型/场景") + return + + var result: Dictionary = await _client.get_binary(url) + is_true(result["ok"], "二进制下载成功:%s" % url) + if not result["ok"]: + return + var payload: PackedByteArray = result["data"] + eq(typeof(payload), TYPE_PACKED_BYTE_ARRAY, "返回类型是 PackedByteArray") + eq(payload.size(), expected_size, "字节数与服务端声明的 fileSize 一致") + + # 证明「字符串往返会损坏数据」:这些字节里含非法 UTF-8 序列 + var round_tripped := payload.get_string_from_utf8().to_utf8_buffer() + ok(round_tripped.size() != payload.size() or round_tripped != payload, + "该载荷经 UTF-8 往返会失真(说明不能拿字符串通道传二进制)") diff --git a/tests/test_catalog_types.gd b/tests/test_catalog_types.gd new file mode 100644 index 0000000..cf2cf12 --- /dev/null +++ b/tests/test_catalog_types.gd @@ -0,0 +1,98 @@ +extends TestCase + +## T-WIN-019:目录类型的**契约字段覆盖** +## +## 来源:跨端集成探针(`build/integration/probe-integration.gd`)拿**发布包里的** +## 客户端打真实 Web 服务端时,用真实 `catalog/export` 报文逐字段比对, +## 发现 `Motion.thumbnailUrl`(契约 data-models 0.6.0 起就有)在客户端类型里**缺失** —— +## 服务端一直在发,客户端默默丢掉。本文件把这个缺口钉住。 +## +## 这里同时用**服务端真实报文形状**(键名与 `GET /api/catalog/export` 一致)做解析测试, +## 而不是自己编一份更"理想"的样例 —— 否则测试会跟着客户端的假设一起漂移。 + +const CatalogTypes = preload("res://src/net/catalog_types.gd") + +## 取自真实服务端报文的动作条目(截取与本端相关的字段,键名保持原样) +func _motion_payload() -> Dictionary: + return { + "id": 1, + "songId": 1, + "name": "霜雪千年(真实 VMD)", + "format": "vmd", + "fileUrl": "/static/motion/2026/09/a8/x.vmd", + "fileSize": 827973, + "fps": 30, + "frameCount": 2901, + "durationMs": 96700, + "keypointFormat": "mediapipe33@0.1.0", + "keypointUrl": "/static/keypoints/2026/09/7d/y.ndjson", + "thumbnailUrl": "/static/motion-thumbnail/2026/09/32/z.png", + "keypointFrames": 2901, + "preprocessState": "done", + "preprocessError": null, + "sourceType": "original", + "sourceName": "素材", + "sourceUrl": "https://example.com", + "license": "MIT", + "author": "QA", + "status": "published", + } + + +func test_motion_parses_all_contract_fields() -> void: + var m := CatalogTypes.Motion.from_dict(_motion_payload()) + eq(m.id, 1, "id") + eq(m.song_id, 1, "songId → song_id") + eq(m.format, "vmd", "format") + eq(m.fps, 30.0, "fps") + eq(m.frame_count, 2901, "frameCount → frame_count") + eq(m.duration_ms, 96700, "durationMs → duration_ms") + eq(m.keypoint_format, "mediapipe33@0.1.0", "keypointFormat → keypoint_format") + eq(m.keypoint_frames, 2901, "keypointFrames → keypoint_frames") + eq(m.preprocess_state, "done", "preprocessState → preprocess_state") + is_true(m.is_scorable(), "预处理完成 → 可评分") + # 缺口回归:这一条在集成探针发现前是失败的(客户端类型里没有这个字段) + eq(m.thumbnail_url, "/static/motion-thumbnail/2026/09/32/z.png", + "thumbnailUrl → thumbnail_url(T-WIN-019 缺字段回归)") + + +func test_motion_thumbnail_missing_is_empty_not_crash() -> void: + # 旧服务端(data-models < 0.6.0)不发 thumbnailUrl:必须是空串而不是解析失败 + var payload := _motion_payload() + payload.erase("thumbnailUrl") + var m := CatalogTypes.Motion.from_dict(payload) + eq(m.thumbnail_url, "", "缺 thumbnailUrl 时为空串") + eq(m.keypoint_frames, 2901, "其余字段仍正常解析") + + +func test_model_and_scene_thumbnail_fields_present() -> void: + # 三个资源类型现在都消费 thumbnailUrl(模型/场景本来就有,动作是本轮补的) + var model := CatalogTypes.Model.from_dict({ + "id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": true, + "fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png", + }) + eq(model.thumbnail_url, "/static/thumbnail/a.png", "Model.thumbnail_url") + eq(model.vrm_version, "1.0", "Model.vrm_version") + is_true(model.is_primary, "Model.is_primary") + + var scene := CatalogTypes.Scene.from_dict({ + "id": 1, "name": "s", "glbUrl": "/static/model/x.glb", + "thumbnailUrl": "/static/thumbnail/b.png", "fileSize": 20, "config": {}, + }) + eq(scene.thumbnail_url, "/static/thumbnail/b.png", "Scene.thumbnail_url") + eq(scene.glb_url, "/static/model/x.glb", "Scene.glb_url") + + +func test_intentionally_unconsumed_model_fields() -> void: + # 记录一处**刻意不消费**的差异,避免以后被当成漏字段: + # `usable` / `unusableReason` 是 Web 端审核用的(humanoid 不达标的模型「入库但标记不可用」), + # 而 `GET /api/catalog/export` 只下发**可用**模型,所以目录里它们恒为 true。 + # 客户端的 CatalogStore 只消费目录,故不解析;不可用模型的可见性由 Web 管理端负责。 + var payload := { + "id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": false, + "fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png", + "usable": true, "unusableReason": null, + } + var model := CatalogTypes.Model.from_dict(payload) + eq(model.id, 1, "usable/unusableReason 不影响其他字段解析") + is_false(model.is_primary, "isPrimary 仍按报文解析") diff --git a/tests/test_catalog_types.gd.uid b/tests/test_catalog_types.gd.uid new file mode 100644 index 0000000..3da2dcb --- /dev/null +++ b/tests/test_catalog_types.gd.uid @@ -0,0 +1 @@ +uid://dmk7mkmy7dwqf