Files
inkling/tests/unit/NoteCard.test.tsx
th-kim0823 4c39a38ed5 feat(notes): notebook_id 필드 + create/upsert 시 default notebook 보장
Note.notebookId 필드 추가(required), NoteRepository.create/importNote/upsertFromSync INSERT 에
notebook_id 컬럼 포함 — 미지정 시 getDefaultNotebookId() 로 가장 오래된 notebook 자동 할당.
hydrate 에 notebookId 반환 추가. 관련 test fixture 5곳 notebookId 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:06:44 +09:00

193 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import type { Note } from '@shared/types';
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
mockOpenMedia: vi.fn(async () => ({ ok: true })),
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({
recommended: 'archived' as const,
rationale: 'stub'
})),
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
deleteNote: vi.fn(),
restoreNote: vi.fn(),
permanentDeleteNote: vi.fn(),
updateAiFields: vi.fn(),
setDueDate: vi.fn(),
setIntent: vi.fn(),
dismissIntent: vi.fn(),
setStatus: mockSetStatus,
classifyStatus: mockClassify,
updateRawText: mockUpdateRawText,
listRevisions: vi.fn(async () => []),
restoreRevision: vi.fn(async () => ({ ok: true as const }))
}
}));
const mockRefreshMeta = vi.fn();
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
)
}));
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
const baseNote: Note = {
id: 'n1',
rawText: 'test',
aiTitle: 'T',
aiSummary: 'S',
aiStatus: 'done',
aiError: null,
aiProvider: null,
aiGeneratedAt: '2026-05-09T00:00:00Z',
titleEditedByUser: false,
summaryEditedByUser: false,
userIntent: null,
intentPromptedAt: '2026-05-09T00:00:00Z',
dueDate: null,
dueDateEditedByUser: false,
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
status: 'active',
statusChangedAt: null,
moveReason: null,
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-09T00:00:00Z',
tags: [],
media: [
{ id: 'm1', kind: 'image', relPath: 'media/n1/img1.png', mime: 'image/png', bytes: 100 },
{ id: 'm2', kind: 'image', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg', bytes: 200 }
],
notebookId: 'nb-default'
};
describe('NoteCard — image rendering', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders <img> for each media item', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const imgs = screen.getAllByRole('presentation');
expect(imgs).toHaveLength(2);
expect(imgs[0]?.getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
expect(imgs[1]?.getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
});
it('clicking <img> calls inboxApi.openMedia', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const first = screen.getAllByRole('presentation')[0];
if (first === undefined) throw new Error('expected at least one img');
fireEvent.click(first);
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
});
});
describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('ai_status=disabled: title fallback to raw_text first line, hide summary/tags', () => {
const disabledNote: Note = {
...baseNote,
aiStatus: 'disabled',
aiTitle: null,
aiSummary: 'should-not-show',
tags: [{ name: 't1', source: 'user' }],
rawText: '첫 줄 본문\n둘째 줄 본문'
};
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
expect(screen.queryByText('should-not-show')).toBeNull();
expect(screen.queryByText('t1')).toBeNull();
});
it('ai_status=disabled: empty raw → "(빈 메모)" fallback', () => {
const disabledNote: Note = {
...baseNote,
aiStatus: 'disabled',
aiTitle: null,
rawText: ''
};
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
expect(screen.getByText('(빈 메모)')).toBeInTheDocument();
});
});
describe('NoteCard — 이동 버튼', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('이동 클릭 → MoveStatusModal 열림', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
});
it('Modal 내부 "완료" 버튼 → setStatus 호출 + onUpdated + onDeleted + refreshMeta', async () => {
const onUpdated = vi.fn();
const onDeleted = vi.fn();
render(
<NoteCard
note={baseNote}
onUpdated={onUpdated}
onDeleted={onDeleted}
mode="inbox"
/>
);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
expect(onDeleted).toHaveBeenCalled();
expect(mockRefreshMeta).toHaveBeenCalled();
});
});
});
describe('NoteCard — raw_text editing', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
// 원문 펼침
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
// 편집 진입
fireEvent.click(screen.getByRole('button', { name: '편집' }));
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: 'new' } });
fireEvent.click(screen.getByRole('button', { name: '저장' }));
await waitFor(() => {
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
});
await waitFor(() => {
expect(onUpdated).toHaveBeenCalled();
});
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
expect(last.rawText).toBe('new');
});
});