From 53a15792660370c3a0ed02527a0ca0715442a958 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Fri, 15 May 2026 10:44:52 +0900
Subject: [PATCH] =?UTF-8?q?feat(promotion):=20store=20promotionCandidates?=
=?UTF-8?q?=20+=20accept/snooze/dismiss=20+=20settings=20=EC=98=81?=
=?UTF-8?q?=EC=86=8D=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SettingsService: promotion_dismissed_tags / promotion_snoozed_until_ms / sidebar_visible / sidebar_width 스키마 + getter/setter 추가
- NotebookRepository: getDefault() (created_at ASC LIMIT 1) 헬퍼 추가
- inboxApi: notebookRepo 옵션 dep + 5개 IPC 핸들러 (list-promotion-candidates / get-dismissed-tags / add-dismissed-tag / get-snoozed-until / set-snoozed-until)
- shared/types: PromotionCandidate 인터페이스 + InboxApi 5개 메서드 추가
- preload: 5개 ipcRenderer.invoke 패스스루
- store: promotionCandidates 상태 + loadPromotionCandidates / acceptPromotion / snoozePromotion / dismissPromotion 액션 + toTitleCase helper
- tests: store.promotion.test.ts 신설 (6개 케이스)
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/main/index.ts | 4 +-
src/main/ipc/inboxApi.ts | 28 +++++
src/main/repository/NotebookRepository.ts | 14 +++
src/main/services/SettingsService.ts | 51 +++++++-
src/preload/index.ts | 6 +
src/renderer/inbox/store.ts | 62 +++++++++-
src/shared/types.ts | 14 +++
tests/unit/store.promotion.test.ts | 144 ++++++++++++++++++++++
8 files changed, 320 insertions(+), 3 deletions(-)
create mode 100644 tests/unit/store.promotion.test.ts
diff --git a/src/main/index.ts b/src/main/index.ts
index ac8f2a2..72cdfba 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -187,7 +187,9 @@ app.whenReady().then(async () => {
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir },
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
- enqueue: (id) => worker.enqueue(id)
+ enqueue: (id) => worker.enqueue(id),
+ // v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
+ notebookRepo
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts
index 98c99b4..1eb1d07 100644
--- a/src/main/ipc/inboxApi.ts
+++ b/src/main/ipc/inboxApi.ts
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
const { ipcMain, dialog, shell } = electron;
import { join, normalize, sep } from 'node:path';
import type { NoteRepository } from '../repository/NoteRepository.js';
+import type { NotebookRepository } from '../repository/NotebookRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
@@ -29,6 +30,9 @@ export interface InboxIpcDeps {
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
enqueue?: (noteId: string) => Promise;
+ // v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
+ // 미주입 시 list-promotion-candidates 는 빈 배열 반환 (graceful fallback).
+ notebookRepo?: NotebookRepository;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -280,6 +284,30 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
+ // v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
+ ipcMain.handle('inbox:list-promotion-candidates', () => {
+ if (!deps.notebookRepo) return [];
+ const defaultNb = deps.notebookRepo.getDefault();
+ if (!defaultNb) return [];
+ return deps.repo.findPromotionCandidates(defaultNb.id);
+ });
+
+ ipcMain.handle('inbox:get-promotion-dismissed-tags', () =>
+ deps.settings.getPromotionDismissedTags()
+ );
+
+ ipcMain.handle('inbox:add-promotion-dismissed-tag', (_e, tag: string) =>
+ deps.settings.addPromotionDismissedTag(tag)
+ );
+
+ ipcMain.handle('inbox:get-promotion-snoozed-until', () =>
+ deps.settings.getPromotionSnoozeUntil()
+ );
+
+ ipcMain.handle('inbox:set-promotion-snoozed-until', (_e, ms: number) =>
+ deps.settings.setPromotionSnoozeUntil(ms)
+ );
+
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
diff --git a/src/main/repository/NotebookRepository.ts b/src/main/repository/NotebookRepository.ts
index 036b1bc..eba56cd 100644
--- a/src/main/repository/NotebookRepository.ts
+++ b/src/main/repository/NotebookRepository.ts
@@ -71,6 +71,20 @@ export class NotebookRepository {
return r ? this.hydrate(r) : null;
}
+ /**
+ * v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default.
+ * m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당.
+ */
+ getDefault(): Notebook | null {
+ const r = this.db.prepare(
+ `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
+ (SELECT COUNT(*) FROM notes n
+ WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
+ FROM notebooks nb ORDER BY nb.created_at ASC LIMIT 1`
+ ).get() as Record | undefined;
+ return r ? this.hydrate(r) : null;
+ }
+
/** notes.notebook_id 갱신만 (status 등은 보존). */
moveNote(noteId: string, notebookId: string): void {
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts
index 3b61286..3e164de 100644
--- a/src/main/services/SettingsService.ts
+++ b/src/main/services/SettingsService.ts
@@ -21,7 +21,12 @@ const SettingsSchema = z.object({
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
- vision_cache_at: z.string().optional()
+ vision_cache_at: z.string().optional(),
+ // v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃.
+ promotion_dismissed_tags: z.array(z.string()).optional(),
+ promotion_snoozed_until_ms: z.number().int().optional(),
+ sidebar_visible: z.boolean().optional(),
+ sidebar_width: z.number().int().min(180).max(400).optional()
}).strict();
export type Settings = z.infer;
@@ -155,6 +160,50 @@ export class SettingsService {
await this.persist(next);
}
+ // v0.4 Task 11 — promotion candidate 영속화.
+ async getPromotionDismissedTags(): Promise {
+ const s = await this.load();
+ return s.promotion_dismissed_tags ?? [];
+ }
+
+ async addPromotionDismissedTag(tag: string): Promise {
+ const s = await this.load();
+ const list = new Set(s.promotion_dismissed_tags ?? []);
+ list.add(tag);
+ await this.persist({ ...s, promotion_dismissed_tags: [...list] });
+ }
+
+ async getPromotionSnoozeUntil(): Promise {
+ const s = await this.load();
+ return s.promotion_snoozed_until_ms ?? 0;
+ }
+
+ async setPromotionSnoozeUntil(ms: number): Promise {
+ const s = await this.load();
+ await this.persist({ ...s, promotion_snoozed_until_ms: ms });
+ }
+
+ // v0.4 Task 15 — sidebar 레이아웃 영속화.
+ async getSidebarVisible(): Promise {
+ const s = await this.load();
+ return s.sidebar_visible ?? false;
+ }
+
+ async setSidebarVisible(v: boolean): Promise {
+ const s = await this.load();
+ await this.persist({ ...s, sidebar_visible: v });
+ }
+
+ async getSidebarWidth(): Promise {
+ const s = await this.load();
+ return s.sidebar_width ?? 240;
+ }
+
+ async setSidebarWidth(v: number): Promise {
+ const s = await this.load();
+ await this.persist({ ...s, sidebar_width: v });
+ }
+
private async persist(next: Settings): Promise {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a09fcde..503e92c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -105,6 +105,12 @@ const api: InklingApi = {
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
+ // v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
+ listPromotionCandidates: () => ipcRenderer.invoke('inbox:list-promotion-candidates'),
+ getPromotionDismissedTags: () => ipcRenderer.invoke('inbox:get-promotion-dismissed-tags'),
+ addPromotionDismissedTag: (tag: string) => ipcRenderer.invoke('inbox:add-promotion-dismissed-tag', tag),
+ getPromotionSnoozeUntil: () => ipcRenderer.invoke('inbox:get-promotion-snoozed-until'),
+ setPromotionSnoozeUntil: (ms: number) => ipcRenderer.invoke('inbox:set-promotion-snoozed-until', ms),
},
// v0.4 — notebook CRUD IPC
notebook: {
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
index 8ab3eff..b0c941e 100644
--- a/src/renderer/inbox/store.ts
+++ b/src/renderer/inbox/store.ts
@@ -1,8 +1,12 @@
import { create } from 'zustand';
-import type { Note, Notebook, ReviewAggregate, WeeklyContinuity } from '@shared/types';
+import type { Note, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import { inboxApi, notebookApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
+function toTitleCase(s: string): string {
+ return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+}
+
export { selectFilteredNotes } from './selectFilteredNotes.js';
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
@@ -50,6 +54,8 @@ interface InboxState {
selectedNotebookId: string | null;
sidebarVisible: boolean;
sidebarWidth: number;
+ // v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
+ promotionCandidates: PromotionCandidate[];
loadInitial: () => Promise;
refreshMeta: () => Promise;
upsertNote: (note: Note) => void;
@@ -85,6 +91,11 @@ interface InboxState {
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise;
toggleSidebar: () => void;
+ // v0.4 Task 11 — promotion candidate actions.
+ loadPromotionCandidates: () => Promise;
+ acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise;
+ snoozePromotion: () => Promise;
+ dismissPromotion: (tag: string) => Promise;
}
const emptyContinuity: WeeklyContinuity = {
@@ -119,6 +130,7 @@ export const useInbox = create((set, get) => ({
selectedNotebookId: null,
sidebarVisible: false,
sidebarWidth: 240,
+ promotionCandidates: [],
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
@@ -459,5 +471,53 @@ export const useInbox = create((set, get) => ({
},
toggleSidebar() {
set({ sidebarVisible: !get().sidebarVisible });
+ },
+ // v0.4 Task 11 — promotion candidate actions.
+ async loadPromotionCandidates() {
+ try {
+ const [dismissed, snoozeUntil, raw] = await Promise.all([
+ inboxApi.getPromotionDismissedTags(),
+ inboxApi.getPromotionSnoozeUntil(),
+ inboxApi.listPromotionCandidates()
+ ]);
+ // snoozed_until > now → 빈 배열 (전체 스누즈).
+ if (snoozeUntil > Date.now()) {
+ set({ promotionCandidates: [] });
+ return;
+ }
+ const dismissedSet = new Set(dismissed);
+ const candidates: PromotionCandidate[] = raw
+ .filter((c) => !dismissedSet.has(c.tag))
+ .map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) }));
+ set({ promotionCandidates: candidates });
+ } catch (e) {
+ console.error('[inbox] loadPromotionCandidates failed', e);
+ set({ promotionCandidates: [] });
+ }
+ },
+ async acceptPromotion(tag, customName, color) {
+ const candidate = get().promotionCandidates.find((c) => c.tag === tag);
+ if (!candidate) return;
+ const r = await notebookApi.create({ name: customName, color });
+ if (!r.ok) return;
+ const notebookId = r.notebook.id;
+ await Promise.all(candidate.noteIds.map((noteId) => notebookApi.moveNote(noteId, notebookId)));
+ // state: candidate 제거 + notebooks 갱신 + sidebar 열기 + 새 notebook 선택.
+ set({
+ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag),
+ notebooks: [...get().notebooks, r.notebook],
+ sidebarVisible: true,
+ selectedNotebookId: notebookId
+ });
+ await get().refreshMeta();
+ },
+ async snoozePromotion() {
+ const snoozeUntil = Date.now() + 24 * 60 * 60 * 1000;
+ await inboxApi.setPromotionSnoozeUntil(snoozeUntil);
+ set({ promotionCandidates: [] });
+ },
+ async dismissPromotion(tag) {
+ await inboxApi.addPromotionDismissedTag(tag);
+ set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
}
}));
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 878a312..a5f2aa7 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -96,6 +96,14 @@ export interface Note {
notebookId: string;
}
+// v0.4 Task 11 — tag 기반 notebook 승격 제안 후보.
+// suggestedName 은 renderer 가 toTitleCase(tag) 로 채움 — IPC 응답에는 없음.
+export interface PromotionCandidate {
+ tag: string;
+ noteIds: string[];
+ suggestedName: string;
+}
+
// v0.4 — Notebook: 노트 묶음 단위. noteCount = status='active' 노트 수.
export interface Notebook {
id: string;
@@ -245,6 +253,12 @@ export interface InboxApi {
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
+ // v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
+ listPromotionCandidates(): Promise;
+ getPromotionDismissedTags(): Promise;
+ addPromotionDismissedTag(tag: string): Promise;
+ getPromotionSnoozeUntil(): Promise;
+ setPromotionSnoozeUntil(ms: number): Promise;
}
export interface NotebookApi {
diff --git a/tests/unit/store.promotion.test.ts b/tests/unit/store.promotion.test.ts
new file mode 100644
index 0000000..2670918
--- /dev/null
+++ b/tests/unit/store.promotion.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../src/renderer/inbox/api.js', () => ({
+ inboxApi: {
+ // promotion IPC
+ listPromotionCandidates: vi.fn(async () => []),
+ getPromotionDismissedTags: vi.fn(async () => []),
+ addPromotionDismissedTag: vi.fn(async () => undefined),
+ getPromotionSnoozeUntil: vi.fn(async () => 0),
+ setPromotionSnoozeUntil: vi.fn(async () => undefined),
+ // refreshMeta deps
+ 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),
+ getTrashCount: vi.fn(async () => 0),
+ 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 })),
+ getSettings: vi.fn(async () => ({ ai_enabled: true }))
+ },
+ notebookApi: {
+ list: vi.fn(async () => []),
+ create: vi.fn(async (i: { name: string; color?: string }) => ({
+ ok: true as const,
+ notebook: { id: 'nb-promo', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 }
+ })),
+ moveNote: vi.fn(async () => ({ ok: true as const })),
+ rename: vi.fn(async () => ({ ok: true as const })),
+ setColor: vi.fn(async () => ({ ok: true as const })),
+ delete: vi.fn(async () => ({ ok: true as const }))
+ }
+}));
+
+import { useInbox } from '../../src/renderer/inbox/store.js';
+import { inboxApi } from '../../src/renderer/inbox/api.js';
+import { notebookApi } from '../../src/renderer/inbox/api.js';
+
+type MockInboxApi = {
+ listPromotionCandidates: ReturnType;
+ getPromotionDismissedTags: ReturnType;
+ addPromotionDismissedTag: ReturnType;
+ getPromotionSnoozeUntil: ReturnType;
+ setPromotionSnoozeUntil: ReturnType;
+};
+type MockNotebookApi = {
+ create: ReturnType;
+ moveNote: ReturnType;
+};
+
+const mockInbox = inboxApi as unknown as MockInboxApi;
+const mockNotebook = notebookApi as unknown as MockNotebookApi;
+
+describe('store promotion actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useInbox.setState({ promotionCandidates: [], notebooks: [], sidebarVisible: false, selectedNotebookId: null } as never);
+ });
+
+ it('loadPromotionCandidates: 후보 목록 반환 + suggestedName toTitleCase 변환', async () => {
+ mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
+ mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
+ mockInbox.listPromotionCandidates.mockResolvedValueOnce([
+ { tag: 'machine-learning', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
+ ]);
+ await useInbox.getState().loadPromotionCandidates();
+ const candidates = useInbox.getState().promotionCandidates;
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]!.suggestedName).toBe('Machine Learning');
+ expect(candidates[0]!.noteIds).toEqual(['n1', 'n2', 'n3']);
+ });
+
+ it('loadPromotionCandidates: snooze 유효 시 빈 배열', async () => {
+ mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
+ // 24h 후 만료되는 snooze
+ mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(Date.now() + 24 * 60 * 60 * 1000);
+ mockInbox.listPromotionCandidates.mockResolvedValueOnce([
+ { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
+ ]);
+ await useInbox.getState().loadPromotionCandidates();
+ expect(useInbox.getState().promotionCandidates).toHaveLength(0);
+ });
+
+ it('loadPromotionCandidates: dismissed tag 는 제외', async () => {
+ mockInbox.getPromotionDismissedTags.mockResolvedValueOnce(['work']);
+ mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
+ mockInbox.listPromotionCandidates.mockResolvedValueOnce([
+ { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' },
+ { tag: 'study', noteIds: ['n4', 'n5', 'n6'], suggestedName: '' }
+ ]);
+ await useInbox.getState().loadPromotionCandidates();
+ const candidates = useInbox.getState().promotionCandidates;
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]!.tag).toBe('study');
+ });
+
+ it('dismissPromotion: addPromotionDismissedTag 호출 + state 에서 그 tag 제거', async () => {
+ useInbox.setState({
+ promotionCandidates: [
+ { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' },
+ { tag: 'study', noteIds: ['n4', 'n5'], suggestedName: 'Study' }
+ ]
+ } as never);
+ await useInbox.getState().dismissPromotion('work');
+ expect(mockInbox.addPromotionDismissedTag).toHaveBeenCalledWith('work');
+ const candidates = useInbox.getState().promotionCandidates;
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]!.tag).toBe('study');
+ });
+
+ it('snoozePromotion: setPromotionSnoozeUntil 24h 후로 호출 + state 비우기', async () => {
+ useInbox.setState({
+ promotionCandidates: [
+ { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' }
+ ]
+ } as never);
+ const before = Date.now();
+ await useInbox.getState().snoozePromotion();
+ const [[ms]] = mockInbox.setPromotionSnoozeUntil.mock.calls as [[number]];
+ expect(ms).toBeGreaterThan(before + 23 * 60 * 60 * 1000);
+ expect(ms).toBeLessThan(before + 25 * 60 * 60 * 1000);
+ expect(useInbox.getState().promotionCandidates).toHaveLength(0);
+ });
+
+ it('acceptPromotion: notebook 생성 + moveNote 호출 + sidebar 열림 + selectedNotebookId 설정', async () => {
+ useInbox.setState({
+ promotionCandidates: [
+ { tag: 'work', noteIds: ['n1', 'n2'], suggestedName: 'Work' }
+ ],
+ notebooks: []
+ } as never);
+ await useInbox.getState().acceptPromotion('work', 'Work', '#0a4b80');
+ expect(mockNotebook.create).toHaveBeenCalledWith({ name: 'Work', color: '#0a4b80' });
+ expect(mockNotebook.moveNote).toHaveBeenCalledTimes(2);
+ expect(mockNotebook.moveNote).toHaveBeenCalledWith('n1', 'nb-promo');
+ expect(mockNotebook.moveNote).toHaveBeenCalledWith('n2', 'nb-promo');
+ const state = useInbox.getState();
+ expect(state.sidebarVisible).toBe(true);
+ expect(state.selectedNotebookId).toBe('nb-promo');
+ expect(state.promotionCandidates.find((c) => c.tag === 'work')).toBeUndefined();
+ expect(state.notebooks.some((n) => n.id === 'nb-promo')).toBe(true);
+ });
+});