Files
opendance-windows/build/integration/probe-integration.gd
T
zijunchiang a78a536476 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。
2026-09-17 05:18:03 +08:00

276 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)