Skip to content

Commit 7cb9f92

Browse files
committed
test: add events-log tests — append, read, replay, git helpers (22 tests)
Cover appendEvent, readEvents (invalid lines, empty file), replay for notes/tasks/skills (created, update, relations, attachments, deduplication), ensureGitignore, ensureGitattributes. Coverage: 45% → 81% lines.
1 parent 89fb63c commit 7cb9f92

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

src/tests/events-log.test.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import {
5+
appendEvent, readEvents,
6+
replayNoteEvents, replayTaskEvents, replaySkillEvents,
7+
ensureGitignore, ensureGitattributes,
8+
type AnyEvent,
9+
} from '@/lib/events-log';
10+
11+
function tmpDir(): string {
12+
return fs.mkdtempSync(path.join(os.tmpdir(), 'gm-events-'));
13+
}
14+
15+
// ---------------------------------------------------------------------------
16+
// appendEvent + readEvents
17+
// ---------------------------------------------------------------------------
18+
19+
describe('appendEvent / readEvents', () => {
20+
it('appends and reads events', () => {
21+
const dir = tmpDir();
22+
const file = path.join(dir, 'events.jsonl');
23+
24+
appendEvent(file, { op: 'created', id: 'n1', title: 'Test', tags: [], createdAt: 1000 } as any);
25+
appendEvent(file, { op: 'update', title: 'Updated' } as any);
26+
27+
const events = readEvents(file);
28+
expect(events).toHaveLength(2);
29+
expect(events[0].op).toBe('created');
30+
expect(events[1].op).toBe('update');
31+
expect(events[0].ts).toBeDefined();
32+
});
33+
34+
it('returns empty for non-existent file', () => {
35+
expect(readEvents('/nonexistent/events.jsonl')).toEqual([]);
36+
});
37+
38+
it('skips invalid JSON lines', () => {
39+
const dir = tmpDir();
40+
const file = path.join(dir, 'events.jsonl');
41+
fs.writeFileSync(file, '{"op":"created","ts":"2026-01-01"}\nINVALID LINE\n{"op":"update","ts":"2026-01-02"}\n');
42+
43+
const events = readEvents(file);
44+
expect(events).toHaveLength(2);
45+
});
46+
47+
it('handles empty file', () => {
48+
const dir = tmpDir();
49+
const file = path.join(dir, 'events.jsonl');
50+
fs.writeFileSync(file, '');
51+
expect(readEvents(file)).toEqual([]);
52+
});
53+
54+
it('handles file with only whitespace lines', () => {
55+
const dir = tmpDir();
56+
const file = path.join(dir, 'events.jsonl');
57+
fs.writeFileSync(file, '\n\n \n');
58+
expect(readEvents(file)).toEqual([]);
59+
});
60+
});
61+
62+
// ---------------------------------------------------------------------------
63+
// replayNoteEvents
64+
// ---------------------------------------------------------------------------
65+
66+
describe('replayNoteEvents', () => {
67+
it('reconstructs note from created event', () => {
68+
const events: AnyEvent[] = [
69+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 'n1', title: 'Hello', tags: ['a'], createdAt: 1000, createdBy: 'alice' },
70+
];
71+
const result = replayNoteEvents(events, 'content here');
72+
expect(result).not.toBeNull();
73+
expect(result!.id).toBe('n1');
74+
expect(result!.title).toBe('Hello');
75+
expect(result!.content).toBe('content here');
76+
expect(result!.tags).toEqual(['a']);
77+
expect(result!.createdBy).toBe('alice');
78+
expect(result!.version).toBe(1);
79+
});
80+
81+
it('returns null without created event', () => {
82+
const events: AnyEvent[] = [
83+
{ ts: '2026-01-01T00:00:00Z', op: 'update', title: 'x' },
84+
];
85+
expect(replayNoteEvents(events, '')).toBeNull();
86+
});
87+
88+
it('applies update events', () => {
89+
const events: AnyEvent[] = [
90+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 'n1', title: 'Old', tags: [], createdAt: 1000 },
91+
{ ts: '2026-01-01T01:00:00Z', op: 'update', title: 'New', tags: ['b'], by: 'bob' },
92+
];
93+
const result = replayNoteEvents(events, 'c');
94+
expect(result!.title).toBe('New');
95+
expect(result!.tags).toEqual(['b']);
96+
expect(result!.updatedBy).toBe('bob');
97+
expect(result!.version).toBe(2);
98+
});
99+
100+
it('applies relation add/remove', () => {
101+
const events: AnyEvent[] = [
102+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 'n1', title: 'T', tags: [], createdAt: 1000 },
103+
{ ts: '2026-01-01T01:00:00Z', op: 'relation', action: 'add', kind: 'depends_on', to: 'n2' },
104+
{ ts: '2026-01-01T02:00:00Z', op: 'relation', action: 'add', kind: 'refs', to: 't1', graph: 'tasks' },
105+
{ ts: '2026-01-01T03:00:00Z', op: 'relation', action: 'remove', kind: 'depends_on', to: 'n2' },
106+
];
107+
const result = replayNoteEvents(events, '');
108+
expect(result!.relations).toHaveLength(1);
109+
expect(result!.relations[0].to).toBe('t1');
110+
expect(result!.relations[0].graph).toBe('tasks');
111+
});
112+
113+
it('applies attachment add/remove', () => {
114+
const events: AnyEvent[] = [
115+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 'n1', title: 'T', tags: [], createdAt: 1000 },
116+
{ ts: '2026-01-01T01:00:00Z', op: 'attachment', action: 'add', file: 'a.png' },
117+
{ ts: '2026-01-01T02:00:00Z', op: 'attachment', action: 'add', file: 'b.pdf' },
118+
{ ts: '2026-01-01T03:00:00Z', op: 'attachment', action: 'remove', file: 'a.png' },
119+
];
120+
const result = replayNoteEvents(events, '');
121+
expect(result!.attachments).toHaveLength(1);
122+
expect(result!.attachments[0].filename).toBe('b.pdf');
123+
});
124+
125+
it('deduplicates relation adds', () => {
126+
const events: AnyEvent[] = [
127+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 'n1', title: 'T', tags: [], createdAt: 1000 },
128+
{ ts: '2026-01-01T01:00:00Z', op: 'relation', action: 'add', kind: 'refs', to: 'n2' },
129+
{ ts: '2026-01-01T02:00:00Z', op: 'relation', action: 'add', kind: 'refs', to: 'n2' },
130+
];
131+
const result = replayNoteEvents(events, '');
132+
expect(result!.relations).toHaveLength(1);
133+
});
134+
});
135+
136+
// ---------------------------------------------------------------------------
137+
// replayTaskEvents
138+
// ---------------------------------------------------------------------------
139+
140+
describe('replayTaskEvents', () => {
141+
it('reconstructs task from created event', () => {
142+
const events: AnyEvent[] = [
143+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 't1', title: 'Fix bug', status: 'todo' as any, priority: 'high' as any, tags: [], dueDate: null, estimate: null, completedAt: null, createdAt: 2000 },
144+
];
145+
const result = replayTaskEvents(events, 'desc');
146+
expect(result).not.toBeNull();
147+
expect(result!.id).toBe('t1');
148+
expect(result!.title).toBe('Fix bug');
149+
expect(result!.status).toBe('todo');
150+
expect(result!.priority).toBe('high');
151+
expect(result!.description).toBe('desc');
152+
});
153+
154+
it('applies task-specific update fields', () => {
155+
const events: AnyEvent[] = [
156+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 't1', title: 'T', status: 'todo' as any, priority: 'low' as any, tags: [], dueDate: null, estimate: null, completedAt: null, createdAt: 2000 },
157+
{ ts: '2026-01-01T01:00:00Z', op: 'update', status: 'done', priority: 'critical', dueDate: 3000, estimate: 8, completedAt: 4000 },
158+
];
159+
const result = replayTaskEvents(events, '');
160+
expect(result!.status).toBe('done');
161+
expect(result!.priority).toBe('critical');
162+
expect(result!.dueDate).toBe(3000);
163+
expect(result!.estimate).toBe(8);
164+
expect(result!.completedAt).toBe(4000);
165+
});
166+
167+
it('returns null without created event', () => {
168+
expect(replayTaskEvents([{ ts: '2026-01-01T00:00:00Z', op: 'update', title: 'x' }], '')).toBeNull();
169+
});
170+
});
171+
172+
// ---------------------------------------------------------------------------
173+
// replaySkillEvents
174+
// ---------------------------------------------------------------------------
175+
176+
describe('replaySkillEvents', () => {
177+
it('reconstructs skill from created event', () => {
178+
const events: AnyEvent[] = [
179+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 's1', title: 'Deploy', tags: ['ops'], steps: ['step1'], triggers: ['deploy'], inputHints: [], filePatterns: ['*.yml'], source: 'user' as any, confidence: 0.9, usageCount: 0, lastUsedAt: null, createdAt: 3000 },
180+
];
181+
const result = replaySkillEvents(events, 'how to deploy');
182+
expect(result).not.toBeNull();
183+
expect(result!.title).toBe('Deploy');
184+
expect(result!.steps).toEqual(['step1']);
185+
expect(result!.triggers).toEqual(['deploy']);
186+
expect(result!.source).toBe('user');
187+
expect(result!.confidence).toBe(0.9);
188+
});
189+
190+
it('applies skill-specific update fields', () => {
191+
const events: AnyEvent[] = [
192+
{ ts: '2026-01-01T00:00:00Z', op: 'created', id: 's1', title: 'S', tags: [], steps: [], triggers: [], inputHints: [], filePatterns: [], source: 'user' as any, confidence: 1, usageCount: 0, lastUsedAt: null, createdAt: 3000 },
193+
{ ts: '2026-01-01T01:00:00Z', op: 'update', steps: ['a', 'b'], confidence: 0.5, usageCount: 3, lastUsedAt: 5000, source: 'learned' },
194+
];
195+
const result = replaySkillEvents(events, '');
196+
expect(result!.steps).toEqual(['a', 'b']);
197+
expect(result!.confidence).toBe(0.5);
198+
expect(result!.usageCount).toBe(3);
199+
expect(result!.lastUsedAt).toBe(5000);
200+
expect(result!.source).toBe('learned');
201+
});
202+
203+
it('returns null without created event', () => {
204+
expect(replaySkillEvents([{ ts: '2026-01-01T00:00:00Z', op: 'update', title: 'x' }], '')).toBeNull();
205+
});
206+
});
207+
208+
// ---------------------------------------------------------------------------
209+
// ensureGitignore / ensureGitattributes
210+
// ---------------------------------------------------------------------------
211+
212+
describe('ensureGitignore', () => {
213+
it('creates .gitignore with pattern', () => {
214+
const dir = tmpDir();
215+
ensureGitignore(dir, '*.md');
216+
const content = fs.readFileSync(path.join(dir, '.gitignore'), 'utf-8');
217+
expect(content).toContain('*.md');
218+
});
219+
220+
it('does not duplicate pattern', () => {
221+
const dir = tmpDir();
222+
ensureGitignore(dir, '*.md');
223+
ensureGitignore(dir, '*.md');
224+
const content = fs.readFileSync(path.join(dir, '.gitignore'), 'utf-8');
225+
expect(content.split('*.md').length - 1).toBe(1);
226+
});
227+
228+
it('appends to existing .gitignore', () => {
229+
const dir = tmpDir();
230+
fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n');
231+
ensureGitignore(dir, '*.md');
232+
const content = fs.readFileSync(path.join(dir, '.gitignore'), 'utf-8');
233+
expect(content).toContain('node_modules');
234+
expect(content).toContain('*.md');
235+
});
236+
});
237+
238+
describe('ensureGitattributes', () => {
239+
it('creates .gitattributes with merge=union', () => {
240+
const dir = tmpDir();
241+
ensureGitattributes(dir);
242+
const content = fs.readFileSync(path.join(dir, '.gitattributes'), 'utf-8');
243+
expect(content).toContain('*/events.jsonl merge=union');
244+
});
245+
246+
it('does not duplicate', () => {
247+
const dir = tmpDir();
248+
ensureGitattributes(dir);
249+
ensureGitattributes(dir);
250+
const content = fs.readFileSync(path.join(dir, '.gitattributes'), 'utf-8');
251+
expect(content.split('merge=union').length - 1).toBe(1);
252+
});
253+
});

0 commit comments

Comments
 (0)