From 862da4f15a0950ea51515d12cc785131cc022bc5 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 11:10:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(store):=20selectedNotebookId=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20list/counts=20=EC=9E=90=EB=8F=99=20refr?= =?UTF-8?q?esh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectNotebook: set 후 loadByView(view) + refreshMeta() 자동 호출 (inbox/completed/trash view 한정 list 갱신, 모든 view 에서 counts 갱신) - loadInitial / loadByView / refreshMeta: selectedNotebookId 를 listByStatus / countsByStatus 에 notebookId 옵션으로 전달 - tests: selectNotebook→loadByView+refreshMeta 호출 검증, notebookId 전달 검증, review view 에선 listByStatus 미호출 검증 (4케이스 추가) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/store.ts | 17 +++++++++--- tests/unit/store.notebook.test.ts | 46 +++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index eb12e60..0569ef8 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -136,10 +136,11 @@ export const useInbox = create((set, get) => ({ // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset. set({ loading: true }); try { + const notebookId = get().selectedNotebookId ?? undefined; const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ // inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보. // listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확. - inboxApi.listByStatus('active', { limit: 50 }), + inboxApi.listByStatus('active', { limit: 50, notebookId }), inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), @@ -148,7 +149,7 @@ export const useInbox = create((set, get) => ({ inboxApi.listExpired(), inboxApi.getFailedCount(), inboxApi.listRecallCandidate(), - inboxApi.countsByStatus(), + inboxApi.countsByStatus({ notebookId }), inboxApi.getSettings() ]); set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false }); @@ -161,6 +162,7 @@ export const useInbox = create((set, get) => ({ }, async refreshMeta() { try { + const notebookId = get().selectedNotebookId ?? undefined; const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), @@ -170,7 +172,7 @@ export const useInbox = create((set, get) => ({ inboxApi.listExpired(), inboxApi.getFailedCount(), inboxApi.listRecallCandidate(), - inboxApi.countsByStatus(), + inboxApi.countsByStatus({ notebookId }), inboxApi.getSettings() ]); set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true }); @@ -287,8 +289,9 @@ export const useInbox = create((set, get) => ({ // fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale). const status = view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view; + const notebookId = get().selectedNotebookId ?? undefined; try { - const notes = await inboxApi.listByStatus(status, { limit: 200 }); + const notes = await inboxApi.listByStatus(status, { limit: 200, notebookId }); if (view === 'trash') { set({ trashNotes: notes, trashCount: notes.length }); } else { @@ -432,6 +435,12 @@ export const useInbox = create((set, get) => ({ }, selectNotebook(id) { set({ selectedNotebookId: id }); + // v0.4 Task 19 — notebook 전환 시 list + counts 자동 갱신. + const v = get().view; + if (v === 'inbox' || v === 'completed' || v === 'trash') { + void get().loadByView(v); + } + void get().refreshMeta(); }, async createNotebook(name, color) { const r = await notebookApi.create({ name, color }); diff --git a/tests/unit/store.notebook.test.ts b/tests/unit/store.notebook.test.ts index 0903b1b..16e312a 100644 --- a/tests/unit/store.notebook.test.ts +++ b/tests/unit/store.notebook.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +const { mockListByStatus, mockCountsByStatus } = vi.hoisted(() => ({ + mockListByStatus: vi.fn(async () => []), + mockCountsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })) +})); + vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { listNotes: vi.fn(async () => []), @@ -12,9 +17,9 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ listExpired: vi.fn(async () => []), getFailedCount: vi.fn(async () => 0), listRecallCandidate: vi.fn(async () => null), - countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + countsByStatus: mockCountsByStatus, getSettings: vi.fn(async () => ({ ai_enabled: true })), - listByStatus: vi.fn(async () => []) + listByStatus: mockListByStatus }, notebookApi: { list: vi.fn(async () => [ @@ -33,7 +38,9 @@ import { useInbox } from '../../src/renderer/inbox/store.js'; describe('store notebooks', () => { beforeEach(() => { - useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240 } as never); + mockListByStatus.mockClear(); + mockCountsByStatus.mockClear(); + useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, view: 'inbox' } as never); }); it('loadNotebooks 가 notebookApi.list 결과 반영', async () => { @@ -74,4 +81,37 @@ describe('store notebooks', () => { expect(useInbox.getState().notebooks.map((n) => n.id)).toEqual(['nb-1']); expect(useInbox.getState().selectedNotebookId).toBe('nb-1'); }); + + it('selectNotebook 가 loadByView + refreshMeta 호출', async () => { + // inbox view 에서 notebook 전환 → listByStatus + countsByStatus 호출됨. + useInbox.setState({ view: 'inbox', selectedNotebookId: null } as never); + useInbox.getState().selectNotebook('nb-X'); + expect(useInbox.getState().selectedNotebookId).toBe('nb-X'); + // 비동기 완료 대기 + await new Promise((r) => setTimeout(r, 0)); + expect(mockListByStatus).toHaveBeenCalled(); + expect(mockCountsByStatus).toHaveBeenCalled(); + }); + + it('loadByView 가 selectedNotebookId 를 inboxApi.listByStatus 에 전달', async () => { + useInbox.setState({ selectedNotebookId: 'nb-X', view: 'inbox' } as never); + await useInbox.getState().loadByView('inbox'); + expect(mockListByStatus).toHaveBeenCalledWith('active', expect.objectContaining({ notebookId: 'nb-X' })); + }); + + it('refreshMeta 가 selectedNotebookId 를 inboxApi.countsByStatus 에 전달', async () => { + useInbox.setState({ selectedNotebookId: 'nb-X' } as never); + await useInbox.getState().refreshMeta(); + expect(mockCountsByStatus).toHaveBeenCalledWith(expect.objectContaining({ notebookId: 'nb-X' })); + }); + + it('selectNotebook: review view 에선 loadByView 를 호출하지 않음', async () => { + useInbox.setState({ view: 'review-weekly', selectedNotebookId: null } as never); + useInbox.getState().selectNotebook('nb-X'); + await new Promise((r) => setTimeout(r, 0)); + // listByStatus 는 호출되지 않아야 함 (review view 는 list 대상 아님) + expect(mockListByStatus).not.toHaveBeenCalled(); + // countsByStatus 는 refreshMeta 경로로 호출됨 + expect(mockCountsByStatus).toHaveBeenCalled(); + }); });