Flow 반전 (F7-D 채택): - 기존: rule.iso ?? ai.dueDate (rule 우선) - 신규: ai.dueDate ?? null (AI 우선) 규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보 힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음). 해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null. PROMPT_VERSION → 3. GenerateInput.dueDateCandidates 신규. buildPrompt(rawText, todayKst, candidates) — 빈 배열일 때 hint 섹션 생략. Tests: - AiWorker.test.ts — 'rule priority' 테스트 → 'AI dueDate wins' flip - AiWorker.test.ts — passes todayKst 테스트 확장 (dueDateCandidates 도 검증) - AiWorker.test.ts — 신규 'passes parseAllCandidates result as dueDateCandidates' - LocalOllamaProvider.test.ts / ollama-golden.test.ts — generate 호출에 dueDateCandidates: [] 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
|
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
|
|
|
|
describe('LocalOllamaProvider', () => {
|
|
let mock: MockAgent;
|
|
let original: ReturnType<typeof getGlobalDispatcher>;
|
|
|
|
beforeEach(() => {
|
|
original = getGlobalDispatcher();
|
|
mock = new MockAgent();
|
|
mock.disableNetConnect();
|
|
setGlobalDispatcher(mock);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
setGlobalDispatcher(original);
|
|
await mock.close();
|
|
});
|
|
|
|
it('generate parses Ollama JSON', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
|
response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] })
|
|
});
|
|
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
|
expect(r.title).toBe('회의');
|
|
});
|
|
|
|
it('generate throws on non-JSON', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
|
response: 'not json'
|
|
});
|
|
await expect(
|
|
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
|
).rejects.toThrow(/json/i);
|
|
});
|
|
|
|
it('generate aborts on timeout', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => {
|
|
await new Promise<void>((r) => setTimeout(r, 500));
|
|
return { statusCode: 200, data: '{}' };
|
|
}) as never);
|
|
await expect(
|
|
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
|
).rejects.toThrow();
|
|
}, 2000);
|
|
|
|
it('healthCheck ok=true when model present', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
|
|
models: [{ name: 'gemma4:e4b' }]
|
|
});
|
|
const h = await new LocalOllamaProvider().healthCheck();
|
|
expect(h.ok).toBe(true);
|
|
expect(h.model).toBe('gemma4:e4b');
|
|
});
|
|
|
|
it('healthCheck ok=false when missing', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
|
|
models: [{ name: 'other:latest' }]
|
|
});
|
|
const h = await new LocalOllamaProvider().healthCheck();
|
|
expect(h.ok).toBe(false);
|
|
expect(h.reason).toMatch(/gemma4:e4b/);
|
|
});
|
|
|
|
it('healthCheck ok=false on connection error', async () => {
|
|
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
|
|
.replyWithError(new Error('ECONNREFUSED'));
|
|
const h = await new LocalOllamaProvider().healthCheck();
|
|
expect(h.ok).toBe(false);
|
|
expect(h.reason).toMatch(/connect|refused|unreachable/i);
|
|
});
|
|
});
|