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.
This commit is contained in:
Vorflux AI 2026-03-27 00:06:21 +00:00
parent 4dcf3b2a62
commit e7676763a9
11 changed files with 965 additions and 8 deletions

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View file

@ -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",

View file

@ -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"
}
}
}

View file

@ -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> = {}): 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)
})
})

View file

@ -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)
}
})
})

View file

@ -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()
})
})

View file

@ -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()
})
})

View file

@ -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<GraphApiMemory> & { 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,
})
})
})

View file

@ -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)
})
})

View file

@ -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

View file

@ -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: {