275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
composeMarkdown,
|
|
type ExportNote
|
|
} from '@main/services/exportFormat.js';
|
|
import { parseExportNote } from '@main/services/importFormat.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('parseExportNote — round-trip with composeMarkdown', () => {
|
|
it('round-trips the base note', () => {
|
|
const md = composeMarkdown(baseNote);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.id).toBe(baseNote.id);
|
|
expect(parsed.createdAt).toBe(baseNote.createdAt);
|
|
expect(parsed.updatedAt).toBe(baseNote.updatedAt);
|
|
expect(parsed.rawText).toBe(baseNote.rawText);
|
|
expect(parsed.aiTitle).toBe(baseNote.aiTitle);
|
|
expect(parsed.aiSummary).toBe(baseNote.aiSummary);
|
|
expect(parsed.aiProvider).toBe(baseNote.aiProvider);
|
|
expect(parsed.aiGeneratedAt).toBe(baseNote.aiGeneratedAt);
|
|
expect(parsed.titleEditedByUser).toBe(false);
|
|
expect(parsed.summaryEditedByUser).toBe(false);
|
|
expect(parsed.tags).toEqual([
|
|
{ name: 'pr', source: 'ai' },
|
|
{ name: 'review', source: 'user' }
|
|
]);
|
|
expect(parsed.images).toEqual([]);
|
|
expect(parsed.exportVersion).toBe(1);
|
|
});
|
|
|
|
it('round-trips a note with media', () => {
|
|
const note: ExportNote = {
|
|
...baseNote,
|
|
media: [
|
|
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 },
|
|
{ rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 }
|
|
]
|
|
};
|
|
const md = composeMarkdown(note);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.images).toEqual([
|
|
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 },
|
|
{ rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 }
|
|
]);
|
|
expect(parsed.rawText).toBe(note.rawText);
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — frontmatter scalar variants', () => {
|
|
it('parses plain scalar', () => {
|
|
const md = composeMarkdown({ ...baseNote, aiTitle: '주간 회고' });
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.aiTitle).toBe('주간 회고');
|
|
});
|
|
|
|
it('parses single-quoted with embedded apostrophe (`` `` escape)', () => {
|
|
const note: ExportNote = { ...baseNote, aiTitle: "it's a: title" };
|
|
const md = composeMarkdown(note);
|
|
// Should be emitted as: title: 'it''s a: title'
|
|
expect(md).toContain("title: 'it''s a: title'");
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.aiTitle).toBe("it's a: title");
|
|
});
|
|
|
|
it('parses block scalar `|-` for multiline summary', () => {
|
|
const note: ExportNote = {
|
|
...baseNote,
|
|
aiSummary: 'line1\nline2\nline3'
|
|
};
|
|
const md = composeMarkdown(note);
|
|
expect(md).toContain('summary: |-');
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.aiSummary).toBe('line1\nline2\nline3');
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — list parsing', () => {
|
|
it('parses tags inline flow', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
tags: [
|
|
{ name: 'foo', source: 'ai' },
|
|
{ name: 'bar baz', source: 'user' }
|
|
]
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.tags).toEqual([
|
|
{ name: 'foo', source: 'ai' },
|
|
{ name: 'bar baz', source: 'user' }
|
|
]);
|
|
});
|
|
|
|
it('parses images list with mime + bytes', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 }]
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.images).toEqual([
|
|
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — body extraction', () => {
|
|
it('extracts rawText with summary present', () => {
|
|
const md = composeMarkdown({ ...baseNote, rawText: '본문\n두 번째 줄' });
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.rawText).toBe('본문\n두 번째 줄');
|
|
});
|
|
|
|
it('extracts rawText with summary absent', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
aiSummary: null,
|
|
rawText: '요약 없는 본문'
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.rawText).toBe('요약 없는 본문');
|
|
});
|
|
|
|
it('extracts rawText with no images', () => {
|
|
const md = composeMarkdown({ ...baseNote, rawText: '이미지 없음', media: [] });
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.rawText).toBe('이미지 없음');
|
|
});
|
|
|
|
it('preserves `>` mid-line in rawText (not parsed as blockquote)', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
rawText: '값 a > b 라는 부등호'
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.rawText).toBe('값 a > b 라는 부등호');
|
|
});
|
|
|
|
it('preserves `# ` mid-line in rawText (not parsed as heading)', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
rawText: '예시: see issue #1 어쩌고 # 가운데 해시'
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.rawText).toBe('예시: see issue #1 어쩌고 # 가운데 해시');
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — provenance', () => {
|
|
it('recovers titleEditedByUser from title_source: user', () => {
|
|
const md = composeMarkdown({ ...baseNote, titleEditedByUser: true });
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.titleEditedByUser).toBe(true);
|
|
});
|
|
|
|
it('recovers summaryEditedByUser from summary_source: user', () => {
|
|
const md = composeMarkdown({ ...baseNote, summaryEditedByUser: true });
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.summaryEditedByUser).toBe(true);
|
|
});
|
|
|
|
it('exposes exportVersion = 1', () => {
|
|
const md = composeMarkdown(baseNote);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.exportVersion).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => {
|
|
it('round-trips status=active (default)', () => {
|
|
const md = composeMarkdown(baseNote);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.status).toBe('active');
|
|
expect(parsed.statusChangedAt).toBeNull();
|
|
expect(parsed.moveReason).toBeNull();
|
|
expect(parsed.dueDate).toBeNull();
|
|
expect(parsed.dueDateEditedByUser).toBe(false);
|
|
});
|
|
|
|
it('round-trips status=archived with statusChangedAt and moveReason', () => {
|
|
const note: ExportNote = {
|
|
...baseNote,
|
|
status: 'archived',
|
|
statusChangedAt: '2026-05-01T10:00:00Z',
|
|
moveReason: 'project done'
|
|
};
|
|
const md = composeMarkdown(note);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.status).toBe('archived');
|
|
expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z');
|
|
expect(parsed.moveReason).toBe('project done');
|
|
});
|
|
|
|
it('round-trips dueDate with dueDateEditedByUser=true', () => {
|
|
const note: ExportNote = {
|
|
...baseNote,
|
|
dueDate: '2026-06-15',
|
|
dueDateEditedByUser: true
|
|
};
|
|
const md = composeMarkdown(note);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.dueDate).toBe('2026-06-15');
|
|
expect(parsed.dueDateEditedByUser).toBe(true);
|
|
});
|
|
|
|
it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => {
|
|
const note: ExportNote = {
|
|
...baseNote,
|
|
dueDate: '2026-07-01',
|
|
dueDateEditedByUser: false
|
|
};
|
|
const md = composeMarkdown(note);
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.dueDate).toBe('2026-07-01');
|
|
expect(parsed.dueDateEditedByUser).toBe(false);
|
|
});
|
|
|
|
it('defaults to status=active for older exports without status field', () => {
|
|
// Simulate a pre-Cut E export that has no status line
|
|
const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`;
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.status).toBe('active');
|
|
expect(parsed.dueDate).toBeNull();
|
|
expect(parsed.moveReason).toBeNull();
|
|
expect(parsed.dueDateEditedByUser).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseExportNote — edge cases', () => {
|
|
it('preserves user_intent when present', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
userIntent: '팀에서 회고 양식 통일',
|
|
intentPromptedAt: '2026-04-25T14:24:02.000Z'
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.userIntent).toBe('팀에서 회고 양식 통일');
|
|
expect(parsed.intentPromptedAt).toBe('2026-04-25T14:24:02.000Z');
|
|
});
|
|
|
|
it('returns null aiTitle / aiSummary when omitted', () => {
|
|
const md = composeMarkdown({
|
|
...baseNote,
|
|
aiTitle: null,
|
|
aiSummary: null
|
|
});
|
|
const parsed = parseExportNote(md);
|
|
expect(parsed.aiTitle).toBeNull();
|
|
expect(parsed.aiSummary).toBeNull();
|
|
});
|
|
|
|
it('throws when input lacks frontmatter delimiter', () => {
|
|
expect(() => parseExportNote('hello world')).toThrow();
|
|
});
|
|
});
|