From cc8b6ba729c70a1136769bec541b1975097b5b0e Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Fri, 18 Sep 2026 03:05:51 +0800 Subject: [PATCH] =?UTF-8?q?test(integration):=20=E8=B7=A8=E7=AB=AF?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E8=A1=A5=E4=B8=A4=E7=B1=BB=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=EF=BC=88=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20/=20=E5=9C=BA=E6=99=AF=20config=20=E7=BC=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 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)。 --- README.md | 7 +- build/integration/probe-integration.gd | 39 +++++++++ build/integration/seed-catalog.mjs | 105 ++++++++++++++++++++++++- tasks/019-cross-end-integration.md | 26 ++++++ 4 files changed, 173 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2539442..fee99f7 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,12 @@ GODOT_BIN=... bash build/accept-ws.sh --skip-slow * `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧 `frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。 -实测(已发布的 v0.2.3 资产):客户端 **30 通过 / 0 失败** + 服务端侧 16 项事实全过, +**契约边界场景**(配合 Web v0.7.5):① **不可用模型**(真实 PMX 经 Assimp 转 GLB 后入库) +必须 `usable=false`、**不进入** `catalog/export`、不影响 `defaultModelId`,且包内客户端 +`bundle.models` 里没有任何非 VRM 模型;② 场景 `config` **只给最少字段**时,服务端补齐的默认值 +与客户端解析出的默认值**逐项一致**(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`…)。 + +实测(已发布的 v0.2.5 资产):客户端 **40 通过 / 0 失败** + 服务端侧 16 项事实全过, 其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接 **仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。 反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明 diff --git a/build/integration/probe-integration.gd b/build/integration/probe-integration.gd index 2bb6b58..a825494 100644 --- a/build/integration/probe-integration.gd +++ b/build/integration/probe-integration.gd @@ -245,6 +245,45 @@ func _run() -> void: 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: diff --git a/build/integration/seed-catalog.mjs b/build/integration/seed-catalog.mjs index 65c720e..acbebcb 100644 --- a/build/integration/seed-catalog.mjs +++ b/build/integration/seed-catalog.mjs @@ -3,14 +3,17 @@ * 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM; * 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。 */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { dirname, join } 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'; +/** 用于把真实 PMX 转成 GLB(模拟"转换只产出 GLB"的入库形态) */ +const ASSIMP_BIN = process.env.ASSIMP_BIN ?? '/usr/bin/assimp'; const SOURCE = { sourceType: 'original', sourceName: '跨端集成验收素材', @@ -102,8 +105,67 @@ const run = async () => { must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`); const sceneId = scene.body.id; + // ---------- 边界 A:不可用模型(只有 GLB、没有 VRM 容器)---------- + // 形态等价于"管线只产出了 GLB":契约 model-formats §1.1 要求这类模型 + // **可入库、可审核,但 usable=false,且不进入 catalog/export**(① 的硬性要求)。 + let unusable = null; + try { + const pmxZip = `${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`; + const entries = execFileSync('unzip', ['-Z1', pmxZip], { encoding: 'utf8' }) + .split('\n').map((line) => line.trim()) + .filter((line) => line.toLowerCase().endsWith('.pmx')); + if (entries.length === 0) throw new Error('压缩包里没有 .pmx'); + const pmxPath = join('/tmp', 'opendance-seed-model.pmx'); + const glbPath = join('/tmp', 'opendance-seed-model.glb'); + execFileSync('unzip', ['-p', pmxZip, entries[0]], { stdio: ['ignore', openSync(pmxPath, 'w'), 'ignore'] }); + execFileSync(ASSIMP_BIN, ['export', pmxPath, glbPath], { stdio: 'ignore' }); + const glb = readFileSync(glbPath); + must(glb.subarray(0, 4).toString('ascii') === 'glTF', `Assimp 转出 GLB(${glb.length} 字节)`); + const created = await request('POST', '/api/models', { + form: fileForm({ name: '不可用模型(仅 GLB,未产出 VRM)', ...SOURCE }, 'unusable.glb', glb, 'model/gltf-binary'), + }); + must(created.status === 201, '上传仅 GLB 的模型', `${created.status} ${JSON.stringify(created.body)}`); + const id = created.body.id; + must(created.body.format === 'glb', `入库格式为 glb(实际 ${created.body.format})`); + must(created.body.usable === false, '该模型标记为不可用(usable=false)', + JSON.stringify(created.body.unusableReason)); + const approve = await request('POST', `/api/reviews/model/${id}/approve`, { + json: { reviewer: 'qa', comment: '通过但不设为默认' }, + }); + must(approve.status === 200 || approve.status === 201, '审核通过(不设为默认模型)', String(approve.status)); + const detail = await request('GET', `/api/models/${id}`); + const reason = String(detail.body?.unusableReason ?? ''); + const drivable = detail.body?.stats?.drivable === true; + must(reason.includes('未产出 VRM'), `不可用原因说明"未产出 VRM":${reason}`); + // 归因准确性(T-WEB-017):骨架已达标时**不得**说"humanoid 不达标" + if (drivable) { + must(!reason.includes('humanoid 不达标'), + `骨架达标(drivable=true,humanoid ${detail.body?.stats?.humanoidMapped}/${detail.body?.stats?.humanoidTotal})` + + `时不得归因于 humanoid:${reason}`); + } else { + console.log(` · 该 GLB 的 drivable=false(humanoid ${detail.body?.stats?.humanoidMapped}/` + + `${detail.body?.stats?.humanoidTotal}),原因归因于 humanoid 属正确`); + } + unusable = { id, reason, drivable }; + rmSync(pmxPath, { force: true }); + rmSync(glbPath, { force: true }); + } catch (error) { + console.log(` · 跳过不可用模型场景(${error.message})—— 需要 unzip 与 ASSIMP_BIN`); + } + + // ---------- 边界 B:场景 config 只给最少字段,服务端必须补齐默认 ---------- + const minimalConfig = { physics: { gravity: { y: -9.8 } } }; + const minimalScene = await request('POST', '/api/scenes', { + form: fileForm( + { name: '最简配置场景(只有 gravity)', config: JSON.stringify(minimalConfig), ...SOURCE }, + 'minimal.glb', glbBytes, 'model/gltf-binary', + ), + }); + must(minimalScene.status === 201, '上传最简配置场景', `${minimalScene.status} ${JSON.stringify(minimalScene.body)}`); + const minimalSceneId = minimalScene.body.id; + // ---------- 审核发布 ---------- - for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) { + for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId], ['scene', minimalSceneId]]) { // 契约:**上传即 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)); @@ -122,6 +184,31 @@ const run = async () => { 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 modelsInCatalog = (catalog.body?.models ?? []); + const nonVrm = modelsInCatalog.filter((m) => m.format !== 'vrm'); + must(nonVrm.length === 0, '目录里的模型全是 VRM(不可用模型不进目录)', JSON.stringify(nonVrm)); + if (unusable) { + must(!modelsInCatalog.some((m) => m.id === unusable.id), `不可用模型 #${unusable.id} 不在目录里`); + must(catalog.body?.defaultModelId === modelId, + `defaultModelId 仍是可用的 VRM 模型(实际 ${catalog.body?.defaultModelId},期望 ${modelId})`); + } + + // 最简配置场景:服务端必须补齐默认值(契约 0.5.0 的字段边界) + const minimalEntry = (catalog.body?.scenes ?? []).find((s) => s.id === minimalSceneId); + must(minimalEntry !== undefined, `最简配置场景 #${minimalSceneId} 在目录里`); + const mc = minimalEntry?.config ?? {}; + must(mc.physics?.gravity?.y === -9.8, `重力保留(${JSON.stringify(mc.physics?.gravity)})`); + must(mc.physics?.colliders === 'auto', `碰撞体补默认 auto(实际 ${mc.physics?.colliders})`); + must(mc.physics?.ground?.friction === 0.6 && mc.physics?.ground?.slopeLimit === 45, + `地面补默认 0.6/45°(实际 ${JSON.stringify(mc.physics?.ground)})`); + must(mc.physics?.wind?.gustAmplitude === 0 && mc.physics?.wind?.base?.x === 0, + `风补默认(静风):${JSON.stringify(mc.physics?.wind)}`); + must(mc.camera?.mode === 'follow', `相机补默认 follow(实际 ${mc.camera?.mode})`); + must(mc.environment?.ambientIntensity === 1, `环境光补默认 1(实际 ${mc.environment?.ambientIntensity})`); + must(mc.spawnPoints === undefined || mc.spawnPoints.length <= 1, + `出生点补默认(最多一条):${JSON.stringify(mc.spawnPoints)}`); + const version = await request('GET', '/api/catalog/version'); console.log(` 目录指纹:${JSON.stringify(version.body)}`); @@ -135,6 +222,18 @@ const run = async () => { keypointFormat: pre.body?.keypointFormat ?? null, catalogRevision: version.body?.revision ?? null, contractVersions: health.body?.contractVersion ?? {}, + unusableModelId: unusable?.id ?? null, + unusableDrivable: unusable?.drivable ?? null, + minimalSceneId, + minimalSceneExpect: { + gravityY: -9.8, + colliders: 'auto', + groundFriction: 0.6, + groundSlopeLimit: 45, + windCalm: true, + cameraMode: 'follow', + ambientIntensity: 1, + }, }, null, 2)); console.log(`\n期望值已写入 ${EXPECT_PATH}`); }; diff --git a/tasks/019-cross-end-integration.md b/tasks/019-cross-end-integration.md index dcb8634..0735b89 100644 --- a/tasks/019-cross-end-integration.md +++ b/tasks/019-cross-end-integration.md @@ -52,6 +52,32 @@ Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真 | ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG | | ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 | +## 契约边界场景(续,配合 Web v0.7.5) + +正常路径之外的**字段边界**同样只在跨端跑起来才会暴露。`seed-catalog.mjs` 与 +`probe-integration.gd` 因此增加两类场景,**两端都断言**: + +| 场景 | Web 侧(播种脚本断言) | Windows 侧(包内探针断言) | +|--|--|--| +| **不可用模型**:真实 PMX 用 Assimp 转 GLB 后入库(等价"管线只产出 GLB") | `usable=false`;原因准确(骨架达标时**不得**说"humanoid 不达标");**不在** `catalog/export`;`defaultModelId` 不变 | `bundle.models` 里没有任何非 VRM 模型;按 id 找不到该模型;`defaultModelId` 仍指向可用的 VRM | +| **场景 config 只给最少字段**(`{physics:{gravity:{y:-9.8}}}`) | 服务端补齐默认(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`、环境光 `1`、出生点 1 条) | 客户端解析出的**同一组默认值**逐项一致 | + +实测(Web v0.7.5 × Windows v0.2.5 包内 pck): + +``` +✓ 该模型标记为不可用(usable=false) +✓ 不可用原因说明"未产出 VRM":未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录 +✓ 骨架达标(drivable=true,humanoid 19/22)时不得归因于 humanoid +✓ 目录里的模型全是 VRM(不可用模型不进目录)/✓ 不可用模型 #2 不在客户端目录里 +✓ defaultModelId 仍是可用的 VRM 模型(实际 1,期望 1) +✓ 最简配置:重力保留 -9.8 / 碰撞体补默认 auto / 地面默认 0.60 / 45° +✓ 最简配置:风默认静风(is_calm=true)/ 相机默认 follow / 环境光默认 1.0 / 出生点 1 条 +``` + +> 这两类场景的价值在**同一份契约的两侧解释**:Web 认为"不可用"的模型长什么样、 +> 客户端是否真的看不到它;以及"缺字段"时两端的默认值是否逐项一致 +> (服务端补齐 vs 客户端收敛,任一侧不一致都会表现为"配置看起来生效了但行为不对")。 + ## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL) ```