test(v2.3): full e2e + integration coverage for Pulse + Birth Ritual

Post-ship verification pass — five parallel write-agents produced 229 new
tests across vitest units, vitest integration, and Playwright browser e2e.
Net suite: 361 vitest pass (up from 251, +110) and 9/9 Playwright pass on
back-to-back runs.

**toast.test.ts (NEW, 661 lines, 42 tests)**
  Silent-lobotomy batch walk proven (multi-event tick processes ALL, not
  just newest, oldest-first ordering preserved). Hover-panic pause/resume
  with remaining-ms math. All 9 event type translations asserted, all 11
  noise types asserted silent. ConnectionDiscovered 1500ms throttle.
  MAX_VISIBLE=4 eviction. clear() tears down all timers. fireDemoSequence
  staggers 4 toasts at 800ms intervals. vi.useFakeTimers + vi.mock of
  eventFeed; vi.resetModules in beforeEach for module-singleton isolation.

**websocket.test.ts (NEW, 247 lines, 30 tests)**
  injectEvent adds to front, respects MAX_EVENTS=200 with FIFO eviction,
  triggers eventFeed emissions. All 6 derived stores (isConnected,
  heartbeat, memoryCount, avgRetention, suppressedCount, uptimeSeconds)
  verified — defaults, post-heartbeat values, clearEvents preserves
  lastHeartbeat. 13 formatUptime boundary cases (0/59/60/3599/3600/
  86399/86400 seconds + negative / NaN / ±Infinity).

**effects.test.ts (EXTENDED, +501 lines, +21 tests, 51 total)**
  createBirthOrb full lifecycle — sprite count (halo + core), cosmic
  center via camera.quaternion, gestation phase (position lock, opacity
  rise, scale easing, color tint), flight Bezier arc above linear
  midpoint at t=0.5, dynamic mid-flight target redirect. onArrive fires
  exactly once at frame 139. Post-arrival fade + disposal cleans scene
  children. Sanhedrin Shatter: target goes undefined mid-flight →
  onArrive NEVER called, implosion spawned, halo blood-red, eventual
  cleanup. dispose() cleans active orbs. Multiple simultaneous orbs.
  Custom gestation/flight frame opts honored. Zero-alloc invariant
  smoke test (6 orbs × 150 frames, no leaks).

**nodes.test.ts (EXTENDED, +197 lines, +10 tests, 42 total)**
  addNode({isBirthRitual:true}) hides mesh/glow/label immediately,
  stamps birthRitualPending sentinel with correct totalFrames +
  targetScale, does NOT enqueue materialization. igniteNode flips
  visibility + enqueues materialization. Idempotent — second call
  no-op. Non-ritual nodes unaffected. Unknown id is safe no-op.
  Position stored in positions map while invisible (force sim still
  sees it). removeNode + late igniteNode is safe.

**events.test.ts (EXTENDED, +268 lines, +7 tests, 55 total)**
  MemoryCreated → mesh hidden immediately, 2 birth-orb sprites added,
  ZERO RingGeometry meshes and ZERO Points particles at spawn. Full
  ritual drive → onArrive fires, node visible + materializing, sentinel
  cleared. Newton's Cradle: target mesh scale exactly 0.001 * 1.8 right
  after arrival. Dual shockwave: exactly 2 Ring meshes added. Re-read
  live position on arrival — force-sim motion during ritual → burst
  lands at the NEW position. Sanhedrin abort path → rainbow burst,
  shockwave, ripple wave are NEVER called (vi.spyOn).

**three-mock.ts (EXTENDED)**
  Added Color.setRGB — production Three.js has it, the Sanhedrin-
  Shatter path in effects.ts uses it. Two write-agents independently
  monkey-patched the mock inline; consolidated as a 5-line mock
  addition so tests stay clean.

**e2e/pulse-toast.spec.ts (NEW, 235 lines, 6 Playwright tests)**
  Navigate /dashboard/settings → click Preview Pulse → assert first
  toast appears within 500ms → assert >= 2 toasts visible at peak.
  Click-to-dismiss removes clicked toast (matched by aria-label).
  Hover survives >8s past the 5.5s dwell. Keyboard Enter dismisses
  focused toast. CSS animation-play-state:paused on .toast-progress-
  fill while hovered, running on mouseleave. Screenshots attached to
  HTML report. Zero backend dependency (fireDemoSequence is purely
  client-side).

**e2e/birth-ritual.spec.ts (NEW, 199 lines, 3 Playwright tests)**
  Canvas mounts on /dashboard/graph (gracefully test.fixme if MCP
  backend absent). Settings button injection + SPA route to /graph
  → screenshot timeline at t=0/500/1200/2000/2400/3000ms attached
  to HTML report. pageerror + console-error listeners catch any
  crash (would re-surface FATAL 6 if reintroduced). Three back-to-
  back births — no errors, canvas still dispatches clicks.

Run commands:
  cd apps/dashboard && npm test           # 361/361 pass, ~600ms
  cd apps/dashboard && npx playwright test # 9/9 pass, ~25s

Typecheck: 0 errors, 0 warnings. Build: clean adapter-static.
This commit is contained in:
Sam Valladares 2026-04-20 18:14:50 -05:00
parent ec614fed85
commit 8fe8bb2f39
8 changed files with 2408 additions and 1 deletions

View file

@ -497,4 +497,505 @@ describe('EffectManager', () => {
expect(effects.pulseEffects.length).toBe(0);
});
});
describe('createBirthOrb (v2.3 Memory Birth Ritual)', () => {
// Build a camera with a Quaternion for createBirthOrb's view-space
// projection. The three-mock's applyQuaternion is identity, so the
// start position collapses to `camera.position + (0, 0, -distance)`.
function makeCamera() {
return {
position: new Vector3(0, 30, 80),
quaternion: new (class {
x = 0; y = 0; z = 0; w = 1;
})(),
} as any;
}
it('adds exactly 2 sprites to the scene on spawn', () => {
const cam = makeCamera();
const baseline = scene.children.length;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
expect(scene.children.length).toBe(baseline + 2);
});
it('both sprite and core use additive blending', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0xff8800) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
const core = scene.children[1] as any;
// AdditiveBlending constant from three-mock is 2
expect(halo.material.blending).toBe(2);
expect(core.material.blending).toBe(2);
// depthTest:false is passed to the SpriteMaterial constructor in
// effects.ts so the orb stays visible through other nodes. The
// three-mock's SpriteMaterial constructor does not persist this
// param, so we can't assert it at the instance level here; the
// production behavior is covered by ui-fixes.test.ts source grep.
expect(halo.material.transparent).toBe(true);
expect(core.material.transparent).toBe(true);
});
it('positions the orb at camera-relative cosmic center on spawn', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {},
{ distanceFromCamera: 40 }
);
const halo = scene.children[0] as any;
const core = scene.children[1] as any;
// mock applyQuaternion is identity, so startPos = camera.pos + (0,0,-40)
expect(halo.position.x).toBeCloseTo(0);
expect(halo.position.y).toBeCloseTo(30);
expect(halo.position.z).toBeCloseTo(40); // 80 + (-40)
expect(core.position.x).toBeCloseTo(halo.position.x);
expect(core.position.y).toBeCloseTo(halo.position.y);
expect(core.position.z).toBeCloseTo(halo.position.z);
});
it('gestation phase: position stays at startPos for all 48 frames', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(100, 100, 100) as any, // far-away target
() => {}
);
const halo = scene.children[0] as any;
const startX = halo.position.x;
const startY = halo.position.y;
const startZ = halo.position.z;
for (let f = 0; f < 48; f++) {
effects.update(nodeMeshMap, cam);
expect(halo.position.x).toBeCloseTo(startX);
expect(halo.position.y).toBeCloseTo(startY);
expect(halo.position.z).toBeCloseTo(startZ);
}
});
it('gestation phase: opacity rises from 0 toward 0.95', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
const core = scene.children[1] as any;
// Spawn opacity
expect(halo.material.opacity).toBe(0);
expect(core.material.opacity).toBe(0);
effects.update(nodeMeshMap, cam); // age 1
const earlyHaloOp = halo.material.opacity;
expect(earlyHaloOp).toBeGreaterThan(0);
expect(earlyHaloOp).toBeLessThan(0.2);
// Run to end of gestation
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam);
expect(halo.material.opacity).toBeCloseTo(0.95, 1);
expect(core.material.opacity).toBeCloseTo(1.0, 1);
// Monotonic-ish growth: late gestation > early gestation
expect(halo.material.opacity).toBeGreaterThan(earlyHaloOp);
});
it('gestation phase: sprite scale grows substantially', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
effects.update(nodeMeshMap, cam); // age 1
const earlyScale = halo.scale.x;
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48
const lateScale = halo.scale.x;
// Halo grows from ~0.5 toward ~5 during gestation (with pulse variation).
expect(lateScale).toBeGreaterThan(earlyScale);
expect(lateScale).toBeGreaterThan(2);
});
it('gestation phase: halo color tints toward event color', () => {
const cam = makeCamera();
const eventColor = new Color(0xff0000); // pure red
effects.createBirthOrb(
cam,
eventColor as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
effects.update(nodeMeshMap, cam); // age 1 — factor ≈ 0.72
const earlyR = halo.material.color.r;
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48 — factor = 1.0
const lateR = halo.material.color.r;
// Red channel should approach the event color's red (1.0) from a dimmer value
expect(lateR).toBeGreaterThan(earlyR);
expect(lateR).toBeCloseTo(1.0, 1);
// Green/blue stay at 0 (event color is pure red)
expect(halo.material.color.g).toBeCloseTo(0);
expect(halo.material.color.b).toBeCloseTo(0);
});
it('flight phase: Bezier arc passes ABOVE the linear midpoint at t=0.5', () => {
const cam = makeCamera();
// startPos = (0, 30, 40), target = (0, 0, 0)
// linear midpoint y = 15; control point y = 15 + 30 + dist*0.15 = 52.5
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
// Drive past gestation (48) + half of flight (45) = 93 frames → t=0.5
for (let f = 0; f < 93; f++) effects.update(nodeMeshMap, cam);
// Linear midpoint y is 15; Bezier midpoint should be notably higher.
expect(halo.position.y).toBeGreaterThan(15);
// And not as high as the control point itself (52.5) — Bezier
// passes through midpoint-ish at t=0.5, biased upward by the arc.
expect(halo.position.y).toBeLessThan(52.5);
});
it('flight phase: orb moves from startPos toward target', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
const halo = scene.children[0] as any;
// End of gestation
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
const gestZ = halo.position.z;
// One tick into flight
effects.update(nodeMeshMap, cam);
const earlyFlightZ = halo.position.z;
// Near end of flight
for (let f = 0; f < 88; f++) effects.update(nodeMeshMap, cam);
const lateFlightZ = halo.position.z;
// Z moves from 40 toward 0
expect(earlyFlightZ).toBeLessThan(gestZ);
expect(lateFlightZ).toBeLessThan(earlyFlightZ);
expect(lateFlightZ).toBeLessThan(5); // close to target z=0
});
it('dynamic target tracking: changing getTargetPos mid-flight redirects the orb', () => {
const cam = makeCamera();
let target = new Vector3(0, 0, 0);
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => target as any,
() => {}
);
const halo = scene.children[0] as any;
// Drive to mid-flight (gestation 48 + 30 flight frames = 78)
for (let f = 0; f < 78; f++) effects.update(nodeMeshMap, cam);
const xBeforeRedirect = halo.position.x;
// Redirect target far to the +X side
target = new Vector3(200, 0, 0);
// A few more flight frames — orb should track the new target
for (let f = 0; f < 10; f++) effects.update(nodeMeshMap, cam);
const xAfterRedirect = halo.position.x;
// With the original target at (0,0,0), x stays near 0 throughout.
// After redirect, x should swing toward the new target's +200.
expect(xAfterRedirect).toBeGreaterThan(xBeforeRedirect + 5);
});
it('onArrive fires exactly once at frame 139 (totalFrames + 1)', () => {
const cam = makeCamera();
let arriveCount = 0;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {
arriveCount++;
}
);
// Drive through gestation (48) + flight (90) = 138 frames. Should NOT have fired.
for (let f = 0; f < 138; f++) effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(0);
// Frame 139 — fires onArrive
effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(1);
// Drive many more frames — must stay at 1
for (let f = 0; f < 50; f++) effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(1);
});
it('post-arrival fade: orb disposes from scene after ~8 fade frames', () => {
const cam = makeCamera();
const baseline = scene.children.length;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
expect(scene.children.length).toBe(baseline + 2);
// Gestation + flight + arrive + fade = 138 + 1 + 8 = 147 frames
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
// Both orb sprites should be gone
expect(scene.children.length).toBe(baseline);
});
it('onArrive callback wrapped in try/catch so a throw does not crash the loop', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {
throw new Error('caller blew up');
}
);
// Should not throw — the production code swallows arrival-callback errors.
expect(() => {
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
}).not.toThrow();
});
it('Sanhedrin Shatter: onArrive NEVER fires when target vanishes mid-flight', () => {
const cam = makeCamera();
let arriveCount = 0;
let target: Vector3 | undefined = new Vector3(0, 0, 0);
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => target as any,
() => {
arriveCount++;
}
);
// Finish gestation (48 frames) with target present
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(0);
// Stop hook yanks the target mid-flight
target = undefined;
// Run enough frames to cover the entire orb lifecycle
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
// onArrive must NEVER fire on aborted orbs
expect(arriveCount).toBe(0);
});
it('Sanhedrin Shatter: implosion is spawned when target vanishes mid-flight', () => {
const cam = makeCamera();
let target: Vector3 | undefined = new Vector3(0, 0, 0);
const baseline = scene.children.length;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => target as any,
() => {}
);
// baseline + 2 sprites
expect(scene.children.length).toBe(baseline + 2);
// Finish gestation
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
// Yank target → abort triggers on next tick
target = undefined;
const beforeAbort = scene.children.length;
effects.update(nodeMeshMap, cam);
// Scene should have grown by at least 1 (the implosion particles)
expect(scene.children.length).toBeGreaterThan(beforeAbort);
});
it('Sanhedrin Shatter: halo turns blood-red on abort', () => {
const cam = makeCamera();
let target: Vector3 | undefined = new Vector3(0, 0, 0);
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any, // cyan — NOT red
() => target as any,
() => {}
);
const halo = scene.children[0] as any;
// Finish gestation
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
// Sanity: halo is NOT red yet (event color cyan has r≈0)
expect(halo.material.color.r).toBeLessThan(0.5);
// Yank target; abort triggers next tick
target = undefined;
effects.update(nodeMeshMap, cam);
// Halo should now be blood red (1.0, 0.15, 0.2)
expect(halo.material.color.r).toBeGreaterThan(0.9);
expect(halo.material.color.g).toBeLessThan(0.3);
expect(halo.material.color.b).toBeLessThan(0.3);
});
it('Sanhedrin Shatter: orb eventually disposes from scene', () => {
const cam = makeCamera();
let target: Vector3 | undefined = new Vector3(0, 0, 0);
const baseline = scene.children.length;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => target as any,
() => {}
);
// Finish gestation
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
// Yank target
target = undefined;
// Drive a long time — orb + implosion should both dispose
// (orb fade ~8 frames, implosion lifetime ~80 frames)
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
expect(scene.children.length).toBe(baseline);
});
it('dispose() removes active birth orbs from the scene', () => {
const cam = makeCamera();
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => {}
);
effects.createBirthOrb(
cam,
new Color(0xff00ff) as any,
() => new Vector3(10, 10, 10) as any,
() => {}
);
// 4 sprites in scene (2 per orb)
expect(scene.children.length).toBeGreaterThanOrEqual(4);
effects.dispose();
// All orb sprites should be gone
expect(scene.children.length).toBe(0);
});
it('multiple orbs in flight: all 3 onArrive callbacks fire exactly once each', () => {
const cam = makeCamera();
let c1 = 0, c2 = 0, c3 = 0;
effects.createBirthOrb(
cam,
new Color(0xff0000) as any,
() => new Vector3(10, 0, 0) as any,
() => { c1++; }
);
effects.createBirthOrb(
cam,
new Color(0x00ff00) as any,
() => new Vector3(-10, 0, 0) as any,
() => { c2++; }
);
effects.createBirthOrb(
cam,
new Color(0x0000ff) as any,
() => new Vector3(0, 0, -10) as any,
() => { c3++; }
);
// Drive past arrival (139) with margin
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
expect(c1).toBe(1);
expect(c2).toBe(1);
expect(c3).toBe(1);
});
it('custom gestation/flight frame counts are honored', () => {
const cam = makeCamera();
let arriveCount = 0;
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(0, 0, 0) as any,
() => { arriveCount++; },
{ gestationFrames: 10, flightFrames: 20 }
);
// Before frame 31 — no arrival
for (let f = 0; f < 30; f++) effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(0);
// Frame 31 — fires
effects.update(nodeMeshMap, cam);
expect(arriveCount).toBe(1);
});
it('zero-alloc invariant (advisory): flight phase runs without throwing across many orbs', () => {
// Advisory test — vitest has no allocator introspection, but the
// inline algebraic Bezier eval in effects.ts is intentionally zero-
// allocation per frame (no `new Vector3`, no `new QuadraticBezierCurve3`).
// Here we just smoke-test that running many orbs across the full
// flight phase does not throw and completes cleanly.
const cam = makeCamera();
for (let k = 0; k < 6; k++) {
effects.createBirthOrb(
cam,
new Color(0x00ffd1) as any,
() => new Vector3(k * 5, 0, 0) as any,
() => {}
);
}
expect(() => {
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
}).not.toThrow();
// All orbs should have cleaned up
expect(scene.children.length).toBe(0);
});
});
});

View file

@ -10,7 +10,7 @@ import { NodeManager } from '../nodes';
import { EdgeManager } from '../edges';
import { EffectManager } from '../effects';
import { ForceSimulation } from '../force-sim';
import { Vector3, Scene } from './three-mock';
import { Vector3, Scene, RingGeometry, Mesh, Points, Sprite } from './three-mock';
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
import type { GraphNode, VestigeEvent } from '$types';
@ -874,4 +874,270 @@ describe('Event-to-Mutation Pipeline', () => {
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
});
});
describe('v2.3 Birth Ritual wiring', () => {
/** Count shockwave rings currently in the scene by their RingGeometry. */
function countRings(s: InstanceType<typeof Scene>): number {
let n = 0;
for (const child of s.children) {
if (child instanceof Mesh && child.geometry instanceof RingGeometry) n++;
}
return n;
}
/** Count Points children — rainbow bursts, spawn bursts, implosions. */
function countPoints(s: InstanceType<typeof Scene>): number {
let n = 0;
for (const child of s.children) if (child instanceof Points) n++;
return n;
}
/** Count Sprite children — birth orb adds a halo + core sprite. */
function countSprites(s: InstanceType<typeof Scene>): number {
let n = 0;
for (const child of s.children) if (child instanceof Sprite) n++;
return n;
}
it('node mesh is hidden immediately after MemoryCreated dispatch', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'ritual-create',
content: 'fresh memory',
node_type: 'fact',
}),
ctx,
allNodes
);
// Ritual path: mesh/glow/label are all .visible = false until
// igniteNode fires on orb arrival.
const mesh = nodeManager.meshMap.get('ritual-create')!;
const glow = nodeManager.glowMap.get('ritual-create')!;
const label = nodeManager.labelSprites.get('ritual-create')!;
expect(mesh.visible).toBe(false);
expect(glow.visible).toBe(false);
expect(label.visible).toBe(false);
// Pending sentinel is stamped on userData.
expect(mesh.userData.birthRitualPending).toBeDefined();
});
it('does NOT fire burst/ripple/shockwave at spawn (only the birth orb)', () => {
const ringsBefore = countRings(scene);
const pointsBefore = countPoints(scene);
const spritesBefore = countSprites(scene);
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'spawn-quiet',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
// Birth orb adds exactly 2 sprites (halo + core). NodeManager's
// addNode also adds a glow Sprite + label Sprite to the NodeManager
// GROUP, not to the scene — so spritesBefore -> after delta is +2.
expect(countSprites(scene) - spritesBefore).toBe(2);
// No arrival-cascade effects yet: no shockwave rings, no rainbow
// burst/spawn burst/ripple particles.
expect(countRings(scene)).toBe(ringsBefore);
expect(countPoints(scene)).toBe(pointsBefore);
});
it('drives through the full ritual: onArrive fires, node becomes visible, scale grows', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'full-ritual',
content: 'visible after arrival',
node_type: 'fact',
}),
ctx,
allNodes
);
const mesh = nodeManager.meshMap.get('full-ritual')!;
expect(mesh.visible).toBe(false);
// Drive the effects update loop past the full ritual duration
// (gestation 48 + flight 90 = 138 frames). After frame 138 the
// orb fires onArrive which ignites the node and queues materialization.
for (let i = 0; i < 140; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// Node is now visible and sentinel is cleared.
expect(mesh.visible).toBe(true);
expect(mesh.userData.birthRitualPending).toBeUndefined();
// Run node animation a few frames to let materialization scale grow.
// Note: onArrive bumped scale by 1.8x (from 0.001 -> 0.0018), then
// materialization easeOutElastic pulls it toward targetScale.
for (let f = 0; f < 10; f++) {
nodeManager.animate(f * 0.016, allNodes, camera);
}
expect(mesh.scale.x).toBeGreaterThan(0.001);
});
it("Newton's Cradle — target mesh scale is multiplied by 1.8x on arrival", () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'newton-cradle',
content: 'recoil test',
node_type: 'fact',
}),
ctx,
allNodes
);
const mesh = nodeManager.meshMap.get('newton-cradle')!;
// Pre-arrival: scale is the addNode initial 0.001.
expect(mesh.scale.x).toBeCloseTo(0.001, 6);
// Drive just to the moment onArrive fires. Gestation (48) +
// flight (90) = 138 frames. Arrival bumps scale by 1.8x BEFORE
// materialization has run any ticks, so the scale should be
// exactly 0.001 * 1.8 = 0.0018 at that instant. We check right
// after onArrive (frame 139) — but effects.update progresses the
// orb's age counter by one each call, and on the tick where
// orb.age > totalFrames, onArrive fires. We then must NOT tick
// nodeManager.animate (or materialization would diverge the scale).
for (let i = 0; i < 140; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// onArrive fired. Scale was 0.001, got multiplied by 1.8 -> 0.0018.
// Materialization is queued but hasn't run yet (no animate() calls).
expect(mesh.scale.x).toBeCloseTo(0.0018, 6);
});
it('dual shockwave — arrival cascade adds TWO RingGeometry meshes, not one', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'dual-shock',
content: 'layered crash',
node_type: 'fact',
}),
ctx,
allNodes
);
const ringsBefore = countRings(scene);
// Drive past full ritual so onArrive fires.
for (let i = 0; i < 140; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// Both shockwaves fire synchronously in the onArrive callback
// (the previous setTimeout-delayed second shockwave was dropped
// because it could outlive the scene on route change).
const ringsAfter = countRings(scene);
expect(ringsAfter - ringsBefore).toBe(2);
});
it('re-reads position on arrival — fires cascade at force-sim-moved position', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'moving-target',
content: 'follow the node',
node_type: 'fact',
}),
ctx,
allNodes
);
// Grab the spawn position, then mutate it to simulate the force
// simulation pushing the node during the ritual.
const movedPos = new Vector3(123, 456, -789);
nodeManager.positions.set('moving-target', movedPos);
// Drive past full ritual.
for (let i = 0; i < 140; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// The onArrive callback re-reads nodeManager.positions and fires
// the cascade at the LIVE position. The two shockwave Ring meshes
// should have been created at movedPos. Find them and check.
const rings = scene.children.filter(
(c) => c instanceof Mesh && c.geometry instanceof RingGeometry
);
expect(rings.length).toBeGreaterThanOrEqual(2);
// Rings for this node: their .position copies from arrivePos at
// spawn time inside createShockwave.
const atMovedPos = rings.filter(
(r) => r.position.x === 123 && r.position.y === 456 && r.position.z === -789
);
expect(atMovedPos.length).toBe(2);
});
it('Sanhedrin abort path — removeNode before arrival prevents the regular cascade', () => {
// Spy on the three arrival-cascade emitters so we can assert
// they were NEVER called when the target is vetoed mid-ritual.
const burstSpy = vi.spyOn(effects, 'createRainbowBurst');
const shockwaveSpy = vi.spyOn(effects, 'createShockwave');
const rippleSpy = vi.spyOn(effects, 'createRippleWave');
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'vetoed',
content: 'about to be shattered',
node_type: 'fact',
}),
ctx,
allNodes
);
// The orb's getTargetPos() closure reads
// nodeManager.positions.get('vetoed'). Dropping the position
// directly simulates the "target gone" state that the Sanhedrin
// veto produces after dissolution completes — without needing to
// drive the full 60-frame dissolution animation.
nodeManager.positions.delete('vetoed');
expect(nodeManager.positions.has('vetoed')).toBe(false);
// Snapshot the orb reference before the update loop disposes it.
// The abort branch flips `aborted` and tints the halo red; we
// assert on those fields after the ritual unwinds.
const orbs = (effects as any).birthOrbs as Array<{
sprite: { material: { color: any } };
core: { material: { color: any } };
aborted: boolean;
}>;
expect(orbs.length).toBe(1);
const orbRef = orbs[0];
// Drive effects past the full ritual. During flight the orb will
// see getTargetPos() === undefined, enter the Sanhedrin branch,
// call createImplosion (anti-birth visual) and SKIP onArrive —
// so the regular rainbow-burst + dual-shockwave + ripple cascade
// never fires.
for (let i = 0; i < 200; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// Core assertion: the three regular-cascade emitters were never
// invoked for the vetoed node.
expect(burstSpy).not.toHaveBeenCalled();
expect(shockwaveSpy).not.toHaveBeenCalled();
expect(rippleSpy).not.toHaveBeenCalled();
// Also confirm the orb actually took the abort branch, not the
// gestation-only no-op path (otherwise this test would pass for
// the wrong reason). The aborted flag is set exactly once inside
// the Sanhedrin branch.
expect(orbRef.aborted).toBe(true);
expect(orbRef.sprite.material.color.r).toBeCloseTo(1.0, 3);
expect(orbRef.sprite.material.color.g).toBeCloseTo(0.15, 3);
burstSpy.mockRestore();
shockwaveSpy.mockRestore();
rippleSpy.mockRestore();
});
});
});

View file

@ -453,4 +453,201 @@ describe('NodeManager', () => {
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
});
});
describe('Birth Ritual integration', () => {
it('addNode with isBirthRitual:true hides mesh, glow, and label immediately', () => {
const node = makeNode({ id: 'ritual-1' });
manager.addNode(node, new Vector3(5, 5, 5), { isBirthRitual: true });
const mesh = manager.meshMap.get('ritual-1')!;
const glow = manager.glowMap.get('ritual-1')!;
const label = manager.labelSprites.get('ritual-1')!;
expect(mesh.visible).toBe(false);
expect(glow.visible).toBe(false);
expect(label.visible).toBe(false);
});
it('addNode with isBirthRitual:true stores a pending sentinel on mesh.userData', () => {
const node = makeNode({ id: 'ritual-sentinel', retention: 0.75 });
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
const mesh = manager.meshMap.get('ritual-sentinel')!;
const pending = mesh.userData.birthRitualPending as any;
expect(pending).toBeDefined();
expect(pending.totalFrames).toBe(30);
// targetScale = 0.5 + retention * 2 = 0.5 + 0.75 * 2 = 2.0
expect(pending.targetScale).toBeCloseTo(2.0, 3);
});
it('addNode with isBirthRitual:true does NOT enqueue materialization', () => {
const ritualNode = makeNode({ id: 'ritual-pending', retention: 0.8 });
manager.addNode(ritualNode, new Vector3(10, 10, 10), { isBirthRitual: true });
// In the real runtime the ritual-pending node is .visible=false
// AND is not yet in the GraphNode[] list — it only gets added to
// the visible node list once igniteNode flips its visibility and
// materialization kicks in. So we pass an empty `nodes` array to
// animate(), which also exercises that the breathing loop skips
// meshes absent from the nodes array.
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 40; f++) {
manager.animate(f * 0.016, [], camera);
}
const mesh = manager.meshMap.get('ritual-pending')!;
// Materialization queue never pushed — a regular materializing
// node would be at scale ≈ targetScale = 2.1 by frame 40. The
// ritual-pending node stays at its addNode initial 0.001 because
// no animation loop is mutating its scale.
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
// Stronger invariant — the sentinel is still there, confirming
// the node never got handed off to the materialization queue.
expect(mesh.userData.birthRitualPending).toBeDefined();
});
it('addNode without opts proceeds with normal materialization (old behavior)', () => {
const node = makeNode({ id: 'normal-spawn' });
manager.addNode(node, new Vector3(1, 2, 3));
const mesh = manager.meshMap.get('normal-spawn')!;
const glow = manager.glowMap.get('normal-spawn')!;
const label = manager.labelSprites.get('normal-spawn')!;
// Default mesh.visible is true in three-mock (Object3D has no explicit field).
// Key invariant: visible is NOT explicitly false like the ritual path.
expect(mesh.visible).not.toBe(false);
expect(glow.visible).not.toBe(false);
expect(label.visible).not.toBe(false);
// And no pending sentinel
expect(mesh.userData.birthRitualPending).toBeUndefined();
// Animation should proceed — scale grows via easeOutElastic
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 20; f++) {
manager.animate(f * 0.016, [node], camera);
}
expect(mesh.scale.x).toBeGreaterThan(0.1);
});
it('igniteNode flips all three visibility flags and queues materialization', () => {
const node = makeNode({ id: 'to-ignite', retention: 0.6 });
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
// Pre-ignite: hidden
const mesh = manager.meshMap.get('to-ignite')!;
const glow = manager.glowMap.get('to-ignite')!;
const label = manager.labelSprites.get('to-ignite')!;
expect(mesh.visible).toBe(false);
manager.igniteNode('to-ignite');
// Post-ignite: visible
expect(mesh.visible).toBe(true);
expect(glow.visible).toBe(true);
expect(label.visible).toBe(true);
// Sentinel is gone
expect(mesh.userData.birthRitualPending).toBeUndefined();
// Materialization was queued — drive animation and the scale
// should grow past the initial 0.001.
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 15; f++) {
manager.animate(f * 0.016, [node], camera);
}
expect(mesh.scale.x).toBeGreaterThan(0.1);
});
it('igniteNode called twice is idempotent (second call is a no-op)', () => {
const node = makeNode({ id: 'double-ignite', retention: 0.5 });
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
manager.igniteNode('double-ignite');
// Capture scale after one round of animation
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 10; f++) {
manager.animate(f * 0.016, [node], camera);
}
const scaleAfterFirst = manager.meshMap.get('double-ignite')!.scale.x;
// Second ignite — should NOT push a duplicate materialization entry.
// If it did, the extra entry (starting at frame 0) would restart
// the scale back near 0.001 or at least visibly reset it.
manager.igniteNode('double-ignite');
for (let f = 0; f < 5; f++) {
manager.animate((f + 10) * 0.016, [node], camera);
}
const scaleAfterSecond = manager.meshMap.get('double-ignite')!.scale.x;
// Scale after second ignite should be greater than or roughly equal
// to scale after first, NOT reset toward 0.001. A duplicate entry
// starting at frame 0 would pull the mesh back near zero on the
// very first subsequent animate() tick via mn.mesh.scale.setScalar.
expect(scaleAfterSecond).toBeGreaterThanOrEqual(scaleAfterFirst * 0.5);
});
it('igniteNode on a regular (non-ritual) node is a no-op', () => {
const node = makeNode({ id: 'regular', retention: 0.5 });
manager.addNode(node, new Vector3(0, 0, 0));
// Regular addNode already queued materialization. Capture state.
const mesh = manager.meshMap.get('regular')!;
const visBefore = mesh.visible;
// Call igniteNode — there's no pending sentinel, should short-circuit.
expect(() => manager.igniteNode('regular')).not.toThrow();
// No pending sentinel means the function returns early after the
// sentinel check, so nothing about the mesh changes.
expect(mesh.visible).toBe(visBefore);
expect(mesh.userData.birthRitualPending).toBeUndefined();
});
it('igniteNode on unknown id is a no-op (no throw)', () => {
expect(() => manager.igniteNode('does-not-exist')).not.toThrow();
expect(manager.meshMap.has('does-not-exist')).toBe(false);
});
it('position is stored in positions map even when the node is invisible', () => {
const node = makeNode({ id: 'invisible-but-positioned' });
const spawnPos = new Vector3(42, -17, 8);
manager.addNode(node, spawnPos, { isBirthRitual: true });
// Force simulation + orb getTargetPos() both rely on positions
// being live immediately — the ritual only hides visuals, not
// physics state.
const stored = manager.positions.get('invisible-but-positioned');
expect(stored).toBeDefined();
expect(stored!.x).toBe(42);
expect(stored!.y).toBe(-17);
expect(stored!.z).toBe(8);
// And the mesh itself is still hidden
expect(manager.meshMap.get('invisible-but-positioned')!.visible).toBe(false);
});
it('removeNode during pending ritual cancels without materialization', () => {
// Sanhedrin abort path at the NodeManager level: a ritual-pending
// node gets removed before igniteNode fires. The remove path
// should still work (dissolution queue takes over) and igniteNode
// called later must not resurrect it.
const node = makeNode({ id: 'aborted-ritual' });
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
manager.removeNode('aborted-ritual');
// Dissolution progresses past totalFrames = 60 and clears state.
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 65; f++) {
manager.animate(f * 0.016, [node], camera);
}
expect(manager.meshMap.has('aborted-ritual')).toBe(false);
// And a late igniteNode call on the dead id is a safe no-op.
expect(() => manager.igniteNode('aborted-ritual')).not.toThrow();
});
});
});

View file

@ -210,6 +210,13 @@ export class Color {
this.b *= s;
return this;
}
setRGB(r: number, g: number, b: number) {
this.r = r;
this.g = g;
this.b = b;
return this;
}
}
export class BufferAttribute {