BLOBBO/src/assets/manifest.test.ts
Claude 324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
  GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
  layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
  no collider, physics or logic line is touched. Animated sub-parts (plate cap,
  belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
  cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
  would have 404'd in production. public/ is vite's default publicDir, so this
  needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
  raycast against the fitted body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00

93 lines
4.3 KiB
TypeScript

/**
* Unit checks for the manifest schema. No test runner / no deps — run directly:
* node --experimental-strip-types src/assets/manifest.test.ts
* (Node ≥22; on Node ≥23 the flag is unnecessary.) Same pattern as
* paint/coverage-math.test.ts: console + throw only, never bundled.
*
* The manifest is hand-edited by a non-programmer, so the property under test
* is "one bad line never costs you the whole pack".
*/
import { validateManifest, mergeManifests, type Manifest } from './manifest'
import { SLOT_IDS, isSlotId, SLOT_LABELS } from './slots'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
// ---- garbage in, empty manifest out (never throws) --------------------------
for (const junk of [null, undefined, 42, 'nope', [], true]) {
const r = validateManifest(junk)
ok(Object.keys(r.manifest).length === 0, `junk input ${JSON.stringify(junk)} -> {}`)
}
// ---- the shipped empty manifest ---------------------------------------------
ok(Object.keys(validateManifest(JSON.parse('{}')).manifest).length === 0, 'empty {} -> {}')
ok(validateManifest({}).problems.length === 0, 'empty {} has no problems')
// ---- a good entry survives intact -------------------------------------------
{
const r = validateManifest({
'machine.boot': {
url: 'assets/live/boot.glb',
offset: [0, 1, 0],
rotationDeg: [0, 90, 0],
scale: 2,
idleClip: 'idle',
},
})
const e = r.manifest['machine.boot']
ok(r.problems.length === 0, 'clean entry produces no problems')
ok(e?.url === 'assets/live/boot.glb', 'url kept')
ok(e?.offset?.[1] === 1, 'offset kept')
ok(e?.rotationDeg?.[1] === 90, 'rotationDeg kept')
ok(e?.scale === 2, 'scale kept')
ok(e?.idleClip === 'idle', 'idleClip kept')
}
// ---- one bad line does not cost you the pack --------------------------------
{
const r = validateManifest({
'machine.boot': { url: 'good.glb' },
'machine.bucket': { url: 'also-good.glb', offset: 'sideways' },
'not.a.slot': { url: 'x.glb' },
'machine.arch': { nourl: true },
'machine.fan': 'just a string',
})
ok(r.manifest['machine.boot'] !== undefined, 'good entry survives a bad neighbour')
ok(r.manifest['machine.bucket'] !== undefined, 'entry with a bad field still loads')
ok(r.manifest['machine.bucket']?.offset === undefined, 'bad offset dropped, not kept')
ok(r.manifest['machine.arch'] === undefined, 'entry without a url is dropped')
ok((r.manifest as Record<string, unknown>)['not.a.slot'] === undefined, 'unknown slot dropped')
ok(r.problems.length === 4, `4 problems reported (got ${r.problems.length})`)
ok(r.problems.some((p) => p.slot === 'not.a.slot'), 'unknown slot is named in problems')
}
// ---- scale accepts a number or a triple, rejects zero -----------------------
ok(validateManifest({ 'machine.fan': { url: 'a', scale: [1, 2, 3] } }).manifest['machine.fan']?.scale !== undefined, 'triple scale ok')
ok(validateManifest({ 'machine.fan': { url: 'a', scale: 0 } }).manifest['machine.fan']?.scale === undefined, 'zero scale rejected')
ok(validateManifest({ 'machine.fan': { url: 'a', offset: [1, 2, NaN] } }).manifest['machine.fan']?.offset === undefined, 'NaN offset rejected')
// ---- merge: later source wins whole entries ---------------------------------
{
const shipped: Manifest = {
'machine.boot': { url: 'shipped.glb', scale: 3 },
'machine.fan': { url: 'fan.glb' },
}
const local: Manifest = { 'machine.boot': { url: 'local.glb' } }
const m = mergeManifests(shipped, local)
ok(m['machine.boot']?.url === 'local.glb', 'local override wins')
ok(m['machine.boot']?.scale === undefined, 'override replaces the whole entry, not per-field')
ok(m['machine.fan']?.url === 'fan.glb', 'untouched shipped entry survives the merge')
ok(shipped['machine.boot']?.url === 'shipped.glb', 'merge does not mutate its inputs')
}
// ---- slot table is coherent --------------------------------------------------
ok(new Set(SLOT_IDS).size === SLOT_IDS.length, 'no duplicate slot ids')
ok(SLOT_IDS.every((s) => typeof SLOT_LABELS[s] === 'string'), 'every slot has a plain-language label')
ok(SLOT_IDS.every(isSlotId), 'every slot id passes isSlotId')
ok(!isSlotId('blob.bodyy') && !isSlotId(''), 'isSlotId rejects near-misses')
console.log(`manifest.test.ts: ${passed} checks passed`)