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 (timestamp-free)', () => { const m = composeManifest({ noteCount: 42, mediaCount: 17 }); const obj = JSON.parse(m); expect(obj.inkling_export_version).toBe(1); expect(obj.note_count).toBe(42); expect(obj.media_count).toBe(17); // exported_at 필드 제거 — sync git history noise 방지. expect(obj.exported_at).toBeUndefined(); }); it('두 번 호출 결과 stable (sync no-op invariant — 같은 input 이면 git diff 0)', () => { const a = composeManifest({ noteCount: 5, mediaCount: 2 }); const b = composeManifest({ noteCount: 5, mediaCount: 2 }); expect(a).toBe(b); }); });