fix(lifecycle): NoteStatus 의 archived 제거 — MoveStatusModal/classifyStatus/store 정리
- NoteStatus 에서 'archived' 제거 (active | completed | trashed 3분기) - MoveStatusModal ALL_STATUSES 에서 'archived' 제거 + statusLabel switch 정리 - classifyStatus VALID/FALLBACK/PROMPT 에서 archived 제거 → completed fallback - inboxApi IPC set-status VALID 배열에서 archived 제거, classify-status fallback → completed - store InboxView 에서 'archived' 제거, InboxCounts.archived 제거, archived: 0 spread 제거 - ImportService.applySyncFromDir — 기존 파일의 status=archived 를 completed 로 coerce - 영향 받는 tests 13개 파일 모두 update (archived → completed, 없어진 UI 옵션 제거) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,15 +13,16 @@ export interface ClassifyStatusOutput {
|
|||||||
rationale: string;
|
rationale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed'];
|
// v0.4 Task 16 — 'archived' 제거. completed / trashed 만 유효 추천값.
|
||||||
|
// AI 응답이 'archived' 를 반환해도 VALID 에 없으므로 FALLBACK(completed) 로 coerce됨.
|
||||||
|
const VALID: readonly NoteStatus[] = ['completed', 'trashed'];
|
||||||
|
|
||||||
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
||||||
가능한 status:
|
가능한 status:
|
||||||
- completed: 작업이 끝났고 더 이상 행동 불필요
|
- completed: 작업이 끝났고 더 이상 행동 불필요
|
||||||
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
|
|
||||||
- trashed: 불필요, 의미 없는 메모
|
- trashed: 불필요, 의미 없는 메모
|
||||||
|
|
||||||
JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" }
|
JSON 출력만 하세요: { "recommended": "completed|trashed", "rationale": "<한 문장 한국어>" }
|
||||||
|
|
||||||
메모 본문:
|
메모 본문:
|
||||||
{{rawText}}
|
{{rawText}}
|
||||||
@@ -32,17 +33,18 @@ JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "ration
|
|||||||
사용자 이동 사유:
|
사용자 이동 사유:
|
||||||
{{reason}}`;
|
{{reason}}`;
|
||||||
|
|
||||||
|
// v0.4 Task 16 — fallback 을 'completed' 로 변경 ('archived' 제거).
|
||||||
const FALLBACK: ClassifyStatusOutput = {
|
const FALLBACK: ClassifyStatusOutput = {
|
||||||
recommended: 'archived',
|
recommended: 'completed',
|
||||||
rationale: '판단 실패 — 안전하게 보관 추천'
|
rationale: '판단 실패 — 완료 처리 추천'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||||
*
|
*
|
||||||
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
||||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default
|
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'completed' fallback
|
||||||
* (사용자 데이터 보존 우선).
|
* (v0.4 Task 16 — 'archived' 제거, 안전 default 를 completed 로 변경).
|
||||||
*/
|
*/
|
||||||
export async function classifyStatus(
|
export async function classifyStatus(
|
||||||
input: ClassifyStatusInput
|
input: ClassifyStatusInput
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'inbox:set-status',
|
'inbox:set-status',
|
||||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||||
if (!VALID.includes(status)) {
|
if (!VALID.includes(status)) {
|
||||||
return { ok: false as const, reason: 'invalid status' as const };
|
return { ok: false as const, reason: 'invalid status' as const };
|
||||||
}
|
}
|
||||||
@@ -242,14 +242,14 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||||
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
|
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 completed fallback
|
||||||
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
// (v0.4 Task 16 — 'archived' 제거). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
||||||
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
|
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
|
||||||
const note = deps.repo.findById(id);
|
const note = deps.repo.findById(id);
|
||||||
if (note === null) {
|
if (note === null) {
|
||||||
return {
|
return {
|
||||||
recommended: 'archived' as const,
|
recommended: 'completed' as const,
|
||||||
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
|
rationale: '메모를 찾을 수 없음 — 완료 처리 추천'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const provider = deps.providerHolder.get();
|
const provider = deps.providerHolder.get();
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export class ImportService {
|
|||||||
userIntent: parsed.userIntent,
|
userIntent: parsed.userIntent,
|
||||||
intentPromptedAt: parsed.intentPromptedAt,
|
intentPromptedAt: parsed.intentPromptedAt,
|
||||||
tags: parsed.tags,
|
tags: parsed.tags,
|
||||||
status: parsed.status,
|
// v0.4 Task 16 — 'archived' 는 NoteStatus 에서 제거됨. 기존 내보내기 파일의
|
||||||
|
// status=archived 를 읽을 때 completed 로 coerce (m008 과 동일 정책).
|
||||||
|
status: parsed.status === 'archived' ? 'completed' : parsed.status,
|
||||||
statusChangedAt: parsed.statusChangedAt,
|
statusChangedAt: parsed.statusChangedAt,
|
||||||
moveReason: parsed.moveReason,
|
moveReason: parsed.moveReason,
|
||||||
dueDate: parsed.dueDate,
|
dueDate: parsed.dueDate,
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ interface Props {
|
|||||||
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
|
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
|
||||||
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
|
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
|
||||||
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
|
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
|
||||||
|
* v0.4 Task 16 — 'archived' 제거. active/completed/trashed 3개 옵션만 노출.
|
||||||
*/
|
*/
|
||||||
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||||
|
|
||||||
export function MoveStatusModal({
|
export function MoveStatusModal({
|
||||||
noteId,
|
noteId,
|
||||||
@@ -150,8 +151,6 @@ export function statusLabel(s: NoteStatus): string {
|
|||||||
return 'Inbox';
|
return 'Inbox';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return '완료';
|
return '완료';
|
||||||
case 'archived':
|
|
||||||
return '보관';
|
|
||||||
case 'trashed':
|
case 'trashed':
|
||||||
return '휴지통';
|
return '휴지통';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ function toTitleCase(s: string): string {
|
|||||||
|
|
||||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||||
|
|
||||||
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
|
// v0.2.9 Cut B Task 4 — 3탭 view enum + settings.
|
||||||
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
// v0.4 Task 16 — 'archived' view 제거 (NoteStatus 에서 archived 삭제됨).
|
||||||
|
// 'inbox' = active, 'completed' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||||
export type InboxView =
|
export type InboxView =
|
||||||
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
|
| 'inbox' | 'completed' | 'trash' | 'settings'
|
||||||
| 'review-daily' | 'review-weekly' | 'review-monthly';
|
| 'review-daily' | 'review-weekly' | 'review-monthly';
|
||||||
|
|
||||||
export interface InboxCounts {
|
export interface InboxCounts {
|
||||||
active: number;
|
active: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
archived: number;
|
|
||||||
trashed: number;
|
trashed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ interface InboxState {
|
|||||||
setTagFilter: (tag: string | null) => void;
|
setTagFilter: (tag: string | null) => void;
|
||||||
setShowSettings: (open: boolean) => void;
|
setShowSettings: (open: boolean) => void;
|
||||||
setView: (view: InboxView) => void;
|
setView: (view: InboxView) => void;
|
||||||
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
|
loadByView: (view: 'inbox' | 'completed' | 'trash') => Promise<void>;
|
||||||
toggleShowTrash: () => Promise<void>;
|
toggleShowTrash: () => Promise<void>;
|
||||||
loadTrash: () => Promise<void>;
|
loadTrash: () => Promise<void>;
|
||||||
restoreNote: (id: string) => Promise<void>;
|
restoreNote: (id: string) => Promise<void>;
|
||||||
@@ -110,7 +110,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
showTrash: false,
|
showTrash: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
view: 'inbox',
|
view: 'inbox',
|
||||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
counts: { active: 0, completed: 0, trashed: 0 },
|
||||||
continuity: emptyContinuity,
|
continuity: emptyContinuity,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
ollamaStatus: { ok: true },
|
ollamaStatus: { ok: true },
|
||||||
@@ -150,8 +150,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
inboxApi.countsByStatus(),
|
inboxApi.countsByStatus(),
|
||||||
inboxApi.getSettings()
|
inboxApi.getSettings()
|
||||||
]);
|
]);
|
||||||
// v0.4 — countsByStatus 응답에서 archived 제거됨 (Task 16 UI 정리 예정). 0 fallback 유지.
|
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts: { ...counts, archived: 0 }, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
|
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
|
||||||
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
|
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
|
||||||
@@ -173,8 +172,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
inboxApi.countsByStatus(),
|
inboxApi.countsByStatus(),
|
||||||
inboxApi.getSettings()
|
inboxApi.getSettings()
|
||||||
]);
|
]);
|
||||||
// v0.4 — countsByStatus 응답에서 archived 제거됨 (Task 16 UI 정리 예정). 0 fallback 유지.
|
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts: { ...counts, archived: 0 }, ai_enabled: settings.ai_enabled ?? true });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
|
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
|
||||||
console.error('[inbox] refreshMeta failed', e);
|
console.error('[inbox] refreshMeta failed', e);
|
||||||
@@ -192,10 +190,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
const view = state.view;
|
const view = state.view;
|
||||||
const showTrash = state.showTrash;
|
const showTrash = state.showTrash;
|
||||||
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
|
const viewStatus: 'active' | 'completed' | 'trashed' | null =
|
||||||
view === 'inbox' ? 'active' :
|
view === 'inbox' ? 'active' :
|
||||||
view === 'completed' ? 'completed' :
|
view === 'completed' ? 'completed' :
|
||||||
view === 'archived' ? 'archived' :
|
|
||||||
view === 'trash' ? 'trashed' : null;
|
view === 'trash' ? 'trashed' : null;
|
||||||
|
|
||||||
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
|
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
|
||||||
@@ -276,7 +273,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
|
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
|
||||||
// 이전 status 로 stale 한 상태이므로 재로드 필요.
|
// 이전 status 로 stale 한 상태이므로 재로드 필요.
|
||||||
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
|
if (view === 'inbox' || view === 'completed' || view === 'trash') {
|
||||||
void get().loadByView(view);
|
void get().loadByView(view);
|
||||||
}
|
}
|
||||||
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
||||||
@@ -393,7 +390,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
const view = get().view;
|
const view = get().view;
|
||||||
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
|
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
|
||||||
const status = view === 'completed' || view === 'archived' || view === 'trash'
|
const status = view === 'completed' || view === 'trash'
|
||||||
? (view === 'trash' ? 'trashed' : view)
|
? (view === 'trash' ? 'trashed' : view)
|
||||||
: view === 'inbox' ? 'active' : undefined;
|
: view === 'inbox' ? 'active' : undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ export interface NoteMedia {
|
|||||||
|
|
||||||
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
|
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
|
||||||
|
|
||||||
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
// v0.2.9 Cut B — 노트 status 3분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
||||||
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
// v0.4 Task 16 — 'archived' 제거. m008 마이그레이션이 DB 의 archived 를 completed 로 통합.
|
||||||
|
export type NoteStatus = 'active' | 'completed' | 'trashed';
|
||||||
|
|
||||||
export interface NoteTag {
|
export interface NoteTag {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -205,7 +206,7 @@ export interface InboxApi {
|
|||||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
|
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
|
||||||
// v0.4 — notebookId 옵션 추가. archived 는 counts 응답에서 제거 (Task 16 UI 정리 예정).
|
// v0.4 — notebookId 옵션 추가. archived 제거 (Task 16 — NoteStatus 에서 제거됨).
|
||||||
listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise<Note[]>;
|
listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise<Note[]>;
|
||||||
countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>;
|
countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>;
|
||||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('App — settings view', () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
useInbox.setState({
|
useInbox.setState({
|
||||||
view: 'inbox',
|
view: 'inbox',
|
||||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
counts: { active: 0, completed: 0, trashed: 0 },
|
||||||
showSettings: false, showTrash: false,
|
showSettings: false, showTrash: false,
|
||||||
notes: [], trashNotes: [], trashCount: 0,
|
notes: [], trashNotes: [], trashCount: 0,
|
||||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||||
@@ -127,14 +127,14 @@ describe('App header — 3 tabs (v0.4)', () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
useInbox.setState({
|
useInbox.setState({
|
||||||
view: 'inbox',
|
view: 'inbox',
|
||||||
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
|
counts: { active: 5, completed: 3, trashed: 1 },
|
||||||
notes: [], trashNotes: [], trashCount: 0,
|
notes: [], trashNotes: [], trashCount: 0,
|
||||||
showTrash: false, showSettings: false,
|
showTrash: false, showSettings: false,
|
||||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||||
});
|
});
|
||||||
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
||||||
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
||||||
// v0.4 — countsByStatus 응답에서 archived 제거 (store 가 archived:0 fallback 추가).
|
// v0.4 Task 16 — countsByStatus 응답에서 archived 제거 (NoteStatus 에서 삭제됨).
|
||||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 });
|
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ describe('App — onboarding wizard', () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
useInbox.setState({
|
useInbox.setState({
|
||||||
view: 'inbox',
|
view: 'inbox',
|
||||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
counts: { active: 0, completed: 0, trashed: 0 },
|
||||||
showSettings: false, showTrash: false,
|
showSettings: false, showTrash: false,
|
||||||
notes: [], trashNotes: [], trashCount: 0,
|
notes: [], trashNotes: [], trashCount: 0,
|
||||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('ImportService.applySyncFromDir', () => {
|
|||||||
expect(note?.rawText).toBe('new body');
|
expect(note?.rawText).toBe('new body');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves status field from frontmatter', async () => {
|
it('preserves status field from frontmatter (archived coerced to completed — v0.4 Task 16)', async () => {
|
||||||
const notesDir = join(workDir, 'notes');
|
const notesDir = join(workDir, 'notes');
|
||||||
await mkdir(notesDir, { recursive: true });
|
await mkdir(notesDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -86,7 +86,8 @@ describe('ImportService.applySyncFromDir', () => {
|
|||||||
);
|
);
|
||||||
await svc.applySyncFromDir(workDir);
|
await svc.applySyncFromDir(workDir);
|
||||||
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
||||||
expect(note?.status).toBe('archived');
|
// archived → completed coerce (m008 와 동일 정책, NoteStatus 에서 archived 삭제됨).
|
||||||
|
expect(note?.status).toBe('completed');
|
||||||
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
||||||
expect(note?.moveReason).toBe('done');
|
expect(note?.moveReason).toBe('done');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('MoveStatusModal', () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders reason textarea + 4 buttons + AI classify button', () => {
|
it('renders reason textarea + 3 buttons + AI classify button (v0.4 — 보관 제거)', () => {
|
||||||
render(
|
render(
|
||||||
<MoveStatusModal
|
<MoveStatusModal
|
||||||
noteId="n1"
|
noteId="n1"
|
||||||
@@ -39,7 +39,7 @@ describe('MoveStatusModal', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -84,7 +84,7 @@ describe('MoveStatusModal', () => {
|
|||||||
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
|
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('currentStatus=completed → Inbox/보관/휴지통 노출, 완료 미노출', () => {
|
it('currentStatus=completed → Inbox/휴지통 노출, 완료/보관 미노출 (v0.4 — 보관 제거)', () => {
|
||||||
render(
|
render(
|
||||||
<MoveStatusModal
|
<MoveStatusModal
|
||||||
noteId="n1"
|
noteId="n1"
|
||||||
@@ -96,31 +96,14 @@ describe('MoveStatusModal', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: '완료' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '완료' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('currentStatus=archived → Inbox 버튼 클릭 시 setStatus("active") 호출', async () => {
|
// v0.4 Task 16 — currentStatus=archived 는 NoteStatus 에서 제거됨. 테스트 제거.
|
||||||
const onMoved = vi.fn();
|
|
||||||
render(
|
|
||||||
<MoveStatusModal
|
|
||||||
noteId="n1"
|
|
||||||
rawText="t"
|
|
||||||
summary=""
|
|
||||||
currentStatus="archived"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onMoved={onMoved}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'active', null);
|
|
||||||
expect(onMoved).toHaveBeenCalledWith('active', null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currentStatus=trashed → Inbox/완료/보관 노출, 휴지통 미노출', () => {
|
it('currentStatus=trashed → Inbox/완료 노출, 휴지통/보관 미노출 (v0.4 — 보관 제거)', () => {
|
||||||
render(
|
render(
|
||||||
<MoveStatusModal
|
<MoveStatusModal
|
||||||
noteId="n1"
|
noteId="n1"
|
||||||
@@ -133,7 +116,7 @@ describe('MoveStatusModal', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +168,7 @@ describe('MoveStatusModal', () => {
|
|||||||
onMoved={onMoved}
|
onMoved={onMoved}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '보관' }));
|
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||||
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
|
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -940,9 +940,9 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
|||||||
|
|
||||||
it('setStatus accepts null reason', () => {
|
it('setStatus accepts null reason', () => {
|
||||||
const { id } = repo.create({ rawText: 'test' });
|
const { id } = repo.create({ rawText: 'test' });
|
||||||
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
repo.setStatus(id, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||||
const note = repo.findById(id)!;
|
const note = repo.findById(id)!;
|
||||||
expect(note.status).toBe('archived');
|
expect(note.status).toBe('completed');
|
||||||
expect(note.moveReason).toBeNull();
|
expect(note.moveReason).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -960,14 +960,14 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
|||||||
it('listByStatus filters correctly', () => {
|
it('listByStatus filters correctly', () => {
|
||||||
const idA = repo.create({ rawText: 'a' }).id;
|
const idA = repo.create({ rawText: 'a' }).id;
|
||||||
const idB = repo.create({ rawText: 'b' }).id;
|
const idB = repo.create({ rawText: 'b' }).id;
|
||||||
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
repo.setStatus(idB, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||||
|
|
||||||
const active = repo.listByStatus('active', { limit: 10 });
|
const active = repo.listByStatus('active', { limit: 10 });
|
||||||
const archived = repo.listByStatus('archived', { limit: 10 });
|
const completed = repo.listByStatus('completed', { limit: 10 });
|
||||||
expect(active.map((n) => n.id)).toContain(idA);
|
expect(active.map((n) => n.id)).toContain(idA);
|
||||||
expect(active.map((n) => n.id)).not.toContain(idB);
|
expect(active.map((n) => n.id)).not.toContain(idB);
|
||||||
expect(archived.map((n) => n.id)).toContain(idB);
|
expect(completed.map((n) => n.id)).toContain(idB);
|
||||||
expect(archived.map((n) => n.id)).not.toContain(idA);
|
expect(completed.map((n) => n.id)).not.toContain(idA);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
|
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
|
||||||
@@ -984,10 +984,10 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
|||||||
it('listByStatus respects limit (cap 200)', () => {
|
it('listByStatus respects limit (cap 200)', () => {
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const id = repo.create({ rawText: `n${i}` }).id;
|
const id = repo.create({ rawText: `n${i}` }).id;
|
||||||
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
repo.setStatus(id, 'completed', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
||||||
}
|
}
|
||||||
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
|
expect(repo.listByStatus('completed', { limit: 3 })).toHaveLength(3);
|
||||||
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
|
expect(repo.listByStatus('completed', { limit: 100 })).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listByStatus default limit 200', () => {
|
it('listByStatus default limit 200', () => {
|
||||||
@@ -1016,7 +1016,7 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
|||||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setStatus("completed"/"archived") also clears deleted_at', () => {
|
it('setStatus("completed") also clears deleted_at', () => {
|
||||||
const { id } = repo.create({ rawText: 'r' });
|
const { id } = repo.create({ rawText: 'r' });
|
||||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||||
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
|
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||||
@@ -1037,13 +1037,12 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
|||||||
const c = repo.create({ rawText: 'c' }).id;
|
const c = repo.create({ rawText: 'c' }).id;
|
||||||
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||||
const d = repo.create({ rawText: 'd' }).id;
|
const d = repo.create({ rawText: 'd' }).id;
|
||||||
repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
repo.setStatus(d, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||||
const e = repo.create({ rawText: 'e' }).id;
|
const e = repo.create({ rawText: 'e' }).id;
|
||||||
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
|
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||||
|
|
||||||
expect(repo.countByStatus('active')).toBe(2);
|
expect(repo.countByStatus('active')).toBe(2);
|
||||||
expect(repo.countByStatus('completed')).toBe(1);
|
expect(repo.countByStatus('completed')).toBe(2);
|
||||||
expect(repo.countByStatus('archived')).toBe(1);
|
|
||||||
expect(repo.countByStatus('trashed')).toBe(1);
|
expect(repo.countByStatus('trashed')).toBe(1);
|
||||||
// sanity — a 가 여전히 active.
|
// sanity — a 가 여전히 active.
|
||||||
expect(repo.findById(a)!.status).toBe('active');
|
expect(repo.findById(a)!.status).toBe('active');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('classifyStatus', () => {
|
|||||||
expect(r.rationale).toBe('처리됨');
|
expect(r.rationale).toBe('처리됨');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to archived on parse failure (invalid JSON)', async () => {
|
it('falls back to completed on parse failure (invalid JSON)', async () => {
|
||||||
const provider = makeProvider(vi.fn(async () => 'not json'));
|
const provider = makeProvider(vi.fn(async () => 'not json'));
|
||||||
const r = await classifyStatus({
|
const r = await classifyStatus({
|
||||||
provider,
|
provider,
|
||||||
@@ -36,11 +36,11 @@ describe('classifyStatus', () => {
|
|||||||
summary: '',
|
summary: '',
|
||||||
reason: 'r'
|
reason: 'r'
|
||||||
});
|
});
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to archived on invalid status value', async () => {
|
it('falls back to completed on invalid status value', async () => {
|
||||||
const provider = makeProvider(
|
const provider = makeProvider(
|
||||||
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
|
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
|
||||||
);
|
);
|
||||||
@@ -50,7 +50,7 @@ describe('classifyStatus', () => {
|
|||||||
summary: '',
|
summary: '',
|
||||||
reason: 'r'
|
reason: 'r'
|
||||||
});
|
});
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles provider throw', async () => {
|
it('handles provider throw', async () => {
|
||||||
@@ -65,7 +65,7 @@ describe('classifyStatus', () => {
|
|||||||
summary: '',
|
summary: '',
|
||||||
reason: 'r'
|
reason: 'r'
|
||||||
});
|
});
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,13 +77,13 @@ describe('classifyStatus', () => {
|
|||||||
summary: '',
|
summary: '',
|
||||||
reason: 'r'
|
reason: 'r'
|
||||||
});
|
});
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('substitutes empty inputs with placeholder text in prompt', async () => {
|
it('substitutes empty inputs with placeholder text in prompt', async () => {
|
||||||
const generateRaw = vi.fn(
|
const generateRaw = vi.fn(
|
||||||
async (_p: string) => '{"recommended":"archived","rationale":"ok"}'
|
async (_p: string) => '{"recommended":"completed","rationale":"ok"}'
|
||||||
);
|
);
|
||||||
const provider = makeProvider(generateRaw);
|
const provider = makeProvider(generateRaw);
|
||||||
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
|
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ describe('inbox:set-status IPC', () => {
|
|||||||
registerInboxApi(makeDeps());
|
registerInboxApi(makeDeps());
|
||||||
const handler = handlers['inbox:set-status'];
|
const handler = handlers['inbox:set-status'];
|
||||||
if (handler === undefined) throw new Error('handler not registered');
|
if (handler === undefined) throw new Error('handler not registered');
|
||||||
const r = await handler(null, 'n1', 'archived', null);
|
const r = await handler(null, 'n1', 'trashed', null);
|
||||||
expect(r).toEqual({ ok: true });
|
expect(r).toEqual({ ok: true });
|
||||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null);
|
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'trashed', null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid status without calling repo', async () => {
|
it('rejects invalid status without calling repo', async () => {
|
||||||
@@ -130,7 +130,7 @@ describe('ai:classify-status IPC', () => {
|
|||||||
expect(prompt).toContain('결재');
|
expect(prompt).toContain('결재');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns archived fallback when note not found', async () => {
|
it('returns completed fallback when note not found (v0.4 — archived 제거)', async () => {
|
||||||
mockFindById.mockReturnValue(null);
|
mockFindById.mockReturnValue(null);
|
||||||
registerInboxApi(makeDeps());
|
registerInboxApi(makeDeps());
|
||||||
const handler = handlers['ai:classify-status'];
|
const handler = handlers['ai:classify-status'];
|
||||||
@@ -139,12 +139,12 @@ describe('ai:classify-status IPC', () => {
|
|||||||
recommended: string;
|
recommended: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
};
|
};
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
expect(r.rationale.length).toBeGreaterThan(0);
|
expect(r.rationale.length).toBeGreaterThan(0);
|
||||||
expect(mockGenerateRaw).not.toHaveBeenCalled();
|
expect(mockGenerateRaw).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns archived fallback when AI throws', async () => {
|
it('returns completed fallback when AI throws (v0.4 — archived 제거)', async () => {
|
||||||
mockFindById.mockReturnValue({
|
mockFindById.mockReturnValue({
|
||||||
id: 'n1',
|
id: 'n1',
|
||||||
rawText: 't',
|
rawText: 't',
|
||||||
@@ -158,6 +158,6 @@ describe('ai:classify-status IPC', () => {
|
|||||||
recommended: string;
|
recommended: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
};
|
};
|
||||||
expect(r.recommended).toBe('archived');
|
expect(r.recommended).toBe('completed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const mockApi = {
|
|||||||
listNotes: vi.fn(async () => [] as Note[]),
|
listNotes: vi.fn(async () => [] as Note[]),
|
||||||
listTrash: vi.fn(async () => [] as Note[]),
|
listTrash: vi.fn(async () => [] as Note[]),
|
||||||
listByStatus: vi.fn(async () => [] as Note[]),
|
listByStatus: vi.fn(async () => [] as Note[]),
|
||||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })),
|
||||||
getTrashCount: vi.fn(async () => 0),
|
getTrashCount: vi.fn(async () => 0),
|
||||||
getContinuity: vi.fn(async () => ({
|
getContinuity: vi.fn(async () => ({
|
||||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||||
@@ -40,7 +40,7 @@ describe('inbox store — view enum', () => {
|
|||||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||||
useInbox.setState({
|
useInbox.setState({
|
||||||
view: 'inbox',
|
view: 'inbox',
|
||||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
counts: { active: 0, completed: 0, trashed: 0 },
|
||||||
notes: [], trashNotes: [], trashCount: 0,
|
notes: [], trashNotes: [], trashCount: 0,
|
||||||
showTrash: false, showSettings: false,
|
showTrash: false, showSettings: false,
|
||||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||||
@@ -65,7 +65,7 @@ describe('inbox store — view enum', () => {
|
|||||||
|
|
||||||
it('counts initialized to zero per status', async () => {
|
it('counts initialized to zero per status', async () => {
|
||||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||||
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
|
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, trashed: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('backward-compat: showTrash mirrors view==="trash"', async () => {
|
it('backward-compat: showTrash mirrors view==="trash"', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user