Files
inkling/tests/unit/exportFormat.test.ts

246 lines
8.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
slugifyTitle,
composeFilename,
composeFrontmatter,
composeMarkdown,
composeIndexJsonl,
composeManifest,
type ExportNote
} from '@main/services/exportFormat.js';
const baseNote: ExportNote = {
id: '014a3b9c-1234-7890-abcd-000000000001',
createdAt: '2026-04-25T14:23:11.000Z',
updatedAt: '2026-04-25T14:24:02.000Z',
rawText: '회고 메모 본문',
aiTitle: '주간 회고 PR 리뷰',
aiSummary: '회고 양식 통일을 위한 메모.',
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: 'local-ollama/gemma4:e4b',
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
userIntent: null,
intentPromptedAt: null,
status: 'active',
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false,
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
media: []
};
describe('slugifyTitle', () => {
it('converts spaces to single hyphen, preserves Korean', () => {
expect(slugifyTitle('주간 회고 PR 리뷰')).toBe('주간-회고-PR-리뷰');
});
it('returns "untitled" for null', () => {
expect(slugifyTitle(null)).toBe('untitled');
});
it('returns "untitled" for empty string', () => {
expect(slugifyTitle('')).toBe('untitled');
});
it('returns "untitled" for whitespace-only', () => {
expect(slugifyTitle(' ')).toBe('untitled');
});
it('strips filesystem-forbidden chars', () => {
expect(slugifyTitle('foo/bar:baz*qux"<>|?\\')).toBe('foobarbazqux');
});
it('collapses multiple whitespace to single hyphen', () => {
expect(slugifyTitle('a b c')).toBe('a-b-c');
});
it('trims leading/trailing hyphens', () => {
expect(slugifyTitle(' hello ')).toBe('hello');
});
it('truncates to 32 codepoints (Korean counted properly)', () => {
const long = '가'.repeat(50);
expect([...slugifyTitle(long)].length).toBeLessThanOrEqual(32);
});
});
describe('composeFilename', () => {
it('combines date prefix + id8 + slug + .md', () => {
expect(composeFilename({
id: '014a3b9c-1234-7890-abcd-000000000001',
createdAt: '2026-04-25T14:23:11.000Z',
aiTitle: '주간 회고'
})).toBe('2026-04-25-014a3b9c-주간-회고.md');
});
it('uses untitled slug for null title', () => {
expect(composeFilename({
id: '01234567-aaaa-bbbb-cccc-000000000000',
createdAt: '2026-04-25T14:23:11.000Z',
aiTitle: null
})).toBe('2026-04-25-01234567-untitled.md');
});
});
describe('composeFrontmatter', () => {
it('produces frontmatter with delimiters and inkling_export_version=1', () => {
const fm = composeFrontmatter(baseNote);
expect(fm.startsWith('---\n')).toBe(true);
expect(fm.trimEnd().endsWith('---')).toBe(true);
expect(fm).toContain('inkling_export_version: 1');
});
it('includes title and source=ai for non-edited title', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).toContain('title: 주간 회고 PR 리뷰');
expect(fm).toContain('title_source: ai');
});
it('marks source=user when edited', () => {
const fm = composeFrontmatter({ ...baseNote, titleEditedByUser: true });
expect(fm).toContain('title_source: user');
});
it('omits null fields', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).not.toContain('user_intent:');
expect(fm).not.toContain('intent_prompted_at:');
});
it('uses block scalar |- for multiline summary', () => {
const fm = composeFrontmatter({ ...baseNote, aiSummary: 'line1\nline2' });
expect(fm).toContain('summary: |-');
expect(fm).toContain(' line1');
expect(fm).toContain(' line2');
});
it('single-quotes title containing colon', () => {
const fm = composeFrontmatter({ ...baseNote, aiTitle: 'a: b' });
expect(fm).toContain("title: 'a: b'");
});
it('emits tags array with inline flow style', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).toContain('tags:');
expect(fm).toContain('- { name: pr, source: ai }');
expect(fm).toContain('- { name: review, source: user }');
});
it('omits tags section when empty', () => {
const fm = composeFrontmatter({ ...baseNote, tags: [] });
expect(fm).not.toContain('tags:');
});
it('emits images array when media present', () => {
const fm = composeFrontmatter({
...baseNote,
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 }]
});
expect(fm).toContain('images:');
expect(fm).toContain('rel: media/014a3b9c__1.png');
expect(fm).toContain('mime: image/png');
expect(fm).toContain('bytes: 1234');
});
it('always emits status: active for a default note', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).toContain('status: active');
});
it('emits due_date and due_date_source together when dueDate present', () => {
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true });
expect(fm).toContain('due_date: 2026-06-01');
expect(fm).toContain('due_date_source: user');
});
it('emits due_date_source: ai when dueDateEditedByUser is false', () => {
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false });
expect(fm).toContain('due_date: 2026-06-01');
expect(fm).toContain('due_date_source: ai');
});
it('omits due_date and due_date_source when dueDate is null', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).not.toContain('due_date:');
expect(fm).not.toContain('due_date_source:');
});
it('emits move_reason when present', () => {
const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' });
expect(fm).toContain('status: archived');
expect(fm).toContain('move_reason: done for now');
});
it('emits status_changed_at when present', () => {
const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' });
expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z');
});
it('status/due_date/move_reason fields appear before images: in frontmatter', () => {
const fm = composeFrontmatter({
...baseNote,
dueDate: '2026-06-01',
dueDateEditedByUser: false,
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }]
});
const statusPos = fm.indexOf('status:');
const imagesPos = fm.indexOf('images:');
expect(statusPos).toBeGreaterThan(-1);
expect(imagesPos).toBeGreaterThan(-1);
expect(statusPos).toBeLessThan(imagesPos);
});
});
describe('composeMarkdown', () => {
it('includes h1 with title, blockquote summary, body', () => {
const md = composeMarkdown(baseNote);
expect(md).toContain('# 주간 회고 PR 리뷰');
expect(md).toContain('> 회고 양식 통일을 위한 메모.');
expect(md).toContain('회고 메모 본문');
});
it('uses fallback heading when title null', () => {
const md = composeMarkdown({ ...baseNote, aiTitle: null });
expect(md).toContain('# (제목 없음)');
});
it('omits blockquote when summary null', () => {
const md = composeMarkdown({ ...baseNote, aiSummary: null });
expect(md).not.toContain('>');
});
it('appends image refs with id8__n.ext naming', () => {
const md = composeMarkdown({
...baseNote,
media: [
{ rel: 'media/old/1.png', mime: 'image/png', bytes: 100 },
{ rel: 'media/old/2.jpg', mime: 'image/jpeg', bytes: 200 }
]
});
expect(md).toContain('![](media/014a3b9c__1.png)');
expect(md).toContain('![](media/014a3b9c__2.jpg)');
});
});
describe('composeIndexJsonl', () => {
it('emits one JSON line per entry with trailing newline', () => {
const out = composeIndexJsonl([
{ note: baseNote, path: 'notes/2026-04-25-014a3b9c-주간-회고.md' }
]);
expect(out.endsWith('\n')).toBe(true);
const lines = out.trimEnd().split('\n');
expect(lines.length).toBe(1);
const obj = JSON.parse(lines[0]!);
expect(obj.id).toBe(baseNote.id);
expect(obj.path).toBe('notes/2026-04-25-014a3b9c-주간-회고.md');
expect(obj.tags).toEqual(['pr', 'review']);
expect(obj.embedding_text).toBe('회고 메모 본문');
});
it('emits two lines for two entries', () => {
const out = composeIndexJsonl([
{ note: baseNote, path: 'notes/a.md' },
{ note: { ...baseNote, id: '02xxxxxx-...', rawText: 'b' }, path: 'notes/b.md' }
]);
expect(out.trimEnd().split('\n').length).toBe(2);
});
});
describe('composeManifest', () => {
it('emits pretty JSON with required fields', () => {
const m = composeManifest({
exportedAt: '2026-04-26T00:00:00.000Z',
noteCount: 42,
mediaCount: 17
});
const obj = JSON.parse(m);
expect(obj.inkling_export_version).toBe(1);
expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z');
expect(obj.note_count).toBe(42);
expect(obj.media_count).toBe(17);
});
});