4 Commits

Author SHA1 Message Date
th-kim0823
4216d42d7c chore(release): v0.3.7 — 이동 modal currentStatus 필터 (Inbox 복원 path)
MoveStatusModal 이 완료/보관/휴지통 3 button hardcode 라
완료/보관/휴지통 노트가 inbox 로 돌아오는 path 가 없던 버그 fix.
currentStatus prop 으로 4 status 중 current 제외 동적 render.
'활성' label 도 헤더 탭과 일치하도록 'Inbox' 로 통일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:38 +09:00
th-kim0823
e2058cfdbe chore(release): v0.3.6 — 이동 modal 복원 (v0.3.5 의도 정정)
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋남.
사용자는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 만 거슬렸지,
사유 입력 + AI 자동 분류 + 수동 status 선택을 한 곳에서 처리하는 modal 은
보존해야 하는 핵심 UX 였음. 단일 "이동" 버튼 → MoveStatusModal path 로 정정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:16:14 +09:00
th-kim0823
2c6bfebb5b chore(release): v0.3.5 — dogfood UX hotfix 7건
v0.3.4 까지 누적된 dogfood UX 결함 hotfix.
사용자 직접 보고 3건 (inbox 재진입, 회고 탈출, 이동 modal 중복) + 동반 갭 4건
(count stale, 필터 잔류, 초기 로드 불일치, 배너 컨텍스트 누수).
데이터/마이그레이션 변경 없음 (스키마 v8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:47:04 +09:00
altair823
e815289b2a chore: gitignore .claude/worktrees + .claude/settings.local.json
Claude Code 로컬 worktree 와 사용자별 권한 override 파일 무시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:09:47 +09:00
13 changed files with 251 additions and 111 deletions

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ test-results/
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
build/icons/
build/icon-source.png
# Claude Code 로컬 worktree + 사용자별 settings
.claude/worktrees/
.claude/settings.local.json

View File

@@ -3,6 +3,73 @@
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
## [0.3.7] — 2026-05-11
`MoveStatusModal` 의 button hardcode 로 인해 완료/보관/휴지통 노트가 inbox 로 돌아올 수 없던 버그 fix. v0.2.9 Cut B 부터 존재한 잠재 결함 (dropdown 의 `possibleTargets` 필터가 modal 까지 흐르지 못함).
### 수정
- **완료/보관/휴지통 노트의 Inbox 복원 path 부재.** `MoveStatusModal``완료/보관/휴지통` 3 button hardcode 라 currentStatus 외 3 status 만 동적으로 노출해야 한다는 의도가 누락. `currentStatus: NoteStatus` prop 추가 + 4 status 중 current 제외 동적 render. NoteCard 가 `local.status` 전달.
- **status label 일관성** — `statusLabel('active')` 가 '활성' 이었으나 헤더 탭 표기는 'Inbox'. modal button + AI 추천 텍스트 양쪽 모두 'Inbox' 로 통일.
### 게이트
- 단위 736 → **739 PASS** (+3: completed/archived/trashed currentStatus button list 검증)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.6 인스톨러 위에 v0.3.7 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.6] — 2026-05-11
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋난 점 정정. 이동 modal (사유 + AI 자동 분류 + 수동 status 선택) 은 보존해야 하는 핵심 UX 였음.
### 수정
- **이동 dropdown → 단일 "이동" 버튼 + `MoveStatusModal` 복원.** v0.3.5 에서 dropdown 항목 클릭 = 즉시 setStatus 로 단순화한 path 를 되돌림. 사용자 의도는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 제거였지, modal 자체 제거가 아니었음. 단일 "이동" 버튼 → modal → 사유 입력 + AI 자동 분류 + 수동 status 선택 path 로 통일.
- **`MoveStatusModal.tsx` + 테스트 6 case 복원** — v0.3.5 에서 dead code 로 판단해 삭제했으나 다시 mount 됨. statusLabel 헬퍼 위치는 modal 내부로 회귀 (orphan `statusLabel.ts` 제거).
- **이동 후 `refreshMeta()` 호출 유지** — v0.3.5 D1 fix (setStatus IPC 가 pushNoteUpdated emit 안 함 → 헤더 탭 count stale) 는 modal `onMoved` callback path 에서도 동일하게 트리거.
### 게이트
- 단위 734 → **736 PASS** (NoteCard 이동 case 3 → 2 + MoveStatusModal 6 복원)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.5 인스톨러 위에 v0.3.6 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.5] — 2026-05-11
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
### 수정
- **Inbox 탭 진입 실패: 다른 보관함에서 inbox 로 못 돌아옴.** `setView('inbox')` 가 reload 를 호출 안 해서 `notes` state 가 이전 view 의 status 로 stale. `loadByView` 시그니처에 `'inbox' → 'active'` 매핑 추가 + setView 의 reload 분기에 inbox 포함.
- **회고 view 탈출 불가.** `ReviewView` 가 App 의 헤더를 우회 (early return) 해서 사용자가 뒤로 갈 길이 없던 문제. `← 돌아가기` 버튼 추가 — 클릭 시 `setView('inbox')`.
- **이동 dropdown 의 modal 중복 질문.** dropdown 에서 "완료로 이동" 선택해도 `MoveStatusModal` 이 떠서 목적지를 재확인. dropdown 클릭 = `inboxApi.setStatus(id, target, null)` 즉시 호출로 단순화. modal path 자체 제거 (`MoveStatusModal.tsx` + 동반 테스트 삭제, `statusLabel` 헬퍼는 별도 `statusLabel.ts` 로 분리).
- **이동 후 헤더 탭 count stale.** `setStatus` IPC 가 `pushNoteUpdated` emit 을 안 해서 `refreshMeta` 가 트리거되지 않던 잠재 버그. dropdown 이동 path 끝에 `refreshMeta()` 명시 호출 추가.
- **View 전환 시 검색/태그 필터 잔류.** `setView``searchResults`/`searchQuery`/`tagFilter` 를 reset 안 해서 이전 view 의 필터가 완료/보관/휴지통/회고에 잘못 적용. 한 번에 reset.
- **Inbox 첫 로드와 탭 reload 결과 불일치.** `loadInitial``listNotes()` (= `deleted_at IS NULL` = active+completed+archived 혼재) 사용 → 헤더 inbox count (active 만) 와 list 불일치. `listByStatus('active', limit:50)` 로 통일.
- **AI 배너가 completed/archived 탭에서도 노출.** OllamaBanner/PendingBanner/FailedBanner/ExpiryBanner/RecallBanner/RecoveryToast 가 `!showTrash` 만 체크해서 active 무관 컨텍스트에서도 그림. `view === 'inbox'` 분기로 한정.
### 갱신
- **이동 dropdown UX** — 메뉴 열린 상태에서 외부 클릭 / Escape 로 닫힘 (mousedown + keydown listener, useEffect 로 menuOpen=true 일 때만 활성).
### 게이트
- 단위 738 → **734 PASS** (MoveStatusModal 6 case 삭제 + NoteCard 메뉴 case 1 → 3 (직접 이동/외부 클릭/Escape) 로 재구성)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.4 인스톨러 위에 v0.3.5 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.4] — 2026-05-11
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.3.3",
"version": "0.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.3.3",
"version": "0.3.7",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.3.4",
"version": "0.3.7",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",

View File

@@ -153,15 +153,21 @@ export function App(): React.ReactElement {
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',

View File

@@ -6,17 +6,24 @@ interface Props {
noteId: string;
rawText: string;
summary: string;
/** 현재 노트 status. 이 값을 제외한 나머지 status 가 이동 버튼으로 노출. */
currentStatus: NoteStatus;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
* 메모 이동 Modal.
*
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
*/
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
export function MoveStatusModal({
noteId,
currentStatus,
onClose,
onMoved
}: Props): React.ReactElement {
@@ -90,9 +97,11 @@ export function MoveStatusModal({
<button onClick={() => void classify()} disabled={classifying}>
{classifying ? '분류 중...' : 'AI 자동 분류'}
</button>
<button onClick={() => void move('completed')}></button>
<button onClick={() => void move('archived')}></button>
<button onClick={() => void move('trashed')}></button>
{ALL_STATUSES.filter((s) => s !== currentStatus).map((s) => (
<button key={s} onClick={() => void move(s)}>
{statusLabel(s)}
</button>
))}
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
@@ -126,7 +135,8 @@ export function MoveStatusModal({
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
// 헤더 탭 표기 ('Inbox') 와 일치. UI 전반에서 active = Inbox 동의어.
return 'Inbox';
case 'completed':
return '완료';
case 'archived':

View File

@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -116,17 +116,12 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const possibleTargets: NoteStatus[] = (
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
).filter((s) => s !== local.status);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
@@ -424,64 +419,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
alignItems: 'center'
}}
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 2,
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
padding: 4,
zIndex: 10,
minWidth: 140,
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
}}
>
{possibleTargets.map((t) => (
<button
key={t}
onClick={() => {
setMoveTarget(t);
setMenuOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '6px 8px',
fontSize: 12,
cursor: 'pointer'
}}
>
{statusLabelWithParticle(t)}
</button>
))}
</div>
)}
</div>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
@@ -509,19 +463,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
</div>
{moveTarget !== null && (
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveTarget(null)}
currentStatus={local.status}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
setMoveTarget(null);
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}

View File

@@ -14,13 +14,37 @@ const periodLabel: Record<Props['period'], string> = {
export function ReviewView({ period }: Props): React.ReactElement {
const reviewData = useInbox((s) => s.reviewData);
const setView = useInbox((s) => s.setView);
const backButton = (
<button
onClick={() => setView('inbox')}
style={{
background: 'transparent',
border: 'none',
fontSize: 14,
cursor: 'pointer',
color: '#0a4b80',
padding: 0
}}
>
</button>
);
if (!reviewData) {
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}> </div>;
return (
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>{backButton}</div>
<div style={{ fontSize: 13, color: '#666' }}> </div>
</div>
);
}
const max = reviewData.tagCounts[0]?.count ?? 1;
return (
<div style={{ padding: 16 }}>
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{backButton}
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
</div>
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
{reviewData.totalCount}
</div>

View File

@@ -52,7 +52,7 @@ interface InboxState {
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
@@ -104,7 +104,9 @@ export const useInbox = create<InboxState>((set, get) => ({
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
// 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(),
@@ -182,13 +184,18 @@ export const useInbox = create<InboxState>((set, get) => ({
else get().setView('inbox');
},
setView(view) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings'
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// settings/inbox 외 status view 면 해당 status fetch.
if (view === 'completed' || view === 'archived' || view === 'trash') {
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
@@ -197,7 +204,8 @@ export const useInbox = create<InboxState>((set, get) => ({
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
const status = view === 'trash' ? 'trashed' : view;
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 });

View File

@@ -32,6 +32,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
@@ -50,6 +51,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>
@@ -69,6 +71,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>
@@ -81,6 +84,59 @@ describe('MoveStatusModal', () => {
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
it('currentStatus=completed → Inbox/보관/휴지통 노출, 완료 미노출', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="completed"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '완료' })).toBeNull();
});
it('currentStatus=archived → Inbox 버튼 클릭 시 setStatus("active") 호출', async () => {
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/완료/보관 노출, 휴지통 미노출', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="trashed"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
@@ -88,6 +144,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>

View File

@@ -32,10 +32,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
}
}));
const mockRefreshMeta = vi.fn();
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn() }) }
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
)
}));
@@ -127,34 +128,36 @@ describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () =
});
});
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
describe('NoteCard — 이동 버튼', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('이동 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
it('이동 클릭 → MoveStatusModal 열림', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
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('dialog', { name: '이동' })).toBeInTheDocument();
});
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
it('Modal 내부 "완료" 버튼 → setStatus 호출 + onUpdated + onDeleted + refreshMeta', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
const onDeleted = vi.fn();
render(
<NoteCard
note={baseNote}
onUpdated={onUpdated}
onDeleted={onDeleted}
mode="inbox"
/>
);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
// Modal 의 dialog role 등장
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
// Modal 내부의 "완료" 버튼 클릭 → setStatus
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
expect(onDeleted).toHaveBeenCalled();
expect(mockRefreshMeta).toHaveBeenCalled();
});
});
});

View File

@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
listByStatus: vi.fn(async () => []),
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),

View File

@@ -3,6 +3,7 @@ import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listByStatus: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),