diff --git a/apps/memory-graph-playground/next-env.d.ts b/apps/memory-graph-playground/next-env.d.ts
index 9edff1c7..c4b7818f 100644
--- a/apps/memory-graph-playground/next-env.d.ts
+++ b/apps/memory-graph-playground/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/bun.lock b/bun.lock
index a634cb4a..945692e6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -306,6 +306,7 @@
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^7.2.1",
+ "vitest": "^3.2.4",
},
"peerDependencies": {
"react": ">=18.0.0",
diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json
index 9cbfd783..fcf06d91 100644
--- a/packages/memory-graph/package.json
+++ b/packages/memory-graph/package.json
@@ -33,7 +33,8 @@
"dev": "vite build --watch",
"build": "vite build && tsc --emitDeclarationOnly",
"check-types": "tsc --noEmit",
- "prepublishOnly": "bun run build"
+ "prepublishOnly": "bun run build",
+ "test": "vitest run"
},
"keywords": [
"supermemory",
@@ -69,6 +70,7 @@
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.3",
- "vite": "^7.2.1"
+ "vite": "^7.2.1",
+ "vitest": "^3.2.4"
}
-}
+}
\ No newline at end of file
diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts
new file mode 100644
index 00000000..352dc743
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts
@@ -0,0 +1,198 @@
+import { describe, it, expect } from "vitest"
+import {
+ normalizeDocCoordinates,
+ getMemoryBorderColor,
+ getEdgeVisualProps,
+ screenToBackendCoords,
+ calculateBackendViewport,
+} from "../hooks/use-graph-data"
+import { DEFAULT_COLORS } from "../constants"
+import type { GraphApiDocument, GraphApiMemory } from "../types"
+
+function makeDoc(id: string, x: number, y: number): GraphApiDocument {
+ return {
+ id,
+ title: `Doc ${id}`,
+ summary: null,
+ documentType: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ x,
+ y,
+ memories: [],
+ }
+}
+
+function makeMemory(overrides: Partial = {}): GraphApiMemory {
+ return {
+ id: "m1",
+ memory: "test",
+ isStatic: false,
+ spaceId: "default",
+ isLatest: true,
+ isForgotten: false,
+ forgetAfter: null,
+ forgetReason: null,
+ version: 1,
+ parentMemoryId: null,
+ rootMemoryId: null,
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ ...overrides,
+ }
+}
+
+describe("normalizeDocCoordinates", () => {
+ it("normalizes coordinates to 100-900 range", () => {
+ const docs = [
+ makeDoc("a", 0, 0),
+ makeDoc("b", 100, 100),
+ makeDoc("c", 50, 50),
+ ]
+ const result = normalizeDocCoordinates(docs)
+
+ for (const doc of result) {
+ expect(doc.x).toBeGreaterThanOrEqual(100)
+ expect(doc.x).toBeLessThanOrEqual(900)
+ expect(doc.y).toBeGreaterThanOrEqual(100)
+ expect(doc.y).toBeLessThanOrEqual(900)
+ }
+ })
+
+ it("maps min to 100 and max to 900", () => {
+ const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 200)]
+ const result = normalizeDocCoordinates(docs)
+
+ expect(result[0]!.x).toBeCloseTo(100)
+ expect(result[0]!.y).toBeCloseTo(100)
+ expect(result[1]!.x).toBeCloseTo(900)
+ expect(result[1]!.y).toBeCloseTo(900)
+ })
+
+ it("handles single document (returns as-is)", () => {
+ const docs = [makeDoc("a", 500, 300)]
+ const result = normalizeDocCoordinates(docs)
+ expect(result).toEqual(docs)
+ })
+
+ it("handles empty array", () => {
+ const result = normalizeDocCoordinates([])
+ expect(result).toEqual([])
+ })
+
+ it("handles documents at same position", () => {
+ const docs = [makeDoc("a", 50, 50), makeDoc("b", 50, 50)]
+ // Should not throw (rangeX/rangeY fallback to 1)
+ expect(() => normalizeDocCoordinates(docs)).not.toThrow()
+ })
+
+ it("preserves document data (only x/y change)", () => {
+ const docs = [makeDoc("a", 0, 0), makeDoc("b", 100, 100)]
+ const result = normalizeDocCoordinates(docs)
+ expect(result[0]!.id).toBe("a")
+ expect(result[0]!.title).toBe("Doc a")
+ expect(result[1]!.id).toBe("b")
+ })
+})
+
+describe("getMemoryBorderColor", () => {
+ const colors = DEFAULT_COLORS
+
+ it("returns forgotten color for forgotten memories", () => {
+ const mem = makeMemory({ isForgotten: true })
+ expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
+ })
+
+ it("returns expiring color for memories expiring within 7 days", () => {
+ const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
+ const mem = makeMemory({ forgetAfter: soon })
+ expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
+ })
+
+ it("returns recent color for memories created within 24 hours", () => {
+ const recent = new Date(Date.now() - 1000).toISOString()
+ const mem = makeMemory({ createdAt: recent })
+ expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderRecent)
+ })
+
+ it("returns default color for normal memories", () => {
+ const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
+ const mem = makeMemory({ createdAt: old })
+ expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
+ })
+
+ it("forgotten takes priority over expiring", () => {
+ const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
+ const mem = makeMemory({ isForgotten: true, forgetAfter: soon })
+ expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
+ })
+})
+
+describe("getEdgeVisualProps", () => {
+ it("returns correct opacity and thickness for similarity 0", () => {
+ const props = getEdgeVisualProps(0)
+ expect(props.opacity).toBeCloseTo(0.3)
+ expect(props.thickness).toBeCloseTo(1)
+ })
+
+ it("returns correct opacity and thickness for similarity 1", () => {
+ const props = getEdgeVisualProps(1)
+ expect(props.opacity).toBeCloseTo(0.8)
+ expect(props.thickness).toBeCloseTo(2.5)
+ })
+
+ it("returns intermediate values for similarity 0.5", () => {
+ const props = getEdgeVisualProps(0.5)
+ expect(props.opacity).toBeCloseTo(0.55)
+ expect(props.thickness).toBeCloseTo(1.75)
+ })
+})
+
+describe("screenToBackendCoords", () => {
+ it("converts screen coordinates to backend coordinates", () => {
+ const result = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
+ expect(result.x).toBeDefined()
+ expect(result.y).toBeDefined()
+ })
+
+ it("accounts for pan offset", () => {
+ const noPan = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
+ const withPan = screenToBackendCoords(400, 300, 100, 50, 1, 800, 600)
+ // Panning right means the backend coordinate should be smaller
+ expect(withPan.x).toBeLessThan(noPan.x)
+ expect(withPan.y).toBeLessThan(noPan.y)
+ })
+
+ it("accounts for zoom", () => {
+ const zoom1 = screenToBackendCoords(400, 300, 0, 0, 1, 800, 600)
+ const zoom2 = screenToBackendCoords(400, 300, 0, 0, 2, 800, 600)
+ // At higher zoom, same screen position maps to smaller backend area
+ expect(zoom2.x).toBeLessThan(zoom1.x)
+ })
+})
+
+describe("calculateBackendViewport", () => {
+ it("returns min/max bounds", () => {
+ const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
+ expect(bounds.minX).toBeDefined()
+ expect(bounds.maxX).toBeDefined()
+ expect(bounds.minY).toBeDefined()
+ expect(bounds.maxY).toBeDefined()
+ expect(bounds.maxX).toBeGreaterThan(bounds.minX)
+ expect(bounds.maxY).toBeGreaterThan(bounds.minY)
+ })
+
+ it("higher zoom produces smaller viewport", () => {
+ const zoom1 = calculateBackendViewport(0, 0, 1, 800, 600)
+ const zoom2 = calculateBackendViewport(0, 0, 2, 800, 600)
+ const area1 = (zoom1.maxX - zoom1.minX) * (zoom1.maxY - zoom1.minY)
+ const area2 = (zoom2.maxX - zoom2.minX) * (zoom2.maxY - zoom2.minY)
+ expect(area2).toBeLessThan(area1)
+ })
+
+ it("minX is non-negative", () => {
+ const bounds = calculateBackendViewport(0, 0, 1, 800, 600)
+ expect(bounds.minX).toBeGreaterThanOrEqual(0)
+ expect(bounds.minY).toBeGreaterThanOrEqual(0)
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/mock-data.test.ts b/packages/memory-graph/src/__tests__/mock-data.test.ts
new file mode 100644
index 00000000..62d85aa9
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/mock-data.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest"
+import { generateMockGraphData } from "../mock-data"
+
+describe("generateMockGraphData", () => {
+ it("produces deterministic output with same seed", () => {
+ const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
+ const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
+
+ expect(data1.documents.length).toBe(data2.documents.length)
+ expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
+ expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
+ expect(data1.documents[0]!.x).toBe(data2.documents[0]!.x)
+ expect(data1.documents[0]!.y).toBe(data2.documents[0]!.y)
+ })
+
+ it("produces different output with different seeds", () => {
+ const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
+ const data2 = generateMockGraphData({ documentCount: 10, seed: 99 })
+
+ // At least some documents should differ
+ const titles1 = data1.documents.map((d) => d.title).join(",")
+ const titles2 = data2.documents.map((d) => d.title).join(",")
+ expect(titles1).not.toBe(titles2)
+ })
+
+ it("generates correct number of documents", () => {
+ const data = generateMockGraphData({ documentCount: 25, seed: 1 })
+ expect(data.documents.length).toBe(25)
+ })
+
+ it("documents have required fields", () => {
+ const data = generateMockGraphData({ documentCount: 5, seed: 1 })
+ for (const doc of data.documents) {
+ expect(doc.id).toBeDefined()
+ expect(doc.title).toBeDefined()
+ expect(doc.summary).toBeDefined()
+ expect(doc.documentType).toBeDefined()
+ expect(doc.createdAt).toBeDefined()
+ expect(doc.updatedAt).toBeDefined()
+ expect(typeof doc.x).toBe("number")
+ expect(typeof doc.y).toBe("number")
+ expect(Array.isArray(doc.memories)).toBe(true)
+ }
+ })
+
+ it("memories have required fields", () => {
+ const data = generateMockGraphData({ documentCount: 5, seed: 1 })
+ const doc = data.documents.find((d) => d.memories.length > 0)
+ expect(doc).toBeDefined()
+
+ for (const mem of doc!.memories) {
+ expect(mem.id).toBeDefined()
+ expect(mem.memory).toBeDefined()
+ expect(typeof mem.isStatic).toBe("boolean")
+ expect(typeof mem.isForgotten).toBe("boolean")
+ expect(typeof mem.isLatest).toBe("boolean")
+ expect(typeof mem.version).toBe("number")
+ expect(mem.createdAt).toBeDefined()
+ expect(mem.updatedAt).toBeDefined()
+ }
+ })
+
+ it("generates edges", () => {
+ const data = generateMockGraphData({
+ documentCount: 20,
+ similarityEdgeRatio: 0.1,
+ seed: 1,
+ })
+ expect(data.edges.length).toBeGreaterThan(0)
+ })
+
+ it("edges reference valid document IDs", () => {
+ const data = generateMockGraphData({
+ documentCount: 20,
+ similarityEdgeRatio: 0.1,
+ seed: 1,
+ })
+ const allDocIds = new Set(data.documents.map((d) => d.id))
+
+ for (const edge of data.edges) {
+ expect(allDocIds.has(edge.source)).toBe(true)
+ expect(allDocIds.has(edge.target)).toBe(true)
+ }
+ })
+
+ it("handles zero documents", () => {
+ const data = generateMockGraphData({ documentCount: 0, seed: 1 })
+ expect(data.documents.length).toBe(0)
+ expect(data.edges.length).toBe(0)
+ })
+
+ it("respects memoriesPerDoc range", () => {
+ const data = generateMockGraphData({
+ documentCount: 50,
+ memoriesPerDoc: [3, 3],
+ seed: 1,
+ })
+ for (const doc of data.documents) {
+ expect(doc.memories.length).toBe(3)
+ }
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts
new file mode 100644
index 00000000..3e0ccab6
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/simulation.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect } from "vitest"
+import { ForceSimulation } from "../canvas/simulation"
+import type { GraphNode, GraphEdge } from "../types"
+
+function makeNode(id: string, x: number, y: number): GraphNode {
+ return {
+ id,
+ type: "document",
+ x,
+ y,
+ size: 50,
+ borderColor: "#fff",
+ isHovered: false,
+ isDragging: false,
+ data: {
+ id,
+ title: id,
+ summary: null,
+ type: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ memories: [],
+ },
+ }
+}
+
+function makeEdge(source: string, target: string): GraphEdge {
+ return {
+ id: `e-${source}-${target}`,
+ source,
+ target,
+ similarity: 0.8,
+ visualProps: { opacity: 0.5, thickness: 1.5 },
+ edgeType: "doc-memory",
+ }
+}
+
+describe("ForceSimulation", () => {
+ it("init creates simulation and isActive returns true", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
+ const edges = [makeEdge("a", "b")]
+ sim.init(nodes, edges)
+ expect(sim.isActive()).toBe(true)
+ sim.destroy()
+ })
+
+ it("destroy stops simulation", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0)]
+ sim.init(nodes, [])
+ sim.destroy()
+ expect(sim.isActive()).toBe(false)
+ })
+
+ it("init moves nodes from initial positions (pre-tick)", () => {
+ const sim = new ForceSimulation()
+ const nodes = [
+ makeNode("a", 0, 0),
+ makeNode("b", 0, 0), // Same position - repulsion should move them
+ ]
+ sim.init(nodes, [])
+
+ // After init with pre-ticks, nodes at same position should have moved apart
+ const dx = nodes[0]!.x - nodes[1]!.x
+ const dy = nodes[0]!.y - nodes[1]!.y
+ const dist = Math.sqrt(dx * dx + dy * dy)
+ expect(dist).toBeGreaterThan(0)
+ sim.destroy()
+ })
+
+ it("update hot-swaps nodes without full re-init", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
+ sim.init(nodes, [])
+
+ // Update with same nodes but different positions
+ nodes[0]!.x = 50
+ expect(() => sim.update(nodes, [])).not.toThrow()
+ expect(sim.isActive()).toBe(true)
+ sim.destroy()
+ })
+
+ it("reheat increases simulation energy", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0)]
+ sim.init(nodes, [])
+ // Should not throw
+ expect(() => sim.reheat()).not.toThrow()
+ sim.destroy()
+ })
+
+ it("coolDown reduces simulation energy", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0)]
+ sim.init(nodes, [])
+ expect(() => sim.coolDown()).not.toThrow()
+ sim.destroy()
+ })
+
+ it("handles empty nodes array", () => {
+ const sim = new ForceSimulation()
+ expect(() => sim.init([], [])).not.toThrow()
+ sim.destroy()
+ })
+
+ it("handles edges with missing nodes gracefully", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0)]
+ const edges = [makeEdge("a", "nonexistent")]
+ // Should not throw even with dangling edge
+ expect(() => sim.init(nodes, edges)).not.toThrow()
+ sim.destroy()
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/spatial-index.test.ts b/packages/memory-graph/src/__tests__/spatial-index.test.ts
new file mode 100644
index 00000000..46d7beab
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/spatial-index.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from "vitest"
+import { SpatialIndex } from "../canvas/hit-test"
+import type { GraphNode } from "../types"
+
+function makeNode(
+ id: string,
+ x: number,
+ y: number,
+ type: "document" | "memory" = "document",
+ size = 50,
+): GraphNode {
+ return {
+ id,
+ type,
+ x,
+ y,
+ size,
+ borderColor: "#fff",
+ isHovered: false,
+ isDragging: false,
+ data: {
+ id,
+ title: id,
+ summary: null,
+ type: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ memories: [],
+ },
+ }
+}
+
+describe("SpatialIndex", () => {
+ it("rebuild returns true on first build", () => {
+ const idx = new SpatialIndex()
+ const result = idx.rebuild([makeNode("a", 100, 100)])
+ expect(result).toBe(true)
+ })
+
+ it("rebuild returns false when hash unchanged", () => {
+ const idx = new SpatialIndex()
+ const nodes = [makeNode("a", 100, 100)]
+ idx.rebuild(nodes)
+ const result = idx.rebuild(nodes)
+ expect(result).toBe(false)
+ })
+
+ it("rebuild returns true when positions change", () => {
+ const idx = new SpatialIndex()
+ const nodes = [makeNode("a", 100, 100)]
+ idx.rebuild(nodes)
+ nodes[0]!.x = 500
+ const result = idx.rebuild(nodes)
+ expect(result).toBe(true)
+ })
+
+ it("rebuild detects sub-pixel movements (10x granularity)", () => {
+ const idx = new SpatialIndex()
+ const nodes = [makeNode("a", 100.0, 100.0)]
+ idx.rebuild(nodes)
+ // Move by 0.2 pixels — should be detected with 10x rounding
+ nodes[0]!.x = 100.2
+ const result = idx.rebuild(nodes)
+ expect(result).toBe(true)
+ })
+
+ it("queryPoint finds correct node (document - square hit test)", () => {
+ const idx = new SpatialIndex()
+ const node = makeNode("a", 100, 100, "document", 50)
+ idx.rebuild([node])
+ const found = idx.queryPoint(105, 105)
+ expect(found).not.toBeNull()
+ expect(found!.id).toBe("a")
+ })
+
+ it("queryPoint finds correct node (memory - circle hit test)", () => {
+ const idx = new SpatialIndex()
+ const node = makeNode("m1", 200, 200, "memory", 36)
+ idx.rebuild([node])
+ // Inside the circle (radius = 18)
+ const found = idx.queryPoint(210, 210)
+ expect(found).not.toBeNull()
+ expect(found!.id).toBe("m1")
+ })
+
+ it("queryPoint returns null for empty grid", () => {
+ const idx = new SpatialIndex()
+ idx.rebuild([])
+ expect(idx.queryPoint(100, 100)).toBeNull()
+ })
+
+ it("queryPoint returns null for distant coordinates", () => {
+ const idx = new SpatialIndex()
+ idx.rebuild([makeNode("a", 100, 100)])
+ expect(idx.queryPoint(5000, 5000)).toBeNull()
+ })
+
+ it("queryPoint handles overlapping nodes (returns last in render order)", () => {
+ const idx = new SpatialIndex()
+ const nodes = [
+ makeNode("a", 100, 100, "document", 50),
+ makeNode("b", 110, 110, "document", 50),
+ ]
+ idx.rebuild(nodes)
+ // Both nodes overlap at (105, 105), should return the last one (higher z)
+ const found = idx.queryPoint(105, 105)
+ expect(found).not.toBeNull()
+ expect(found!.id).toBe("b")
+ })
+
+ it("queryPoint works across cell boundaries", () => {
+ const idx = new SpatialIndex()
+ // Node at cell boundary (cellSize = 200)
+ const node = makeNode("edge", 199, 199, "document", 50)
+ idx.rebuild([node])
+ // Query from adjacent cell
+ const found = idx.queryPoint(201, 201)
+ expect(found).not.toBeNull()
+ expect(found!.id).toBe("edge")
+ })
+
+ it("handles many nodes without errors", () => {
+ const idx = new SpatialIndex()
+ const nodes = Array.from({ length: 1000 }, (_, i) =>
+ makeNode(`n${i}`, Math.random() * 2000, Math.random() * 2000),
+ )
+ expect(() => idx.rebuild(nodes)).not.toThrow()
+ // Should find at least some nodes
+ const found = idx.queryPoint(nodes[0]!.x, nodes[0]!.y)
+ expect(found).not.toBeNull()
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts
new file mode 100644
index 00000000..27580a2a
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/version-chain.test.ts
@@ -0,0 +1,186 @@
+import { describe, it, expect } from "vitest"
+import { VersionChainIndex } from "../canvas/version-chain"
+import type { GraphApiDocument, GraphApiMemory } from "../types"
+
+function makeMem(overrides: Partial & { id: string }): GraphApiMemory {
+ return {
+ memory: `Memory ${overrides.id}`,
+ isStatic: false,
+ spaceId: "default",
+ isLatest: true,
+ isForgotten: false,
+ forgetAfter: null,
+ forgetReason: null,
+ version: 1,
+ parentMemoryId: null,
+ rootMemoryId: null,
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ ...overrides,
+ }
+}
+
+function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
+ return {
+ id,
+ title: `Doc ${id}`,
+ summary: null,
+ documentType: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ x: 0,
+ y: 0,
+ memories,
+ }
+}
+
+describe("VersionChainIndex", () => {
+ it("getChain returns null for version 1 memories (no chain)", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ ])
+ idx.rebuild([doc])
+ // version <= 1 returns null per implementation
+ expect(idx.getChain("m1")).toBeNull()
+ })
+
+ it("builds chain by walking parentMemoryId backwards then reversing", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ makeMem({ id: "m3", parentMemoryId: "m2", rootMemoryId: "m1", version: 3 }),
+ ])
+ idx.rebuild([doc])
+
+ // Query from the latest (version 3) — walks back m3->m2->m1, reverses to [m1,m2,m3]
+ const chain = idx.getChain("m3")
+ expect(chain).not.toBeNull()
+ expect(chain!.length).toBe(3)
+ expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
+ })
+
+ it("getChain from middle element walks back to root", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ makeMem({ id: "m3", parentMemoryId: "m2", rootMemoryId: "m1", version: 3 }),
+ ])
+ idx.rebuild([doc])
+
+ // Query from m2 (version 2) — walks back m2->m1, reverses to [m1,m2]
+ // Note: it doesn't walk forward to m3, only backward
+ const chain = idx.getChain("m2")
+ expect(chain).not.toBeNull()
+ expect(chain!.length).toBe(2)
+ expect(chain!.map((e) => e.id)).toEqual(["m1", "m2"])
+ })
+
+ it("caches chain results for all entries in the chain", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ ])
+ idx.rebuild([doc])
+
+ const chain1 = idx.getChain("m2")
+ // After querying m2, m1 should also be cached (same chain object)
+ const chain2 = idx.getChain("m1")
+ // m1 is version 1, but it was cached as part of m2's chain
+ expect(chain2).toBe(chain1) // same reference
+ })
+
+ it("getChain returns null for unknown ID", () => {
+ const idx = new VersionChainIndex()
+ idx.rebuild([makeDoc("d1", [makeMem({ id: "m1", version: 1 })])])
+ expect(idx.getChain("nonexistent")).toBeNull()
+ })
+
+ it("handles empty documents array", () => {
+ const idx = new VersionChainIndex()
+ expect(() => idx.rebuild([])).not.toThrow()
+ expect(idx.getChain("anything")).toBeNull()
+ })
+
+ it("rebuild clears previous chains (new array reference)", () => {
+ const idx = new VersionChainIndex()
+ const doc1 = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ ])
+ idx.rebuild([doc1])
+ expect(idx.getChain("m2")).not.toBeNull()
+
+ // Rebuild with different data (new array reference)
+ const doc2 = makeDoc("d2", [makeMem({ id: "m3", version: 1 })])
+ idx.rebuild([doc2])
+ expect(idx.getChain("m2")).toBeNull()
+ expect(idx.getChain("m1")).toBeNull()
+ })
+
+ it("rebuild skips if same array reference", () => {
+ const idx = new VersionChainIndex()
+ const docs = [makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ ])]
+ idx.rebuild(docs)
+ const chain1 = idx.getChain("m2")
+
+ // Same reference — rebuild is a no-op
+ idx.rebuild(docs)
+ const chain2 = idx.getChain("m2")
+ expect(chain2).toBe(chain1)
+ })
+
+ it("handles multiple independent chains across documents", () => {
+ const idx = new VersionChainIndex()
+ const docs = [
+ makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2 }),
+ ]),
+ makeDoc("d2", [
+ makeMem({ id: "m3", version: 1 }),
+ makeMem({ id: "m4", parentMemoryId: "m3", rootMemoryId: "m3", version: 2 }),
+ ]),
+ ]
+ idx.rebuild(docs)
+
+ const chain1 = idx.getChain("m2")
+ const chain2 = idx.getChain("m4")
+ expect(chain1).not.toBeNull()
+ expect(chain2).not.toBeNull()
+ expect(chain1!.map((e) => e.id)).toEqual(["m1", "m2"])
+ expect(chain2!.map((e) => e.id)).toEqual(["m3", "m4"])
+ })
+
+ it("chain entries have correct fields", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1, isForgotten: true, isLatest: false }),
+ makeMem({ id: "m2", parentMemoryId: "m1", rootMemoryId: "m1", version: 2, isLatest: true }),
+ ])
+ idx.rebuild([doc])
+
+ const chain = idx.getChain("m2")
+ expect(chain).not.toBeNull()
+ expect(chain![0]).toEqual({
+ id: "m1",
+ version: 1,
+ memory: "Memory m1",
+ isForgotten: true,
+ isLatest: false,
+ })
+ expect(chain![1]).toEqual({
+ id: "m2",
+ version: 2,
+ memory: "Memory m2",
+ isForgotten: false,
+ isLatest: true,
+ })
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts
new file mode 100644
index 00000000..9b9d8ce3
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/viewport.test.ts
@@ -0,0 +1,217 @@
+import { describe, it, expect } from "vitest"
+import { ViewportState } from "../canvas/viewport"
+import type { GraphNode } from "../types"
+
+function makeNode(id: string, x: number, y: number): GraphNode {
+ return {
+ id,
+ type: "document",
+ x,
+ y,
+ size: 50,
+ borderColor: "#fff",
+ isHovered: false,
+ isDragging: false,
+ data: {
+ id,
+ title: id,
+ summary: null,
+ type: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ memories: [],
+ },
+ }
+}
+
+/** Run tick() until animation converges (or max iterations) */
+function tickUntilSettled(vp: ViewportState, maxIter = 500): void {
+ for (let i = 0; i < maxIter; i++) {
+ if (!vp.tick()) break
+ }
+}
+
+describe("ViewportState", () => {
+ it("constructor sets initial values", () => {
+ const vp = new ViewportState()
+ expect(vp.panX).toBe(0)
+ expect(vp.panY).toBe(0)
+ expect(vp.zoom).toBe(0.5) // default initial zoom
+ })
+
+ it("constructor accepts custom initial values", () => {
+ const vp = new ViewportState(10, 20, 1.5)
+ expect(vp.panX).toBe(10)
+ expect(vp.panY).toBe(20)
+ expect(vp.zoom).toBe(1.5)
+ })
+
+ it("worldToScreen and screenToWorld are inverse operations", () => {
+ const vp = new ViewportState(100, 50, 1.5)
+
+ const worldX = 300
+ const worldY = 400
+ const screen = vp.worldToScreen(worldX, worldY)
+ const world = vp.screenToWorld(screen.x, screen.y)
+
+ expect(world.x).toBeCloseTo(worldX, 5)
+ expect(world.y).toBeCloseTo(worldY, 5)
+ })
+
+ it("worldToScreen applies zoom and pan: screen = world * zoom + pan", () => {
+ const vp = new ViewportState(10, 20, 2)
+
+ const screen = vp.worldToScreen(100, 200)
+ expect(screen.x).toBe(100 * 2 + 10) // 210
+ expect(screen.y).toBe(200 * 2 + 20) // 420
+ })
+
+ it("screenToWorld reverses: world = (screen - pan) / zoom", () => {
+ const vp = new ViewportState(10, 20, 2)
+
+ const world = vp.screenToWorld(210, 420)
+ expect(world.x).toBeCloseTo(100, 5)
+ expect(world.y).toBeCloseTo(200, 5)
+ })
+
+ it("pan offsets correctly and accumulates", () => {
+ const vp = new ViewportState(0, 0, 1)
+ vp.pan(50, 30)
+ expect(vp.panX).toBe(50)
+ expect(vp.panY).toBe(30)
+
+ vp.pan(10, 20)
+ expect(vp.panX).toBe(60)
+ expect(vp.panY).toBe(50)
+ })
+
+ it("pan cancels any animated pan target", () => {
+ const vp = new ViewportState(0, 0, 1)
+ vp.centerOn(500, 500, 800, 600) // sets targetPanX/Y
+ vp.pan(10, 10) // should cancel the target
+ // After pan, tick should return false (no animation)
+ expect(vp.tick()).toBe(false)
+ })
+
+ it("zoomImmediate multiplies current zoom by delta", () => {
+ const vp = new ViewportState(0, 0, 1)
+ const initialZoom = vp.zoom
+ vp.zoomImmediate(2, 0, 0)
+ expect(vp.zoom).toBeCloseTo(initialZoom * 2)
+ })
+
+ it("zoomImmediate preserves world point under anchor", () => {
+ const vp = new ViewportState(100, 50, 1)
+ const anchorX = 400
+ const anchorY = 300
+
+ // Get world point under anchor before zoom
+ const worldBefore = vp.screenToWorld(anchorX, anchorY)
+ vp.zoomImmediate(2, anchorX, anchorY)
+ // After zoom, same screen point should map to same world point
+ const worldAfter = vp.screenToWorld(anchorX, anchorY)
+
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 3)
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 3)
+ })
+
+ it("zoomImmediate clamps to MIN_ZOOM (0.1)", () => {
+ const vp = new ViewportState(0, 0, 0.5)
+ // Try to zoom way down: 0.5 * 0.01 = 0.005, should clamp to 0.1
+ vp.zoomImmediate(0.01, 0, 0)
+ expect(vp.zoom).toBeCloseTo(0.1)
+ })
+
+ it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => {
+ const vp = new ViewportState(0, 0, 2)
+ // Try to zoom way up: 2 * 100 = 200, should clamp to 5
+ vp.zoomImmediate(100, 0, 0)
+ expect(vp.zoom).toBeCloseTo(5.0)
+ })
+
+ it("zoomTo sets target zoom (animated via tick)", () => {
+ const vp = new ViewportState(0, 0, 0.5)
+ vp.zoomTo(2, 400, 300)
+ // Zoom hasn't changed yet — it's animated
+ expect(vp.zoom).toBe(0.5)
+ // After ticking, zoom should approach target
+ tickUntilSettled(vp)
+ expect(vp.zoom).toBeCloseTo(2, 1)
+ })
+
+ it("tick returns false when no animation is active", () => {
+ const vp = new ViewportState()
+ expect(vp.tick()).toBe(false)
+ })
+
+ it("tick returns true during inertia", () => {
+ const vp = new ViewportState()
+ vp.releaseWithVelocity(10, 10)
+ expect(vp.tick()).toBe(true)
+ })
+
+ it("tick returns true during zoom animation", () => {
+ const vp = new ViewportState(0, 0, 0.5)
+ vp.zoomTo(2, 0, 0)
+ expect(vp.tick()).toBe(true)
+ })
+
+ it("tick returns true during pan animation", () => {
+ const vp = new ViewportState(0, 0, 1)
+ vp.centerOn(500, 500, 800, 600)
+ expect(vp.tick()).toBe(true)
+ })
+
+ it("fitToNodes centers and scales to fit all nodes", () => {
+ const vp = new ViewportState(0, 0, 0.5)
+ const nodes = [
+ makeNode("a", 0, 0),
+ makeNode("b", 1000, 0),
+ makeNode("c", 0, 1000),
+ makeNode("d", 1000, 1000),
+ ]
+ vp.fitToNodes(nodes, 800, 600)
+ tickUntilSettled(vp)
+
+ // After fitting, all nodes should be visible within the viewport
+ for (const node of nodes) {
+ const screen = vp.worldToScreen(node.x, node.y)
+ expect(screen.x).toBeGreaterThan(-100)
+ expect(screen.x).toBeLessThan(900)
+ expect(screen.y).toBeGreaterThan(-100)
+ expect(screen.y).toBeLessThan(700)
+ }
+ })
+
+ it("fitToNodes handles single node without throwing", () => {
+ const vp = new ViewportState()
+ expect(() => vp.fitToNodes([makeNode("a", 500, 500)], 800, 600)).not.toThrow()
+ })
+
+ it("fitToNodes handles empty nodes array without throwing", () => {
+ const vp = new ViewportState()
+ const zoomBefore = vp.zoom
+ vp.fitToNodes([], 800, 600)
+ // Should be a no-op
+ expect(vp.zoom).toBe(zoomBefore)
+ })
+
+ it("centerOn animates pan to center a world point on screen", () => {
+ const vp = new ViewportState(0, 0, 1)
+ vp.centerOn(500, 300, 800, 600)
+ tickUntilSettled(vp)
+
+ // After settling, world point (500, 300) should map to screen center (400, 300)
+ const screen = vp.worldToScreen(500, 300)
+ expect(screen.x).toBeCloseTo(400, 0)
+ expect(screen.y).toBeCloseTo(300, 0)
+ })
+
+ it("inertia decays to zero", () => {
+ const vp = new ViewportState()
+ vp.releaseWithVelocity(100, 100)
+ tickUntilSettled(vp)
+ // After settling, tick should return false
+ expect(vp.tick()).toBe(false)
+ })
+})
diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts
index ea436d6a..91e37eca 100644
--- a/packages/memory-graph/src/hooks/use-graph-data.ts
+++ b/packages/memory-graph/src/hooks/use-graph-data.ts
@@ -14,7 +14,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const MEMORY_CLUSTER_SPREAD = 150
-function getMemoryBorderColor(
+export function getMemoryBorderColor(
mem: GraphApiMemory,
colors: GraphThemeColors,
): string {
@@ -28,14 +28,14 @@ function getMemoryBorderColor(
return colors.memStrokeDefault
}
-function getEdgeVisualProps(similarity: number) {
+export function getEdgeVisualProps(similarity: number) {
return {
opacity: 0.3 + similarity * 0.5,
thickness: 1 + similarity * 1.5,
}
}
-function normalizeDocCoordinates(
+export function normalizeDocCoordinates(
documents: GraphApiDocument[],
): GraphApiDocument[] {
if (documents.length <= 1) return documents
diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts
index fe7a4cd1..e8a1b1ad 100644
--- a/packages/memory-graph/vite.config.ts
+++ b/packages/memory-graph/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from "vite"
+import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import { resolve } from "node:path"
@@ -9,6 +9,10 @@ export default defineConfig({
"@": resolve(__dirname, "./src"),
},
},
+ test: {
+ include: ["src/**/*.test.ts"],
+ environment: "node",
+ },
build: {
lib: {
entry: {