BLOBBO/src/editor/manifest-io.test.ts
type-two 2823c93382 Wire the Workshop: registry boot, course.tramp slot, editor in the production build
- src/main.ts: await initAssets() before the world is built (createBlob captures
  blob.mesh by reference). Safe: preload races each url against a deadline with
  allSettled, so a stalled asset is a slow boot on built-ins, never a hang.
- src/game.ts: course.tramp hook (hides the MATERIAL — the custom model is a
  child, so hiding the mesh would hide it too). Collider untouched.
- vite.config.ts: editor.html as a build input. Without it the Workshop existed
  only under vite dev — invisible in the production deploy it is built for.
- editor cannon.barrel: anchor the preview at the cannon group's origin (where
  the runtime attaches it) while still hiding the pivot's tube. Preview was
  0.45m above where the game renders it — WYSIWYG off by a measurable amount.
- manifest-io.test: drop the stale NOT_YET_IN_THE_RUNTIME guard now that the
  runtime adopted all four ids; the check is strict both ways, which is what
  stops the two halves drifting again.

Verified: 10/10 test suites (360 assertions), sacred-parity fingerprint
49df4f20 unchanged from pre-workshop main, dist/editor.html + dist/assets/
manifest.json ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:23 +10:00

183 lines
9.9 KiB
TypeScript

/**
* Unit checks for the editor's pure core. No test runner / no deps — run directly:
* node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
* src/editor/manifest-io.test.ts
* (from the repo root; the --import hook teaches node the repo's extension-less
* imports, which its ESM resolver rejects on its own). Same shape as
* paint/coverage-math.test.ts: console + throw only, never bundled.
*/
import {
IDB_README, SHIPPABLE_README,
cleanManifest, composeFit, idbKey, identityTransform, isIdbUrl, isNeutralFit,
manifestsEqual, parseEntry, parseManifest, serializeManifest,
} from './manifest-io'
import type { Manifest } from './manifest-io'
import { buildTinyGlb, isGlb } from './tiny-glb'
import { SLOTS, SLOT_IDS, slotById } from './slots'
// node:fs, reached through a computed specifier so tsc (whose lib is DOM-only,
// and whose config this lane may not edit) never tries to resolve it. Only this
// file — which never ships — touches it.
const { readFileSync } = await import('node' + ':fs')
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function eq(a: unknown, b: unknown, msg: string): void {
ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
}
// ---- the slot list, the stage and the runtime ------------------------------
// Static source check: the stage builds its objects inside WebGL-only code that
// can't run here, but the registration calls are greppable.
//
// The three lists are deliberately NOT required to be equal, because two honest
// gaps exist and hiding either one is how the editor started lying:
//
// NOT_IN_THIS_COURSE — the runtime supports the slot but this course builds no
// such object (nothing calls createFan or createSeeSaw). The editor still
// LISTS these, marked unavailable, rather than pretending the game has no
// fan slot at all. `stage.slots.has(id)` is what the UI tests at run time.
// The runtime has since adopted cannon.base / course.tramp / course.tunnel /
// course.finish, so the editor may offer every id it registers: the check below
// is now strict in both directions. It is the guard that keeps the two halves
// from drifting again — a slot the stage previews but the runtime would discard
// is exactly the bug that silently ate a reskin before the vocabularies merged.
{
const stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
const registered = new Set(
[...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
const knownIds = new Set<string>(SLOT_IDS)
const NOT_IN_THIS_COURSE = new Set(['machine.fan', 'machine.seesaw'])
for (const id of SLOT_IDS) {
ok(registered.has(id) || NOT_IN_THIS_COURSE.has(id),
`the stage builds something for the "${id}" slot, or it is a known course gap`)
}
for (const id of registered) {
ok(knownIds.has(id),
`stage.ts registers "${id}", which the runtime knows about`)
}
}
// ---- slot catalogue --------------------------------------------------------
{
ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'slot ids are unique')
ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0), 'every slot is labelled')
ok(SLOTS.every((s) => !/[._]/.test(s.label)), 'no slot id leaks into an on-screen label')
const derived = SLOTS.filter((s) => s.derivedFrom)
ok(derived.length > 0, 'at least one derived slot exists (the ghost)')
ok(derived.every((s) => slotById(s.derivedFrom!) !== undefined), 'derived slots point at real slots')
ok(SLOTS.filter((s) => s.paintable).length === 1, 'exactly one paintable slot (the body)')
}
// ---- entry parsing ---------------------------------------------------------
{
ok(parseEntry(null) === null, 'null is not an entry')
ok(parseEntry({}) === null, 'an entry without a url is dropped')
ok(parseEntry({ url: '' }) === null, 'an empty url is dropped')
eq(parseEntry({ url: 'a.glb' }), { url: 'a.glb' }, 'a bare url survives')
eq(parseEntry({ url: 'a.glb', offset: [1, 2] }), { url: 'a.glb' }, 'a short offset is dropped')
eq(parseEntry({ url: 'a.glb', offset: [1, 'x', 3] }), { url: 'a.glb' }, 'a non-numeric offset is dropped')
eq(parseEntry({ url: 'a.glb', scale: 2 }), { url: 'a.glb', scale: 2 }, 'a uniform scale survives')
eq(parseEntry({ url: 'a.glb', scale: [1, 2, 3] }), { url: 'a.glb', scale: [1, 2, 3] }, 'a triple scale survives')
eq(parseEntry({ url: 'a.glb', idleClip: 'idle' }), { url: 'a.glb', idleClip: 'idle' }, 'idleClip survives')
}
// ---- manifest parsing is total --------------------------------------------
{
eq(parseManifest('not json at all'), {}, 'bad JSON parses to an empty manifest')
eq(parseManifest('[1,2,3]'), {}, 'an array parses to an empty manifest')
eq(parseManifest('null'), {}, 'null parses to an empty manifest')
eq(parseManifest('{}'), {}, 'an empty object parses to an empty manifest')
eq(parseManifest('{"machine.boot":{"url":"a.glb"},"bad":7}'),
{ 'machine.boot': { url: 'a.glb' } }, 'malformed slots are skipped, good ones kept')
eq(parseManifest(`{"_readme":"hi","machine.boot":{"url":"a.glb"}}`),
{ 'machine.boot': { url: 'a.glb' } }, '_readme is metadata, not a slot')
}
// ---- round trip ------------------------------------------------------------
{
const m: Manifest = {
'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb', offset: [0, 0.2, 0], rotationDeg: [0, 90, 0], scale: 1.4 },
'blob.body': { url: 'assets/live/blobbo.glb', scale: [1, 1.2, 1], idleClip: 'idle' },
}
const text = serializeManifest(m)
const back = parseManifest(text)
ok(manifestsEqual(m, back), 'serialize → parse round-trips exactly')
eq(back, cleanManifest(m), 'the round trip deep-equals the cleaned manifest')
ok(JSON.parse(text)._readme === IDB_README, 'an export holding idb: urls carries the commit-it note')
ok(Object.keys(JSON.parse(text)).indexOf('_readme') === 0, '_readme comes first so it reads like a header')
ok(text.endsWith('\n'), 'the export ends with a newline (diff-friendly)')
const shippable: Manifest = { 'machine.boot': { url: 'assets/live/boot.glb' } }
const shippableDoc = JSON.parse(serializeManifest(shippable))
ok(shippableDoc._readme === SHIPPABLE_README, 'a shippable export gets the ship-it note')
ok(shippableDoc._readme !== IDB_README, 'a shippable export does NOT nag about idb urls')
// key order must not change equality
const reordered: Manifest = { 'blob.body': m['blob.body']!, 'machine.boot': m['machine.boot']! }
ok(manifestsEqual(m, reordered), 'slot order does not affect manifest equality')
ok(Object.keys(cleanManifest(reordered))[0] === 'blob.body', 'exports are sorted by slot')
}
// ---- idb urls --------------------------------------------------------------
{
ok(isIdbUrl('idb:x'), 'idb: prefix detected')
ok(!isIdbUrl('assets/live/x.glb'), 'a normal path is not an idb url')
ok(idbKey('idb:machine.boot-a.glb') === 'machine.boot-a.glb', 'the key is everything after idb:')
ok(idbKey('assets/live/x.glb') === '', 'a non-idb url has no key')
}
// ---- fit maths -------------------------------------------------------------
{
const base = { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [1, 1, 1] } as const
const neutral = composeFit({ ...base, position: [...base.position], rotationDeg: [...base.rotationDeg], scale: [...base.scale] } as never, { url: 'a.glb' })
eq(neutral.position, [1, 2, 3], 'an empty fit leaves the asset exactly on the procedural pose')
eq(neutral.rotationDeg, [0, 45, 0], 'an empty fit keeps the procedural rotation')
eq(neutral.scale, [1, 1, 1], 'an empty fit keeps the procedural scale')
const fitted = composeFit(
{ position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [2, 2, 2] },
{ url: 'a.glb', offset: [0, 0.5, -1], rotationDeg: [0, 45, 10], scale: 3 })
eq(fitted.position, [1, 2.5, 2], 'offsets add to the base position')
eq(fitted.rotationDeg, [0, 90, 10], 'rotations add to the base rotation')
eq(fitted.scale, [6, 6, 6], 'scale multiplies the base scale')
const nonUniform = composeFit(identityTransform(), { url: 'a.glb', scale: [1, 2, 3] })
eq(nonUniform.scale, [1, 2, 3], 'a triple scale is applied per axis')
ok(isNeutralFit({ url: 'a.glb' }), 'a bare entry is a neutral fit')
ok(isNeutralFit({ url: 'a.glb', offset: [0, 0, 0], rotationDeg: [0, 0, 0], scale: 1 }), 'explicit zeros are neutral')
ok(!isNeutralFit({ url: 'a.glb', offset: [0, 0.1, 0] }), 'any offset is not neutral')
ok(!isNeutralFit({ url: 'a.glb', scale: 1.0001 }), 'any scale change is not neutral')
}
// ---- tiny glb --------------------------------------------------------------
{
const glb = buildTinyGlb()
ok(isGlb(glb), 'the generated buffer has a valid GLB header and length')
ok(glb.byteLength % 4 === 0, 'the GLB total length is 4-byte aligned')
const view = new DataView(glb)
const jsonLen = view.getUint32(12, true)
ok(jsonLen % 4 === 0, 'the JSON chunk is padded to 4 bytes')
const json = JSON.parse(new TextDecoder().decode(new Uint8Array(glb, 20, jsonLen)).trim())
ok(json.asset.version === '2.0', 'the glTF asset version is 2.0')
ok(json.meshes[0].primitives[0].attributes.TEXCOORD_0 !== undefined, 'the quad ships UVs by default')
ok(json.materials.length === 1, 'the quad has exactly one material')
const binStart = 20 + jsonLen
ok(view.getUint32(binStart + 4, true) === 0x004e4942, 'the second chunk is the BIN chunk')
ok(binStart + 8 + view.getUint32(binStart, true) === glb.byteLength, 'the BIN chunk fills the rest of the file')
const noUv = buildTinyGlb({ withUv: false })
ok(isGlb(noUv), 'the UV-less variant is still a valid GLB')
const noUvLen = new DataView(noUv).getUint32(12, true)
const noUvJson = JSON.parse(new TextDecoder().decode(new Uint8Array(noUv, 20, noUvLen)).trim())
ok(noUvJson.meshes[0].primitives[0].attributes.TEXCOORD_0 === undefined, 'the UV-less variant really has no UVs')
ok(!isGlb(new ArrayBuffer(8)), 'a stub buffer is not a GLB')
}
console.log(`editor manifest-io.test: ${passed} assertions passed ✓`)