// @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 for each media item', () => { render( {}} 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 calls inboxApi.openMedia', () => { render( {}} 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(); 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(); expect(screen.getByText('(빈 메모)')).toBeInTheDocument(); }); }); describe('NoteCard — 이동 버튼', () => { beforeEach(() => { vi.clearAllMocks(); cleanup(); }); it('이동 클릭 → MoveStatusModal 열림', () => { render( {}} 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( ); 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(); // 원문 펼침 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'); }); });