173 lines
7.5 KiB
TypeScript
173 lines
7.5 KiB
TypeScript
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<InferenceProvider['generate']>[0], opts?: Parameters<InferenceProvider['generate']>[1]) => Promise<AiResponse>,
|
|
getVisionModel: () => Promise<string | null>
|
|
): 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<Parameters<InferenceProvider['generate']>> = [];
|
|
const generate = vi.fn(async (
|
|
input: Parameters<InferenceProvider['generate']>[0],
|
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
|
): Promise<AiResponse> => {
|
|
calls.push([input, opts]);
|
|
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
|
});
|
|
const getVisionModel = vi.fn(async (): Promise<string | null> => '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<Parameters<InferenceProvider['generate']>> = [];
|
|
const generate = vi.fn(async (
|
|
input: Parameters<InferenceProvider['generate']>[0],
|
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
|
): Promise<AiResponse> => {
|
|
calls.push([input, opts]);
|
|
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
|
});
|
|
const getVisionModel = vi.fn(async (): Promise<string | null> => 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<Parameters<InferenceProvider['generate']>> = [];
|
|
const generate = vi.fn(async (
|
|
input: Parameters<InferenceProvider['generate']>[0],
|
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
|
): Promise<AiResponse> => {
|
|
calls.push([input, opts]);
|
|
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
|
});
|
|
const getVisionModel = vi.fn(async (): Promise<string | null> => '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<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null }));
|
|
const getVisionModel = vi.fn(async (): Promise<string | null> => '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<Parameters<InferenceProvider['generate']>> = [];
|
|
const generate = vi.fn(async (
|
|
input: Parameters<InferenceProvider['generate']>[0],
|
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
|
): Promise<AiResponse> => {
|
|
calls.push([input, opts]);
|
|
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
|
});
|
|
const getVisionModel = vi.fn(async (): Promise<string | null> => '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();
|
|
});
|
|
});
|