Skip to content

Commit 66cfa63

Browse files
committed
test: add interface and utility unit tests
Add unit tests for: - interfaces: graph-expander, readable-graph - utils: wayback
1 parent 623073a commit 66cfa63

File tree

3 files changed

+1283
-0
lines changed

3 files changed

+1283
-0
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { GraphExpander, Neighbor } from "./graph-expander";
4+
5+
describe("GraphExpander interface", () => {
6+
it("should define the correct shape for a graph expander implementation", () => {
7+
// Create a mock implementation to verify interface structure
8+
const mockExpander: GraphExpander<{ id: string; label: string }> = {
9+
getNeighbors: async (nodeId: string): Promise<Neighbor[]> => {
10+
if (nodeId === "A") {
11+
return [
12+
{ targetId: "B", relationshipType: "citation" },
13+
{ targetId: "C", relationshipType: "authorship" },
14+
];
15+
}
16+
return [];
17+
},
18+
getDegree: (nodeId: string): number => {
19+
const degrees: Record<string, number> = { A: 5, B: 2, C: 10 };
20+
return degrees[nodeId] ?? 0;
21+
},
22+
getNode: async (nodeId: string) => {
23+
const nodes: Record<string, { id: string; label: string }> = {
24+
A: { id: "A", label: "Node A" },
25+
B: { id: "B", label: "Node B" },
26+
};
27+
return nodes[nodeId] ?? null;
28+
},
29+
addEdge: (_source: string, _target: string, _relationshipType: string): void => {
30+
// Edge tracking implementation
31+
},
32+
};
33+
34+
// Verify the mock implementation satisfies the interface
35+
// eslint-disable-next-line @typescript-eslint/unbound-method
36+
expect(mockExpander.getNeighbors).toBeDefined();
37+
// eslint-disable-next-line @typescript-eslint/unbound-method
38+
expect(mockExpander.getDegree).toBeDefined();
39+
// eslint-disable-next-line @typescript-eslint/unbound-method
40+
expect(mockExpander.getNode).toBeDefined();
41+
// eslint-disable-next-line @typescript-eslint/unbound-method
42+
expect(mockExpander.addEdge).toBeDefined();
43+
});
44+
45+
it("should allow async getNeighbors to return neighbors", async () => {
46+
const expander: GraphExpander<string> = {
47+
getNeighbors: async () => [
48+
{ targetId: "target1", relationshipType: "follows" },
49+
],
50+
getDegree: () => 1,
51+
getNode: async () => "node-data",
52+
addEdge: () => {},
53+
};
54+
55+
const neighbors = await expander.getNeighbors("source");
56+
57+
expect(neighbors).toHaveLength(1);
58+
expect(neighbors[0].targetId).toBe("target1");
59+
expect(neighbors[0].relationshipType).toBe("follows");
60+
});
61+
62+
it("should allow getDegree to return node degree synchronously", () => {
63+
const degreeMap = new Map<string, number>([
64+
["high-degree", 100],
65+
["low-degree", 2],
66+
]);
67+
68+
const expander: GraphExpander<unknown> = {
69+
getNeighbors: async () => [],
70+
getDegree: (nodeId: string) => degreeMap.get(nodeId) ?? 0,
71+
getNode: async () => null,
72+
addEdge: () => {},
73+
};
74+
75+
expect(expander.getDegree("high-degree")).toBe(100);
76+
expect(expander.getDegree("low-degree")).toBe(2);
77+
expect(expander.getDegree("unknown")).toBe(0);
78+
});
79+
80+
it("should allow getNode to return null for missing nodes", async () => {
81+
const expander: GraphExpander<{ name: string }> = {
82+
getNeighbors: async () => [],
83+
getDegree: () => 0,
84+
getNode: async (nodeId: string) => {
85+
if (nodeId === "exists") {
86+
return { name: "Existing Node" };
87+
}
88+
return null;
89+
},
90+
addEdge: () => {},
91+
};
92+
93+
const existingNode = await expander.getNode("exists");
94+
const missingNode = await expander.getNode("missing");
95+
96+
expect(existingNode).toEqual({ name: "Existing Node" });
97+
expect(missingNode).toBeNull();
98+
});
99+
100+
it("should support generic node data types", async () => {
101+
interface WorkNode {
102+
id: string;
103+
title: string;
104+
year: number;
105+
citations: number;
106+
}
107+
108+
const expander: GraphExpander<WorkNode> = {
109+
getNeighbors: async () => [],
110+
getDegree: () => 0,
111+
getNode: async (nodeId: string) => ({
112+
id: nodeId,
113+
title: "Sample Work",
114+
year: 2024,
115+
citations: 42,
116+
}),
117+
addEdge: () => {},
118+
};
119+
120+
const node = await expander.getNode("W123");
121+
122+
expect(node).not.toBeNull();
123+
expect(node!.id).toBe("W123");
124+
expect(node!.title).toBe("Sample Work");
125+
expect(node!.year).toBe(2024);
126+
expect(node!.citations).toBe(42);
127+
});
128+
129+
it("should track edges via addEdge method", () => {
130+
const edges: Array<{ source: string; target: string; type: string }> = [];
131+
132+
const expander: GraphExpander<unknown> = {
133+
getNeighbors: async () => [],
134+
getDegree: () => 0,
135+
getNode: async () => null,
136+
addEdge: (source: string, target: string, relationshipType: string) => {
137+
edges.push({ source, target, type: relationshipType });
138+
},
139+
};
140+
141+
expander.addEdge("A", "B", "citation");
142+
expander.addEdge("B", "C", "authorship");
143+
expander.addEdge("A", "C", "affiliation");
144+
145+
expect(edges).toHaveLength(3);
146+
expect(edges[0]).toEqual({ source: "A", target: "B", type: "citation" });
147+
expect(edges[1]).toEqual({ source: "B", target: "C", type: "authorship" });
148+
expect(edges[2]).toEqual({ source: "A", target: "C", type: "affiliation" });
149+
});
150+
});
151+
152+
describe("Neighbor interface", () => {
153+
it("should have required targetId and relationshipType properties", () => {
154+
const neighbor: Neighbor = {
155+
targetId: "target-node-123",
156+
relationshipType: "cites",
157+
};
158+
159+
expect(neighbor.targetId).toBe("target-node-123");
160+
expect(neighbor.relationshipType).toBe("cites");
161+
});
162+
163+
it("should support various relationship types", () => {
164+
const relationships: Neighbor[] = [
165+
{ targetId: "W1", relationshipType: "citation" },
166+
{ targetId: "A1", relationshipType: "authorship" },
167+
{ targetId: "I1", relationshipType: "affiliation" },
168+
{ targetId: "S1", relationshipType: "published_in" },
169+
{ targetId: "C1", relationshipType: "topic" },
170+
];
171+
172+
expect(relationships).toHaveLength(5);
173+
expect(relationships.map((r) => r.relationshipType)).toEqual([
174+
"citation",
175+
"authorship",
176+
"affiliation",
177+
"published_in",
178+
"topic",
179+
]);
180+
});
181+
182+
it("should work in arrays returned by getNeighbors", async () => {
183+
const mockNeighbors: Neighbor[] = [
184+
{ targetId: "node-1", relationshipType: "link" },
185+
{ targetId: "node-2", relationshipType: "link" },
186+
{ targetId: "node-3", relationshipType: "reference" },
187+
];
188+
189+
const expander: GraphExpander<unknown> = {
190+
getNeighbors: async () => mockNeighbors,
191+
getDegree: () => mockNeighbors.length,
192+
getNode: async () => null,
193+
addEdge: () => {},
194+
};
195+
196+
const neighbors = await expander.getNeighbors("source");
197+
198+
expect(neighbors).toEqual(mockNeighbors);
199+
expect(expander.getDegree("source")).toBe(3);
200+
});
201+
});
202+
203+
describe("GraphExpander use cases", () => {
204+
it("should support lazy loading pattern for API-backed graphs", async () => {
205+
const cache = new Map<string, { id: string; data: string }>();
206+
const fetchedNodes: string[] = [];
207+
208+
const lazyExpander: GraphExpander<{ id: string; data: string }> = {
209+
getNeighbors: async (nodeId: string) => {
210+
// Simulate API call
211+
fetchedNodes.push(nodeId);
212+
return [
213+
{ targetId: `${nodeId}-child-1`, relationshipType: "contains" },
214+
{ targetId: `${nodeId}-child-2`, relationshipType: "contains" },
215+
];
216+
},
217+
getDegree: (nodeId: string) => {
218+
// Return cached degree
219+
return cache.has(nodeId) ? 2 : 0;
220+
},
221+
getNode: async (nodeId: string) => {
222+
if (cache.has(nodeId)) {
223+
return cache.get(nodeId)!;
224+
}
225+
// Simulate fetch
226+
const node = { id: nodeId, data: `Data for ${nodeId}` };
227+
cache.set(nodeId, node);
228+
return node;
229+
},
230+
addEdge: () => {},
231+
};
232+
233+
// First access fetches from "API"
234+
const neighbors = await lazyExpander.getNeighbors("root");
235+
expect(fetchedNodes).toContain("root");
236+
expect(neighbors).toHaveLength(2);
237+
238+
// Node access populates cache
239+
await lazyExpander.getNode("root");
240+
expect(cache.has("root")).toBe(true);
241+
});
242+
243+
it("should support bidirectional BFS priority computation", () => {
244+
// Low-degree nodes should be prioritized in bidirectional BFS
245+
const expander: GraphExpander<unknown> = {
246+
getNeighbors: async () => [],
247+
getDegree: (nodeId: string) => {
248+
const degrees: Record<string, number> = {
249+
specific: 5, // Low degree - prioritize
250+
generic: 1000, // High degree - deprioritize
251+
};
252+
return degrees[nodeId] ?? 0;
253+
},
254+
getNode: async () => null,
255+
addEdge: () => {},
256+
};
257+
258+
const specificDegree = expander.getDegree("specific");
259+
const genericDegree = expander.getDegree("generic");
260+
261+
// Verify priority ordering works
262+
expect(specificDegree).toBeLessThan(genericDegree);
263+
});
264+
265+
it("should support filtering edges before adding", () => {
266+
const addedEdges: Array<{ source: string; target: string; type: string }> = [];
267+
const allowedTypes = new Set(["citation", "authorship"]);
268+
269+
const filteringExpander: GraphExpander<unknown> = {
270+
getNeighbors: async () => [],
271+
getDegree: () => 0,
272+
getNode: async () => null,
273+
addEdge: (source: string, target: string, relationshipType: string) => {
274+
if (allowedTypes.has(relationshipType)) {
275+
addedEdges.push({ source, target, type: relationshipType });
276+
}
277+
},
278+
};
279+
280+
filteringExpander.addEdge("A", "B", "citation");
281+
filteringExpander.addEdge("A", "C", "spam");
282+
filteringExpander.addEdge("B", "C", "authorship");
283+
filteringExpander.addEdge("C", "D", "unknown");
284+
285+
expect(addedEdges).toHaveLength(2);
286+
expect(addedEdges.map((e) => e.type)).toEqual(["citation", "authorship"]);
287+
});
288+
});

0 commit comments

Comments
 (0)