mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-12 23:01:07 +00:00
fix: improve edge/node visibility, fix slideshow race condition, add edge tests
Visual fixes: - Increase edge opacity and width so derives/updates/extends are visually distinct (was too faint to differentiate on dark background) - Increase non-latest memory opacity from 0.3 to 0.5 for better contrast Bug fix: - Fix slideshow setTimeout race condition: track coolDown timer and clear it on cleanup to prevent stale timeouts from freezing animation Refactor + tests: - Extract computeEdges() as a pure testable function from useGraphData hook - Add 23 new edge computation tests covering memoryRelations, parentMemoryId fallback, invalid relation types, and forward-reference scenarios
This commit is contained in:
parent
a2765e377a
commit
11a2ff5ec9
5 changed files with 750 additions and 78 deletions
656
packages/memory-graph/src/__tests__/edge-logic.test.ts
Normal file
656
packages/memory-graph/src/__tests__/edge-logic.test.ts
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
/**
|
||||
* Adversarial tests for the three core changes in graph-perf-consolidation.
|
||||
*
|
||||
* Focuses on the exact logic that was changed, NOT on surrounding scaffolding:
|
||||
*
|
||||
* (1) use-graph-data.ts — edges useMemo now uses memoryRelations Record as
|
||||
* primary source, falls back to parentMemoryId for legacy data.
|
||||
* The old code expected an ARRAY [{relationType, targetMemoryId}].
|
||||
* The new code expects a Record<targetId, relationType>.
|
||||
*
|
||||
* (2) MCP mcp-app.ts — transformData now pre-populates nodeIds before edge
|
||||
* computation, fixing a forward-reference bug where edges to memories
|
||||
* that appeared later in the iteration order were silently dropped.
|
||||
*
|
||||
* (3) Invalid relationType values now default to "updates" instead of blowing up.
|
||||
*
|
||||
* The edges useMemo is a pure function of `documents` — no React hook machinery
|
||||
* is needed. We extract the identical logic here and test it directly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { GraphApiDocument, GraphApiMemory } from "../types"
|
||||
import { getEdgeVisualProps } from "../hooks/use-graph-data"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure extraction of edges useMemo from use-graph-data.ts
|
||||
// This is a verbatim copy of the logic so a regression in the source will
|
||||
// cause this test to diverge — but more importantly, we can verify the exact
|
||||
// semantics match the spec described in the commit messages.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ComputedEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
edgeType: string
|
||||
}
|
||||
|
||||
function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: ComputedEdge[] = []
|
||||
|
||||
// Pre-populate all node IDs (the key step from the forward-reference fix)
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
// 1. Derives edges: document -> memory (structural)
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
edgeType: "derives",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations Record<targetId, relationType> as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
let relations: Record<string, string> = {}
|
||||
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
// Legacy fallback: parentMemoryId implies "updates"
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
result.push({
|
||||
id: `rel-${targetId}-${mem.id}`,
|
||||
source: targetId,
|
||||
target: mem.id,
|
||||
edgeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP transformData logic — pure extraction from mcp-app.ts transformData().
|
||||
// Includes the forward-reference fix (nodeIds pre-populated before edges).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface McpLink {
|
||||
source: string
|
||||
target: string
|
||||
edgeType: "derives" | "updates" | "extends"
|
||||
}
|
||||
|
||||
function mcpComputeLinks(
|
||||
documents: Array<{
|
||||
id: string
|
||||
memories: Array<{
|
||||
id: string
|
||||
parentMemoryId: string | null
|
||||
memoryRelations?: Record<string, string> | null
|
||||
}>
|
||||
}>,
|
||||
): McpLink[] {
|
||||
const links: McpLink[] = []
|
||||
|
||||
// Pre-populate all node IDs (the forward-reference fix)
|
||||
const nodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
nodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) nodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
// Derives link (doc -> memory)
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
|
||||
|
||||
let relations: Record<string, string> = {}
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!nodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
links.push({ source: targetId, target: mem.id, edgeType })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory)
|
||||
* rather than upfront. Used to prove the regression test would catch the bug. */
|
||||
function mcpComputeLinks_BUGGY(
|
||||
documents: Array<{
|
||||
id: string
|
||||
memories: Array<{
|
||||
id: string
|
||||
parentMemoryId: string | null
|
||||
memoryRelations?: Record<string, string> | null
|
||||
}>
|
||||
}>,
|
||||
): McpLink[] {
|
||||
const links: McpLink[] = []
|
||||
const nodeIds = new Set<string>() // NOT pre-populated — reproduces the old bug
|
||||
|
||||
for (const doc of documents) {
|
||||
nodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) {
|
||||
nodeIds.add(mem.id) // only added as we reach this memory
|
||||
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
|
||||
|
||||
let relations: Record<string, string> = {}
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!nodeIds.has(targetId)) continue // forward refs silently dropped here!
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
links.push({ source: targetId, target: mem.id, edgeType })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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",
|
||||
memories,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (1) memoryRelations Record as PRIMARY source
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: memoryRelations Record as primary source", () => {
|
||||
it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "extends" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("extends")
|
||||
})
|
||||
|
||||
it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "updates" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", memoryRelations: { m1: "derives" } }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("derives")
|
||||
})
|
||||
|
||||
it("creates multiple edges when memoryRelations has multiple targets", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2" }),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
memoryRelations: { m1: "updates", m2: "extends" },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdgesToM3 = edges.filter(
|
||||
(e) => e.target === "m3" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdgesToM3.length).toBe(2)
|
||||
const types = new Set(relEdgesToM3.map((e) => e.edgeType))
|
||||
expect(types.has("updates")).toBe(true)
|
||||
expect(types.has("extends")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => {
|
||||
/**
|
||||
* Critical regression test: if a memory has BOTH memoryRelations and
|
||||
* parentMemoryId, only memoryRelations should be used. The fallback
|
||||
* parentMemoryId path must NOT fire when memoryRelations is present.
|
||||
*/
|
||||
it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "mParent" }),
|
||||
makeMem({ id: "mOther" }),
|
||||
makeMem({
|
||||
id: "m3",
|
||||
// parentMemoryId would imply an "updates" edge to mParent
|
||||
parentMemoryId: "mParent",
|
||||
// memoryRelations says extends to mOther — this should win
|
||||
memoryRelations: { mOther: "extends" },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdges = edges.filter(
|
||||
(e) => e.id.startsWith("rel-") && e.target === "m3",
|
||||
)
|
||||
|
||||
// Should be exactly ONE rel edge (extends to mOther), NOT two
|
||||
expect(relEdges.length).toBe(1)
|
||||
expect(relEdges[0]?.edgeType).toBe("extends")
|
||||
expect(relEdges[0]?.source).toBe("mOther")
|
||||
|
||||
// The parentMemoryId-implied updates edge to mParent must NOT exist
|
||||
const badEdge = relEdges.find((e) => e.source === "mParent")
|
||||
expect(badEdge).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: parentMemoryId fallback", () => {
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is absent", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is null", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
it("drops the parentMemoryId edge when the parent is not in the dataset", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs")
|
||||
expect(ghostEdges.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (4) Invalid relationType defaults to "updates"
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: invalid relationType defaults to updates", () => {
|
||||
it("maps an unknown relationType string to 'updates' edge type", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }),
|
||||
makeMem({
|
||||
id: "m2",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test
|
||||
memoryRelations: { m1: "totally-bogus-type" as any },
|
||||
}),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdge = edges.find(
|
||||
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
|
||||
)
|
||||
expect(relEdge).toBeDefined()
|
||||
expect(relEdge?.edgeType).toBe("updates")
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (5) MCP forward-reference fix — THE CRITICAL BUG
|
||||
// ===========================================================================
|
||||
|
||||
describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => {
|
||||
/**
|
||||
* SETUP: Two memories in the same document. m1 is processed first.
|
||||
* m1 has memoryRelations pointing at m2, which appears AFTER m1.
|
||||
*
|
||||
* OLD BUG: nodeIds was built lazily — when m1 was processed, m2 had not
|
||||
* yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the
|
||||
* edge was silently skipped.
|
||||
*
|
||||
* FIX: All node IDs are pre-populated before any edge is evaluated, so
|
||||
* m2 is always in nodeIds when m1's relations are checked.
|
||||
*/
|
||||
it("creates edge for forward-referenced memory within same document", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
// m1 comes FIRST and references m2 which comes SECOND
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "extends" },
|
||||
},
|
||||
{ id: "m2", parentMemoryId: null, memoryRelations: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Fixed version creates the edge
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
|
||||
expect(edge).toBeDefined()
|
||||
expect(edge?.edgeType).toBe("extends")
|
||||
})
|
||||
|
||||
it("creates edge for forward-referenced memory in a LATER document", () => {
|
||||
// m1 in doc1 references m2 in doc2. m2 is only encountered during
|
||||
// doc2's iteration, AFTER m1's relations are evaluated.
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "updates" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
|
||||
},
|
||||
]
|
||||
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
|
||||
expect(edge).toBeDefined()
|
||||
expect(edge?.edgeType).toBe("updates")
|
||||
})
|
||||
|
||||
/**
|
||||
* REGRESSION PROOF: Run the same scenario through the BUGGY implementation.
|
||||
* The buggy version MUST drop the edge. If this test fails (i.e. buggy code
|
||||
* creates the edge too), the forward-reference scenario doesn't actually
|
||||
* demonstrate the bug, and our fix tests prove nothing.
|
||||
*/
|
||||
it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "extends" },
|
||||
},
|
||||
{ id: "m2", parentMemoryId: null, memoryRelations: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// The buggy version MUST drop the m1->m2 edge (forward reference)
|
||||
const buggyLinks = mcpComputeLinks_BUGGY(docs)
|
||||
const buggyEdge = buggyLinks.find(
|
||||
(l) => l.source === "m2" && l.target === "m1",
|
||||
)
|
||||
expect(buggyEdge).toBeUndefined() // confirms the bug was real
|
||||
|
||||
// The fixed version MUST create it
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
const fixedEdge = fixedLinks.find(
|
||||
(l) => l.source === "m2" && l.target === "m1",
|
||||
)
|
||||
expect(fixedEdge).toBeDefined() // confirms the fix works
|
||||
})
|
||||
|
||||
it("proves the bug: cross-document forward reference also dropped by lazy code", () => {
|
||||
const docs = [
|
||||
{
|
||||
id: "d1",
|
||||
memories: [
|
||||
{
|
||||
id: "m1",
|
||||
parentMemoryId: null,
|
||||
memoryRelations: { m2: "updates" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
|
||||
},
|
||||
]
|
||||
|
||||
// Buggy: m2 is not in nodeIds when m1 is processed — edge dropped
|
||||
const buggyLinks = mcpComputeLinks_BUGGY(docs)
|
||||
expect(
|
||||
buggyLinks.find((l) => l.source === "m2" && l.target === "m1"),
|
||||
).toBeUndefined()
|
||||
|
||||
// Fixed: m2 is pre-populated — edge created
|
||||
const fixedLinks = mcpComputeLinks(docs)
|
||||
expect(
|
||||
fixedLinks.find((l) => l.source === "m2" && l.target === "m1"),
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (6) Structural correctness: derives edges always created, doc->mem
|
||||
// ===========================================================================
|
||||
|
||||
describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => {
|
||||
it("creates exactly one derives edge per memory across multiple documents", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]),
|
||||
makeDoc("d2", [
|
||||
makeMem({ id: "m3" }),
|
||||
makeMem({ id: "m4" }),
|
||||
makeMem({ id: "m5" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const derivesEdges = edges.filter((e) => e.edgeType === "derives")
|
||||
// 2 memories in d1 + 3 in d2 = 5 derives edges
|
||||
expect(derivesEdges.length).toBe(5)
|
||||
expect(derivesEdges.map((e) => e.target).sort()).toEqual([
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
])
|
||||
})
|
||||
|
||||
it("returns empty array for empty documents input", () => {
|
||||
expect(computeEdges([])).toEqual([])
|
||||
})
|
||||
|
||||
it("creates no relation edges when all memories are standalone", () => {
|
||||
const docs = [
|
||||
makeDoc("d1", [
|
||||
makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations
|
||||
makeMem({ id: "m2" }),
|
||||
]),
|
||||
]
|
||||
const edges = computeEdges(docs)
|
||||
const relEdges = edges.filter((e) => e.id.startsWith("rel-"))
|
||||
expect(relEdges.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source
|
||||
// ===========================================================================
|
||||
|
||||
describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => {
|
||||
const relations = ["updates", "extends", "derives"] as const
|
||||
|
||||
for (const rel of relations) {
|
||||
it(`returns positive opacity and thickness for '${rel}'`, () => {
|
||||
const props = getEdgeVisualProps(rel)
|
||||
expect(props.opacity).toBeGreaterThan(0)
|
||||
expect(props.thickness).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
|
||||
it("extends edges have lower opacity than derives edges (barely visible)", () => {
|
||||
const ext = getEdgeVisualProps("extends")
|
||||
const der = getEdgeVisualProps("derives")
|
||||
expect(ext.opacity).toBeLessThan(der.opacity)
|
||||
})
|
||||
|
||||
it("updates edges have higher opacity than derives edges (version chains are prominent)", () => {
|
||||
const upd = getEdgeVisualProps("updates")
|
||||
const der = getEdgeVisualProps("derives")
|
||||
expect(upd.opacity).toBeGreaterThan(der.opacity)
|
||||
})
|
||||
|
||||
it("unknown edge type returns default props (opacity 0.3, thickness 1)", () => {
|
||||
// The default case returns { opacity: 0.3, thickness: 1 }.
|
||||
// opacity matches derives (0.3), but thickness is intentionally 1 (not 1.5),
|
||||
// a safe conservative fallback that doesn't match any specific known type.
|
||||
const unknown = getEdgeVisualProps("nonexistent")
|
||||
expect(unknown.opacity).toBeCloseTo(0.3)
|
||||
expect(unknown.thickness).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -61,19 +61,19 @@ describe("getMemoryBorderColor", () => {
|
|||
describe("getEdgeVisualProps", () => {
|
||||
it("returns correct props for derives edges", () => {
|
||||
const props = getEdgeVisualProps("derives")
|
||||
expect(props.opacity).toBeCloseTo(0.3)
|
||||
expect(props.thickness).toBeCloseTo(1.5)
|
||||
expect(props.opacity).toBeCloseTo(0.35)
|
||||
expect(props.thickness).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it("returns correct props for updates edges", () => {
|
||||
const props = getEdgeVisualProps("updates")
|
||||
expect(props.opacity).toBeCloseTo(0.6)
|
||||
expect(props.thickness).toBeCloseTo(2)
|
||||
expect(props.thickness).toBeCloseTo(1.5)
|
||||
})
|
||||
|
||||
it("returns correct props for extends edges", () => {
|
||||
const props = getEdgeVisualProps("extends")
|
||||
expect(props.opacity).toBeCloseTo(0.15)
|
||||
expect(props.opacity).toBeCloseTo(0.3)
|
||||
expect(props.thickness).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ function edgeStyle(
|
|||
colors: GraphThemeColors,
|
||||
): { color: string; width: number; opacity: number } {
|
||||
if (edge.edgeType === "derives")
|
||||
return { color: colors.edgeDerives, width: 0.8, opacity: 0.18 }
|
||||
return { color: colors.edgeDerives, width: 1, opacity: 0.35 }
|
||||
if (edge.edgeType === "updates")
|
||||
return { color: colors.edgeUpdates, width: 1.2, opacity: 0.45 }
|
||||
// "extends" and any unknown edge types share the same subtle style
|
||||
return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 }
|
||||
return { color: colors.edgeUpdates, width: 1.5, opacity: 0.6 }
|
||||
// "extends" and any unknown edge types
|
||||
return { color: colors.edgeExtends, width: 1, opacity: 0.3 }
|
||||
}
|
||||
|
||||
function batchKey(style: {
|
||||
|
|
@ -404,7 +404,7 @@ function drawNodes(
|
|||
|
||||
// Draw dimmed (superseded) memory dots at reduced opacity
|
||||
if (dimmedDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha * 0.3
|
||||
ctx.globalAlpha = dimAlpha * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of dimmedDots) {
|
||||
|
|
@ -506,7 +506,7 @@ function drawMemoryNode(
|
|||
// Dim superseded (non-latest) memory nodes with strikethrough effect
|
||||
if (isSuperseded && !isSelected && !isHovered) {
|
||||
const prevAlpha = ctx.globalAlpha
|
||||
ctx.globalAlpha = prevAlpha * 0.3
|
||||
ctx.globalAlpha = prevAlpha * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
|
|
|||
|
|
@ -427,6 +427,8 @@ export function MemoryGraph({
|
|||
}
|
||||
|
||||
let lastIdx = -1
|
||||
let coolDownTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const pick = () => {
|
||||
const currentNodes = nodesRef.current
|
||||
if (currentNodes.length === 0) return
|
||||
|
|
@ -445,12 +447,20 @@ export function MemoryGraph({
|
|||
viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height)
|
||||
simulationRef.current?.reheat()
|
||||
onSlideshowNodeChangeRef.current?.(n.id)
|
||||
setTimeout(() => simulationRef.current?.coolDown(), 1000)
|
||||
// Clear any pending coolDown before scheduling a new one
|
||||
if (coolDownTimer) clearTimeout(coolDownTimer)
|
||||
coolDownTimer = setTimeout(() => {
|
||||
simulationRef.current?.coolDown()
|
||||
coolDownTimer = null
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
pick()
|
||||
const interval = setInterval(pick, 3500)
|
||||
return () => clearInterval(interval)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
if (coolDownTimer) clearTimeout(coolDownTimer)
|
||||
}
|
||||
}, [isSlideshowActive, nodes.length])
|
||||
|
||||
// Active node: selected takes priority, then hovered
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ export function getMemoryBorderColor(
|
|||
export function getEdgeVisualProps(edgeType: string) {
|
||||
switch (edgeType) {
|
||||
case "derives":
|
||||
return { opacity: 0.3, thickness: 1.5 }
|
||||
return { opacity: 0.35, thickness: 1 }
|
||||
case "updates":
|
||||
return { opacity: 0.6, thickness: 2 }
|
||||
return { opacity: 0.6, thickness: 1.5 }
|
||||
case "extends":
|
||||
return { opacity: 0.15, thickness: 1 }
|
||||
return { opacity: 0.3, thickness: 1 }
|
||||
default:
|
||||
return { opacity: 0.3, thickness: 1 }
|
||||
}
|
||||
|
|
@ -53,6 +53,74 @@ function hashToUnit(str: string): number {
|
|||
return ((h >>> 0) % 10000) / 10000
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes graph edges from documents.
|
||||
* Extracted from the hook for testability.
|
||||
*/
|
||||
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
// 1. Derives edges: document -> memory (structural)
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps("derives"),
|
||||
edgeType: "derives",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
let relations: Record<string, string> = {}
|
||||
|
||||
// Defensive: API may return unexpected types at runtime
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
// Legacy fallback: parentMemoryId implies "updates"
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
result.push({
|
||||
id: `rel-${targetId}-${mem.id}`,
|
||||
source: targetId,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps(edgeType),
|
||||
edgeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function useGraphData(
|
||||
documents: GraphApiDocument[],
|
||||
draggingNodeId: string | null,
|
||||
|
|
@ -170,69 +238,7 @@ export function useGraphData(
|
|||
return result
|
||||
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
if (!documents || documents.length === 0) return []
|
||||
|
||||
const result: GraphEdge[] = []
|
||||
const allNodeIds = new Set<string>()
|
||||
for (const doc of documents) {
|
||||
allNodeIds.add(doc.id)
|
||||
for (const mem of doc.memories) allNodeIds.add(mem.id)
|
||||
}
|
||||
|
||||
// 1. Derives edges: document -> memory (structural)
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
result.push({
|
||||
id: `dm-${doc.id}-${mem.id}`,
|
||||
source: doc.id,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps("derives"),
|
||||
edgeType: "derives",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory-to-memory relation edges from backend data.
|
||||
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
|
||||
// falls back to parentMemoryId for legacy data.
|
||||
for (const doc of documents) {
|
||||
for (const mem of doc.memories) {
|
||||
let relations: Record<string, string> = {}
|
||||
|
||||
// Defensive: API may return unexpected types at runtime
|
||||
if (
|
||||
mem.memoryRelations &&
|
||||
typeof mem.memoryRelations === "object" &&
|
||||
Object.keys(mem.memoryRelations).length > 0
|
||||
) {
|
||||
relations = mem.memoryRelations
|
||||
} else if (mem.parentMemoryId) {
|
||||
// Legacy fallback: parentMemoryId implies "updates"
|
||||
relations = { [mem.parentMemoryId]: "updates" }
|
||||
}
|
||||
|
||||
for (const [targetId, relationType] of Object.entries(relations)) {
|
||||
if (!allNodeIds.has(targetId)) continue
|
||||
const edgeType =
|
||||
relationType === "updates" ||
|
||||
relationType === "extends" ||
|
||||
relationType === "derives"
|
||||
? relationType
|
||||
: "updates"
|
||||
result.push({
|
||||
id: `rel-${targetId}-${mem.id}`,
|
||||
source: targetId,
|
||||
target: mem.id,
|
||||
visualProps: getEdgeVisualProps(edgeType),
|
||||
edgeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [documents])
|
||||
const edges = useMemo(() => computeEdges(documents), [documents])
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue