GitNexus/gitnexus/test/unit/graph.test.ts
Gergő Magyar bf09eab95b
feat: configure prettier with pre-commit hook (#563)
* feat: configure prettier with pre-commit hook integration

Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo
root with husky pre-commit hook integration. Moves husky from
gitnexus/ to root package.json for reliable hook installation.

- Root package.json with prepare/format/format:check scripts
- .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4
- .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md
- .gitattributes enforcing LF line endings for Windows consistency
- Pre-commit hook uses direct node_modules/.bin/ paths (no npx)

* style: apply prettier formatting to entire codebase

One-time bulk format. No logic changes.
Use .git-blame-ignore-revs to skip this commit in git blame.

* chore: add .git-blame-ignore-revs for prettier format commit

* perf: pre-commit hook runs only tests related to staged files

Use vitest --related to scope test execution to tests that import
the changed files, instead of running the full suite on every commit.

* perf: remove vitest from pre-commit hook, keep in CI only

Pre-commit now runs lint-staged + tsc only. Tests run in CI
(ci-tests.yml) where they belong — keeps commits fast.

* ci: add prettier format check to quality workflow

PRs will now fail if code isn't formatted with prettier.
2026-03-28 14:58:04 +00:00

250 lines
8.7 KiB
TypeScript

/**
* P0 Unit Tests: Knowledge Graph
*
* Tests: createKnowledgeGraph() — addNode, getNode, removeNode,
* iterNodes, addRelationship, removeNodesByFile, counts.
*/
import { describe, it, expect } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js';
function makeNode(id: string, name: string, filePath: string = 'src/test.ts'): GraphNode {
return {
id,
label: 'Function',
properties: { name, filePath, startLine: 1, endLine: 10 },
};
}
function makeRel(
src: string,
tgt: string,
type: GraphRelationship['type'] = 'CALLS',
): GraphRelationship {
return {
id: `${src}-${type}-${tgt}`,
sourceId: src,
targetId: tgt,
type,
confidence: 1.0,
reason: '',
};
}
describe('createKnowledgeGraph', () => {
// ─── addNode / getNode ─────────────────────────────────────────────
it('adds and retrieves a node', () => {
const g = createKnowledgeGraph();
const node = makeNode('fn:foo', 'foo');
g.addNode(node);
expect(g.getNode('fn:foo')).toBe(node);
});
it('returns undefined for unknown node', () => {
const g = createKnowledgeGraph();
expect(g.getNode('nonexistent')).toBeUndefined();
});
it('duplicate addNode is a no-op', () => {
const g = createKnowledgeGraph();
const node1 = makeNode('fn:foo', 'foo');
const node2 = makeNode('fn:foo', 'bar'); // same ID, different name
g.addNode(node1);
g.addNode(node2);
expect(g.nodeCount).toBe(1);
expect(g.getNode('fn:foo')!.properties.name).toBe('foo'); // first one wins
});
// ─── removeNode ─────────────────────────────────────────────────────
it('removes a node and its relationships', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
expect(g.relationshipCount).toBe(1);
const removed = g.removeNode('fn:a');
expect(removed).toBe(true);
expect(g.getNode('fn:a')).toBeUndefined();
expect(g.nodeCount).toBe(1);
expect(g.relationshipCount).toBe(0); // relationship involving fn:a removed
});
it('removeNode returns false for unknown node', () => {
const g = createKnowledgeGraph();
expect(g.removeNode('nope')).toBe(false);
});
// ─── removeNodesByFile ──────────────────────────────────────────────
it('removes all nodes belonging to a file', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a', 'src/foo.ts'));
g.addNode(makeNode('fn:b', 'b', 'src/foo.ts'));
g.addNode(makeNode('fn:c', 'c', 'src/bar.ts'));
const removed = g.removeNodesByFile('src/foo.ts');
expect(removed).toBe(2);
expect(g.nodeCount).toBe(1);
expect(g.getNode('fn:c')).toBeDefined();
});
// ─── iterNodes / iterRelationships ─────────────────────────────────
it('iterNodes yields all nodes', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
const ids = [...g.iterNodes()].map((n) => n.id);
expect(ids).toHaveLength(2);
expect(ids).toContain('fn:a');
expect(ids).toContain('fn:b');
});
it('iterRelationships yields all relationships', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
const rels = [...g.iterRelationships()];
expect(rels).toHaveLength(1);
expect(rels[0].sourceId).toBe('fn:a');
});
// ─── nodeCount / relationshipCount ─────────────────────────────────
it('nodeCount reflects current node count', () => {
const g = createKnowledgeGraph();
expect(g.nodeCount).toBe(0);
g.addNode(makeNode('fn:a', 'a'));
expect(g.nodeCount).toBe(1);
g.addNode(makeNode('fn:b', 'b'));
expect(g.nodeCount).toBe(2);
});
it('relationshipCount reflects current relationship count', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
expect(g.relationshipCount).toBe(0);
g.addRelationship(makeRel('fn:a', 'fn:b'));
expect(g.relationshipCount).toBe(1);
});
// ─── addRelationship ───────────────────────────────────────────────
it('duplicate addRelationship is a no-op', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
g.addRelationship(makeRel('fn:a', 'fn:b')); // same ID
expect(g.relationshipCount).toBe(1);
});
// ─── nodes / relationships arrays ──────────────────────────────────
it('.nodes returns an array copy', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
const arr1 = g.nodes;
const arr2 = g.nodes;
expect(arr1).not.toBe(arr2); // different array instances
expect(arr1).toHaveLength(1);
});
it('.relationships returns an array copy', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
const arr1 = g.relationships;
const arr2 = g.relationships;
expect(arr1).not.toBe(arr2);
expect(arr1).toHaveLength(1);
});
// ─── forEachNode / forEachRelationship ──────────────────────────────
it('forEachNode calls fn for every node', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
const ids: string[] = [];
g.forEachNode((n) => ids.push(n.id));
expect(ids).toHaveLength(2);
});
it('forEachRelationship calls fn for every relationship', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
const types: string[] = [];
g.forEachRelationship((r) => types.push(r.type));
expect(types).toEqual(['CALLS']);
});
// ─── removeRelationship ─────────────────────────────────────────────
it('removes a relationship by id', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
expect(g.relationshipCount).toBe(1);
const removed = g.removeRelationship('fn:a-CALLS-fn:b');
expect(removed).toBe(true);
expect(g.relationshipCount).toBe(0);
});
it('removeRelationship returns false for unknown id', () => {
const g = createKnowledgeGraph();
expect(g.removeRelationship('nonexistent')).toBe(false);
});
it('removeRelationship returns false on second call with same id', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
expect(g.removeRelationship('fn:a-CALLS-fn:b')).toBe(true);
expect(g.removeRelationship('fn:a-CALLS-fn:b')).toBe(false);
});
it('removeRelationship does not affect nodes', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
g.removeRelationship('fn:a-CALLS-fn:b');
expect(g.nodeCount).toBe(2);
expect(g.getNode('fn:a')).toBeDefined();
expect(g.getNode('fn:b')).toBeDefined();
});
it('removeRelationship leaves other relationships intact', () => {
const g = createKnowledgeGraph();
g.addNode(makeNode('fn:a', 'a'));
g.addNode(makeNode('fn:b', 'b'));
g.addNode(makeNode('fn:c', 'c'));
g.addRelationship(makeRel('fn:a', 'fn:b'));
g.addRelationship(makeRel('fn:b', 'fn:c'));
expect(g.relationshipCount).toBe(2);
g.removeRelationship('fn:a-CALLS-fn:b');
expect(g.relationshipCount).toBe(1);
const remaining = [...g.iterRelationships()];
expect(remaining[0].sourceId).toBe('fn:b');
expect(remaining[0].targetId).toBe('fn:c');
});
});