diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff42df..4a63f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,41 @@ 본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다. 형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다. +## [0.3.8] — 2026-05-11 + +전수 audit 후 발견된 사용자 상호작용 hole 8건 일괄 hotfix. 핵심은 (1) push-based status 동기화 root fix, (2) modal Escape affordance 통일, (3) IPC 실패 resilience. + +### 수정 + +- **`inbox:set-status` IPC 가 `pushNoteUpdated` emit 안 함 (root fix).** 이전엔 setStatus 후 counts/list/search 가 모두 stale → 호출처마다 `refreshMeta()` 명시 호출이 필요했음 (v0.3.5 워크어라운드). 이제 IPC 핸들러가 `repo.findById()` 후 `pushNoteUpdated` 호출. renderer 의 `onNoteUpdated` 콜백 1개 path 로 모든 status 전이가 일관 갱신. +- **`upsertNote` 가 current view 무시 → 잘못된 status 노트 잔류.** 이전엔 trashed 외 모든 status 를 `notes` 에 누적 → 사용자가 inbox 에서 완료로 옮긴 노트가 list 에 잔류. v0.3.8 부터 `viewStatus` (inbox→active, completed→completed, archived→archived) 와 매칭되는 status 만 유지. `searchResults` 도 동일 패턴. +- **`upsertNote` 의 trash 판정을 `deletedAt` → `status='trashed'` 로 전환.** m004 이후 status 가 single source of truth, deletedAt 은 backward-compat mirror. sync conflict 후 두 컬럼 불일치 가능성 대비. +- **`restoreNote` 가 status 도 'active' 로 갱신.** 이전엔 `deletedAt: null` 만 clear → upsertNote 가 status='trashed' 그대로 라 여전히 trashNotes 에 잔류. +- **OnboardingWizard close path 부재.** IPC 실패 시 무한 wizard 잠금 → `try/catch + setBusy/error state + "지금 건너뛰기" 버튼 + Escape` 추가. 첫 launch 사용자 막힘 회피. +- **Modal Escape key dismiss 통일.** `MoveStatusModal` / `RevisionHistoryModal` / `ConflictModal` / `SyncHelpModal` / `OnboardingWizard` 모두 `keydown` listener 추가. MoveStatusModal 은 overlay 클릭 close 도 추가 (다른 modal 들은 이미 외부 클릭 지원). +- **`store.ts` async 함수 error-resilient.** `loadInitial` / `loadByView` / `searchNotes` / `loadReview` / `refreshMeta` 가 IPC throw 시 try/catch 로 감싸 무한 loading / stale data 회피. loadInitial 은 catch 시 `loading: false`, loadByView 는 빈 list, searchNotes 는 빈 결과, loadReview 는 빈 aggregate 로 graceful fallback. + +### 갱신 + +- **NoteCard 의 명시적 `refreshMeta` 호출 보존** — onNoteUpdated path 가 이미 refreshMeta 호출하므로 redundant 지만 backup 으로 유지 (2번 fetch 만 발생, 무해). + +### 게이트 + +- 단위 739 → **745 PASS** (+6: view-aware upsertNote 3 + setStatus push emit 1 + Modal Escape 1 + Modal overlay 클릭 1) +- typecheck 0 errors (src) +- 신규 npm dependency 0 + +### 업그레이드 + +v0.3.7 인스톨러 위에 v0.3.8 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음. + +### 미수정 (낮은 우선순위 / 별도 작업) + +- ai_status='pending' 노트 cancel UI (P1, 별도 spec 필요) +- ai_status='failed' 노트 per-note 재시도 UI (P2) +- FTS5 query escape (P2, 확인 필요) +- 동시 편집 race condition (P2) + ## [0.3.7] — 2026-05-11 `MoveStatusModal` 의 button hardcode 로 인해 완료/보관/휴지통 노트가 inbox 로 돌아올 수 없던 버그 fix. v0.2.9 Cut B 부터 존재한 잠재 결함 (dropdown 의 `possibleTargets` 필터가 modal 까지 흐르지 못함). diff --git a/package-lock.json b/package-lock.json index e70f49e..10c42ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.3.7", + "version": "0.3.8", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index f8b6882..f8feb75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.3.7", + "version": "0.3.8", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 79f3cdb..8d02ebd 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -198,6 +198,9 @@ export function registerInboxApi(deps: InboxIpcDeps): void { // v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함). // Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는 // NoteRepository.setStatus 내부에서 처리 (deleted_at sync). + // v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store + // (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이 + // 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신. ipcMain.handle( 'inbox:set-status', async (_e, id: string, status: NoteStatus, reason: string | null) => { @@ -206,6 +209,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: false as const, reason: 'invalid status' as const }; } deps.repo.setStatus(id, status, reason); + const updated = deps.repo.findById(id); + if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated); return { ok: true as const }; } ); diff --git a/src/renderer/inbox/components/ConflictModal.tsx b/src/renderer/inbox/components/ConflictModal.tsx index 0b22a2d..0d07c3b 100644 --- a/src/renderer/inbox/components/ConflictModal.tsx +++ b/src/renderer/inbox/components/ConflictModal.tsx @@ -38,6 +38,15 @@ export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React return () => { cancelled = true; }; }, []); + // Escape key 로 닫기. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + async function onChoose(path: string, choice: 'local' | 'remote') { setBusy(path); setError(null); diff --git a/src/renderer/inbox/components/MoveStatusModal.tsx b/src/renderer/inbox/components/MoveStatusModal.tsx index fa8bac1..77c276f 100644 --- a/src/renderer/inbox/components/MoveStatusModal.tsx +++ b/src/renderer/inbox/components/MoveStatusModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { inboxApi } from '../api.js'; import type { NoteStatus } from '@shared/types'; @@ -34,6 +34,15 @@ export function MoveStatusModal({ } | null>(null); const [classifying, setClassifying] = useState(false); + // Escape key 로 modal 닫기. mount 동안만 listener 활성. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + async function move(status: NoteStatus): Promise { const trimmedReason = reason.trim() === '' ? null : reason.trim(); await inboxApi.setStatus(noteId, status, trimmedReason); @@ -55,6 +64,7 @@ export function MoveStatusModal({
e.stopPropagation()} style={{ background: '#fff', padding: 16, diff --git a/src/renderer/inbox/components/OnboardingWizard.tsx b/src/renderer/inbox/components/OnboardingWizard.tsx index 47e2cc3..7307529 100644 --- a/src/renderer/inbox/components/OnboardingWizard.tsx +++ b/src/renderer/inbox/components/OnboardingWizard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { inboxApi } from '../api.js'; /** @@ -10,12 +10,42 @@ import { inboxApi } from '../api.js'; * 가 SettingsPage 에서 추후 선택 가능. */ export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + async function choose(aiEnabled: boolean | null): Promise { - if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled); - await inboxApi.setOnboardingCompleted(true); + setBusy(true); + setError(null); + try { + if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled); + await inboxApi.setOnboardingCompleted(true); + onClose(); + } catch (e) { + // IPC 실패 (예: settings 저장 throw) 시 modal stuck 방지. 사용자에게 메시지 표시 + + // "지금 건너뛰기" 로 fallback 길 제공. choose() 가 throw 하지 않고 무한 wizard 잠금 + // 회피. + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + function skip(): void { + // IPC 자체가 실패하는 상태 → ai_enabled 변경/onboarding flag 저장 모두 포기하고 wizard 만 닫기. + // 다음 launch 에 다시 wizard 가 뜸 (onboarding_completed=false 상태). 그래도 사용자가 + // 진입 자체는 가능. onClose(); } + // Escape key 로 wizard 종료 (skip 동일 — onboarding flag 미저장). + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') skip(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + return (
void }): React.Re ollama.com/download

- - - + + +
+ {error !== null && ( +
+
설정 저장 실패: {error}
+ +
+ )}
); diff --git a/src/renderer/inbox/components/RevisionHistoryModal.tsx b/src/renderer/inbox/components/RevisionHistoryModal.tsx index 4257aeb..79e078a 100644 --- a/src/renderer/inbox/components/RevisionHistoryModal.tsx +++ b/src/renderer/inbox/components/RevisionHistoryModal.tsx @@ -37,6 +37,15 @@ export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): Re const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Escape key 로 닫기. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + useEffect(() => { let cancelled = false; void (async () => { diff --git a/src/renderer/inbox/components/SyncHelpModal.tsx b/src/renderer/inbox/components/SyncHelpModal.tsx index 1d88dc8..00f81d4 100644 --- a/src/renderer/inbox/components/SyncHelpModal.tsx +++ b/src/renderer/inbox/components/SyncHelpModal.tsx @@ -36,6 +36,15 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' }); }, [initialAnchor]); + // Escape key 로 닫기. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + return (
e.stopPropagation()}> diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 3f69d27..78ff106 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -102,68 +102,117 @@ export const useInbox = create((set, get) => ({ searchResults: null, reviewData: null, async loadInitial() { + // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset. set({ loading: true }); - 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.getContinuity(), - inboxApi.getPendingCount(), - inboxApi.getOllamaStatus(), - inboxApi.getTodayCount(), - inboxApi.getTrashCount(), - inboxApi.listExpired(), - inboxApi.getFailedCount(), - inboxApi.listRecallCandidate(), - inboxApi.countsByStatus(), - inboxApi.getSettings() - ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false }); + try { + 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.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount(), + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus(), + inboxApi.getSettings() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false }); + } catch (e) { + // 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피. + // 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능. + console.error('[inbox] loadInitial failed', e); + set({ loading: false }); + } }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ - inboxApi.getContinuity(), - inboxApi.getPendingCount(), - inboxApi.getOllamaStatus(), - inboxApi.getTodayCount(), - inboxApi.getTrashCount(), - inboxApi.listExpired(), - inboxApi.getFailedCount(), - inboxApi.listRecallCandidate(), - inboxApi.countsByStatus(), - inboxApi.getSettings() - ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true }); + try { + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount(), + inboxApi.listRecallCandidate(), + inboxApi.countsByStatus(), + inboxApi.getSettings() + ]); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true }); + } catch (e) { + // refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복). + console.error('[inbox] refreshMeta failed', e); + } }, 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); - const ti = get().trashNotes.findIndex((n) => n.id === note.id); - const nextTrash = get().trashNotes.slice(); - if (ti >= 0) nextTrash[ti] = note; + // v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거. + // 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서 + // 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로 + // 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수. + // + // trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded + // (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값 + // (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거). + const state = get(); + const view = state.view; + const showTrash = state.showTrash; + const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null = + view === 'inbox' ? 'active' : + view === 'completed' ? 'completed' : + view === 'archived' ? 'archived' : + view === 'trash' ? 'trashed' : null; + + // trashNotes — note.status='trashed' 면 upsert, 아니면 제거. + const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id); + let nextTrash = cleanTrash; + if (note.status === 'trashed') { + const ti = state.trashNotes.findIndex((n) => n.id === note.id); + nextTrash = cleanTrash.slice(); + if (ti >= 0) nextTrash.splice(ti, 0, note); else nextTrash.unshift(note); - 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); - const i = get().notes.findIndex((n) => n.id === note.id); - const nextNotes = get().notes.slice(); - if (i >= 0) nextNotes[i] = note; - else nextNotes.unshift(note); - set({ - notes: nextNotes, - trashNotes: cleanTrash, - ...(showTrash ? { trashCount: cleanTrash.length } : {}) - }); } + + // notes — current view 의 status 와 매칭되는 경우만 유지/upsert. + // viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip. + const cleanNotes = state.notes.filter((n) => n.id !== note.id); + let nextNotes = state.notes; + if (viewStatus !== null) { + if (note.status === viewStatus) { + const i = state.notes.findIndex((n) => n.id === note.id); + nextNotes = cleanNotes.slice(); + if (i >= 0) nextNotes.splice(i, 0, note); + else nextNotes.unshift(note); + } else { + nextNotes = cleanNotes; + } + } + + // searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와 + // 안 맞으면 제거, 맞으면 upsert). + let nextSearch = state.searchResults; + if (state.searchResults !== null) { + const cleanSearch = state.searchResults.filter((n) => n.id !== note.id); + if (viewStatus === null || note.status === viewStatus) { + // search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep. + const i = state.searchResults.findIndex((n) => n.id === note.id); + nextSearch = cleanSearch.slice(); + if (i >= 0) nextSearch.splice(i, 0, note); + else nextSearch.unshift(note); + } else { + nextSearch = cleanSearch; + } + } + + set({ + notes: nextNotes, + trashNotes: nextTrash, + searchResults: nextSearch, + ...(showTrash ? { trashCount: nextTrash.length } : {}) + }); }, removeNote(id) { const cleanNotes = get().notes.filter((n) => n.id !== id); @@ -204,13 +253,21 @@ export const useInbox = create((set, get) => ({ if (view === 'review-monthly') void get().loadReview('monthly'); }, async loadByView(view) { + // v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지. + // fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale). const status = view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view; - const notes = await inboxApi.listByStatus(status, { limit: 200 }); - if (view === 'trash') { - set({ trashNotes: notes, trashCount: notes.length }); - } else { - set({ notes }); + try { + const notes = await inboxApi.listByStatus(status, { limit: 200 }); + if (view === 'trash') { + set({ trashNotes: notes, trashCount: notes.length }); + } else { + set({ notes }); + } + } catch (e) { + console.error('[inbox] loadByView failed', view, e); + if (view === 'trash') set({ trashNotes: [] }); + else set({ notes: [] }); } }, async toggleShowTrash() { @@ -225,11 +282,12 @@ export const useInbox = create((set, get) => ({ async restoreNote(id) { await inboxApi.restoreNote(id); // 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음 - // (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘. + // (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘. // 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출). + // v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅. const note = get().trashNotes.find((n) => n.id === id); if (note) { - get().upsertNote({ ...note, deletedAt: null }); + get().upsertNote({ ...note, deletedAt: null, status: 'active' }); } }, async permanentDeleteNote(id) { @@ -306,14 +364,27 @@ export const useInbox = create((set, get) => ({ const status = view === 'completed' || view === 'archived' || view === 'trash' ? (view === 'trash' ? 'trashed' : view) : view === 'inbox' ? 'active' : undefined; - const r = await inboxApi.search(q, status ? { status } : {}); - set({ searchResults: r }); + try { + const r = await inboxApi.search(q, status ? { status } : {}); + set({ searchResults: r }); + } catch (e) { + // FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로. + console.error('[inbox] searchNotes failed', e); + set({ searchResults: [] }); + } }, clearSearch() { set({ searchQuery: '', searchResults: null }); }, async loadReview(period) { - const data = await inboxApi.reviewAggregate(period); - set({ reviewData: data }); + try { + const data = await inboxApi.reviewAggregate(period); + set({ reviewData: data }); + } catch (e) { + // review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피. + // 빈 aggregate 로 set 해서 사용자에게 "0건" 표기. + console.error('[inbox] loadReview failed', period, e); + set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } }); + } } })); diff --git a/tests/unit/MoveStatusModal.test.tsx b/tests/unit/MoveStatusModal.test.tsx index 7e41104..606f068 100644 --- a/tests/unit/MoveStatusModal.test.tsx +++ b/tests/unit/MoveStatusModal.test.tsx @@ -137,6 +137,42 @@ describe('MoveStatusModal', () => { expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull(); }); + it('Escape key → onClose 호출', () => { + const onClose = vi.fn(); + render( + + ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('overlay 클릭 → onClose, modal body 클릭 → 무반응', () => { + const onClose = vi.fn(); + render( + + ); + // body 클릭 (textarea) → onClose 호출 안 됨 + fireEvent.click(screen.getByRole('textbox')); + expect(onClose).not.toHaveBeenCalled(); + // overlay (dialog) 클릭 → onClose + fireEvent.click(screen.getByRole('dialog', { name: '이동' })); + expect(onClose).toHaveBeenCalled(); + }); + it('빈 사유 → null reason 전달', async () => { const onMoved = vi.fn(); render( diff --git a/tests/unit/inboxApi-setStatus.test.ts b/tests/unit/inboxApi-setStatus.test.ts index bba5969..a74c4fd 100644 --- a/tests/unit/inboxApi-setStatus.test.ts +++ b/tests/unit/inboxApi-setStatus.test.ts @@ -83,6 +83,20 @@ describe('inbox:set-status IPC', () => { expect(r.reason).toBe('invalid status'); expect(mockSetStatus).not.toHaveBeenCalled(); }); + + it('emits note:updated to renderer after setStatus (v0.3.8 push-based)', async () => { + const send = vi.fn(); + const win = { webContents: { send }, isDestroyed: () => false } as never; + const deps = makeDeps(); + deps.getInboxWindow = () => win; + const updatedNote = { id: 'n1', status: 'completed' }; + mockFindById.mockReturnValue(updatedNote); + registerInboxApi(deps); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + await handler(null, 'n1', 'completed', null); + expect(send).toHaveBeenCalledWith('note:updated', updatedNote); + }); }); describe('ai:classify-status IPC', () => { diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts index 7bb173d..e124365 100644 --- a/tests/unit/store.trash.test.ts +++ b/tests/unit/store.trash.test.ts @@ -95,6 +95,37 @@ describe('useInbox — trash state (v0.2.3 #4)', () => { expect(useInbox.getState().notes).toHaveLength(1); }); + it('view-aware upsertNote — inbox view 에서 status=completed 노트 push → notes 에서 제거', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + // view='inbox' (default), active 노트 upsert + useInbox.getState().upsertNote(noteStub('a')); + expect(useInbox.getState().notes).toHaveLength(1); + // 같은 노트가 completed 로 status 변경 → 현재 view 와 안 맞으므로 notes 에서 제거 + const completed: Note = { ...noteStub('a'), status: 'completed' }; + useInbox.getState().upsertNote(completed); + expect(useInbox.getState().notes).toHaveLength(0); + }); + + it('view-aware upsertNote — completed view 에서 active 노트 push → notes 에 추가 안 됨', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ view: 'completed' }); + useInbox.getState().upsertNote(noteStub('a')); // status='active' + expect(useInbox.getState().notes).toHaveLength(0); + // completed status 면 추가 + const completed: Note = { ...noteStub('a'), status: 'completed' }; + useInbox.getState().upsertNote(completed); + expect(useInbox.getState().notes).toHaveLength(1); + }); + + it('view-aware upsertNote — searchResults 가 있을 때 status mismatch → searchResults 에서 제거', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + // 이전 test 가 view='completed' 로 설정한 채 끝났을 수 있어 명시적 초기화. + useInbox.setState({ view: 'inbox', searchResults: [noteStub('a')] }); + const completed: Note = { ...noteStub('a'), status: 'completed' }; + useInbox.getState().upsertNote(completed); + expect(useInbox.getState().searchResults).toHaveLength(0); + }); + it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => { mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 }); const { useInbox } = await import('../../src/renderer/inbox/store.js');