import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; 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 { ProviderHolder } from '@main/ai/ProviderHolder.js'; import { MediaStore } from '@main/services/MediaStore.js'; import type { AiResponse } from '@main/ai/schema.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; describe('AiWorker — vision path (v0.3.1 Cut F)', () => { let db: Database.Database; let repo: NoteRepository; let workDir: string; let mediaStore: MediaStore; beforeEach(async () => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); repo = new NoteRepository(db); workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-')); mediaStore = new MediaStore(workDir); }); afterEach(async () => { db.close(); await rm(workDir, { recursive: true, force: true }); }); function makeWorker( generate: (input: Parameters[0], opts?: Parameters[1]) => Promise, getVisionModel: () => Promise ): AiWorker { const provider: InferenceProvider = { name: 'fake', generate, abort: () => {}, healthCheck: vi.fn(async () => ({ ok: true })) }; const holder = new ProviderHolder(provider); const settings = { getVisionModel }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; return new AiWorker(repo, holder, { backoffsMs: [0, 0, 0], logger, settings, mediaStore, now: () => new Date('2026-05-10T05:00:00Z') }); } it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => { const { id } = repo.create({ rawText: '이미지 메모' }); await mkdir(join(workDir, 'media', id), { recursive: true }); await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]); const calls: Array> = []; const generate = vi.fn(async ( input: Parameters[0], opts?: Parameters[1] ): Promise => { calls.push([input, opts]); return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }; }); const getVisionModel = vi.fn(async (): Promise => 'gemma3:12b-vision'); const worker = makeWorker(generate, getVisionModel); await worker.enqueue(id); await worker.drain(); expect(calls.length).toBeGreaterThan(0); const [callInput, callOpts] = calls[0]!; expect(callInput.images).toHaveLength(1); expect(callInput.images![0]!.mime).toBe('image/png'); expect(callOpts?.visionModel).toBe('gemma3:12b-vision'); }); it('visionModel null이면 text-only (images undefined)', async () => { const { id } = repo.create({ rawText: 'just text' }); const calls: Array> = []; const generate = vi.fn(async ( input: Parameters[0], opts?: Parameters[1] ): Promise => { calls.push([input, opts]); return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }; }); const getVisionModel = vi.fn(async (): Promise => null); const worker = makeWorker(generate, getVisionModel); await worker.enqueue(id); await worker.drain(); expect(calls.length).toBeGreaterThan(0); expect(calls[0]![0].images).toBeUndefined(); }); it('v0.3.14 — 본문 빈 + 이미지만 첨부 → generate 호출 skip + 자동 placeholder', async () => { const { id } = repo.create({ rawText: '' }); // 빈 본문 await mkdir(join(workDir, 'media', id), { recursive: true }); await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]); const calls: Array> = []; const generate = vi.fn(async ( input: Parameters[0], opts?: Parameters[1] ): Promise => { calls.push([input, opts]); return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }; }); const getVisionModel = vi.fn(async (): Promise => 'gemma4:26b'); const worker = makeWorker(generate, getVisionModel); await worker.enqueue(id); await worker.drain(); // vision 호출 자체 skip expect(calls.length).toBe(0); // 노트가 자동 placeholder 로 done const note = repo.findById(id); expect(note?.aiStatus).toBe('done'); expect(note?.aiTitle).toContain('첨부 이미지'); expect(note?.aiSummary).toContain('이미지'); expect(note?.aiProvider).toBe('image-only-skip'); }); it('v0.3.14 — 이미지 다수 첨부 시 placeholder 가 개수 포함', async () => { const { id } = repo.create({ rawText: '' }); await mkdir(join(workDir, 'media', id), { recursive: true }); await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89])); await writeFile(join(workDir, 'media', id, '2.png'), Buffer.from([0x89])); repo.insertMedia([ { noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 }, { noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 } ]); const generate = vi.fn(async (): Promise => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null })); const getVisionModel = vi.fn(async (): Promise => 'gemma4:26b'); const worker = makeWorker(generate, getVisionModel); await worker.enqueue(id); await worker.drain(); const note = repo.findById(id); expect(note?.aiTitle).toContain('2장'); }); it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => { const { id } = repo.create({ rawText: 'big image' }); await mkdir(join(workDir, 'media', id), { recursive: true }); await writeFile(join(workDir, 'media', id, '1.png'), Buffer.alloc(6 * 1024 * 1024)); repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]); const calls: Array> = []; const generate = vi.fn(async ( input: Parameters[0], opts?: Parameters[1] ): Promise => { calls.push([input, opts]); return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }; }); const getVisionModel = vi.fn(async (): Promise => 'gemma3:12b-vision'); const worker = makeWorker(generate, getVisionModel); await worker.enqueue(id); await worker.drain(); expect(calls.length).toBe(0); // AiWorker catch 분기가 처리 — note 는 여전히 DB 에 존재 const note = repo.findById(id); expect(note).toBeTruthy(); }); });