mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-27 01:36:22 +02:00
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:
parent
ec614fed85
commit
8fe8bb2f39
8 changed files with 2408 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue