// timeline_test.mjs — Lane B M1 self-check. Runs under plain `node`, no deps. // node scenegod/web/timeline_test.mjs import assert from 'node:assert/strict'; import { StageStub } from './stagestub.js'; import { Timeline } from './timeline.js'; const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; const scene = { version: 1, name: 'test', fps: 30, duration: 3, entities: [ { id: 'e1', kind: 'character', label: 'lady', source: { type: 'assets', path: 'characters/lady.glb' }, params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, tracks: { transform: [ { t: 0, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }, { t: 2, pos: [10, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }, ], clips: [ { path: 'animations/walk.fbx', clipIndex: 0, start: 1.0, in: 0.0, out: 2.4, loop: 1, fade: 0 }, ], }, }, { id: 'e2', kind: 'light', label: 'sun', params: { type: 'key', color: '#000000', intensity: 0 }, transform: { pos: [0, 5, 0], rot: [0, 0, 0], scale: 1 }, tracks: { params: [ { t: 0, key: 'intensity', value: 0, ease: 'step' }, { t: 2, key: 'intensity', value: 10, ease: 'step' }, ], }, }, { id: 'cam1', kind: 'camera', params: { fov: 45 }, transform: { pos: [0, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} }, { id: 'cam2', kind: 'camera', params: { fov: 60 }, transform: { pos: [5, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} }, ], cameraCuts: [{ t: 0, camera: 'cam1' }, { t: 1.5, camera: 'cam2' }], audio: [], }; const stage = new StageStub(); await stage.applyState(scene); const tl = new Timeline(stage); tl.load(scene); await tl.preload(); const last = (pred) => { for (let i = stage.applied.length - 1; i >= 0; i--) if (pred(stage.applied[i])) return stage.applied[i]; return null; }; // step through 90 frames (3s @ 30fps) for (let f = 0; f <= 90; f++) { stage.applied.length = 0; tl.step(f); if (f === 30) { // t=1.0 → midpoint of a 2s linear move const tr = last((a) => a.type === 'transform' && a.id === 'e1'); assert.ok(tr && near(tr.pos[0], 5, 1e-4), `midpoint x should be ~5, got ${tr && tr.pos[0]}`); } if (f === 15) { // t=0.5 → ease:step holds start (0) const p = last((a) => a.type === 'param' && a.id === 'e2' && a.key === 'intensity'); assert.ok(p && near(p.value, 0), `step-ease should hold 0, got ${p && p.value}`); } if (f === 45) { // t=1.5 → clip active, local = 0.5 into walk const c = last((a) => a.type === 'clip' && a.id === 'e1'); assert.ok(c && near(c.time, 0.5, 1e-6), `clip local time should be 0.5, got ${c && c.time}`); const cam = last((a) => a.type === 'camera'); // camera flips to cam2 at t>=1.5 // cut fires only on change; check stage state instead assert.equal(stage._active, 'cam2', `active cam should be cam2 at t=1.5, is ${stage._active}`); } if (f === 20) { // t=0.667 → before the 1.5s cut, still cam1 assert.equal(stage._active, 'cam1', `active cam should be cam1 before cut, is ${stage._active}`); } if (f === 0) { // clip not started at t=0 const c = last((a) => a.type === 'clip' && a.id === 'e1'); assert.equal(c, null, 'clip should be inactive at t=0'); } } // clip sample frames: at t=1.0 (start) local=0, at t=2.4 local≈1.4 stage.applied.length = 0; tl.step(30); // t=1.0 let c = last((a) => a.type === 'clip' && a.id === 'e1'); assert.ok(c && near(c.time, 0, 1e-6), `clip local at start should be 0, got ${c && c.time}`); stage.applied.length = 0; tl.step(72); // t=2.4 c = last((a) => a.type === 'clip' && a.id === 'e1'); assert.ok(c && near(c.time, 1.4, 1e-6), `clip local at 2.4 should be 1.4, got ${c && c.time}`); // toJSON deep-equals a re-loaded copy (lossless round-trip) const out = tl.toJSON(); const tl2 = new Timeline(new StageStub()); tl2.load(out); assert.deepEqual(tl2.toJSON(), out, 'round-trip must be lossless'); // unknown entity fields preserved (Lane A owns them) const withExtra = structuredClone(scene); withExtra.entities[0].laneAOnly = { gizmo: 'move', foo: [1, 2, 3] }; const tl3 = new Timeline(new StageStub()); tl3.load(withExtra); assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive'); console.log('OK — timeline_test.mjs: all assertions passed');