mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
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:
parent
4dcf3b2a62
commit
e7676763a9
11 changed files with 965 additions and 8 deletions
2
apps/memory-graph-playground/next-env.d.ts
vendored
2
apps/memory-graph-playground/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
198
packages/memory-graph/src/__tests__/graph-data-utils.test.ts
Normal file
198
packages/memory-graph/src/__tests__/graph-data-utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
102
packages/memory-graph/src/__tests__/mock-data.test.ts
Normal file
102
packages/memory-graph/src/__tests__/mock-data.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
115
packages/memory-graph/src/__tests__/simulation.test.ts
Normal file
115
packages/memory-graph/src/__tests__/simulation.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
132
packages/memory-graph/src/__tests__/spatial-index.test.ts
Normal file
132
packages/memory-graph/src/__tests__/spatial-index.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
186
packages/memory-graph/src/__tests__/version-chain.test.ts
Normal file
186
packages/memory-graph/src/__tests__/version-chain.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
217
packages/memory-graph/src/__tests__/viewport.test.ts
Normal file
217
packages/memory-graph/src/__tests__/viewport.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue