Files
opendance-windows/tests/test_api_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

235 lines
9.9 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 TestCase
## T-WIN-002 集成测试:直接调用**真实运行中**的 Web 服务端。
##
## 若服务端不可达则整组跳过(不伪装成通过)。
## 启动服务端:cd opendance-web/web-server && npm run start:prod
##
## 覆盖:
## - GET /health、/api/catalog/version、/api/catalog/export 的真实响应
## - NDJSON 关键点序列解析
## - **跨实现交叉校验**:本端 PoseFeatures 计算的 9 个关节角
## 与服务端 TypeScript 实现算出的 angles 必须一致
const ApiClient = preload("res://src/net/api_client.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
const PoseFeatures = preload("res://src/score/pose_features.gd")
var _client: ApiClient = null
var _available := false
var _unavailable_reason := ""
## 服务端基地址:默认契约里的 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,
# 被运行器判为「未执行任何断言」
if not _available:
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
var tree := Engine.get_main_loop() as SceneTree
_client = ApiClient.new(_api_base())
_client.timeout_seconds = 8.0
tree.root.add_child(_client)
var result: Dictionary = await _client.health()
_available = result["ok"] and int(result["status"]) == 200
if _available:
var data: Dictionary = result["data"]
ok(bool(data.get("database", {}).get("connected", false)), "服务端报告数据库已连接")
else:
_unavailable_reason = str(result.get("error", ""))
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
## 契约版本**不能写死在测试里**:写死过一次(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", {})
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:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_version()
is_true(result["ok"], "GET /api/catalog/version 成功")
var revision := str(result["data"].get("revision", ""))
eq(revision.length(), 64, "revision 为 64 位十六进制指纹")
func test_catalog_export_parses_into_typed_objects() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_export()
is_true(result["ok"], "GET /api/catalog/export 成功")
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(result["data"])
not_null(bundle, "目录解析成功")
eq(bundle.songs.size(), result["data"].get("songs", []).size(), "曲目数量一致")
eq(bundle.motions.size(), result["data"].get("motions", []).size(), "动作数量一致")
eq(bundle.models.size(), result["data"].get("models", []).size(), "模型数量一致")
# 契约 §4.1:defaultModelId 由服务端解析,客户端直接用
var resolved := bundle.default_model()
if bundle.models.size() > 0:
not_null(resolved, "存在已发布模型时可解析出默认模型")
if resolved != null:
eq(resolved.status, "published", "默认模型状态为 published")
else:
is_null(resolved, "无已发布模型时默认模型为 null")
# 契约要求来源五元组齐备
for song in bundle.songs:
is_true(song.source.is_complete(), "曲目「%s」来源五元组齐备" % song.title)
for model in bundle.models:
is_true(model.source.is_complete(), "模型「%s」来源五元组齐备" % model.name)
func test_keypoint_sequence_matches_contract_and_local_math() -> void:
if not await _ensure_client():
return
var export_result: Dictionary = await _client.catalog_export()
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(export_result["data"])
var target: CatalogTypes.Motion = null
for motion in bundle.motions:
if motion.is_scorable():
target = motion
break
if target == null:
skip("关键点序列", "服务端暂无已完成预处理的动作(先上传并预处理一个 BVH)")
return
eq(target.keypoint_format, KeypointSequence.EXPECTED_FORMAT, "keypointFormat 与契约一致")
var sequence_result: Dictionary = await _client.motion_keypoints(target.id)
is_true(sequence_result["ok"], "GET /api/motions/%d/keypoints 成功" % target.id)
var sequence: KeypointSequence.ParsedSequence = KeypointSequence.parse_ndjson(str(sequence_result["data"]))
eq(sequence.skipped_lines, 0, "NDJSON 无跳过行")
eq(sequence.frame_count(), target.keypoint_frames, "帧数与 motion.keypointFrames 一致")
ok(sequence.frame_count() > 0, "序列非空")
# 每帧 33 点(一次性校验,避免刷屏式断言)
var shape_ok := true
for frame in sequence.frames:
if frame.points.size() != 33 or frame.confidence.size() != 33:
shape_ok = false
break
is_true(shape_ok, "每帧均为 33 个关键点 + 33 个置信度")
# 坐标落在 MediaPipe 归一化范围
var first: KeypointFrame.PoseFrame = sequence.frames[0]
var in_range := true
for p in first.points:
if p.x < -0.001 or p.x > 1.001 or p.y < -0.001 or p.y > 1.001:
in_range = false
break
is_true(in_range, "关键点坐标落在归一化范围")
# 帧率推导合理
var fps := sequence.estimated_fps()
ok(fps > 1.0 and fps < 240.0, "由时间戳推导的帧率合理", "实际 %.2f" % fps)
# ★ 跨实现交叉校验:服务端 TypeScript 与本端 GDScript 的关节角必须一致。
# GDScript 的 % 运算符不支持 %e,故用 str() 输出浮点。
var comparison := KeypointSequence.compare_angles(sequence, 1e-3)
ok(int(comparison["checked"]) > 0, "存在可比对的服务端关节角", "比对帧数 %d" % int(comparison["checked"]))
ok(
int(comparison["mismatched_frames"]) == 0,
"所有帧的 9 个关节角与服务端实现一致",
"不一致帧数 %d,最大偏差 %s" % [int(comparison["mismatched_frames"]), str(comparison["max_delta"])]
)
ok(float(comparison["max_delta"]) < 1e-3, "关节角最大偏差小于 1e-3", "实际 %s" % str(comparison["max_delta"]))
func test_unknown_route_returns_structured_error() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.get_json("/api/songs/99999999")
is_false(result["ok"], "不存在的资源返回失败")
eq(int(result["status"]), 404, "状态码为 404")
ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"]))
## 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 往返会失真(说明不能拿字符串通道传二进制)")