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();
+ });
});