전수 audit 후 핵심 root fix 3 + edge cases 5: ROOT - inbox:set-status IPC 가 pushNoteUpdated emit (이전엔 stale → 호출처별 refreshMeta 필요) - upsertNote 가 current view status 인식 (이전엔 잘못된 status 노트 잔류) - store async 함수 try/catch (이전엔 IPC fail 시 무한 loading) EDGE - restoreNote 가 status='active' 도 갱신 - upsertNote trash 판정 deletedAt → status='trashed' - Modal Escape dismiss 통일 (5개 modal) - OnboardingWizard IPC fail fallback (try/catch + skip) - MoveStatusModal overlay 클릭 close Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
137 lines
6.9 KiB
TypeScript
137 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { Note } from '@shared/types';
|
|
|
|
const mockApi = {
|
|
listNotes: vi.fn(async () => [] as Note[]),
|
|
listTrash: vi.fn(async () => [] as Note[]),
|
|
getTrashCount: vi.fn(async () => 0),
|
|
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
|
getPendingCount: vi.fn(async () => 0),
|
|
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
|
getTodayCount: vi.fn(async () => 0),
|
|
restoreNote: vi.fn(async () => {}),
|
|
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
|
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
|
deleteNote: vi.fn(async () => {}),
|
|
onNoteUpdated: vi.fn(() => () => {}),
|
|
updateAiFields: vi.fn(async () => {}),
|
|
setDueDate: vi.fn(async () => {}),
|
|
setIntent: vi.fn(async () => {}),
|
|
dismissIntent: vi.fn(async () => {})
|
|
};
|
|
|
|
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
|
|
|
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
|
id, rawText: 'x',
|
|
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
|
aiProvider: null, aiGeneratedAt: null,
|
|
titleEditedByUser: false, summaryEditedByUser: false,
|
|
userIntent: null, intentPromptedAt: null,
|
|
dueDate: null, dueDateEditedByUser: false,
|
|
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
|
status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null,
|
|
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
|
tags: [], media: []
|
|
});
|
|
|
|
describe('useInbox — trash state (v0.2.3 #4)', () => {
|
|
beforeEach(async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.setState({
|
|
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
|
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
|
ollamaStatus: { ok: true },
|
|
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
|
|
});
|
|
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
|
});
|
|
|
|
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
|
|
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
await useInbox.getState().toggleShowTrash();
|
|
expect(useInbox.getState().showTrash).toBe(true);
|
|
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
|
expect(mockApi.listTrash).toHaveBeenCalled();
|
|
await useInbox.getState().toggleShowTrash();
|
|
expect(useInbox.getState().showTrash).toBe(false);
|
|
});
|
|
|
|
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
|
expect(useInbox.getState().notes).toHaveLength(0);
|
|
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
|
});
|
|
|
|
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.getState().upsertNote(noteStub('a'));
|
|
expect(useInbox.getState().notes).toHaveLength(1);
|
|
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
|
expect(useInbox.getState().notes).toHaveLength(0);
|
|
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
|
});
|
|
|
|
it('restoreNote calls api + moves note from trashNotes to notes (낙관적 갱신)', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
|
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
|
await useInbox.getState().restoreNote('a');
|
|
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
|
|
// main 은 restore 시 pushNoteUpdated 안 보냄 — store 자가 갱신 검증
|
|
expect(useInbox.getState().trashNotes).toHaveLength(0);
|
|
expect(useInbox.getState().notes).toHaveLength(1);
|
|
expect(useInbox.getState().notes[0]!.deletedAt).toBeNull();
|
|
});
|
|
|
|
it('upsertNote with showTrash=false preserves server trashCount (regression I1)', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
// server 가 trashCount=5 알려줬는데 trashNotes 는 미로드 (showTrash=false 기본)
|
|
useInbox.setState({ trashCount: 5, trashNotes: [] });
|
|
useInbox.getState().upsertNote(noteStub('active-1'));
|
|
expect(useInbox.getState().trashCount).toBe(5); // server 값 보존
|
|
expect(useInbox.getState().notes).toHaveLength(1);
|
|
});
|
|
|
|
it('view-aware upsertNote — inbox view 에서 status=completed 노트 push → notes 에서 제거', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
// view='inbox' (default), active 노트 upsert
|
|
useInbox.getState().upsertNote(noteStub('a'));
|
|
expect(useInbox.getState().notes).toHaveLength(1);
|
|
// 같은 노트가 completed 로 status 변경 → 현재 view 와 안 맞으므로 notes 에서 제거
|
|
const completed: Note = { ...noteStub('a'), status: 'completed' };
|
|
useInbox.getState().upsertNote(completed);
|
|
expect(useInbox.getState().notes).toHaveLength(0);
|
|
});
|
|
|
|
it('view-aware upsertNote — completed view 에서 active 노트 push → notes 에 추가 안 됨', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.setState({ view: 'completed' });
|
|
useInbox.getState().upsertNote(noteStub('a')); // status='active'
|
|
expect(useInbox.getState().notes).toHaveLength(0);
|
|
// completed status 면 추가
|
|
const completed: Note = { ...noteStub('a'), status: 'completed' };
|
|
useInbox.getState().upsertNote(completed);
|
|
expect(useInbox.getState().notes).toHaveLength(1);
|
|
});
|
|
|
|
it('view-aware upsertNote — searchResults 가 있을 때 status mismatch → searchResults 에서 제거', async () => {
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
// 이전 test 가 view='completed' 로 설정한 채 끝났을 수 있어 명시적 초기화.
|
|
useInbox.setState({ view: 'inbox', searchResults: [noteStub('a')] });
|
|
const completed: Note = { ...noteStub('a'), status: 'completed' };
|
|
useInbox.getState().upsertNote(completed);
|
|
expect(useInbox.getState().searchResults).toHaveLength(0);
|
|
});
|
|
|
|
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
|
|
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
|
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
|
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
|
await useInbox.getState().emptyTrash();
|
|
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
|
});
|
|
});
|