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>
252 lines
9.1 KiB
TypeScript
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('');
|
|
expect(md).toContain('');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|