chore(release): v0.3.8 — UX hole 일괄 hotfix 8건

전수 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>
This commit is contained in:
th-kim0823
2026-05-11 16:50:10 +09:00
parent 4216d42d7c
commit 014c06e1f0
13 changed files with 342 additions and 76 deletions

View File

@@ -137,6 +137,42 @@ describe('MoveStatusModal', () => {
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
});
it('Escape key → onClose 호출', () => {
const onClose = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={onClose}
onMoved={vi.fn()}
/>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('overlay 클릭 → onClose, modal body 클릭 → 무반응', () => {
const onClose = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={onClose}
onMoved={vi.fn()}
/>
);
// body 클릭 (textarea) → onClose 호출 안 됨
fireEvent.click(screen.getByRole('textbox'));
expect(onClose).not.toHaveBeenCalled();
// overlay (dialog) 클릭 → onClose
fireEvent.click(screen.getByRole('dialog', { name: '이동' }));
expect(onClose).toHaveBeenCalled();
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(

View File

@@ -83,6 +83,20 @@ describe('inbox:set-status IPC', () => {
expect(r.reason).toBe('invalid status');
expect(mockSetStatus).not.toHaveBeenCalled();
});
it('emits note:updated to renderer after setStatus (v0.3.8 push-based)', async () => {
const send = vi.fn();
const win = { webContents: { send }, isDestroyed: () => false } as never;
const deps = makeDeps();
deps.getInboxWindow = () => win;
const updatedNote = { id: 'n1', status: 'completed' };
mockFindById.mockReturnValue(updatedNote);
registerInboxApi(deps);
const handler = handlers['inbox:set-status'];
if (handler === undefined) throw new Error('handler not registered');
await handler(null, 'n1', 'completed', null);
expect(send).toHaveBeenCalledWith('note:updated', updatedNote);
});
});
describe('ai:classify-status IPC', () => {

View File

@@ -95,6 +95,37 @@ describe('useInbox — trash state (v0.2.3 #4)', () => {
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');