PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영. - `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨. - `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능. - `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거. - `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정. 테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신. 게이트: typecheck 0 errors, 245/245 unit tests pass. Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
9.5 KiB
TypeScript
281 lines
9.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { runMigrations } from '@main/db/migrations/index.js';
|
|
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
|
import { AiWorker } from '@main/ai/AiWorker.js';
|
|
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
|
|
import type { AiResponse } from '@main/ai/schema.js';
|
|
|
|
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
|
|
return {
|
|
name: 'mock',
|
|
generate: vi.fn(async (): Promise<AiResponse> => ({
|
|
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null
|
|
})),
|
|
healthCheck: vi.fn(async () => ({ ok: true })),
|
|
...overrides
|
|
} as InferenceProvider;
|
|
}
|
|
|
|
describe('AiWorker', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
});
|
|
|
|
it('processes a pending job and marks done', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
const updates: string[] = [];
|
|
const w = new AiWorker(repo, makeProvider(), {
|
|
backoffsMs: [0, 0, 0],
|
|
onUpdate: (note) => updates.push(note.aiStatus)
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
expect(repo.findById(id)?.aiStatus).toBe('done');
|
|
expect(updates).toContain('done');
|
|
});
|
|
|
|
it('retries 3 times then marks failed', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => { throw new Error('boom'); })
|
|
});
|
|
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const note = repo.findById(id)!;
|
|
expect(note.aiStatus).toBe('failed');
|
|
expect(note.aiError).toContain('boom');
|
|
expect(provider.generate).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('loadFromDb re-queues all pending', async () => {
|
|
const a = repo.create({ rawText: 'a' }).id;
|
|
const b = repo.create({ rawText: 'b' }).id;
|
|
const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
|
|
await w.loadFromDb();
|
|
await w.drain();
|
|
expect(repo.findById(a)?.aiStatus).toBe('done');
|
|
expect(repo.findById(b)?.aiStatus).toBe('done');
|
|
});
|
|
|
|
it('processes sequentially (concurrency 1)', async () => {
|
|
const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
|
|
let running = 0;
|
|
let max = 0;
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => {
|
|
running++; max = Math.max(max, running);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
running--;
|
|
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
|
|
})
|
|
});
|
|
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
|
for (const id of ids) await w.enqueue(id);
|
|
await w.drain();
|
|
expect(max).toBe(1);
|
|
});
|
|
|
|
it('AI dueDate wins over rule candidates (flow reversed from F1)', async () => {
|
|
const provider = {
|
|
name: 'mock',
|
|
generate: async () => ({
|
|
title: '내일',
|
|
summary: 'a\nb\nc',
|
|
tags: [],
|
|
dueDate: '2026-12-31' // AI says far future, rule has '내일'
|
|
}),
|
|
healthCheck: async () => ({ ok: true })
|
|
} as any;
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0],
|
|
now: () => new Date('2026-04-26T00:00:00.000Z')
|
|
});
|
|
const { id } = repo.create({ rawText: '내일 회의' });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const note = repo.findById(id)!;
|
|
expect(note.dueDate).toBe('2026-12-31'); // AI value, not rule
|
|
});
|
|
|
|
it('rule null + AI value → AI used', async () => {
|
|
const provider = {
|
|
name: 'mock',
|
|
generate: async () => ({
|
|
title: '월말 마감',
|
|
summary: 'a\nb\nc',
|
|
tags: [],
|
|
dueDate: '2026-04-30' // AI resolves "월말"
|
|
}),
|
|
healthCheck: async () => ({ ok: true })
|
|
} as any;
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0],
|
|
now: () => new Date('2026-04-26T00:00:00.000Z')
|
|
});
|
|
const { id } = repo.create({ rawText: '월말 마감' });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const note = repo.findById(id)!;
|
|
expect(note.dueDate).toBe('2026-04-30');
|
|
});
|
|
|
|
it('rule null + AI null → null', async () => {
|
|
const provider = {
|
|
name: 'mock',
|
|
generate: async () => ({
|
|
title: '아무 메모',
|
|
summary: 'a\nb\nc',
|
|
tags: [],
|
|
dueDate: null
|
|
}),
|
|
healthCheck: async () => ({ ok: true })
|
|
} as any;
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0],
|
|
now: () => new Date('2026-04-26T00:00:00.000Z')
|
|
});
|
|
const { id } = repo.create({ rawText: '아무 메모' });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const note = repo.findById(id)!;
|
|
expect(note.dueDate).toBeNull();
|
|
});
|
|
|
|
it('passes todayKst to provider.generate', async () => {
|
|
const seen: any = {};
|
|
const provider = {
|
|
name: 'mock',
|
|
generate: async (input: any) => {
|
|
seen.todayKst = input.todayKst;
|
|
seen.dueDateCandidates = input.dueDateCandidates;
|
|
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
|
|
},
|
|
healthCheck: async () => ({ ok: true })
|
|
} as any;
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0],
|
|
now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
|
|
});
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
expect(seen.todayKst).toBe('2026-04-27');
|
|
expect(Array.isArray(seen.dueDateCandidates)).toBe(true);
|
|
expect(seen.dueDateCandidates.length).toBe(0);
|
|
});
|
|
|
|
it('passes parseAllCandidates result to provider.generate as dueDateCandidates', async () => {
|
|
let captured: any = null;
|
|
const provider = {
|
|
name: 'mock',
|
|
generate: async (input: any) => {
|
|
captured = input;
|
|
return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null };
|
|
},
|
|
healthCheck: async () => ({ ok: true })
|
|
} as any;
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0],
|
|
now: () => new Date('2026-04-26T00:00:00.000Z')
|
|
});
|
|
const { id } = repo.create({ rawText: '내일 모레 회의' });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
expect(captured.dueDateCandidates).toBeDefined();
|
|
expect(Array.isArray(captured.dueDateCandidates)).toBe(true);
|
|
expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
|
|
});
|
|
});
|
|
|
|
describe('AiWorker telemetry emit', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let events: Array<{ kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }>;
|
|
const collectingTelemetry = {
|
|
emit: async (ev: { kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }) => {
|
|
events.push(ev);
|
|
}
|
|
};
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
events = [];
|
|
});
|
|
|
|
it('emits ai_succeeded with durationMs/attempts on success', async () => {
|
|
const { id } = repo.create({ rawText: '수요일 회의 메모' });
|
|
const w = new AiWorker(repo, makeProvider(), {
|
|
backoffsMs: [0, 0, 0],
|
|
telemetry: collectingTelemetry
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const succeeded = events.find((e) => e.kind === 'ai_succeeded');
|
|
expect(succeeded).toBeDefined();
|
|
expect(succeeded!.payload.noteId).toBe(id);
|
|
// attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
|
|
// 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
|
|
expect(succeeded!.payload.attempts).toBe(1);
|
|
expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('emits ai_failed with reason=unreachable on network error', async () => {
|
|
const { id } = repo.create({ rawText: '메모' });
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
|
|
});
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0, 0, 0],
|
|
telemetry: collectingTelemetry
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const failed = events.find((e) => e.kind === 'ai_failed');
|
|
expect(failed).toBeDefined();
|
|
expect(failed!.payload.reason).toBe('unreachable');
|
|
expect(failed!.payload.attempts).toBe(3);
|
|
});
|
|
|
|
it('emits ai_failed with reason=schema on zod failure', async () => {
|
|
const { id } = repo.create({ rawText: '메모' });
|
|
const { ZodError } = await import('zod');
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => { throw new ZodError([]); })
|
|
});
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0, 0, 0],
|
|
telemetry: collectingTelemetry
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const failed = events.find((e) => e.kind === 'ai_failed');
|
|
expect(failed).toBeDefined();
|
|
expect(failed!.payload.reason).toBe('schema');
|
|
});
|
|
|
|
it('emits ai_failed with reason=other on unrecognized error', async () => {
|
|
const { id } = repo.create({ rawText: '메모' });
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => { throw new Error('mystery'); })
|
|
});
|
|
const w = new AiWorker(repo, provider, {
|
|
backoffsMs: [0, 0, 0],
|
|
telemetry: collectingTelemetry
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const failed = events.find((e) => e.kind === 'ai_failed');
|
|
expect(failed).toBeDefined();
|
|
expect(failed!.payload.reason).toBe('other');
|
|
});
|
|
});
|