From df85b88424511d591cc027fb9e005ddc999dd55c Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 21:43:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(trash):=20T13=20review=20=E2=80=94=20trashC?= =?UTF-8?q?ount=20clobber=20guard=20+=20restoreNote=20test=20(review=20I1+?= =?UTF-8?q?I2+M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I1: trashCount 가 upsertNote 안에서 항상 trashNotes.length 로 덮어써져 server 값 (refreshMeta) 손상. showTrash=true (trashNotes cache-loaded) 일 때만 local recompute. - I2: restoreNote 의 "fallback for missed event" 주석 부정확 — main 은 trash/restore 시 pushNoteUpdated 안 보냄. 자가 갱신이 primary mechanism. 주석 정정. - M5: restoreNote 테스트가 IPC 호출만 검증, 노트 이동 미검증. trashNotes → notes 라우팅 + deletedAt=null 어설션 추가. + I1 회귀 가드 테스트 신규. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/store.ts | 26 ++++++++++++++++++++++---- tests/unit/store.trash.test.ts | 16 +++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index dbfcd2c..2c5e359 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -66,6 +66,9 @@ export const useInbox = create((set, get) => ({ set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount }); }, upsertNote(note) { + // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 + // 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존. + const showTrash = get().showTrash; if (note.deletedAt !== null) { // trash 노트: notes 에서 제거 + trashNotes 에 upsert const cleanNotes = get().notes.filter((n) => n.id !== note.id); @@ -73,7 +76,11 @@ export const useInbox = create((set, get) => ({ const nextTrash = get().trashNotes.slice(); if (ti >= 0) nextTrash[ti] = note; else nextTrash.unshift(note); - set({ notes: cleanNotes, trashNotes: nextTrash, trashCount: nextTrash.length }); + set({ + notes: cleanNotes, + trashNotes: nextTrash, + ...(showTrash ? { trashCount: nextTrash.length } : {}) + }); } else { // active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함) const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id); @@ -81,13 +88,22 @@ export const useInbox = create((set, get) => ({ const nextNotes = get().notes.slice(); if (i >= 0) nextNotes[i] = note; else nextNotes.unshift(note); - set({ notes: nextNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length }); + set({ + notes: nextNotes, + trashNotes: cleanTrash, + ...(showTrash ? { trashCount: cleanTrash.length } : {}) + }); } }, removeNote(id) { const cleanNotes = get().notes.filter((n) => n.id !== id); const cleanTrash = get().trashNotes.filter((n) => n.id !== id); - set({ notes: cleanNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length }); + const showTrash = get().showTrash; + set({ + notes: cleanNotes, + trashNotes: cleanTrash, + ...(showTrash ? { trashCount: cleanTrash.length } : {}) + }); }, setTagFilter(tag) { set({ tagFilter: tag }); @@ -103,7 +119,9 @@ export const useInbox = create((set, get) => ({ }, async restoreNote(id) { await inboxApi.restoreNote(id); - // onNoteUpdated 이벤트 미수신 케이스 대비 (renderer 자가 갱신) + // 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음 + // (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘. + // 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출). const note = get().trashNotes.find((n) => n.id === id); if (note) { get().upsertNote({ ...note, deletedAt: null }); diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts index b6cfedf..fff4a9a 100644 --- a/tests/unit/store.trash.test.ts +++ b/tests/unit/store.trash.test.ts @@ -73,11 +73,25 @@ describe('useInbox — trash state (v0.2.3 #4)', () => { expect(useInbox.getState().trashNotes).toHaveLength(1); }); - it('restoreNote calls api', async () => { + 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('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {