feat(integration): 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 修两个真实缺陷(v0.2.3)
单测打的是假 HTTP 夹具,Web 端测试只验自己那一侧 —— 两端各自绿灯不代表契约字段
真的对得上。本版加了跨端集成验收,并在首次运行就抓到两个真实缺陷。
一、跨端集成验收(T-WIN-019)
* build/integration/probe-integration.gd:只加载**包内**代码
(`godot --main-pack <pck> --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。
This commit is contained in:
Executable
+135
@@ -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 <zip> --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 "============================================================"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dexb1tstsyjuh
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user