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 { AiTelemetryEmitter } from '@main/ai/AiWorker.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; import { ProviderHolder } from '@main/ai/ProviderHolder.js'; type EmittedEvent = { kind: string; payload: unknown }; function makeProvider(overrides: Partial = {}): InferenceProvider { return { name: 'mock', generate: vi.fn(async (): Promise => ({ title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null, notebookMatch: 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, new ProviderHolder(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, new ProviderHolder(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, new ProviderHolder(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, notebookMatch: null }; }) }); const w = new AiWorker(repo, new ProviderHolder(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, new ProviderHolder(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, new ProviderHolder(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, notebookMatch: null }), healthCheck: async () => ({ ok: true }) } as any; const w = new AiWorker(repo, new ProviderHolder(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, notebookMatch: null }; }, healthCheck: async () => ({ ok: true }) } as any; const w = new AiWorker(repo, new ProviderHolder(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, notebookMatch: null }; }, healthCheck: async () => ({ ok: true }) } as any; const w = new AiWorker(repo, new ProviderHolder(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; tagId?: number; vocabSize?: number } }>; const collectingTelemetry: AiTelemetryEmitter = { emit: async (ev) => { events.push(ev as typeof events[number]); } }; 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, new ProviderHolder(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('unreachable error — ai_failed NOT emitted (infinite retry, no markAiFailed)', 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, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], telemetry: collectingTelemetry }); await w.enqueue(id); await new Promise((r) => setTimeout(r, 200)); const failed = events.find((e) => e.kind === 'ai_failed'); expect(failed).toBeUndefined(); expect(repo.findById(id)!.aiStatus).toBe('pending'); }); 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, new ProviderHolder(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, new ProviderHolder(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'); }); }); describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => { const { id } = repo.create({ rawText: 'x' }); // 먼저 trash — pending_jobs cleanup 됨 repo.trash(id, '2026-05-01T12:00:00.000Z'); // 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내) db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z'); const generate = vi.fn(); const provider = makeProvider({ generate: generate as any }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); await w.loadFromDb(); await w.drain(); expect(generate).not.toHaveBeenCalled(); expect(repo.findById(id)!.aiStatus).toBe('pending'); }); }); describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); }) }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); const { id } = repo.create({ rawText: 'x' }); await w.enqueue(id); // 무한 retry — drain() 은 끝나지 않음. 짧게 대기 후 검증. await new Promise((r) => setTimeout(r, 200)); expect(repo.findById(id)!.aiStatus).toBe('pending'); expect(provider.generate).toHaveBeenCalled(); expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2); const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!; expect(job.attempts).toBe(0); }); it('timeout — unreachable 동일 (Q2=A)', async () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('Request timeout'); }) }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); const { id } = repo.create({ rawText: 'x' }); await w.enqueue(id); await new Promise((r) => setTimeout(r, 200)); expect(repo.findById(id)!.aiStatus).toBe('pending'); expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2); }); it('schema fail max 3 — markAiFailed + ai_failed emit (reason=schema)', async () => { const { ZodError } = await import('zod'); const provider = makeProvider({ generate: vi.fn(async () => { throw new ZodError([{ code: 'custom', message: 'bad', path: [] } as any]); }) }); const events: any[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); const { id } = repo.create({ rawText: 'x' }); await w.enqueue(id); await w.drain(); expect(repo.findById(id)!.aiStatus).toBe('failed'); expect((provider.generate as any).mock.calls.length).toBe(3); const failed = events.find((e) => e.kind === 'ai_failed'); expect(failed).toBeDefined(); expect(failed.payload.reason).toBe('schema'); }); it('other fail max 3 — markAiFailed + ai_failed emit (reason=other)', async () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('something weird'); }) }); const events: any[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); const { id } = repo.create({ rawText: 'x' }); await w.enqueue(id); await w.drain(); expect(repo.findById(id)!.aiStatus).toBe('failed'); const failed = events.find((e) => e.kind === 'ai_failed'); expect(failed.payload.reason).toBe('other'); }); it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => { const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000] }); expect((w as any).nextBackoffMs(0)).toBe(30_000); expect((w as any).nextBackoffMs(2)).toBe(120_000); expect((w as any).nextBackoffMs(5)).toBe(900_000); expect((w as any).nextBackoffMs(10)).toBe(900_000); // cap }); it('success 후 unreachableBackoffStep reset', async () => { let callCount = 0; const provider = makeProvider({ generate: vi.fn(async (): Promise => { callCount += 1; if (callCount <= 2) throw new Error('ECONNREFUSED'); return { title: 't', summary: 's', tags: [], dueDate: null, notebookMatch: null }; }) }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); const { id } = repo.create({ rawText: 'x' }); await w.enqueue(id); await w.drain(); expect(repo.findById(id)!.aiStatus).toBe('done'); expect(callCount).toBe(3); expect((w as any).unreachableBackoffStep).toBe(0); }); }); describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('fetches vocab and passes to provider.generate', async () => { // Pre-seed 1 note with tag 'design' so vocab non-empty const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const generateMock = vi.fn(async () => ({ title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null, notebookMatch: null })); const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), { backoffsMs: [0, 0, 0] }); await w.enqueue(id); await w.drain(); expect(generateMock).toHaveBeenCalledWith( expect.objectContaining({ vocab: expect.arrayContaining(['design']) }), expect.anything() ); }); it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => { // Pre-seed: 'design' in vocab const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design', 'newtag'], // 1 hit + 1 miss dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); expect(hit).toHaveLength(1); expect(miss).toHaveLength(1); const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number }; const missPayload = miss[0]!.payload as { vocabSize: number }; expect(hitPayload.tagId).toBeGreaterThan(0); expect(hitPayload.vocabSize).toBe(1); expect(missPayload.vocabSize).toBe(1); }); it('all tags miss when vocab is empty', async () => { // No seed → vocab=[] const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); expect(miss).toHaveLength(3); expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0); }); it('emits one event per tag (3 tags → 3 events)', async () => { // Pre-seed: all 3 in vocab const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); const hits = emits.filter((e) => e.kind === 'tag_vocab_hit'); expect(hits).toHaveLength(3); }); it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => { // Pre-seed: 'design' in vocab const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적 dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup) expect(miss).toHaveLength(1); // 'meeting' 1 miss }); }); describe('AiWorker notebook matching', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('AI 응답의 notebookMatch 가 valid 이름이면 moveNote 호출', async () => { const moveNote = vi.fn(); const notebookRepo = { list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }], findByName: (n: string) => n === '회사' ? { id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } : null, moveNote }; const provider = makeProvider({ generate: vi.fn(async (): Promise => ({ title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사' })) }); const { id } = repo.create({ rawText: '회사 업무' }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], notebookRepo }); await w.enqueue(id); await w.drain(); expect(moveNote).toHaveBeenCalledWith(id, 'nb-회사'); }); it('AI 응답 notebookMatch null 시 moveNote 호출 X', async () => { const moveNote = vi.fn(); const notebookRepo = { list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }], findByName: (_n: string) => null, moveNote }; const provider = makeProvider({ generate: vi.fn(async (): Promise => ({ title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null })) }); const { id } = repo.create({ rawText: '일반 메모' }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], notebookRepo }); await w.enqueue(id); await w.drain(); expect(moveNote).not.toHaveBeenCalled(); }); it('notebooks 배열을 provider.generate 에 전달', async () => { let capturedNotebooks: string[] | undefined; const notebookRepo = { list: () => [ { id: 'nb-1', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }, { id: 'nb-2', name: '개인', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } ], findByName: (_n: string) => null, moveNote: vi.fn() }; const provider = makeProvider({ generate: vi.fn(async (input: any): Promise => { capturedNotebooks = input.notebooks; return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null }; }) }); const { id } = repo.create({ rawText: '테스트' }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], notebookRepo }); await w.enqueue(id); await w.drain(); expect(capturedNotebooks).toEqual(['회사', '개인']); }); it('notebookRepo 미전달 시 notebooks 빈 배열 전달 + moveNote 호출 X', async () => { let capturedNotebooks: string[] | undefined; const provider = makeProvider({ generate: vi.fn(async (input: any): Promise => { capturedNotebooks = input.notebooks; return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사' }; }) }); const { id } = repo.create({ rawText: '테스트' }); const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0] }); await w.enqueue(id); await w.drain(); expect(capturedNotebooks).toEqual([]); // notebookRepo 없으므로 moveNote 미호출 — note 는 done 상태 expect(repo.findById(id)?.aiStatus).toBe('done'); }); }); describe('vocab COLLATE NOCASE', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('hits when vocab has lowercase and AI returns capital', async () => { // Pre-seed: 'design' in vocab (lowercase) const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design' dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1); expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0); }); it('hits when vocab has capital and AI returns lowercase', async () => { // Scenario: vocab contains 'Design' (capital), AI returns 'design' (lowercase). // getTopUsedTags filters via KEBAB_CASE_RE (/^[a-z0-9-]+$/) so 'Design' would be // stripped in production. We stub getTopUsedTags to inject the capital vocab directly, // and pre-seed the DB so getTagIdByName (COLLATE NOCASE) can resolve 'design' → tagId. const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['Design'], provider: 'p' }); // Inject capital vocab bypassing the kebab filter vi.spyOn(repo, 'getTopUsedTags').mockReturnValueOnce(['Design']); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design' dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1); expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0); }); it('still hits when both vocab and AI tag are same lowercase (regression)', async () => { // Pre-seed: 'design' in vocab (lowercase) const seed = repo.create({ rawText: 'seed' }).id; repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); const { id } = repo.create({ rawText: 'x' }); const provider = makeProvider({ generate: vi.fn(async () => ({ title: 't', summary: 'a\nb\nc', tags: ['design'], // same lowercase — should still hit dueDate: null, notebookMatch: null })) }); const emits: EmittedEvent[] = []; const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); await w.enqueue(id); await w.drain(); expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1); expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0); }); });