feat(promotion): store promotionCandidates + accept/snooze/dismiss + settings 영속화
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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/... 초기화 직후로 이동.
|
||||
|
||||
@@ -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<void>;
|
||||
// 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 });
|
||||
|
||||
@@ -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<string, unknown> | 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=?`)
|
||||
|
||||
@@ -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<typeof SettingsSchema>;
|
||||
@@ -155,6 +160,50 @@ export class SettingsService {
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
// v0.4 Task 11 — promotion candidate 영속화.
|
||||
async getPromotionDismissedTags(): Promise<string[]> {
|
||||
const s = await this.load();
|
||||
return s.promotion_dismissed_tags ?? [];
|
||||
}
|
||||
|
||||
async addPromotionDismissedTag(tag: string): Promise<void> {
|
||||
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<number> {
|
||||
const s = await this.load();
|
||||
return s.promotion_snoozed_until_ms ?? 0;
|
||||
}
|
||||
|
||||
async setPromotionSnoozeUntil(ms: number): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, promotion_snoozed_until_ms: ms });
|
||||
}
|
||||
|
||||
// v0.4 Task 15 — sidebar 레이아웃 영속화.
|
||||
async getSidebarVisible(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.sidebar_visible ?? false;
|
||||
}
|
||||
|
||||
async setSidebarVisible(v: boolean): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, sidebar_visible: v });
|
||||
}
|
||||
|
||||
async getSidebarWidth(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.sidebar_width ?? 240;
|
||||
}
|
||||
|
||||
async setSidebarWidth(v: number): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, sidebar_width: v });
|
||||
}
|
||||
|
||||
private async persist(next: Settings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -85,6 +91,11 @@ interface InboxState {
|
||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
// v0.4 Task 11 — promotion candidate actions.
|
||||
loadPromotionCandidates: () => Promise<void>;
|
||||
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
|
||||
snoozePromotion: () => Promise<void>;
|
||||
dismissPromotion: (tag: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -119,6 +130,7 @@ export const useInbox = create<InboxState>((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<InboxState>((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) });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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<PromotionCandidate[]>;
|
||||
getPromotionDismissedTags(): Promise<string[]>;
|
||||
addPromotionDismissedTag(tag: string): Promise<void>;
|
||||
getPromotionSnoozeUntil(): Promise<number>;
|
||||
setPromotionSnoozeUntil(ms: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotebookApi {
|
||||
|
||||
144
tests/unit/store.promotion.test.ts
Normal file
144
tests/unit/store.promotion.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
getPromotionDismissedTags: ReturnType<typeof vi.fn>;
|
||||
addPromotionDismissedTag: ReturnType<typeof vi.fn>;
|
||||
getPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
|
||||
setPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type MockNotebookApi = {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
moveNote: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user