Files
inkling/tests/unit/exportFormat.test.ts
th-kim0823 2b5ba8a50e fix(sync): manifest.exported_at 제거 — no-op push 회피
dogfood: 노트 변경이 0건이어도 자동 sync 가 매번 commit + push 를 생성.
원인은 manifest.json 의 exported_at timestamp 가 매 export 마다 갱신되어
git diff 가 항상 1줄 발생.

해결: composeManifest 의 exportedAt 입력 제거 + 출력 JSON 에서 필드 삭제.
이 필드는 ImportService 가 read 하지 않고 UI 표시도 없는 cosmetic 정보였음.
이제 노트 변경 있을 때만 commit/push 가 일어난다.

회귀 테스트: 같은 input 으로 두 번 호출 시 stable 출력 invariant 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:12:08 +09:00

252 lines
9.1 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 (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);
});
});