Files
opendance-windows/build/integration/probe-integration.gd
T
zijunchiang cc8b6ba729 test(integration): 跨端验收补两类契约边界(不可用模型 / 场景 config 缺字段)
正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:

1. 不可用模型:用 Assimp 把真实 PMX 转成 GLB 后入库(等价"管线只产出 GLB"),
   Web 侧断言 usable=false、原因准确(骨架达标时不得说"humanoid 不达标")、
   不在 catalog/export、defaultModelId 不变;Windows 侧(包内探针)断言
   bundle.models 里没有任何非 VRM 模型、按 id 找不到该模型、defaultModelId 仍指向可用 VRM。
2. 场景 config 只给 {physics:{gravity:{y:-9.8}}}:Web 侧断言服务端补齐默认
   (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条);
   Windows 侧断言客户端解析出的**同一组默认值**逐项一致。

实测(Web v0.7.5 × Windows v0.2.5 包内 pck,真实 MySQL):
  ✓ usable=false 且原因为「未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标)…」
  ✓ 目录里的模型全是 VRM/不可用模型 #2 不在目录里/defaultModelId 仍为 1
  ✓ 最简配置:重力 -9.8 保留,碰撞体 auto、地面 0.6/45°、静风、相机 follow、
    环境光 1.0、出生点 1 条,两端逐项一致
跨端集成验收全部通过。

顺带修掉播种脚本自身的顺序错误(把依赖 catalog 的断言插到了 catalog 取回之前),
以及把断言改成「先取目录再断言」。不影响发布产物(build/ 已排除出 pck)。
2026-09-18 03:05:51 +08:00

315 lines
15 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"])
# ---------- 5b. ② 目录边界:不可用模型不得出现在客户端目录里(①)----------
var nonVrm: Array = []
for m in models:
if str(m.format) != "vrm":
nonVrm.append("%s(%s)" % [m.name, m.format])
_check(nonVrm.is_empty(),
"目录里的模型全是 VRM(不可用模型不进目录):%s" % ("OK" if nonVrm.is_empty() else str(nonVrm)))
if _expect.has("unusableModelId") and _expect["unusableModelId"] != null:
var unusable_id := int(_expect["unusableModelId"])
_check(bundle.find_model(unusable_id) == null,
"不可用模型 #%d 不在客户端目录里" % unusable_id)
_check(bundle.default_model_id == _expect["modelId"],
"defaultModelId 仍指向可用的 VRM 模型(实际 %d)" % bundle.default_model_id)
# ---------- 5c. ④ 场景 config 缺字段时的默认值(契约 0.5.0 边界)----------
if _expect.has("minimalSceneId") and _expect.has("minimalSceneExpect"):
var minimal: CatalogTypes.Scene = bundle.find_scene(int(_expect["minimalSceneId"]))
if minimal == null:
_fail("目录里找不到最简配置场景 #%d" % int(_expect["minimalSceneId"]))
else:
var want_min: Dictionary = _expect["minimalSceneExpect"]
var cfg2: CatalogTypes.SceneConfig = minimal.config
_check(_near(cfg2.physics.gravity.y, float(want_min["gravityY"])),
"最简配置:重力保留 %.1f" % cfg2.physics.gravity.y)
_check(cfg2.physics.colliders == str(want_min["colliders"]),
"最简配置:碰撞体补默认 %s" % cfg2.physics.colliders)
_check(_near(cfg2.physics.ground.friction, float(want_min["groundFriction"]))
and _near(cfg2.physics.ground.slope_limit, float(want_min["groundSlopeLimit"])),
"最简配置:地面默认 摩擦 %.2f / 坡度 %.0f°" % [
cfg2.physics.ground.friction, cfg2.physics.ground.slope_limit])
_check(cfg2.physics.wind.is_calm() == bool(want_min["windCalm"]),
"最简配置:风默认静风(is_calm=%s)" % str(cfg2.physics.wind.is_calm()))
_check(cfg2.camera.mode == str(want_min["cameraMode"]),
"最简配置:相机默认 %s" % cfg2.camera.mode)
_check(_near(cfg2.environment.ambient_intensity, float(want_min["ambientIntensity"])),
"最简配置:环境光默认 %.1f" % cfg2.environment.ambient_intensity)
_check(cfg2.spawn_points.size() <= 1,
"最简配置:出生点默认最多一条(实际 %d)" % cfg2.spawn_points.size())
# ---------- 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)