- 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>
183 lines
9.9 KiB
TypeScript
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 ✓`)
|