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:
2026-09-17 05:18:03 +08:00
parent e4e534f2a2
commit a78a536476
15 changed files with 889 additions and 23 deletions
+77 -4
View File
@@ -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 往返会失真(说明不能拿字符串通道传二进制)")
+98
View File
@@ -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 仍按报文解析")
+1
View File
@@ -0,0 +1 @@
uid://dmk7mkmy7dwqf