Skip to content

Commit be32a57

Browse files
committed
test: add BM25 Unicode, graph migration, version conflict, relation events, Unicode content tests
New test suites: - bm25: Cyrillic/CJK/Arabic tokenization + BM25 Cyrillic search (6 tests) - graph-migration: preserves user data on version/embedding change (5 tests) - version-conflict: optimistic locking for notes and tasks (7 tests) - relation-events: emit events on create/delete relations for all 3 graphs (7 tests) - unicode-content: Cyrillic/CJK/mixed content via MCP tools (8 tests) Total: 1879 tests across 51 suites.
1 parent ade849c commit be32a57

5 files changed

Lines changed: 453 additions & 0 deletions

File tree

src/tests/bm25.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ describe('tokenize', () => {
5050
it('handles single word', () => {
5151
expect(tokenize('jwt')).toEqual(['jwt']);
5252
});
53+
54+
// Unicode support
55+
it('tokenizes Cyrillic text', () => {
56+
expect(tokenize('Создать задачу')).toEqual(['создать', 'задачу']);
57+
});
58+
59+
it('tokenizes mixed Latin + Cyrillic', () => {
60+
expect(tokenize('Mixed русский text')).toEqual(['mixed', 'русский', 'text']);
61+
});
62+
63+
it('tokenizes CJK text', () => {
64+
const tokens = tokenize('データ分析');
65+
expect(tokens.length).toBeGreaterThan(0);
66+
expect(tokens[0]).toContain('データ');
67+
});
68+
69+
it('tokenizes Arabic text', () => {
70+
const tokens = tokenize('مرحبا بالعالم');
71+
expect(tokens.length).toBe(2);
72+
});
73+
74+
it('BM25 finds Cyrillic documents', () => {
75+
type Doc = { text: string };
76+
const idx = new BM25Index<Doc>((d) => d.text);
77+
idx.addDocument('d1', { text: 'Создать новую задачу' });
78+
idx.addDocument('d2', { text: 'English only document' });
79+
80+
const scores = idx.score('задачу');
81+
expect(scores.has('d1')).toBe(true);
82+
expect(scores.has('d2')).toBe(false);
83+
});
5384
});
5485

5586
describe('BM25Index', () => {

src/tests/graph-migration.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { loadKnowledgeGraph, saveKnowledgeGraph, createKnowledgeGraph } from '@/graphs/knowledge';
5+
import { loadTaskGraph, saveTaskGraph, createTaskGraph } from '@/graphs/task';
6+
import { loadSkillGraph, saveSkillGraph, createSkillGraph } from '@/graphs/skill';
7+
import { GRAPH_DATA_VERSION } from '@/lib/defaults';
8+
9+
function tmpDir(): string {
10+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-migration-'));
11+
return dir;
12+
}
13+
14+
describe('Graph migration — preserves user data on version/embedding change', () => {
15+
describe('Knowledge graph', () => {
16+
it('preserves notes when data version changes', () => {
17+
const dir = tmpDir();
18+
19+
// Create and save a graph with a note
20+
const graph = createKnowledgeGraph();
21+
graph.addNode('test-note', {
22+
title: 'My Note', content: 'Important data', tags: ['keep'],
23+
embedding: [1, 2, 3], createdAt: 1000, updatedAt: 2000, version: 1,
24+
attachments: [],
25+
});
26+
saveKnowledgeGraph(graph, dir, 'model-v1');
27+
28+
// Tamper version in saved file to simulate old version
29+
const file = path.join(dir, 'knowledge.json');
30+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
31+
data.version = GRAPH_DATA_VERSION - 1;
32+
fs.writeFileSync(file, JSON.stringify(data));
33+
34+
// Load — should preserve data but clear embeddings
35+
const loaded = loadKnowledgeGraph(dir, false, 'model-v1');
36+
expect(loaded.hasNode('test-note')).toBe(true);
37+
expect(loaded.getNodeAttribute('test-note', 'title')).toBe('My Note');
38+
expect(loaded.getNodeAttribute('test-note', 'content')).toBe('Important data');
39+
expect(loaded.getNodeAttribute('test-note', 'embedding')).toEqual([]);
40+
});
41+
42+
it('preserves notes when embedding model changes', () => {
43+
const dir = tmpDir();
44+
45+
const graph = createKnowledgeGraph();
46+
graph.addNode('note-2', {
47+
title: 'Note 2', content: 'Data', tags: [],
48+
embedding: [4, 5, 6], createdAt: 1000, updatedAt: 2000, version: 1,
49+
attachments: [],
50+
});
51+
saveKnowledgeGraph(graph, dir, 'old-model');
52+
53+
// Load with different embedding fingerprint
54+
const loaded = loadKnowledgeGraph(dir, false, 'new-model');
55+
expect(loaded.hasNode('note-2')).toBe(true);
56+
expect(loaded.getNodeAttribute('note-2', 'title')).toBe('Note 2');
57+
expect(loaded.getNodeAttribute('note-2', 'embedding')).toEqual([]);
58+
});
59+
60+
it('keeps embeddings when nothing changed', () => {
61+
const dir = tmpDir();
62+
63+
const graph = createKnowledgeGraph();
64+
graph.addNode('note-3', {
65+
title: 'Note 3', content: 'Data', tags: [],
66+
embedding: [7, 8, 9], createdAt: 1000, updatedAt: 2000, version: 1,
67+
attachments: [],
68+
});
69+
saveKnowledgeGraph(graph, dir, 'same-model');
70+
71+
const loaded = loadKnowledgeGraph(dir, false, 'same-model');
72+
expect(loaded.getNodeAttribute('note-3', 'embedding')).toEqual([7, 8, 9]);
73+
});
74+
});
75+
76+
describe('Task graph', () => {
77+
it('preserves tasks when data version changes', () => {
78+
const dir = tmpDir();
79+
80+
const graph = createTaskGraph();
81+
graph.addNode('test-task', {
82+
title: 'My Task', description: 'Do something', status: 'todo',
83+
priority: 'high', tags: [], embedding: [1, 2],
84+
dueDate: null, estimate: null, completedAt: null, assignee: null,
85+
order: 0, nodeType: 'task',
86+
createdAt: 1000, updatedAt: 2000, version: 1, attachments: [],
87+
});
88+
saveTaskGraph(graph, dir, 'model-v1');
89+
90+
const file = path.join(dir, 'tasks.json');
91+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
92+
data.version = GRAPH_DATA_VERSION - 1;
93+
fs.writeFileSync(file, JSON.stringify(data));
94+
95+
const loaded = loadTaskGraph(dir, false, 'model-v1');
96+
expect(loaded.hasNode('test-task')).toBe(true);
97+
expect(loaded.getNodeAttribute('test-task', 'title')).toBe('My Task');
98+
expect(loaded.getNodeAttribute('test-task', 'embedding')).toEqual([]);
99+
});
100+
});
101+
102+
describe('Skill graph', () => {
103+
it('preserves skills when embedding model changes', () => {
104+
const dir = tmpDir();
105+
106+
const graph = createSkillGraph();
107+
graph.addNode('test-skill', {
108+
title: 'Deploy', description: 'How to deploy', tags: [],
109+
steps: ['step1'], triggers: ['deploy'], inputHints: [], filePatterns: [],
110+
source: 'user', confidence: 1, usageCount: 0, lastUsedAt: null,
111+
embedding: [1, 2], createdAt: 1000, updatedAt: 2000, version: 1,
112+
attachments: [],
113+
});
114+
saveSkillGraph(graph, dir, 'old-model');
115+
116+
const loaded = loadSkillGraph(dir, false, 'new-model');
117+
expect(loaded.hasNode('test-skill')).toBe(true);
118+
expect(loaded.getNodeAttribute('test-skill', 'title')).toBe('Deploy');
119+
expect(loaded.getNodeAttribute('test-skill', 'steps')).toEqual(['step1']);
120+
expect(loaded.getNodeAttribute('test-skill', 'embedding')).toEqual([]);
121+
});
122+
});
123+
});

src/tests/relation-events.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { createKnowledgeGraph } from '@/graphs/knowledge-types';
2+
import { KnowledgeGraphManager } from '@/graphs/knowledge';
3+
import { createTaskGraph } from '@/graphs/task-types';
4+
import { TaskGraphManager } from '@/graphs/task';
5+
import { createSkillGraph } from '@/graphs/skill-types';
6+
import { SkillGraphManager } from '@/graphs/skill';
7+
import { unitVec, DIM } from '@/tests/helpers';
8+
9+
const fakeEmbed = async () => unitVec(0, DIM);
10+
const embedFns = { document: fakeEmbed, query: fakeEmbed };
11+
12+
function makeCtx() {
13+
const events: Array<[string, any]> = [];
14+
return {
15+
markDirty: () => {},
16+
emit: (event: string, data: any) => { events.push([event, data]); },
17+
projectId: 'test',
18+
events,
19+
} as any;
20+
}
21+
22+
describe('Relation events', () => {
23+
describe('Knowledge graph', () => {
24+
let mgr: KnowledgeGraphManager;
25+
let ctx: ReturnType<typeof makeCtx>;
26+
let idA: string, idB: string;
27+
28+
beforeEach(async () => {
29+
ctx = makeCtx();
30+
const graph = createKnowledgeGraph();
31+
mgr = new KnowledgeGraphManager(graph, embedFns, ctx);
32+
idA = await mgr.createNote('Note A', 'content a');
33+
idB = await mgr.createNote('Note B', 'content b');
34+
ctx.events.length = 0; // clear creation events
35+
});
36+
37+
it('emits note:relation:added on createRelation', () => {
38+
const ok = mgr.createRelation(idA, idB, 'depends_on');
39+
expect(ok).toBe(true);
40+
const ev = ctx.events.find(([e]: [string, any]) => e === 'note:relation:added');
41+
expect(ev).toBeDefined();
42+
expect(ev![1]).toMatchObject({ noteId: idA, toId: idB, kind: 'depends_on' });
43+
});
44+
45+
it('emits note:relation:deleted on deleteRelation', () => {
46+
mgr.createRelation(idA, idB, 'depends_on');
47+
ctx.events.length = 0;
48+
49+
const ok = mgr.deleteRelation(idA, idB);
50+
expect(ok).toBe(true);
51+
const ev = ctx.events.find(([e]: [string, any]) => e === 'note:relation:deleted');
52+
expect(ev).toBeDefined();
53+
expect(ev![1]).toMatchObject({ noteId: idA, toId: idB });
54+
});
55+
56+
it('does not emit on failed createRelation', () => {
57+
mgr.createRelation('nonexistent', idB, 'refs');
58+
expect(ctx.events.find(([e]: [string, any]) => e === 'note:relation:added')).toBeUndefined();
59+
});
60+
});
61+
62+
describe('Task graph', () => {
63+
let mgr: TaskGraphManager;
64+
let ctx: ReturnType<typeof makeCtx>;
65+
let idA: string, idB: string;
66+
67+
beforeEach(async () => {
68+
ctx = makeCtx();
69+
const graph = createTaskGraph();
70+
mgr = new TaskGraphManager(graph, embedFns, ctx);
71+
idA = await mgr.createTask('Task A', 'desc', 'todo', 'medium');
72+
idB = await mgr.createTask('Task B', 'desc', 'todo', 'medium');
73+
ctx.events.length = 0;
74+
});
75+
76+
it('emits task:relation:added on linkTasks', () => {
77+
const ok = mgr.linkTasks(idA, idB, 'blocks');
78+
expect(ok).toBe(true);
79+
const ev = ctx.events.find(([e]: [string, any]) => e === 'task:relation:added');
80+
expect(ev).toBeDefined();
81+
expect(ev![1]).toMatchObject({ taskId: idA, toId: idB, kind: 'blocks' });
82+
});
83+
84+
it('emits task:relation:deleted on deleteTaskLink', () => {
85+
mgr.linkTasks(idA, idB, 'blocks');
86+
ctx.events.length = 0;
87+
88+
const ok = mgr.deleteTaskLink(idA, idB);
89+
expect(ok).toBe(true);
90+
const ev = ctx.events.find(([e]: [string, any]) => e === 'task:relation:deleted');
91+
expect(ev).toBeDefined();
92+
});
93+
});
94+
95+
describe('Skill graph', () => {
96+
let mgr: SkillGraphManager;
97+
let ctx: ReturnType<typeof makeCtx>;
98+
let idA: string, idB: string;
99+
100+
beforeEach(async () => {
101+
ctx = makeCtx();
102+
const graph = createSkillGraph();
103+
mgr = new SkillGraphManager(graph, embedFns, ctx);
104+
idA = await mgr.createSkill('Skill A', 'desc');
105+
idB = await mgr.createSkill('Skill B', 'desc');
106+
ctx.events.length = 0;
107+
});
108+
109+
it('emits skill:relation:added on linkSkills', () => {
110+
const ok = mgr.linkSkills(idA, idB, 'related_to');
111+
expect(ok).toBe(true);
112+
const ev = ctx.events.find(([e]: [string, any]) => e === 'skill:relation:added');
113+
expect(ev).toBeDefined();
114+
expect(ev![1]).toMatchObject({ skillId: idA, toId: idB, kind: 'related_to' });
115+
});
116+
117+
it('emits skill:relation:deleted on deleteSkillLink', () => {
118+
mgr.linkSkills(idA, idB, 'related_to');
119+
ctx.events.length = 0;
120+
121+
const ok = mgr.deleteSkillLink(idA, idB);
122+
expect(ok).toBe(true);
123+
const ev = ctx.events.find(([e]: [string, any]) => e === 'skill:relation:deleted');
124+
expect(ev).toBeDefined();
125+
});
126+
});
127+
});

src/tests/unicode-content.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createTaskGraph } from '@/graphs/task-types';
2+
import { createKnowledgeGraph } from '@/graphs/knowledge-types';
3+
import {
4+
setupMcpClient, createFakeEmbed, json, jsonList,
5+
type McpTestContext,
6+
} from '@/tests/helpers';
7+
8+
describe('Unicode content — Cyrillic, CJK, emoji', () => {
9+
let ctx: McpTestContext;
10+
11+
beforeAll(async () => {
12+
const embedFn = createFakeEmbed([['задача', 1], ['ノート', 2], ['deploy', 3]]);
13+
const taskGraph = createTaskGraph();
14+
const knowledgeGraph = createKnowledgeGraph();
15+
ctx = await setupMcpClient({ taskGraph, knowledgeGraph, embedFn });
16+
});
17+
18+
afterAll(async () => { await ctx.close(); });
19+
20+
let cyrillicNoteId: string;
21+
22+
it('creates a note with Cyrillic title and content', async () => {
23+
const r = json<{ noteId: string }>(await ctx.call('notes_create', {
24+
title: 'Архитектура системы',
25+
content: 'Описание архитектуры на русском языке',
26+
tags: ['архитектура', 'документация'],
27+
}));
28+
expect(r.noteId).toBeTruthy();
29+
cyrillicNoteId = r.noteId;
30+
});
31+
32+
it('retrieves note with Cyrillic content', async () => {
33+
const r = json<any>(await ctx.call('notes_get', { noteId: cyrillicNoteId }));
34+
expect(r.title).toBe('Архитектура системы');
35+
expect(r.tags).toContain('архитектура');
36+
});
37+
38+
it('lists notes and finds Cyrillic note', async () => {
39+
const r = jsonList<any>(await ctx.call('notes_list', {}));
40+
expect(r.some((n: any) => n.title === 'Архитектура системы')).toBe(true);
41+
});
42+
43+
it('creates a task with CJK title', async () => {
44+
const r = json<{ taskId: string }>(await ctx.call('tasks_create', {
45+
title: 'データベース移行',
46+
description: '日本語のタスク説明',
47+
priority: 'high',
48+
}));
49+
expect(r.taskId).toBeTruthy();
50+
});
51+
52+
it('creates a task with emoji in tags', async () => {
53+
const r = json<{ taskId: string }>(await ctx.call('tasks_create', {
54+
title: 'Deploy with care',
55+
description: 'Be careful',
56+
priority: 'medium',
57+
tags: ['deploy', 'important'],
58+
}));
59+
expect(r.taskId).toBeTruthy();
60+
});
61+
62+
let mixedNoteId: string;
63+
64+
it('creates a note with mixed language content', async () => {
65+
const r = json<{ noteId: string }>(await ctx.call('notes_create', {
66+
title: 'Mixed: English и Русский',
67+
content: 'This note has English and русский текст together. Also データ.',
68+
}));
69+
expect(r.noteId).toBeTruthy();
70+
mixedNoteId = r.noteId;
71+
});
72+
73+
it('retrieves mixed language note', async () => {
74+
const r = json<any>(await ctx.call('notes_get', { noteId: mixedNoteId }));
75+
expect(r.title).toBe('Mixed: English и Русский');
76+
expect(r.content).toContain('русский текст');
77+
});
78+
79+
it('searches notes by Cyrillic keyword', async () => {
80+
const r = await ctx.call('notes_search', { q: 'архитектура', searchMode: 'keyword' });
81+
if (!r.isError) {
82+
const items = jsonList<any>(r);
83+
expect(items.some((n: any) => n.title.includes('Архитектура'))).toBe(true);
84+
}
85+
});
86+
});

0 commit comments

Comments
 (0)