v0.3.4 까지 누적된 dogfood UX 결함 hotfix. 사용자 직접 보고 3건 (inbox 재진입, 회고 탈출, 이동 modal 중복) + 동반 갭 4건 (count stale, 필터 잔류, 초기 로드 불일치, 배너 컨텍스트 누수). 데이터/마이그레이션 변경 없음 (스키마 v8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
7.7 KiB
TypeScript
216 lines
7.7 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 }
|
|
]
|
|
};
|
|
|
|
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 — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
cleanup();
|
|
});
|
|
|
|
it('이동 ▾ 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
|
|
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
|
|
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
|
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
|
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
|
|
});
|
|
|
|
it('메뉴 항목 클릭 → 즉시 setStatus 호출 (modal 없음)', 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();
|
|
// status 변경 → 현재 view (inbox) 에서 제거되어야 함.
|
|
expect(onDeleted).toHaveBeenCalled();
|
|
// 헤더 탭 count 동기화.
|
|
expect(mockRefreshMeta).toHaveBeenCalled();
|
|
});
|
|
// modal 미존재 검증.
|
|
expect(screen.queryByRole('dialog', { name: '이동' })).toBeNull();
|
|
});
|
|
|
|
it('이동 메뉴 외부 클릭 시 dropdown 닫힘', () => {
|
|
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
|
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
|
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
|
|
fireEvent.mouseDown(document.body);
|
|
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
|
|
});
|
|
|
|
it('이동 메뉴 열린 상태에서 Escape → dropdown 닫힘', () => {
|
|
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
|
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
|
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|