feat: live memory materialization — nodes spawn in 3D graph in real-time

When memories are created, promoted, deleted, or dreamed via MCP tools,
the 3D graph now shows spectacular live animations:

- Rainbow particle burst + elastic scale-up on MemoryCreated
- Ripple wave cascading to nearby nodes
- Green pulse + node growth on MemoryPromoted
- Implosion + dissolution on MemoryDeleted
- Edge growth animation on ConnectionDiscovered
- Purple cascade on DreamStarted/DreamProgress/DreamCompleted
- FIFO eviction at 50 live nodes to guard performance

Also: graph center defaults to most-connected node, legacy HTML
redirects to SvelteKit dashboard, CSS height chain fix in layout.

Testing: 150 unit tests (vitest), 11 e2e tests (Playwright with
MCP Streamable HTTP client), 22 proof screenshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-03-03 14:04:31 -06:00
parent 816b577f69
commit 9bdcc69ce3
76 changed files with 5915 additions and 332 deletions

View file

@ -0,0 +1,310 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { EdgeManager } from '../edges';
import { Vector3 } from './three-mock';
import { makeEdge, resetNodeCounter } from './helpers';
describe('EdgeManager', () => {
let manager: EdgeManager;
let positions: Map<string, InstanceType<typeof Vector3>>;
beforeEach(() => {
resetNodeCounter();
manager = new EdgeManager();
positions = new Map([
['a', new Vector3(0, 0, 0)],
['b', new Vector3(10, 0, 0)],
['c', new Vector3(0, 10, 0)],
['d', new Vector3(10, 10, 0)],
]);
});
describe('createEdges', () => {
it('creates line objects for all edges', () => {
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(2);
});
it('skips edges with missing node positions', () => {
const edges = [makeEdge('a', 'missing')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(0);
});
it('stores source/target in userData', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
const line = manager.group.children[0];
expect(line.userData.source).toBe('a');
expect(line.userData.target).toBe('b');
});
it('caps opacity at 0.6', () => {
const edges = [makeEdge('a', 'b', { weight: 10.0 })];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeLessThanOrEqual(0.6);
});
});
describe('addEdge — growth animation', () => {
it('adds a new line to the group', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
expect(manager.group.children.length).toBe(1);
});
it('starts with zero-length line at source position', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// Both endpoints should be at source (a) position
expect(attrs.getX(0)).toBe(0);
expect(attrs.getX(1)).toBe(0); // not yet at target
});
it('starts with zero opacity', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0);
});
it('grows to full length over 45 frames', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Animate through growth
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// End point should be at target position
expect(attrs.getX(1)).toBeCloseTo(10, 0);
});
it('opacity increases during growth', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
for (let f = 0; f < 25; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBeGreaterThan(0);
});
it('reaches final opacity after growth completes', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
for (let f = 0; f < 46; f++) {
manager.animateEdges(positions);
}
const line = manager.group.children[0] as any;
expect(line.material.opacity).toBe(0.5);
});
it('uses easeOutCubic for smooth deceleration', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Record end point X at each frame
const xValues: number[] = [];
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
xValues.push(attrs.getX(1));
}
// easeOutCubic: fast start, slow end
// First half should cover more than 50% of distance
const midIdx = Math.floor(xValues.length / 2);
const midProgress = xValues[midIdx] / 10;
expect(midProgress).toBeGreaterThan(0.5);
});
it('skips edges with missing positions', () => {
const edge = makeEdge('a', 'missing');
manager.addEdge(edge, positions);
// Line should be created but with no geometry update
expect(manager.group.children.length).toBe(0);
});
});
describe('removeEdgesForNode', () => {
it('marks connected edges for dissolution', () => {
const edges = [makeEdge('a', 'b'), makeEdge('b', 'c'), makeEdge('c', 'd')];
manager.createEdges(edges, positions);
expect(manager.group.children.length).toBe(3);
manager.removeEdgesForNode('b');
// After dissolution animation completes
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
// Only edge c->d should remain
expect(manager.group.children.length).toBe(1);
expect(manager.group.children[0].userData.source).toBe('c');
});
it('dissolving edges fade out over 40 frames', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
const line = manager.group.children[0] as any;
const initialOpacity = line.material.opacity;
manager.removeEdgesForNode('a');
// Midway through dissolution
for (let f = 0; f < 20; f++) {
manager.animateEdges(positions);
}
expect(line.material.opacity).toBeLessThan(initialOpacity);
expect(line.material.opacity).toBeGreaterThan(0);
});
it('fully removes edge after dissolution completes', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
manager.removeEdgesForNode('a');
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
expect(manager.group.children.length).toBe(0);
});
it('cancels active growth animation if edge is dissolving', () => {
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// Partially grow
for (let f = 0; f < 10; f++) {
manager.animateEdges(positions);
}
// Then dissolve
manager.removeEdgesForNode('a');
for (let f = 0; f < 45; f++) {
manager.animateEdges(positions);
}
expect(manager.group.children.length).toBe(0);
});
});
describe('updatePositions', () => {
it('updates static edge endpoints', () => {
const edges = [makeEdge('a', 'b')];
manager.createEdges(edges, positions);
// Move node a
positions.set('a', new Vector3(5, 5, 5));
manager.updatePositions(positions);
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
expect(attrs.getX(0)).toBe(5);
expect(attrs.getY(0)).toBe(5);
});
it('skips edges currently being animated', () => {
// Add a growing edge
const edge = makeEdge('a', 'b');
manager.addEdge(edge, positions);
// updatePositions should not override the animation
manager.updatePositions(positions);
// Growing edge should still be at its animated state
const line = manager.group.children[0] as any;
const attrs = line.geometry.attributes.position;
// Point 1 should still be at source (zero-length start), not target
expect(attrs.getX(1)).toBe(0);
});
});
describe('multiple simultaneous edge animations', () => {
it('handles multiple edges growing at once', () => {
manager.addEdge(makeEdge('a', 'b'), positions);
manager.addEdge(makeEdge('c', 'd'), positions);
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
// Both should be fully grown
expect(manager.group.children.length).toBe(2);
manager.group.children.forEach((child) => {
expect((child as any).material.opacity).toBe(0.5);
});
});
it('handles mixed growing and dissolving edges', () => {
// Create a static edge
manager.createEdges([makeEdge('a', 'b')], positions);
// Add a growing edge
manager.addEdge(makeEdge('c', 'd'), positions);
// Dissolve the static edge
manager.removeEdgesForNode('a');
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
// Only the new edge should remain
expect(manager.group.children.length).toBe(1);
expect(manager.group.children[0].userData.source).toBe('c');
});
});
describe('dispose', () => {
it('clears animation queues and disposes materials without error', () => {
manager.createEdges([makeEdge('a', 'b')], positions);
manager.addEdge(makeEdge('c', 'd'), positions);
// Dispose should not throw and should clean up materials
expect(() => manager.dispose()).not.toThrow();
// After dispose, adding new animations should not interact with old state
manager.addEdge(makeEdge('a', 'c'), positions);
expect(() => {
for (let f = 0; f < 50; f++) {
manager.animateEdges(positions);
}
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,499 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { EffectManager } from '../effects';
import { Vector3, Color, Scene } from './three-mock';
describe('EffectManager', () => {
let scene: InstanceType<typeof Scene>;
let effects: EffectManager;
let nodeMeshMap: Map<string, any>;
let camera: any;
beforeEach(() => {
scene = new Scene();
effects = new EffectManager(scene as any);
camera = { position: new Vector3(0, 30, 80) };
nodeMeshMap = new Map();
});
function createMockMesh(id: string, pos: InstanceType<typeof Vector3>) {
const mesh = {
scale: new Vector3(1, 1, 1),
position: pos.clone(),
material: {
emissive: new Color(0x000000),
emissiveIntensity: 0.5,
},
userData: { nodeId: id },
};
nodeMeshMap.set(id, mesh);
return mesh;
}
describe('pulse effects', () => {
it('adds pulse and decays it over time', () => {
createMockMesh('a', new Vector3(0, 0, 0));
effects.addPulse('a', 1.0, new Color(0xff0000) as any, 0.1);
expect(effects.pulseEffects.length).toBe(1);
// Update a few times
for (let i = 0; i < 5; i++) {
effects.update(nodeMeshMap, camera);
}
expect(effects.pulseEffects[0].intensity).toBeLessThan(1.0);
});
it('removes pulse when intensity reaches zero', () => {
createMockMesh('a', new Vector3(0, 0, 0));
effects.addPulse('a', 0.5, new Color(0xff0000) as any, 0.1);
for (let i = 0; i < 20; i++) {
effects.update(nodeMeshMap, camera);
}
expect(effects.pulseEffects.length).toBe(0);
});
it('modulates mesh emissive color and intensity', () => {
const mesh = createMockMesh('a', new Vector3(0, 0, 0));
const pulseColor = new Color(0xff0000);
effects.addPulse('a', 1.0, pulseColor as any, 0.05);
effects.update(nodeMeshMap, camera);
// Emissive intensity should be elevated
expect(mesh.material.emissiveIntensity).toBeGreaterThan(0.5);
});
});
describe('createSpawnBurst', () => {
it('adds particles to the scene', () => {
const childCount = scene.children.length;
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
expect(scene.children.length).toBe(childCount + 1);
});
it('creates 60 particles', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(60);
});
it('particles move outward and fade over 120 frames', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
for (let i = 0; i < 121; i++) {
effects.update(nodeMeshMap, camera);
}
// Burst should be cleaned up
expect(scene.children.length).toBe(0);
});
it('particle opacity decreases over time', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
effects.update(nodeMeshMap, camera);
const earlyOpacity = pts.material.opacity;
for (let i = 0; i < 60; i++) {
effects.update(nodeMeshMap, camera);
}
expect(pts.material.opacity).toBeLessThan(earlyOpacity);
});
});
describe('createRainbowBurst', () => {
it('creates 120 particles (2x normal burst)', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(120);
});
it('has a 180-frame (3-second) lifespan', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
// Run for 179 frames — should still exist
for (let i = 0; i < 179; i++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(1);
// Frame 180 — should be cleaned up
effects.update(nodeMeshMap, camera);
expect(scene.children.length).toBe(1); // age increments to 180
effects.update(nodeMeshMap, camera);
expect(scene.children.length).toBe(0);
});
it('color cycles through rainbow HSL', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
const initialColor = { r: pts.material.color.r, g: pts.material.color.g, b: pts.material.color.b };
// Advance several frames
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera);
}
// Color should have changed due to HSL cycling
const currentColor = pts.material.color;
const colorChanged =
Math.abs(currentColor.r - initialColor.r) > 0.01 ||
Math.abs(currentColor.g - initialColor.g) > 0.01 ||
Math.abs(currentColor.b - initialColor.b) > 0.01;
expect(colorChanged).toBe(true);
});
it('particle size pulses (not monotonically decreasing)', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
const sizes: number[] = [];
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera);
sizes.push(pts.material.size);
}
// Check that size varies (pulses) — not monotonically decreasing
let monotonic = true;
for (let i = 1; i < sizes.length; i++) {
if (sizes[i] > sizes[i - 1]) {
monotonic = false;
break;
}
}
expect(monotonic).toBe(false);
});
it('has hueOffset attribute for per-particle variation', () => {
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
const pts = scene.children[0] as any;
expect(pts.geometry.attributes.hueOffset).toBeDefined();
expect(pts.geometry.attributes.hueOffset.count).toBe(120);
});
});
describe('createRippleWave', () => {
it('creates a ripple wave state', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(5, 0, 0)],
['n2', new Vector3(15, 0, 0)],
]);
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Ripple wave is internal state — verify it runs without error
for (let i = 0; i < 100; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
});
it('pulses nearby nodes as wavefront reaches them', () => {
const nodePositions = new Map<string, any>([
['close', new Vector3(5, 0, 0)],
['far', new Vector3(50, 0, 0)],
]);
const closeMesh = createMockMesh('close', new Vector3(5, 0, 0));
const farMesh = createMockMesh('far', new Vector3(50, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run until wavefront reaches "close" (dist=5, speed=1.2, ~4 frames)
for (let i = 0; i < 10; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Should have pulsed the close node — check for pulse effect
const closeHasPulse = effects.pulseEffects.some((p) => p.nodeId === 'close');
expect(closeHasPulse).toBe(true);
});
it('pulses each node only once', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(3, 0, 0)],
]);
createMockMesh('n1', new Vector3(3, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run many frames
for (let i = 0; i < 30; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Count pulses for n1 — should be exactly 1
const n1Pulses = effects.pulseEffects.filter((p) => p.nodeId === 'n1');
// Could be 0 if already decayed, but should have been created once
expect(n1Pulses.length).toBeLessThanOrEqual(1);
});
it('applies scale bump to contacted nodes', () => {
const nodePositions = new Map<string, any>([
['bump', new Vector3(3, 0, 0)],
]);
const mesh = createMockMesh('bump', new Vector3(3, 0, 0));
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Run until wavefront reaches the node
for (let i = 0; i < 10; i++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// Scale should have been bumped (1.3x)
expect(mesh.scale.x).toBeGreaterThan(1.0);
});
it('completes and cleans up after 90 frames', () => {
effects.createRippleWave(new Vector3(0, 0, 0) as any);
for (let i = 0; i < 95; i++) {
effects.update(nodeMeshMap, camera, new Map());
}
// Internal rippleWaves array should be empty (no way to check directly,
// but running more frames should not cause any errors)
effects.update(nodeMeshMap, camera, new Map());
});
});
describe('createImplosion', () => {
it('creates 40 particles', () => {
effects.createImplosion(new Vector3(5, 5, 5) as any, new Color(0xff4757) as any);
const pts = scene.children[scene.children.length - 1] as any;
expect(pts.geometry.attributes.position.count).toBe(40);
});
it('particles start spread out around the target', () => {
const center = new Vector3(5, 5, 5);
effects.createImplosion(center as any, new Color(0xff4757) as any);
const pts = scene.children[0] as any;
const positions = pts.geometry.attributes.position;
// At least some particles should be far from center
let maxDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
const dist = Math.sqrt(
(px - center.x) ** 2 + (py - center.y) ** 2 + (pz - center.z) ** 2
);
if (dist > maxDist) maxDist = dist;
}
expect(maxDist).toBeGreaterThan(2);
});
it('particles move INWARD toward center', () => {
const center = new Vector3(0, 0, 0);
effects.createImplosion(center as any, new Color(0xff4757) as any);
const pts = scene.children[0] as any;
const positions = pts.geometry.attributes.position;
// Record initial average distance
let initialAvgDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
initialAvgDist += Math.sqrt(px * px + py * py + pz * pz);
}
initialAvgDist /= positions.count;
// Advance 30 frames
for (let f = 0; f < 30; f++) {
effects.update(nodeMeshMap, camera);
}
// Record new average distance
let newAvgDist = 0;
for (let i = 0; i < positions.count; i++) {
const px = positions.getX(i);
const py = positions.getY(i);
const pz = positions.getZ(i);
newAvgDist += Math.sqrt(px * px + py * py + pz * pz);
}
newAvgDist /= positions.count;
expect(newAvgDist).toBeLessThan(initialAvgDist);
});
it('creates a flash at convergence (frame 60)', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
// Run to convergence
for (let f = 0; f < 60; f++) {
effects.update(nodeMeshMap, camera);
}
// Should have particles + flash mesh
expect(scene.children.length).toBe(2);
});
it('flash fades out and everything cleans up by frame 80', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
for (let f = 0; f < 85; f++) {
effects.update(nodeMeshMap, camera);
}
// Everything should be cleaned up
expect(scene.children.length).toBe(0);
});
it('flash sphere expands during fade-out', () => {
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
for (let f = 0; f < 65; f++) {
effects.update(nodeMeshMap, camera);
}
// Find the flash mesh (should be the second child)
const flash = scene.children.find(
(c) => c instanceof Object && 'geometry' in c && !(('attributes' in (c as any).geometry))
);
// Flash should have expanded beyond scale 1
if (flash) {
expect(flash.scale.x).toBeGreaterThan(1);
}
});
});
describe('createShockwave', () => {
it('adds a ring mesh to the scene', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
expect(scene.children.length).toBe(1);
});
it('ring expands over time', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
const ring = scene.children[0] as any;
const initialScale = ring.scale.x;
for (let f = 0; f < 30; f++) {
effects.update(nodeMeshMap, camera);
}
expect(ring.scale.x).toBeGreaterThan(initialScale);
});
it('ring fades out and cleans up after 60 frames', () => {
effects.createShockwave(
new Vector3(0, 0, 0) as any,
new Color(0x00ffd1) as any,
camera
);
for (let f = 0; f < 65; f++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(0);
});
});
describe('createConnectionFlash', () => {
it('creates a line between two points', () => {
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 10, 10) as any,
new Color(0x00d4ff) as any
);
expect(scene.children.length).toBe(1);
});
it('fades out and cleans up', () => {
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 10, 10) as any,
new Color(0x00d4ff) as any
);
for (let f = 0; f < 100; f++) {
effects.update(nodeMeshMap, camera);
}
expect(scene.children.length).toBe(0);
});
});
describe('multiple simultaneous effects', () => {
it('handles all effect types simultaneously', () => {
const nodePositions = new Map<string, any>([
['n1', new Vector3(5, 0, 0)],
]);
createMockMesh('n1', new Vector3(5, 0, 0));
effects.addPulse('n1', 1.0, new Color(0xff0000) as any, 0.05);
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
effects.createRainbowBurst(new Vector3(5, 5, 5) as any, new Color(0xff00ff) as any);
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 0, 0) as any,
new Color(0x00d4ff) as any
);
effects.createImplosion(new Vector3(-5, -5, -5) as any, new Color(0xff4757) as any);
effects.createRippleWave(new Vector3(0, 0, 0) as any);
// Should not throw for 200 frames
for (let f = 0; f < 200; f++) {
effects.update(nodeMeshMap, camera, nodePositions);
}
// All effects should have cleaned up
expect(scene.children.length).toBe(0);
});
});
describe('dispose', () => {
it('cleans up all active effects', () => {
effects.createSpawnBurst(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any);
effects.createRainbowBurst(new Vector3(0, 0, 0) as any, new Color(0xff00ff) as any);
effects.createImplosion(new Vector3(0, 0, 0) as any, new Color(0xff4757) as any);
effects.createShockwave(new Vector3(0, 0, 0) as any, new Color(0x00ffd1) as any, camera);
effects.createConnectionFlash(
new Vector3(0, 0, 0) as any,
new Vector3(10, 0, 0) as any,
new Color(0x00d4ff) as any
);
effects.dispose();
expect(effects.pulseEffects.length).toBe(0);
});
});
});

View file

@ -0,0 +1,864 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { mapEventToEffects, resetLiveSpawnTracking, type GraphMutationContext, type GraphMutation } from '../events';
import { NodeManager } from '../nodes';
import { EdgeManager } from '../edges';
import { EffectManager } from '../effects';
import { ForceSimulation } from '../force-sim';
import { Vector3, Scene } from './three-mock';
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
import type { GraphNode, VestigeEvent } from '$types';
describe('Event-to-Mutation Pipeline', () => {
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
let effects: EffectManager;
let forceSim: ForceSimulation;
let scene: InstanceType<typeof Scene>;
let camera: any;
let mutations: GraphMutation[];
let allNodes: GraphNode[];
let ctx: GraphMutationContext;
beforeEach(() => {
resetNodeCounter();
resetLiveSpawnTracking();
scene = new Scene();
camera = { position: new Vector3(0, 30, 80) };
nodeManager = new NodeManager();
edgeManager = new EdgeManager();
effects = new EffectManager(scene as any);
mutations = [];
// Create initial graph with 5 nodes
const initialNodes = [
makeNode({ id: 'n1', type: 'fact', tags: ['rust', 'bug-fix'] }),
makeNode({ id: 'n2', type: 'concept', tags: ['architecture'] }),
makeNode({ id: 'n3', type: 'decision', tags: ['rust'] }),
makeNode({ id: 'n4', type: 'fact', tags: ['testing'] }),
makeNode({ id: 'n5', type: 'event', tags: ['session'] }),
];
const positions = nodeManager.createNodes(initialNodes);
edgeManager.createEdges(
[makeEdge('n1', 'n2'), makeEdge('n2', 'n3'), makeEdge('n3', 'n4')],
positions
);
forceSim = new ForceSimulation(positions);
allNodes = [...initialNodes];
ctx = {
effects,
nodeManager,
edgeManager,
forceSim,
camera,
onMutation: (m: GraphMutation) => mutations.push(m),
};
});
describe('MemoryCreated', () => {
it('creates a new node in all managers', () => {
const event = makeEvent('MemoryCreated', {
id: 'new-1',
content: 'I love Rust',
node_type: 'fact',
tags: ['rust', 'preference'],
retention: 0.9,
});
mapEventToEffects(event, ctx, allNodes);
expect(nodeManager.meshMap.has('new-1')).toBe(true);
expect(nodeManager.positions.has('new-1')).toBe(true);
expect(forceSim.positions.has('new-1')).toBe(true);
});
it('emits nodeAdded mutation', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-2',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
const nodeAdded = mutations.find((m) => m.type === 'nodeAdded');
expect(nodeAdded).toBeDefined();
expect((nodeAdded as any).node.id).toBe('new-2');
});
it('builds GraphNode from event data', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-3',
content: 'Complex memory about architecture decisions in Rust systems',
node_type: 'decision',
tags: ['architecture', 'rust'],
retention: 0.75,
}),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.type).toBe('decision');
expect(mutation.node.tags).toEqual(['architecture', 'rust']);
expect(mutation.node.retention).toBe(0.75);
expect(mutation.node.label).toBe('Complex memory about architecture decisions in Rust systems');
expect(mutation.node.isCenter).toBe(false);
});
it('truncates label to 60 characters', () => {
const longContent = 'A'.repeat(100);
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'long',
content: longContent,
node_type: 'fact',
}),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.label.length).toBe(60);
});
it('spawns node near related nodes (tag overlap scoring)', () => {
// Create a memory with rust tag — should spawn near n1 (which has rust tag)
const n1Pos = nodeManager.positions.get('n1')!;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'rust-memory',
content: 'Rust borrow checker tip',
node_type: 'fact',
tags: ['rust'],
}),
ctx,
allNodes
);
const newPos = nodeManager.positions.get('rust-memory')!;
const distToN1 = newPos.distanceTo(n1Pos);
// Should be relatively close to n1 (within jitter range ~10 units)
expect(distToN1).toBeLessThan(20);
});
it('triggers rainbow burst effect', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'new-burst',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
// Scene should have new particles (rainbow burst + shockwave + possibly more)
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('triggers double shockwave (second delayed)', () => {
vi.useFakeTimers();
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'double-shock',
content: 'test',
node_type: 'fact',
}),
ctx,
allNodes
);
const initialChildren = scene.children.length;
// Advance past the setTimeout
vi.advanceTimersByTime(200);
// Second shockwave should have been added
expect(scene.children.length).toBeGreaterThan(initialChildren);
vi.useRealTimers();
});
it('uses default values when event data is incomplete', () => {
mapEventToEffects(
makeEvent('MemoryCreated', { id: 'minimal' }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeAdded') as any;
expect(mutation.node.type).toBe('fact');
expect(mutation.node.retention).toBe(0.9);
expect(mutation.node.tags).toEqual([]);
});
it('ignores event without id', () => {
mapEventToEffects(
makeEvent('MemoryCreated', { content: 'no id' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('FIFO eviction', () => {
it('evicts oldest live node when exceeding 50 cap', () => {
// Create 51 live nodes
for (let i = 0; i < 51; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `live-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
// First live node should have been evicted
const removedMutations = mutations.filter((m) => m.type === 'nodeRemoved');
expect(removedMutations.length).toBeGreaterThan(0);
expect((removedMutations[0] as any).nodeId).toBe('live-0');
});
it('evicted node is removed from all managers', () => {
for (let i = 0; i < 51; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `evict-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
// First node should be gone from node manager and force sim
expect(forceSim.positions.has('evict-0')).toBe(false);
});
it('initial nodes are NOT subject to FIFO eviction', () => {
// Even after adding 50 live nodes, initial nodes should still exist
for (let i = 0; i < 50; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `extra-${i}`,
content: `Memory ${i}`,
node_type: 'fact',
}),
ctx,
allNodes
);
}
expect(nodeManager.meshMap.has('n1')).toBe(true);
expect(nodeManager.meshMap.has('n2')).toBe(true);
expect(nodeManager.meshMap.has('n3')).toBe(true);
});
});
describe('ConnectionDiscovered', () => {
it('adds edge with growth animation', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n4',
weight: 0.8,
connection_type: 'causal',
}),
ctx,
allNodes
);
// Edge should have been added
expect(edgeManager.group.children.length).toBeGreaterThan(3); // 3 initial + 1 new
});
it('emits edgeAdded mutation with correct data', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
weight: 0.7,
connection_type: 'semantic',
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
expect(edgeMutation).toBeDefined();
expect(edgeMutation.edge.source).toBe('n1');
expect(edgeMutation.edge.target).toBe('n5');
expect(edgeMutation.edge.weight).toBe(0.7);
expect(edgeMutation.edge.type).toBe('semantic');
});
it('creates connection flash between endpoints', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n2',
}),
ctx,
allNodes
);
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('pulses both endpoint nodes', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n2',
}),
ctx,
allNodes
);
const n1Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
const n2Pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
expect(n1Pulse).toBeDefined();
expect(n2Pulse).toBeDefined();
});
it('uses default weight and type when not provided', () => {
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded') as any;
expect(edgeMutation.edge.weight).toBe(0.5);
expect(edgeMutation.edge.type).toBe('semantic');
});
});
describe('MemoryDeleted', () => {
it('removes node from all managers', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n1' }),
ctx,
allNodes
);
// Force sim should have removed the node
expect(forceSim.positions.has('n1')).toBe(false);
});
it('removes connected edges', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n2' }),
ctx,
allNodes
);
// Should emit both edgesRemoved and nodeRemoved mutations
const edgesRemoved = mutations.find((m) => m.type === 'edgesRemoved');
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
expect(edgesRemoved).toBeDefined();
expect(nodeRemoved).toBeDefined();
});
it('creates implosion effect at node position', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'n3' }),
ctx,
allNodes
);
// Should have added implosion particles
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('removes from live tracking if was live-spawned', () => {
// First, create a live node
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'temp-live',
content: 'temporary',
node_type: 'fact',
}),
ctx,
allNodes
);
expect(nodeManager.meshMap.has('temp-live')).toBe(true);
// Now delete it
mutations = [];
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'temp-live' }),
ctx,
allNodes
);
const nodeRemoved = mutations.find((m) => m.type === 'nodeRemoved');
expect(nodeRemoved).toBeDefined();
});
it('ignores event without id', () => {
mapEventToEffects(
makeEvent('MemoryDeleted', {}),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('MemoryPromoted', () => {
it('grows the node to new retention', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1', new_retention: 0.95 }),
ctx,
allNodes
);
// Should have updated userData
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.95);
});
it('emits nodeUpdated mutation', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n2', new_retention: 0.98 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.nodeId).toBe('n2');
expect(mutation.retention).toBe(0.98);
});
it('creates green pulse + shockwave + spawn burst', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1' }),
ctx,
allNodes
);
// Should have green pulse
const greenPulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(greenPulse).toBeDefined();
// Should have added visual effects to scene
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
it('uses default retention when not provided', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'n1' }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation.retention).toBe(0.95); // default
});
it('ignores nonexistent node', () => {
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'nonexistent' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
});
describe('MemoryDemoted', () => {
it('shrinks the node', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
ctx,
allNodes
);
expect(nodeManager.meshMap.get('n1')!.userData.retention).toBe(0.3);
});
it('emits nodeUpdated mutation', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n2', new_retention: 0.2 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.retention).toBe(0.2);
});
it('creates red pulse (subtler than promotion)', () => {
mapEventToEffects(
makeEvent('MemoryDemoted', { id: 'n1', new_retention: 0.3 }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(pulse).toBeDefined();
expect(pulse!.decay).toBe(0.03); // faster decay = subtler
});
});
describe('MemoryUpdated', () => {
it('creates blue pulse on existing node', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n1');
expect(pulse).toBeDefined();
});
it('updates retention if provided', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1', retention: 0.85 }),
ctx,
allNodes
);
const mutation = mutations.find((m) => m.type === 'nodeUpdated') as any;
expect(mutation).toBeDefined();
expect(mutation.retention).toBe(0.85);
});
it('does not emit mutation without retention data', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'n1' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
});
it('ignores nonexistent node', () => {
mapEventToEffects(
makeEvent('MemoryUpdated', { id: 'nonexistent' }),
ctx,
allNodes
);
expect(mutations.length).toBe(0);
expect(effects.pulseEffects.length).toBe(0);
});
});
describe('SearchPerformed', () => {
it('pulses all nodes', () => {
mapEventToEffects(
makeEvent('SearchPerformed', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5); // 5 initial nodes
});
});
describe('DreamStarted', () => {
it('pulses all nodes with purple', () => {
mapEventToEffects(
makeEvent('DreamStarted', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5);
// Purple pulse with slow decay
effects.pulseEffects.forEach((p) => {
expect(p.decay).toBe(0.005);
});
});
});
describe('DreamProgress', () => {
it('pulses specific memory with high intensity', () => {
mapEventToEffects(
makeEvent('DreamProgress', { memory_id: 'n3' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n3');
expect(pulse).toBeDefined();
expect(pulse!.intensity).toBe(1.5);
});
it('ignores nonexistent memory', () => {
mapEventToEffects(
makeEvent('DreamProgress', { memory_id: 'nonexistent' }),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(0);
});
});
describe('DreamCompleted', () => {
it('creates center burst + shockwave', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('DreamCompleted', {}),
ctx,
allNodes
);
expect(scene.children.length).toBeGreaterThan(childrenBefore);
});
});
describe('RetentionDecayed', () => {
it('adds red pulse to decayed node', () => {
mapEventToEffects(
makeEvent('RetentionDecayed', { id: 'n2' }),
ctx,
allNodes
);
const pulse = effects.pulseEffects.find((p) => p.nodeId === 'n2');
expect(pulse).toBeDefined();
});
});
describe('ConsolidationCompleted', () => {
it('pulses all nodes with orange', () => {
mapEventToEffects(
makeEvent('ConsolidationCompleted', {}),
ctx,
allNodes
);
expect(effects.pulseEffects.length).toBe(5);
});
});
describe('ActivationSpread', () => {
it('creates flashes from source to all targets', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
makeEvent('ActivationSpread', {
source_id: 'n1',
target_ids: ['n2', 'n3', 'n4'],
}),
ctx,
allNodes
);
expect(scene.children.length).toBe(childrenBefore + 3);
});
});
describe('spawn position scoring', () => {
it('type match scores higher than tag match', () => {
// n1 is type: 'fact', tags: ['rust', 'bug-fix']
// n2 is type: 'concept', tags: ['architecture']
// Creating a 'fact' with 'architecture' tag — should favor n1 (type match = 2 points)
// vs n2 (tag match = 1 point)
const n1Pos = nodeManager.positions.get('n1')!;
const n2Pos = nodeManager.positions.get('n2')!;
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'type-vs-tag',
content: 'test',
node_type: 'fact',
tags: ['architecture'],
}),
ctx,
allNodes
);
const newPos = nodeManager.positions.get('type-vs-tag')!;
const distToN1 = newPos.distanceTo(n1Pos);
const distToN2 = newPos.distanceTo(n2Pos);
// Should be closer to n1 (type match wins)
expect(distToN1).toBeLessThan(distToN2);
});
it('falls back to random position when no matches', () => {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'no-match',
content: 'test',
node_type: 'place', // no existing 'place' nodes
tags: ['geography'], // no matching tags
}),
ctx,
allNodes
);
const pos = nodeManager.positions.get('no-match')!;
// Should be somewhere in the graph space
expect(Math.abs(pos.x)).toBeLessThan(100);
expect(Math.abs(pos.y)).toBeLessThan(100);
});
});
describe('full lifecycle integration', () => {
it('create → promote → delete lifecycle', () => {
// 1. Create
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'lifecycle',
content: 'lifecycle test',
node_type: 'fact',
retention: 0.7,
}),
ctx,
allNodes
);
expect(nodeManager.meshMap.has('lifecycle')).toBe(true);
// 2. Promote
mutations = [];
mapEventToEffects(
makeEvent('MemoryPromoted', { id: 'lifecycle', new_retention: 0.95 }),
ctx,
allNodes
);
expect(nodeManager.meshMap.get('lifecycle')!.userData.retention).toBe(0.95);
// 3. Delete
mutations = [];
mapEventToEffects(
makeEvent('MemoryDeleted', { id: 'lifecycle' }),
ctx,
allNodes
);
expect(forceSim.positions.has('lifecycle')).toBe(false);
});
it('rapid-fire 10 creates without errors', () => {
for (let i = 0; i < 10; i++) {
mapEventToEffects(
makeEvent('MemoryCreated', {
id: `rapid-${i}`,
content: `Rapid memory ${i}`,
node_type: i % 2 === 0 ? 'fact' : 'concept',
tags: ['rapid'],
retention: 0.5 + Math.random() * 0.5,
}),
ctx,
allNodes
);
}
expect(nodeManager.meshMap.size).toBe(15); // 5 initial + 10 new
expect(forceSim.positions.size).toBe(15);
// All mutations should have been emitted
const nodeAdded = mutations.filter((m) => m.type === 'nodeAdded');
expect(nodeAdded.length).toBe(10);
});
it('create + connection discovered pipeline', () => {
// Create two new memories
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'connect-a',
content: 'Connection source',
node_type: 'fact',
}),
ctx,
allNodes
);
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'connect-b',
content: 'Connection target',
node_type: 'fact',
}),
ctx,
allNodes
);
// Then discover a connection between them
mutations = [];
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'connect-a',
target_id: 'connect-b',
weight: 0.9,
}),
ctx,
allNodes
);
const edgeMutation = mutations.find((m) => m.type === 'edgeAdded');
expect(edgeMutation).toBeDefined();
});
it('dream sequence: start → progress → complete → connections', () => {
mapEventToEffects(makeEvent('DreamStarted', {}), ctx, allNodes);
expect(effects.pulseEffects.length).toBe(5);
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n1' }), ctx, allNodes);
mapEventToEffects(makeEvent('DreamProgress', { memory_id: 'n3' }), ctx, allNodes);
mapEventToEffects(makeEvent('DreamCompleted', {}), ctx, allNodes);
// Connections discovered during dream
mapEventToEffects(
makeEvent('ConnectionDiscovered', {
source_id: 'n1',
target_id: 'n5',
weight: 0.6,
}),
ctx,
allNodes
);
// Should have emitted edgeAdded
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
});
});
});

View file

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock three.js before any imports that use it
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { ForceSimulation } from '../force-sim';
import { Vector3 } from './three-mock';
import { makeNode, makeEdge, resetNodeCounter, tickN } from './helpers';
describe('ForceSimulation', () => {
beforeEach(() => resetNodeCounter());
function createSim(nodeCount: number) {
const positions = new Map<string, InstanceType<typeof Vector3>>();
for (let i = 0; i < nodeCount; i++) {
positions.set(`n${i}`, new Vector3(i * 10, 0, 0));
}
return new ForceSimulation(positions);
}
describe('initialization', () => {
it('creates velocities for all positions', () => {
const sim = createSim(5);
expect(sim.velocities.size).toBe(5);
for (const vel of sim.velocities.values()) {
expect(vel.x).toBe(0);
expect(vel.y).toBe(0);
expect(vel.z).toBe(0);
}
});
it('starts running at step 0', () => {
const sim = createSim(3);
expect(sim.running).toBe(true);
expect(sim.step).toBe(0);
});
});
describe('tick', () => {
it('increments step count each tick', () => {
const sim = createSim(3);
sim.tick([]);
expect(sim.step).toBe(1);
sim.tick([]);
expect(sim.step).toBe(2);
});
it('stops ticking after maxSteps', () => {
const sim = createSim(2);
tickN(sim, [], 301);
const posAfter300 = sim.positions.get('n0')!.clone();
tickN(sim, [], 10);
expect(sim.positions.get('n0')!.x).toBe(posAfter300.x);
});
it('does not tick when not running', () => {
const sim = createSim(2);
sim.running = false;
const posBefore = sim.positions.get('n0')!.clone();
sim.tick([]);
expect(sim.step).toBe(0);
expect(sim.positions.get('n0')!.x).toBe(posBefore.x);
});
it('applies repulsion between nodes', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(1, 0, 0));
const sim = new ForceSimulation(positions);
sim.tick([]);
// After repulsion, nodes should have moved apart
const a = sim.positions.get('a')!;
const b = sim.positions.get('b')!;
expect(b.x - a.x).toBeGreaterThan(1); // farther apart
});
it('applies attraction along edges', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(100, 0, 0));
const sim = new ForceSimulation(positions);
const edges = [makeEdge('a', 'b', { weight: 1.0 })];
tickN(sim, edges, 50);
// After many ticks with attraction, nodes should be closer
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
expect(dist).toBeLessThan(100);
});
it('applies centering force toward origin', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('far', new Vector3(1000, 1000, 1000));
const sim = new ForceSimulation(positions);
tickN(sim, [], 100);
const pos = sim.positions.get('far')!;
// Should have moved closer to origin
expect(pos.length()).toBeLessThan(1000 * Math.sqrt(3));
});
});
describe('addNode', () => {
it('adds position and velocity entries', () => {
const sim = createSim(2);
const newPos = new Vector3(5, 5, 5);
sim.addNode('new', newPos);
expect(sim.positions.has('new')).toBe(true);
expect(sim.velocities.has('new')).toBe(true);
expect(sim.positions.get('new')!.x).toBe(5);
});
it('clones the input position', () => {
const sim = createSim(1);
const input = new Vector3(10, 10, 10);
sim.addNode('new', input);
input.x = 999;
expect(sim.positions.get('new')!.x).toBe(10);
});
it('re-energizes physics so simulation stays alive', () => {
const sim = createSim(2);
// Exhaust the simulation
tickN(sim, [], 305);
expect(sim.step).toBe(301); // stopped at maxSteps+1
// Add a node
sim.addNode('live', new Vector3(0, 0, 0));
expect(sim.running).toBe(true);
// Should be able to tick again
const stepBefore = sim.step;
sim.tick([]);
expect(sim.step).toBe(stepBefore + 1);
});
it('extends maxSteps by cooldown amount', () => {
const sim = createSim(2);
tickN(sim, [], 250);
const stepAtAdd = sim.step;
sim.addNode('live', new Vector3(0, 0, 0));
// Should be able to tick at least 100 more times
let ticked = 0;
for (let i = 0; i < 100; i++) {
const stepBefore = sim.step;
sim.tick([]);
if (sim.step > stepBefore) ticked++;
}
expect(ticked).toBe(100);
});
});
describe('removeNode', () => {
it('removes position and velocity entries', () => {
const sim = createSim(3);
sim.removeNode('n1');
expect(sim.positions.has('n1')).toBe(false);
expect(sim.velocities.has('n1')).toBe(false);
expect(sim.positions.size).toBe(2);
});
it('simulation continues without removed node', () => {
const sim = createSim(3);
sim.removeNode('n1');
// Should not throw
tickN(sim, [], 10);
expect(sim.positions.has('n0')).toBe(true);
expect(sim.positions.has('n2')).toBe(true);
});
});
describe('reset', () => {
it('resets step count and running state', () => {
const sim = createSim(3);
tickN(sim, [], 100);
sim.running = false;
sim.reset();
expect(sim.step).toBe(0);
expect(sim.running).toBe(true);
});
it('zeroes all velocities', () => {
const sim = createSim(3);
tickN(sim, [], 10);
sim.reset();
for (const vel of sim.velocities.values()) {
expect(vel.x).toBe(0);
expect(vel.y).toBe(0);
expect(vel.z).toBe(0);
}
});
});
describe('physics convergence', () => {
it('two connected nodes reach equilibrium', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(-50, 0, 0));
positions.set('b', new Vector3(50, 0, 0));
const sim = new ForceSimulation(positions);
const edges = [makeEdge('a', 'b', { weight: 0.5 })];
tickN(sim, edges, 300);
// Should reach equilibrium — velocities near zero
const velA = sim.velocities.get('a')!;
const velB = sim.velocities.get('b')!;
expect(Math.abs(velA.x)).toBeLessThan(0.01);
expect(Math.abs(velB.x)).toBeLessThan(0.01);
});
it('unconnected nodes repel to stable separation', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
positions.set('a', new Vector3(0, 0, 0));
positions.set('b', new Vector3(5, 0, 0));
const sim = new ForceSimulation(positions);
tickN(sim, [], 300);
const dist = sim.positions.get('a')!.distanceTo(sim.positions.get('b')!);
expect(dist).toBeGreaterThan(5); // repelled farther apart
});
it('multiple nodes form a spread-out cluster', () => {
const positions = new Map<string, InstanceType<typeof Vector3>>();
for (let i = 0; i < 10; i++) {
positions.set(`n${i}`, new Vector3(Math.random() * 2, Math.random() * 2, Math.random() * 2));
}
const sim = new ForceSimulation(positions);
const edges = [makeEdge('n0', 'n1'), makeEdge('n2', 'n3'), makeEdge('n4', 'n5')];
tickN(sim, edges, 300);
// All nodes should be spread out, no two should overlap
const ids = Array.from(positions.keys());
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const dist = sim.positions.get(ids[i])!.distanceTo(sim.positions.get(ids[j])!);
expect(dist).toBeGreaterThan(0.1);
}
}
});
});
});

View file

@ -0,0 +1,50 @@
/**
* Test helpers: factories for creating test data.
*/
import type { GraphNode, GraphEdge, VestigeEvent, VestigeEventType } from '$types';
let nodeCounter = 0;
export function makeNode(overrides: Partial<GraphNode> = {}): GraphNode {
nodeCounter++;
return {
id: `node-${nodeCounter}`,
label: `Test Node ${nodeCounter}`,
type: 'fact',
retention: 0.8,
tags: ['test'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isCenter: false,
...overrides,
};
}
export function makeEdge(
source: string,
target: string,
overrides: Partial<GraphEdge> = {}
): GraphEdge {
return {
source,
target,
weight: 0.5,
type: 'semantic',
...overrides,
};
}
export function makeEvent(type: VestigeEventType, data: Record<string, unknown> = {}): VestigeEvent {
return { type, data };
}
export function resetNodeCounter() {
nodeCounter = 0;
}
/** Run simulation for N ticks */
export function tickN(sim: { tick: (edges: GraphEdge[]) => void }, edges: GraphEdge[], n: number) {
for (let i = 0; i < n; i++) {
sim.tick(edges);
}
}

View file

@ -0,0 +1,456 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('three', async () => {
const mock = await import('./three-mock');
return { ...mock };
});
import { NodeManager } from '../nodes';
import { Vector3 } from './three-mock';
import { makeNode, resetNodeCounter } from './helpers';
describe('NodeManager', () => {
let manager: NodeManager;
beforeEach(() => {
resetNodeCounter();
manager = new NodeManager();
});
describe('createNodes', () => {
it('creates meshes, glows, and labels for all nodes', () => {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
const positions = manager.createNodes(nodes);
expect(positions.size).toBe(3);
expect(manager.meshMap.size).toBe(3);
expect(manager.glowMap.size).toBe(3);
expect(manager.labelSprites.size).toBe(3);
});
it('positions center node at origin', () => {
const nodes = [
makeNode({ id: 'center', isCenter: true }),
makeNode({ id: 'other' }),
];
const positions = manager.createNodes(nodes);
const centerPos = positions.get('center')!;
expect(centerPos.x).toBe(0);
expect(centerPos.y).toBe(0);
expect(centerPos.z).toBe(0);
});
it('scales mesh size by retention', () => {
const highRet = makeNode({ id: 'high', retention: 1.0 });
const lowRet = makeNode({ id: 'low', retention: 0.1 });
manager.createNodes([highRet, lowRet]);
// SphereGeometry size = 0.5 + retention * 2
// High retention should have larger geometry (indirectly via userData)
const highMesh = manager.meshMap.get('high')!;
const lowMesh = manager.meshMap.get('low')!;
expect(highMesh.userData.retention).toBe(1.0);
expect(lowMesh.userData.retention).toBe(0.1);
});
it('uses Fibonacci sphere distribution for initial positions', () => {
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
const positions = manager.createNodes(nodes);
// No two nodes should be at the same position
const posArr = Array.from(positions.values());
for (let i = 0; i < posArr.length; i++) {
for (let j = i + 1; j < posArr.length; j++) {
const dist = posArr[i].distanceTo(posArr[j]);
expect(dist).toBeGreaterThan(0.1);
}
}
});
it('stores node type in userData', () => {
const nodes = [
makeNode({ id: 'fact', type: 'fact' }),
makeNode({ id: 'concept', type: 'concept' }),
makeNode({ id: 'decision', type: 'decision' }),
];
manager.createNodes(nodes);
expect(manager.meshMap.get('fact')!.userData.type).toBe('fact');
expect(manager.meshMap.get('concept')!.userData.type).toBe('concept');
expect(manager.meshMap.get('decision')!.userData.type).toBe('decision');
});
});
describe('addNode — materialization', () => {
it('adds a new node at specified position', () => {
const node = makeNode({ id: 'live-1' });
const pos = new Vector3(10, 20, 30);
const result = manager.addNode(node, pos);
expect(manager.meshMap.has('live-1')).toBe(true);
expect(manager.glowMap.has('live-1')).toBe(true);
expect(manager.labelSprites.has('live-1')).toBe(true);
expect(manager.positions.has('live-1')).toBe(true);
expect(result.x).toBe(10);
expect(result.y).toBe(20);
expect(result.z).toBe(30);
});
it('starts node at near-zero scale (not zero to avoid GPU issues)', () => {
const node = makeNode({ id: 'live-2' });
manager.addNode(node);
const mesh = manager.meshMap.get('live-2')!;
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
});
it('generates random position if none provided', () => {
const node = makeNode({ id: 'live-3' });
const pos = manager.addNode(node);
// Should be within ±20 range
expect(Math.abs(pos.x)).toBeLessThanOrEqual(20);
expect(Math.abs(pos.y)).toBeLessThanOrEqual(20);
expect(Math.abs(pos.z)).toBeLessThanOrEqual(20);
});
it('clones the input position to prevent external mutation', () => {
const node = makeNode({ id: 'live-4' });
const input = new Vector3(5, 5, 5);
manager.addNode(node, input);
input.x = 999;
expect(manager.positions.get('live-4')!.x).toBe(5);
});
it('label starts fully transparent', () => {
const node = makeNode({ id: 'live-5' });
manager.addNode(node);
const label = manager.labelSprites.get('live-5')!;
expect((label.material as any).opacity).toBe(0);
});
it('glow starts fully transparent', () => {
const node = makeNode({ id: 'live-6' });
manager.addNode(node);
const glow = manager.glowMap.get('live-6')!;
expect((glow.material as any).opacity).toBe(0);
});
});
describe('materialization animation choreography', () => {
function setupAndAnimate(frames: number) {
const nodes = [makeNode({ id: 'existing', retention: 0.5 })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'live', retention: 0.9 });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < frames; f++) {
manager.animate(f * 0.016, allNodes, camera);
}
return {
mesh: manager.meshMap.get('live')!,
glow: manager.glowMap.get('live')!,
label: manager.labelSprites.get('live')!,
};
}
it('mesh scale increases during first 30 frames', () => {
const { mesh } = setupAndAnimate(15);
expect(mesh.scale.x).toBeGreaterThan(0.001);
});
it('mesh reaches approximately full scale by frame 30', () => {
const { mesh } = setupAndAnimate(30);
// easeOutElastic should be near 1.0 at t=1
expect(mesh.scale.x).toBeGreaterThan(0.8);
});
it('glow starts fading in at frame 5', () => {
// Before frame 5: opacity should be 0
const before = setupAndAnimate(4);
expect((before.glow.material as any).opacity).toBe(0);
// After frame 7: opacity should be positive
const after = setupAndAnimate(8);
expect((after.glow.material as any).opacity).toBeGreaterThan(0);
});
it('label starts fading in after frame 40', () => {
// At frame 39: label should still be transparent
const before = setupAndAnimate(39);
expect((before.label.material as any).opacity).toBe(0);
// At frame 50: label should have some opacity
const after = setupAndAnimate(50);
expect((after.label.material as any).opacity).toBeGreaterThan(0);
});
it('label has positive opacity at frame 55 (during materialization window)', () => {
// Label fade-in runs from frame 40 to 60 (during materialization).
// After frame 60, distance-based visibility takes over which depends on camera position.
// Test within the materialization window itself.
const { label } = setupAndAnimate(55);
expect((label.material as any).opacity).toBeGreaterThan(0);
});
it('elastic overshoot occurs during materialization', () => {
// easeOutElastic should cause scale > 1.0 at some point
let maxScale = 0;
const nodes = [makeNode({ id: 'existing' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'elastic', retention: 0.5 });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, allNodes, camera);
const mesh = manager.meshMap.get('elastic')!;
if (mesh.scale.x > maxScale) maxScale = mesh.scale.x;
}
// Elastic should overshoot past 1.0
expect(maxScale).toBeGreaterThan(1.0);
});
});
describe('removeNode — dissolution', () => {
function setupWithNode() {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' })];
manager.createNodes(nodes);
return nodes;
}
it('marks node for dissolution without immediate removal', () => {
setupWithNode();
manager.removeNode('a');
// Mesh should still exist during dissolution animation
expect(manager.meshMap.has('a')).toBe(true);
});
it('node is fully removed after dissolution animation completes (60 frames)', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 65; f++) {
manager.animate(f * 0.016, nodes, camera);
}
expect(manager.meshMap.has('a')).toBe(false);
expect(manager.glowMap.has('a')).toBe(false);
expect(manager.labelSprites.has('a')).toBe(false);
expect(manager.positions.has('a')).toBe(false);
});
it('node shrinks during dissolution using easeInBack', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
// Run to near completion (frame 55/60) where shrink is deep
for (let f = 0; f < 55; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const currentScale = manager.meshMap.get('a')!.scale.x;
// At frame 55/60, easeInBack(0.917) ≈ 0.87, shrink = 1-0.87 = 0.13
// The originalScale from breathing was ~1.0, scale should be very small
expect(currentScale).toBeLessThan(1.0);
});
it('opacity fades during dissolution', () => {
const nodes = setupWithNode();
manager.removeNode('a');
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 50; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const mesh = manager.meshMap.get('a');
if (mesh) {
expect((mesh.material as any).opacity).toBeLessThan(0.5);
}
});
it('cancels materialization if node removed during spawn', () => {
const nodes = [makeNode({ id: 'base' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'spawn-then-die' });
manager.addNode(liveNode);
// Immediately remove before materialization finishes
manager.removeNode('spawn-then-die');
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
// Run past both animation durations
for (let f = 0; f < 70; f++) {
manager.animate(f * 0.016, allNodes, camera);
}
expect(manager.meshMap.has('spawn-then-die')).toBe(false);
});
});
describe('growNode — retention change animation', () => {
it('grows node to new retention scale', () => {
const nodes = [makeNode({ id: 'grow', retention: 0.3 })];
manager.createNodes(nodes);
const originalScale = manager.meshMap.get('grow')!.scale.x;
manager.growNode('grow', 0.9);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
// Target scale = 0.5 + 0.9 * 2 = 2.3
const mesh = manager.meshMap.get('grow')!;
// Should be near target scale after animation completes
expect(mesh.scale.x).toBeGreaterThan(originalScale);
});
it('shrinks node when retention decreases (demotion)', () => {
const nodes = [makeNode({ id: 'shrink', retention: 0.9 })];
manager.createNodes(nodes);
manager.growNode('shrink', 0.2);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
// Target scale = 0.5 + 0.2 * 2 = 0.9 (less than 0.5 + 0.9*2 = 2.3)
const mesh = manager.meshMap.get('shrink')!;
expect(mesh.userData.retention).toBe(0.2);
});
it('also grows the glow sprite', () => {
const nodes = [makeNode({ id: 'glow-grow', retention: 0.3 })];
manager.createNodes(nodes);
const originalGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
manager.growNode('glow-grow', 0.95);
const camera = { position: new Vector3(0, 30, 80) } as any;
for (let f = 0; f < 35; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const newGlowScale = manager.glowMap.get('glow-grow')!.scale.x;
expect(newGlowScale).toBeGreaterThan(originalGlowScale);
});
it('handles nonexistent node gracefully', () => {
expect(() => manager.growNode('nonexistent', 0.5)).not.toThrow();
});
});
describe('breathing animation', () => {
it('breathing only affects non-animating nodes', () => {
const nodes = [makeNode({ id: 'normal' })];
manager.createNodes(nodes);
const liveNode = makeNode({ id: 'materializing' });
manager.addNode(liveNode);
const allNodes = [...nodes, liveNode];
const camera = { position: new Vector3(0, 30, 80) } as any;
// During first few frames, materializing node should use animation scale
manager.animate(0.016, allNodes, camera);
// The materializing node's scale should be from the animation, not breathing
const matMesh = manager.meshMap.get('materializing')!;
// Its scale should be small (just started materializing)
expect(matMesh.scale.x).toBeLessThan(0.5);
});
it('hover increases emissive intensity', () => {
const nodes = [makeNode({ id: 'hover-test', retention: 0.5 })];
manager.createNodes(nodes);
manager.hoveredNode = 'hover-test';
const camera = { position: new Vector3(0, 30, 80) } as any;
manager.animate(0, nodes, camera);
const mat = manager.meshMap.get('hover-test')!.material as any;
expect(mat.emissiveIntensity).toBe(1.0);
});
it('selected node gets elevated emissive intensity', () => {
const nodes = [makeNode({ id: 'sel-test', retention: 0.5 })];
manager.createNodes(nodes);
manager.selectedNode = 'sel-test';
const camera = { position: new Vector3(0, 30, 80) } as any;
manager.animate(0, nodes, camera);
const mat = manager.meshMap.get('sel-test')!.material as any;
expect(mat.emissiveIntensity).toBe(0.8);
});
});
describe('label visibility', () => {
it('labels visible for nearby nodes', () => {
const nodes = [makeNode({ id: 'near', retention: 0.5 })];
manager.createNodes(nodes);
// Camera very close to the node
const nodePos = manager.positions.get('near')!;
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 10)) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const label = manager.labelSprites.get('near')!;
expect((label.material as any).opacity).toBeGreaterThan(0.5);
});
it('labels invisible for distant nodes', () => {
const nodes = [makeNode({ id: 'far', retention: 0.5 })];
manager.createNodes(nodes);
const nodePos = manager.positions.get('far')!;
const camera = { position: nodePos.clone().add(new Vector3(0, 0, 200)) } as any;
for (let f = 0; f < 30; f++) {
manager.animate(f * 0.016, nodes, camera);
}
const label = manager.labelSprites.get('far')!;
expect((label.material as any).opacity).toBeLessThan(0.1);
});
});
describe('dispose', () => {
it('clears all animation queues', () => {
const nodes = [makeNode({ id: 'a' })];
manager.createNodes(nodes);
manager.addNode(makeNode({ id: 'b' }));
manager.removeNode('a');
manager.dispose();
// Internal arrays should be empty (tested indirectly by no errors on next animate)
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
});
});
});

View file

@ -0,0 +1,37 @@
/**
* Test setup: minimal DOM stubs for canvas-based text rendering.
*/
import { vi } from 'vitest';
// Minimal canvas 2D context mock
const mockContext2D = {
clearRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '',
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
};
// Minimal canvas element mock
const mockCanvas = {
width: 512,
height: 64,
getContext: vi.fn(() => mockContext2D),
toDataURL: vi.fn(() => 'data:image/png;base64,'),
};
// Stub document.createElement for canvas
if (typeof globalThis.document === 'undefined') {
(globalThis as any).document = {
createElement: vi.fn((tag: string) => {
if (tag === 'canvas') return { ...mockCanvas };
return {};
}),
};
}

View file

@ -0,0 +1,438 @@
/**
* Lightweight Three.js mock for unit/integration tests.
* Implements the subset of Three.js APIs used by the graph modules.
*/
import { vi } from 'vitest';
export class Vector3 {
x: number;
y: number;
z: number;
constructor(x = 0, y = 0, z = 0) {
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new Vector3(this.x, this.y, this.z);
}
copy(v: Vector3) {
this.x = v.x;
this.y = v.y;
this.z = v.z;
return this;
}
set(x: number, y: number, z: number) {
this.x = x;
this.y = y;
this.z = z;
return this;
}
add(v: Vector3) {
this.x += v.x;
this.y += v.y;
this.z += v.z;
return this;
}
sub(v: Vector3) {
this.x -= v.x;
this.y -= v.y;
this.z -= v.z;
return this;
}
subVectors(a: Vector3, b: Vector3) {
this.x = a.x - b.x;
this.y = a.y - b.y;
this.z = a.z - b.z;
return this;
}
multiplyScalar(s: number) {
this.x *= s;
this.y *= s;
this.z *= s;
return this;
}
normalize() {
const len = this.length() || 1;
this.x /= len;
this.y /= len;
this.z /= len;
return this;
}
length() {
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
}
distanceTo(v: Vector3) {
const dx = this.x - v.x;
const dy = this.y - v.y;
const dz = this.z - v.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
lerp(v: Vector3, alpha: number) {
this.x += (v.x - this.x) * alpha;
this.y += (v.y - this.y) * alpha;
this.z += (v.z - this.z) * alpha;
return this;
}
setScalar(s: number) {
this.x = s;
this.y = s;
this.z = s;
return this;
}
}
export class Vector2 {
x: number;
y: number;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
}
export class Color {
r: number;
g: number;
b: number;
constructor(colorOrR?: number | string, g?: number, b?: number) {
if (typeof colorOrR === 'string') {
this.r = 1;
this.g = 1;
this.b = 1;
} else if (typeof colorOrR === 'number' && g === undefined) {
this.r = ((colorOrR >> 16) & 255) / 255;
this.g = ((colorOrR >> 8) & 255) / 255;
this.b = (colorOrR & 255) / 255;
} else {
this.r = colorOrR ?? 1;
this.g = g ?? 1;
this.b = b ?? 1;
}
}
clone() {
const c = new Color();
c.r = this.r;
c.g = this.g;
c.b = this.b;
return c;
}
copy(c: Color) {
this.r = c.r;
this.g = c.g;
this.b = c.b;
return this;
}
lerp(c: Color, alpha: number) {
this.r += (c.r - this.r) * alpha;
this.g += (c.g - this.g) * alpha;
this.b += (c.b - this.b) * alpha;
return this;
}
setHSL(h: number, s: number, l: number) {
this.r = h;
this.g = s;
this.b = l;
return this;
}
offsetHSL(_h: number, _s: number, _l: number) {
return this;
}
}
export class BufferAttribute {
array: Float32Array;
itemSize: number;
count: number;
needsUpdate = false;
constructor(array: Float32Array, itemSize: number) {
this.array = array;
this.itemSize = itemSize;
this.count = array.length / itemSize;
}
getX(index: number) {
return this.array[index * this.itemSize];
}
getY(index: number) {
return this.array[index * this.itemSize + 1];
}
getZ(index: number) {
return this.array[index * this.itemSize + 2];
}
setX(index: number, x: number) {
this.array[index * this.itemSize] = x;
}
setY(index: number, y: number) {
this.array[index * this.itemSize + 1] = y;
}
setZ(index: number, z: number) {
this.array[index * this.itemSize + 2] = z;
}
setXYZ(index: number, x: number, y: number, z: number) {
const i = index * this.itemSize;
this.array[i] = x;
this.array[i + 1] = y;
this.array[i + 2] = z;
}
}
export class BufferGeometry {
attributes: Record<string, BufferAttribute> = {};
setAttribute(name: string, attr: BufferAttribute) {
this.attributes[name] = attr;
return this;
}
getAttribute(name: string) {
return this.attributes[name];
}
setFromPoints(points: Vector3[]) {
const arr = new Float32Array(points.length * 3);
points.forEach((p, i) => {
arr[i * 3] = p.x;
arr[i * 3 + 1] = p.y;
arr[i * 3 + 2] = p.z;
});
this.setAttribute('position', new BufferAttribute(arr, 3));
return this;
}
dispose() {}
}
export class SphereGeometry extends BufferGeometry {
constructor(_radius?: number, _w?: number, _h?: number) {
super();
}
}
export class RingGeometry extends BufferGeometry {
constructor(_inner?: number, _outer?: number, _segments?: number) {
super();
}
}
class BaseMaterial {
color = new Color();
transparent = false;
opacity = 1;
blending = 0;
side = 0;
map: { dispose: () => void } | null = null;
emissive = new Color();
emissiveIntensity = 0;
roughness = 0;
metalness = 0;
depthTest = true;
sizeAttenuation = true;
size = 1;
needsUpdate = false;
dispose() {}
}
export class MeshStandardMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (params.color instanceof Color) this.color = params.color;
if (params.emissive instanceof Color) this.emissive = params.emissive;
if (typeof params.emissiveIntensity === 'number') this.emissiveIntensity = params.emissiveIntensity;
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
if (typeof params.roughness === 'number') this.roughness = params.roughness;
if (typeof params.metalness === 'number') this.metalness = params.metalness;
}
}
}
export class MeshBasicMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
if (typeof params.transparent === 'boolean') this.transparent = params.transparent;
}
}
}
export class LineBasicMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class PointsMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (params.color instanceof Color) this.color = params.color;
else if (typeof params.color === 'number') this.color = new Color(params.color);
if (typeof params.size === 'number') this.size = params.size;
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class SpriteMaterial extends BaseMaterial {
constructor(params?: Record<string, unknown>) {
super();
if (params) {
if (typeof params.opacity === 'number') this.opacity = params.opacity;
}
}
}
export class Object3D {
position = new Vector3();
scale = new Vector3(1, 1, 1);
userData: Record<string, unknown> = {};
children: Object3D[] = [];
parent: Object3D | null = null;
add(child: Object3D) {
this.children.push(child);
child.parent = this;
return this;
}
remove(child: Object3D) {
const idx = this.children.indexOf(child);
if (idx !== -1) {
this.children.splice(idx, 1);
child.parent = null;
}
return this;
}
traverse(callback: (obj: Object3D) => void) {
callback(this);
for (const child of this.children) {
child.traverse(callback);
}
}
lookAt(_target: Vector3) {}
}
export class Group extends Object3D {}
export class Scene extends Object3D {}
export class Mesh extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Line extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Points extends Object3D {
geometry: BufferGeometry;
material: BaseMaterial;
constructor(geometry?: BufferGeometry, material?: BaseMaterial) {
super();
this.geometry = geometry ?? new BufferGeometry();
this.material = material ?? new BaseMaterial();
}
}
export class Sprite extends Object3D {
material: BaseMaterial;
constructor(material?: BaseMaterial) {
super();
this.material = material ?? new BaseMaterial();
}
}
export class PerspectiveCamera extends Object3D {
fov = 60;
aspect = 1;
near = 0.1;
far = 2000;
}
export class Camera extends Object3D {}
export class CanvasTexture {
needsUpdate = false;
constructor(_canvas: unknown) {}
dispose() {}
}
// Blending constants
export const AdditiveBlending = 2;
export const DoubleSide = 2;
// Install mock globally for 'three' imports
export function installThreeMock() {
vi.mock('three', () => ({
Vector3,
Vector2,
Color,
BufferAttribute,
BufferGeometry,
SphereGeometry,
RingGeometry,
MeshStandardMaterial,
MeshBasicMaterial,
LineBasicMaterial,
PointsMaterial,
SpriteMaterial,
Object3D,
Group,
Scene,
Mesh,
Line,
Points,
Sprite,
PerspectiveCamera,
Camera,
CanvasTexture,
AdditiveBlending,
DoubleSide,
}));
}