/** * 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(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 ✓`)