From e7676763a998f0008c58e745a2cbd353958607d9 Mon Sep 17 00:00:00 2001 From: Vorflux AI Date: Fri, 27 Mar 2026 00:06:21 +0000 Subject: [PATCH] Add comprehensive unit tests for memory-graph package 79 tests across 6 test files covering: - SpatialIndex: grid rebuild, queryPoint hit-testing, boundary cells, hash detection - ViewportState: worldToScreen/screenToWorld roundtrip, pan, zoomImmediate, zoomTo animation, fitToNodes, centerOn, inertia decay - ForceSimulation: init/destroy lifecycle, update hot-swap, reheat/coolDown - VersionChainIndex: chain building via parentMemoryId, caching, rebuild - Graph data utils: normalizeDocCoordinates, getMemoryBorderColor, getEdgeVisualProps, screenToBackendCoords, calculateBackendViewport - Mock data: deterministic seeded output, correct counts, valid edge references Also exports pure utility functions from use-graph-data.ts for testability. --- apps/memory-graph-playground/next-env.d.ts | 2 +- bun.lock | 1 + packages/memory-graph/package.json | 8 +- .../src/__tests__/graph-data-utils.test.ts | 198 ++++++++++++++++ .../src/__tests__/mock-data.test.ts | 102 ++++++++ .../src/__tests__/simulation.test.ts | 115 ++++++++++ .../src/__tests__/spatial-index.test.ts | 132 +++++++++++ .../src/__tests__/version-chain.test.ts | 186 +++++++++++++++ .../src/__tests__/viewport.test.ts | 217 ++++++++++++++++++ .../memory-graph/src/hooks/use-graph-data.ts | 6 +- packages/memory-graph/vite.config.ts | 6 +- 11 files changed, 965 insertions(+), 8 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/graph-data-utils.test.ts create mode 100644 packages/memory-graph/src/__tests__/mock-data.test.ts create mode 100644 packages/memory-graph/src/__tests__/simulation.test.ts create mode 100644 packages/memory-graph/src/__tests__/spatial-index.test.ts create mode 100644 packages/memory-graph/src/__tests__/version-chain.test.ts create mode 100644 packages/memory-graph/src/__tests__/viewport.test.ts 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: {