feat(desktop): information architecture overhaul — home, records, meetings first

Wave 2 of the 2026-09-28 design overhaul ("records are the main
character, a quiet instrument"). Decisions: design.md.

- Shell: sidebar with primary Home / Records / Meetings (Ctrl+1..3) and a
  secondary "Tools" group; every item is a button with aria-current.
  The status bar is removed — its "100% LOCAL PRIVACY · ZERO CLOUD SYNC"
  claim contradicted device sync; the sidebar shows only queried state.
- Navigation context (routes, params, back, useRouteRequest) so Home can
  open a specific meeting or record.
- Home replaces the dashboard: dictation key, start actions, only real
  problems, one-line summary, recent records and meetings, file
  transcription, free-tier limits.
- Settings become a full-screen route with vertical tabs (tabs no longer
  clip) and a shared row grammar; long-standing bugs fixed: team and
  enterprise tiers crashed the license tab (currentTier undefined),
  first-run onboarding could not be left, Pro+ saw "upgrade to Pro+".
- Records: date-grouped single list with in-place expansion, search and
  tags together, mode/favorite filters, load more past 50, undoable
  delete. Meetings: list with search/rename/delete, summary-first detail
  (summary | transcript | documents | my notes), notes persisted locally
  instead of being lost. Tools pages cleaned up; stale-closure example
  chips and a 1550x mouse-distance bug fixed.
- Shared pieces: PageHeader, EmptyStateCard, SearchInput ("/" works),
  ConfirmDialog, notify with undo; display-labels maps domain values to
  typed i18n keys so missing keys fail to compile.
- Mono font stack falls back to Pretendard for Hangul (Korean labels no
  longer render spaced out).
- i18n: new ko/en keys via scripts/i18n-add-keys.mjs (one read-merge-
  write so parallel editors cannot clobber locale files).

Some renderer files also carried pre-existing in-flight changes (error
recovery deep links, meeting document hooks); they are included as-is.
This commit is contained in:
Yun Chan 2026-09-28 20:46:08 +09:00
parent ba9ef9741e
commit f064c93197
96 changed files with 12886 additions and 8924 deletions

View file

@ -0,0 +1,335 @@
import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MeetingDocument } from '@d3ro/core/types'
import {
MEETING_DETAIL_TABS,
MeetingDocumentsStore,
extractActionItems,
meetingDocumentsPortFromApi,
resolveSelectedDocumentId,
selectPrimaryActionItems,
toggleActionItem,
withoutChecklistLines,
type DocumentWriteOutcome,
type MeetingDocumentsPort,
} from '../../src/renderer/components/meeting/meeting-documents-model'
function doc(id: string, content: string): MeetingDocument {
return {
id,
sessionId: 's1',
templateType: 'summary' as MeetingDocument['templateType'],
title: id,
content,
promptUsed: null,
llmModel: null,
llmLatencyMs: null,
createdAt: 0,
updatedAt: 0,
}
}
function fakePort(overrides: Partial<MeetingDocumentsPort> = {}): {
port: MeetingDocumentsPort
saveContent: ReturnType<typeof vi.fn<MeetingDocumentsPort['saveContent']>>
remove: ReturnType<typeof vi.fn<MeetingDocumentsPort['remove']>>
} {
const saveContent = vi.fn<MeetingDocumentsPort['saveContent']>(
overrides.saveContent ?? (async (): Promise<DocumentWriteOutcome> => ({ ok: true })),
)
const remove = vi.fn<MeetingDocumentsPort['remove']>(
overrides.remove ?? (async (): Promise<DocumentWriteOutcome> => ({ ok: true })),
)
return { port: { saveContent, remove }, saveContent, remove }
}
const CHECKLIST = [
'# 회의록',
'',
'- [ ] 예산 재검토',
'본문 문단',
' * [X] 들여쓴 별표 항목',
'-[ ] 붙여 쓴 항목',
'- [x] 완료 항목',
].join('\n')
describe('extractActionItems — 소스 줄 번호를 보존한다', () => {
it('들여쓰기·* 불릿·[X]·붙여 쓴 괄호를 모두 인식하고 줄 번호를 단다', () => {
expect(extractActionItems(CHECKLIST)).toEqual([
{ id: 'todo-2', lineIndex: 2, text: '예산 재검토', done: false },
{ id: 'todo-4', lineIndex: 4, text: '들여쓴 별표 항목', done: true },
{ id: 'todo-5', lineIndex: 5, text: '붙여 쓴 항목', done: false },
{ id: 'todo-6', lineIndex: 6, text: '완료 항목', done: true },
])
})
it('CRLF 본문도 인식한다', () => {
expect(extractActionItems('- [ ] a\r\n- [x] b\r\n')).toEqual([
{ id: 'todo-0', lineIndex: 0, text: 'a', done: false },
{ id: 'todo-1', lineIndex: 1, text: 'b', done: true },
])
})
})
describe('toggleActionItem — 목록 위치가 아니라 소스 줄을 고친다', () => {
it('해당 줄의 체크 표시 한 글자만 바꾼다', () => {
const next = toggleActionItem(CHECKLIST, 2, true)
expect(next).toBe(CHECKLIST.replace('- [ ] 예산 재검토', '- [x] 예산 재검토'))
})
it('들여쓴 * [X] 를 해제하면 들여쓰기와 불릿은 그대로다', () => {
const next = toggleActionItem(CHECKLIST, 4, false)
expect(next?.split('\n')[4]).toBe(' * [ ] 들여쓴 별표 항목')
expect(next?.split('\n').filter((_, i) => i !== 4)).toEqual(CHECKLIST.split('\n').filter((_, i) => i !== 4))
})
it('CRLF 를 보존한다', () => {
expect(toggleActionItem('- [ ] a\r\n- [ ] b', 1, true)).toBe('- [ ] a\r\n- [x] b')
})
it('체크리스트가 아닌 줄·범위 밖·같은 상태면 null', () => {
expect(toggleActionItem(CHECKLIST, 3, true)).toBeNull()
expect(toggleActionItem(CHECKLIST, 99, true)).toBeNull()
expect(toggleActionItem(CHECKLIST, 6, true)).toBeNull()
})
})
describe('selectPrimaryActionItems — 이전 목록을 남기지 않는다', () => {
it('1순위 문서에 체크리스트가 없거나 문서가 없으면 빈 목록', () => {
expect(selectPrimaryActionItems([doc('a', '본문만'), doc('b', '- [ ] x')])).toEqual([])
expect(selectPrimaryActionItems([])).toEqual([])
})
})
describe('상세 탭 · 문서 선택 정합', () => {
it('요약이 첫 탭이다(요약 먼저, 전사는 근거)', () => {
expect(MEETING_DETAIL_TABS[0]).toBe('summary')
expect(MEETING_DETAIL_TABS).toEqual(['summary', 'transcript', 'documents', 'notes'])
})
it('사라진 문서를 고르고 있으면 첫 문서, 문서가 없으면 null', () => {
const docs = [doc('a', ''), doc('b', '')]
expect(resolveSelectedDocumentId('b', docs)).toBe('b')
expect(resolveSelectedDocumentId('gone', docs)).toBe('a')
expect(resolveSelectedDocumentId(null, docs)).toBe('a')
expect(resolveSelectedDocumentId('gone', [])).toBeNull()
})
})
describe('withoutChecklistLines — 요약 본문에서 할 일 줄만 뺀다(표시 전용)', () => {
it('체크리스트 줄만 빠지고 나머지 줄은 그대로', () => {
expect(withoutChecklistLines('# 요약\n- [ ] 할 일\n본문\n- [x] 끝남\n- 일반 목록')).toBe('# 요약\n본문\n- 일반 목록')
})
})
describe('MeetingDocumentsStore', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('편집은 즉시 반영하고 저장은 문서별로 디바운스한다(A 의 대기 저장이 B 입력에 취소되지 않는다)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500)
store.editContent('a', 'A1')
expect(store.getState().documents[0].content).toBe('A1')
await vi.advanceTimersByTimeAsync(200)
store.editContent('b', 'B1')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['b', 'B1'],
])
})
it('연속 입력은 마지막 본문 한 번만 저장한다', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(300)
store.editContent('a', 'A12')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent.mock.calls).toEqual([['a', 'A12']])
})
it('flush 는 대기 중인 저장을 바로 실행한다(탭을 떠날 때)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await store.flush('a')
expect(saveContent.mock.calls).toEqual([['a', 'A1']])
await vi.advanceTimersByTimeAsync(1000)
expect(saveContent).toHaveBeenCalledTimes(1)
})
it('flushAll 은 모든 문서의 대기 저장을 실행한다(화면을 떠날 때)', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500)
store.editContent('a', 'A1')
store.editContent('b', 'B1')
await store.flushAll()
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['b', 'B1'],
])
})
it('자동저장 실패는 error 로 드러나고 입력은 지우지 않는다', async () => {
const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'db locked' }) })
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(600)
expect(store.getState().error).toBe('db locked')
expect(store.getState().documents[0].content).toBe('A1')
store.clearError()
expect(store.getState().error).toBeNull()
})
it('[회귀] 액션 아이템 체크는 문서 markdown 에 [x] 로 저장되고, 이후 다른 문서의 자동저장에도 유지된다', async () => {
const { port, saveContent } = fakePort()
const primary = '# 할 일\n- [ ] 예산 재검토\n- [ ] 일정 공유'
const store = new MeetingDocumentsStore([doc('a', primary), doc('b', 'B0')], port, 500)
const item = selectPrimaryActionItems(store.getState().documents)[1]
const result = await store.toggleActionItem('a', item.lineIndex, true)
expect(result).toEqual({ status: 'saved' })
expect(saveContent).toHaveBeenLastCalledWith('a', '# 할 일\n- [ ] 예산 재검토\n- [x] 일정 공유')
store.editContent('b', 'B1')
await vi.advanceTimersByTimeAsync(600)
expect(selectPrimaryActionItems(store.getState().documents).map((i) => i.done)).toEqual([false, true])
})
it('체크는 대기 중인 편집과 함께 최신 본문으로 저장되고, 대기 타이머는 다시 쓰지 않는다', async () => {
const { port, saveContent } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
store.editContent('a', '- [ ] a\n- [ ] b')
await store.toggleActionItem('a', 1, true)
expect(saveContent.mock.calls).toEqual([['a', '- [ ] a\n- [x] b']])
await vi.advanceTimersByTimeAsync(1000)
expect(saveContent).toHaveBeenCalledTimes(1)
})
it('체크 저장이 실패하면 체크를 되돌리고 error 를 남긴다', async () => {
const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'nope' }) })
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
const result = await store.toggleActionItem('a', 0, true)
expect(result).toEqual({ status: 'failed', message: 'nope' })
expect(store.getState().documents[0].content).toBe('- [ ] a')
expect(store.getState().error).toBe('nope')
})
it('포트가 던져도 실패로 처리한다', async () => {
const { port } = fakePort({
saveContent: async () => {
throw new Error('ipc down')
},
})
const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500)
expect(await store.toggleActionItem('a', 0, true)).toEqual({ status: 'failed', message: 'ipc down' })
})
it('같은 문서의 저장은 순서대로 하나씩 실행된다', async () => {
const resolvers: Array<() => void> = []
const { port, saveContent } = fakePort({
saveContent: () => new Promise<DocumentWriteOutcome>((resolve) => resolvers.push(() => resolve({ ok: true }))),
})
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
store.editContent('a', 'A1')
await vi.advanceTimersByTimeAsync(600)
store.editContent('a', 'A2')
await vi.advanceTimersByTimeAsync(600)
expect(saveContent).toHaveBeenCalledTimes(1)
resolvers[0]()
await vi.advanceTimersByTimeAsync(0)
expect(saveContent.mock.calls).toEqual([
['a', 'A1'],
['a', 'A2'],
])
resolvers[1]()
})
it('삭제 실패면 문서를 남기고 error 를 드러낸다', async () => {
const { port } = fakePort({ remove: async () => ({ ok: false, message: 'fk' }) })
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
expect(await store.remove('a')).toEqual({ status: 'failed', message: 'fk' })
expect(store.getState().documents.map((d) => d.id)).toEqual(['a'])
expect(store.getState().error).toBe('fk')
})
it('삭제가 확정되면 목록에서 빼고 대기 중인 자동저장은 쓰지 않는다', async () => {
const { port, saveContent, remove } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '- [ ] a'), doc('b', 'B0')], port, 500)
store.editContent('a', '- [ ] a!')
expect(await store.remove('a')).toEqual({ status: 'saved' })
await vi.advanceTimersByTimeAsync(1000)
expect(remove).toHaveBeenCalledWith('a')
expect(saveContent).not.toHaveBeenCalled()
expect(store.getState().documents.map((d) => d.id)).toEqual(['b'])
// 1순위 문서가 바뀌면 액션 아이템도 새 1순위에서 파생된다(삭제된 문서 목록이 남지 않는다).
expect(selectPrimaryActionItems(store.getState().documents)).toEqual([])
})
it('add 는 생성된 문서를 끝에 붙이고, 같은 id 는 두 번 붙이지 않는다', () => {
const { port } = fakePort()
const store = new MeetingDocumentsStore([doc('a', '')], port, 500)
store.add(doc('b', 'B'))
store.add(doc('b', 'B'))
expect(store.getState().documents.map((d) => d.id)).toEqual(['a', 'b'])
})
it('구독자에게 변경을 알리고, 바뀌지 않으면 스냅샷을 유지한다', () => {
const { port } = fakePort()
const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500)
const listener = vi.fn()
const unsubscribe = store.subscribe(listener)
const before = store.getState()
store.editContent('a', 'A0')
expect(store.getState()).toBe(before)
store.editContent('a', 'A1')
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})
describe('meetingDocumentsPortFromApi', () => {
it('IPCResult 를 저장 결과로 옮긴다', async () => {
const port = meetingDocumentsPortFromApi({
updateDocument: async () => ({ success: true, data: undefined }),
deleteDocument: async () => ({ success: false, error: { code: 1 as never, message: 'gone' } }),
})
expect(await port.saveContent('a', 'x')).toEqual({ ok: true })
expect(await port.remove('a')).toEqual({ ok: false, message: 'gone' })
})
})
describe('회의 상세 문서 구조 회귀', () => {
const dir = path.join(__dirname, '../../src/renderer/components/meeting')
const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8')
const documentTab = fs.readFileSync(path.join(dir, 'DocumentTab.tsx'), 'utf-8')
const editor = fs.readFileSync(path.join(dir, 'MarkdownEditor.tsx'), 'utf-8')
it('액션 아이템은 별도 상태 사본이 아니라 문서에서 파생한다', () => {
expect(detailTabs).not.toMatch(/setActionItems/)
expect(detailTabs).toMatch(/selectPrimaryActionItems/)
})
it('DocumentTab 은 문서마다 key 로 렌더링되고, 자체 디바운스 타이머를 두지 않는다', () => {
expect(detailTabs).toMatch(/<DocumentTab\s+key=\{currentDoc\.id\}/)
expect(documentTab).not.toMatch(/setTimeout/)
})
it('MarkdownEditor 는 content prop 을 내부 draft 로 복사하지 않는다', () => {
expect(editor).not.toMatch(/setDraft/)
})
it('문서 저장·삭제 IPC 는 뷰에서 직접 부르지 않는다', () => {
expect(detailTabs).not.toMatch(/meetingMode\.(updateDocument|deleteDocument)/)
})
})

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import type { MeetingDocument, MeetingSessionSummary } from '@d3ro/core/types'
import { appendMeetingPage, filterMeetingsByTitle, hasMoreMeetings } from '../../src/renderer/pages/meeting/meeting-list'
import { formatElapsed, shouldShowStatusBadge } from '../../src/renderer/components/meeting/meeting-format'
import {
clearNotepad,
loadNotepad,
notepadStorageKey,
saveNotepad,
type NotepadStorage,
} from '../../src/renderer/components/meeting/meeting-notepad-storage'
import {
formatActionItemsForCopy,
selectSummarySource,
summaryCopyText,
} from '../../src/renderer/components/meeting/meeting-summary-model'
function session(id: string, title: string | null): MeetingSessionSummary {
return { id, title, status: 'completed', startedAt: 0, endedAt: null, durationMs: null, memoCount: 0 }
}
function memoryStorage(): NotepadStorage & { data: Map<string, string> } {
const data = new Map<string, string>()
return {
data,
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => void data.set(k, v),
removeItem: (k) => void data.delete(k),
}
}
describe('회의 목록 모델', () => {
it('다음 페이지를 붙일 때 밀려 온 중복은 버린다', () => {
const merged = appendMeetingPage([session('a', 'A'), session('b', 'B')], [session('b', 'B'), session('c', 'C')])
expect(merged.map((s) => s.id)).toEqual(['a', 'b', 'c'])
expect(hasMoreMeetings(3, 3)).toBe(false)
expect(hasMoreMeetings(2, 3)).toBe(true)
})
it('제목 검색은 대소문자·공백을 무시하고, 제목 없는 회의는 대체 제목으로 찾는다', () => {
const list = [session('a', '주간 Sync 회의'), session('b', null)]
expect(filterMeetingsByTitle(list, 'sync 회의', '무제 회의').map((s) => s.id)).toEqual(['a'])
expect(filterMeetingsByTitle(list, '무제', '무제 회의').map((s) => s.id)).toEqual(['b'])
expect(filterMeetingsByTitle(list, ' ', '무제 회의')).toBe(list)
})
it('완료는 배지를 달지 않고 예외 상태만 드러낸다', () => {
expect(shouldShowStatusBadge('completed')).toBe(false)
expect(shouldShowStatusBadge('error')).toBe(true)
expect(formatElapsed(65_000)).toBe('01:05')
expect(formatElapsed(3_725_000)).toBe('1:02:05')
})
})
describe('내 메모 보관 — 닫아도 사라지지 않는다', () => {
it('회의 id 별로 저장·복원하고, 비우거나 회의를 지우면 키를 지운다', () => {
const storage = memoryStorage()
expect(saveNotepad(storage, 's1', '- 예산 재검토')).toBe(true)
expect(loadNotepad(storage, 's1')).toBe('- 예산 재검토')
expect(loadNotepad(storage, 's2')).toBe('')
saveNotepad(storage, 's1', ' ')
expect(storage.data.has(notepadStorageKey('s1'))).toBe(false)
saveNotepad(storage, 's1', 'x')
clearNotepad(storage, 's1')
expect(loadNotepad(storage, 's1')).toBe('')
})
it('저장소가 막히면 실패를 알리고 던지지 않는다', () => {
const broken: NotepadStorage = {
getItem: () => {
throw new Error('blocked')
},
setItem: () => {
throw new Error('quota')
},
removeItem: () => {
throw new Error('blocked')
},
}
expect(saveNotepad(broken, 's1', 'x')).toBe(false)
expect(loadNotepad(broken, 's1')).toBe('')
expect(() => clearNotepad(broken, 's1')).not.toThrow()
expect(saveNotepad(null, 's1', 'x')).toBe(false)
})
})
describe('요약 출처 — 1순위 문서 → 동기화된 회의록 → 없음', () => {
const doc = (content: string): MeetingDocument => ({
id: 'd1',
sessionId: 's1',
templateType: 'minutes',
title: '회의록',
content,
promptUsed: null,
llmModel: null,
llmLatencyMs: null,
createdAt: 0,
updatedAt: 0,
})
const labels = { decisions: '결정 사항', actionItems: '할 일' }
it('문서가 있으면 문서가 요약이다', () => {
const source = selectSummarySource([doc('# 요약\n본문')], '다른 회의록', null)
expect(source.kind).toBe('document')
expect(summaryCopyText(source, labels)).toBe('# 요약\n본문')
})
it('문서가 없으면 minutesMarkdown, 그다음 구조화된 minutes, 없으면 none', () => {
expect(selectSummarySource([], ' md ', null).kind).toBe('markdown')
const minutes = { summary: '요약', decisions: ['출시 연기'], actionItems: [{ task: '공지', assignee: '민수' }], timeline: [] }
const source = selectSummarySource([], null, minutes)
expect(source.kind).toBe('minutes')
expect(summaryCopyText(source, labels)).toBe('요약\n\n## 결정 사항\n- 출시 연기\n\n## 할 일\n- [ ] 공지 (민수)')
expect(selectSummarySource([], '', null).kind).toBe('none')
expect(summaryCopyText({ kind: 'none' }, labels)).toBe('')
})
it('할 일 복사는 체크리스트 형식', () => {
expect(formatActionItemsForCopy([{ text: 'a', done: true }, { text: 'b', done: false }])).toBe('- [x] a\n- [ ] b')
})
})

View file

@ -0,0 +1,281 @@
// 기록 화면(2026-09-28 개편) — 필터 조합, 서버 조회 선택, 날짜 묶음, 미리보기, 페이지 이어 불러오기.
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
import type { HistoryEntry, HistoryPage } from '@d3ro/core/types'
import {
EMPTY_RECORDS_FILTER,
applyRecordsFilter,
groupRecordsByDay,
isRecordsFilterActive,
matchesRecordText,
mergeRecords,
recordDayKind,
recordOriginalText,
recordPreview,
recordPrimaryText,
selectRecordsSource,
type RecordsFilter,
type RecordsSource,
} from '../../src/renderer/pages/history/records-view'
import { RecordsListStore, hasMoreRecords, type RecordsPort } from '../../src/renderer/pages/history/records-list-store'
const DAY = 86_400_000
function entry(id: string, over: Partial<HistoryEntry> = {}): HistoryEntry {
return {
id,
title: null,
originalText: `original ${id}`,
polishedText: null,
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: 'dictation',
status: 'completed',
errorCode: null,
audioLocalPath: null,
duration: 0,
detectedLanguage: null,
micDevice: null,
wordCount: 0,
sttModel: null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
createdAt: 1_000,
updatedAt: 1_000,
appVersion: 'test',
summaryText: null,
isFavorite: false,
...over,
}
}
const filter = (over: Partial<RecordsFilter>): RecordsFilter => ({ ...EMPTY_RECORDS_FILTER, ...over })
describe('기록 필터 — 검색과 태그를 함께 쓴다', () => {
it('서버 조회는 태그 → 검색 → 전체 순으로 하나만 고른다', () => {
expect(selectRecordsSource(filter({}))).toEqual({ kind: 'all' })
expect(selectRecordsSource(filter({ query: ' 회의 ' }))).toEqual({ kind: 'search', query: '회의' })
expect(selectRecordsSource(filter({ query: '회의', tag: 'work' }))).toEqual({ kind: 'tag', tag: 'work' })
})
it('태그로 불러온 목록 위에서 검색어를 다시 거른다 (태그를 골라도 검색이 살아 있다)', () => {
const rows = [
entry('a', { originalText: '예산 회의' }),
entry('b', { originalText: '점심 메모', polishedText: '점심 약속 정리' }),
entry('c', { originalText: 'x', summaryText: '회의 요약' }),
]
const result = applyRecordsFilter(rows, filter({ tag: 'work', query: '회의' }))
expect(result.map((e) => e.id)).toEqual(['a', 'c'])
})
it('검색만 걸리면 서버가 이미 거른 결과이므로 다시 거르지 않는다', () => {
const rows = [entry('a', { originalText: '다른 필드에서 걸린 결과' })]
expect(applyRecordsFilter(rows, filter({ query: '없는말' }))).toHaveLength(1)
})
it('모드 필터: 파일 = file-transcription, 명령·번역 = command + translate', () => {
const rows = [
entry('d', { mode: 'dictation' }),
entry('f', { mode: 'file-transcription' }),
entry('c', { mode: 'command' }),
entry('t', { mode: 'translate' }),
entry('cap', { mode: 'caption' }),
]
expect(applyRecordsFilter(rows, filter({ mode: 'file' })).map((e) => e.id)).toEqual(['f'])
expect(applyRecordsFilter(rows, filter({ mode: 'commandTranslate' })).map((e) => e.id)).toEqual(['c', 't'])
expect(applyRecordsFilter(rows, filter({ mode: 'caption' })).map((e) => e.id)).toEqual(['cap'])
expect(applyRecordsFilter(rows, filter({ mode: 'all' }))).toHaveLength(5)
})
it('즐겨찾기만 보기와 되돌리기를 기다리는 삭제(hidden)를 함께 적용한다', () => {
const rows = [entry('a', { isFavorite: true }), entry('b', { isFavorite: true }), entry('c')]
expect(applyRecordsFilter(rows, filter({ favoritesOnly: true }), new Set(['b'])).map((e) => e.id)).toEqual(['a'])
})
it('필터가 걸렸는지 — 공백 검색어는 필터가 아니다', () => {
expect(isRecordsFilterActive(EMPTY_RECORDS_FILTER)).toBe(false)
expect(isRecordsFilterActive(filter({ query: ' ' }))).toBe(false)
expect(isRecordsFilterActive(filter({ mode: 'caption' }))).toBe(true)
expect(isRecordsFilterActive(filter({ favoritesOnly: true }))).toBe(true)
expect(isRecordsFilterActive(filter({ tag: 'x' }))).toBe(true)
})
it('텍스트 검색은 대소문자를 가리지 않는다', () => {
expect(matchesRecordText(entry('a', { title: 'Weekly Sync' }), 'weekly')).toBe(true)
expect(matchesRecordText(entry('a'), 'nothing')).toBe(false)
})
})
describe('기록 목록 — 날짜 묶음 · 미리보기 · 복사 대상', () => {
it('최신순 목록을 로컬 날짜별로 묶는다', () => {
const key = (ts: number) => String(Math.floor(ts / DAY))
const rows = [entry('a', { createdAt: 2 * DAY + 5 }), entry('b', { createdAt: 2 * DAY + 1 }), entry('c', { createdAt: DAY })]
const groups = groupRecordsByDay(rows, key)
expect(groups.map((g) => g.entries.map((e) => e.id))).toEqual([['a', 'b'], ['c']])
})
it('날짜 라벨 종류: 오늘 / 어제 / 올해 / 그 전', () => {
const now = new Date(2026, 8, 28, 15, 0).getTime()
expect(recordDayKind(new Date(2026, 8, 28, 0, 1).getTime(), now)).toBe('today')
expect(recordDayKind(new Date(2026, 8, 27, 23, 59).getTime(), now)).toBe('yesterday')
expect(recordDayKind(new Date(2026, 0, 2).getTime(), now)).toBe('thisYear')
expect(recordDayKind(new Date(2025, 11, 31).getTime(), now)).toBe('older')
})
it('다듬은 글이 있으면 그것을 먼저 쓰고, 원문 복사는 그때만 있다', () => {
const polished = entry('a', { originalText: '음 그러니까 내일', polishedText: '내일 보고합니다.' })
expect(recordPrimaryText(polished)).toBe('내일 보고합니다.')
expect(recordOriginalText(polished)).toBe('음 그러니까 내일')
const raw = entry('b', { originalText: '원문만' })
expect(recordPrimaryText(raw)).toBe('원문만')
expect(recordOriginalText(raw)).toBeNull()
expect(recordOriginalText(entry('c', { originalText: '같다', polishedText: '같다' }))).toBeNull()
})
it('한 줄 미리보기 — 제목이 있으면 제목 + 본문 첫 줄', () => {
expect(recordPreview(entry('a', { title: '주간 회의', originalText: '\n 첫 줄\n둘째 줄' }))).toEqual({
title: '주간 회의',
snippet: '첫 줄',
})
expect(recordPreview(entry('b', { originalText: '첫 줄\n둘째 줄' }))).toEqual({ title: '첫 줄', snippet: null })
})
it('페이지를 이어 붙일 때 같은 id 는 한 번만, 최신순 유지', () => {
const merged = mergeRecords([entry('a', { createdAt: 3 }), entry('b', { createdAt: 2 })], [
entry('b', { createdAt: 2, isFavorite: true }),
entry('c', { createdAt: 1 }),
])
expect(merged.map((e) => e.id)).toEqual(['a', 'b', 'c'])
expect(merged[1].isFavorite).toBe(true)
})
})
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
function pageOf(entries: HistoryEntry[], total: number, page = 0, pageSize = 2): HistoryPage {
return { entries, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
describe('RecordsListStore — 50건 너머로 이어 불러오기', () => {
const all: RecordsSource = { kind: 'all' }
it('첫 페이지 뒤 loadMore 로 다음 페이지를 붙이고, 끝에 닿으면 더 없다', async () => {
const calls: Array<[string, number, number]> = []
const rows = [entry('a', { createdAt: 5 }), entry('b', { createdAt: 4 }), entry('c', { createdAt: 3 })]
const port: RecordsPort = {
fetchPage: async (source, page, pageSize) => {
calls.push([source.kind, page, pageSize])
return pageOf(rows.slice(page * pageSize, page * pageSize + pageSize), rows.length, page, pageSize)
},
fetchById: async () => null,
}
const store = new RecordsListStore(port, undefined, 2)
await store.setSource(all)
expect(store.getState().entries.map((e) => e.id)).toEqual(['a', 'b'])
expect(hasMoreRecords(store.getState(), 2)).toBe(true)
await store.loadMore()
expect(store.getState().entries.map((e) => e.id)).toEqual(['a', 'b', 'c'])
expect(hasMoreRecords(store.getState(), 2)).toBe(false)
expect(calls).toEqual([
['all', 0, 2],
['all', 1, 2],
])
// 새로 고침은 불러온 범위 전체를 한 번에
await store.reload()
expect(calls[2]).toEqual(['all', 0, 4])
})
it('첫 페이지 실패는 error 상태 — 무시하지 않는다. 다시 시도하면 회복한다', async () => {
let fail = true
const port: RecordsPort = {
fetchPage: async () => (fail ? null : pageOf([entry('a')], 1)),
fetchById: async () => null,
}
const store = new RecordsListStore(port, undefined, 2)
await store.setSource(all)
expect(store.getState().status).toBe('error')
fail = false
await store.reload()
expect(store.getState().status).toBe('ready')
expect(store.getState().entries).toHaveLength(1)
})
it('다음 페이지 실패는 목록을 두고 loadMoreFailed 로 알린다', async () => {
const port: RecordsPort = {
fetchPage: async (_s, page) => (page === 0 ? pageOf([entry('a'), entry('b')], 5) : Promise.reject(new Error('x'))),
fetchById: async () => null,
}
const store = new RecordsListStore(port, undefined, 2)
await store.setSource(all)
await store.loadMore()
expect(store.getState().entries).toHaveLength(2)
expect(store.getState().loadMoreFailed).toBe(true)
})
it('검색어가 바뀌면 늦게 도착한 이전 응답을 버린다', async () => {
const slow = deferred<HistoryPage | null>()
const port: RecordsPort = {
fetchPage: (source) =>
source.kind === 'search' && source.query === 'old' ? slow.promise : Promise.resolve(pageOf([entry('new')], 1)),
fetchById: async () => null,
}
const store = new RecordsListStore(port, undefined, 2)
const first = store.setSource({ kind: 'search', query: 'old' })
await store.setSource({ kind: 'search', query: 'new' })
slow.resolve(pageOf([entry('stale')], 1))
await first
expect(store.getState().entries.map((e) => e.id)).toEqual(['new'])
})
it('홈에서 넘긴 오래된 기록은 한 건 가져와 끼워 넣고, 새로 고쳐도 남긴다', async () => {
const port: RecordsPort = {
fetchPage: async () => pageOf([entry('recent', { createdAt: 10 })], 100),
fetchById: async (id) => (id === 'old' ? entry('old', { createdAt: 1 }) : null),
}
const store = new RecordsListStore(port, undefined, 2)
await store.setSource(all)
expect(await store.ensureRecord('old')).toBe(true)
expect(await store.ensureRecord('missing')).toBe(false)
await store.reload()
expect(store.getState().entries.map((e) => e.id)).toEqual(['recent', 'old'])
})
it('삭제가 확정되면 목록과 건수에서 뺀다', async () => {
const port: RecordsPort = {
fetchPage: async () => pageOf([entry('a'), entry('b')], 2),
fetchById: async () => null,
}
const store = new RecordsListStore(port, undefined, 2)
await store.setSource(all)
store.remove('a')
expect(store.getState().entries.map((e) => e.id)).toEqual(['b'])
expect(store.getState().total).toBe(1)
})
})
describe('HistoryPage 배선 — 기록 화면', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../src/renderer/pages/HistoryPage.tsx'), 'utf-8')
it('제목은 메뉴와 같은 "기록"', () => {
expect(src).toMatch(/title=\{t\('nav\.records'\)\}/)
})
it('홈에서 넘긴 recordId·query 를 받는다', () => {
expect(src).toMatch(/useRouteRequest\('records'/)
})
it('한 건 삭제는 확인 없이 숨기고 알림이 끝날 때(onExpire) 지운다', () => {
expect(src).toMatch(/onExpire:\s*\(\)\s*=>\s*void commitDelete\(id\)/)
})
})

View file

@ -0,0 +1,97 @@
// tests/unit/renderer-tools-model.test.ts
// 도구 화면(명령어·AI 대화·지식 베이스·사전)의 순수 판단:
// - 명령어 요약·라디오 선택은 main 의 경로 규칙과 같다(지시문은 기본 액션 'custom' 일 때만)
// - 시험 실행은 실제 경로를 따른다(원문 삽입 설정에서 'refine' 을 돌리지 않는다)
// - AI 대화 상태 표지는 세션이 꺼져 있으면 없다
// - 지연 삭제 중인 항목은 목록에서 빠진다
import { describe, expect, it } from 'vitest'
import {
COMMAND_NONE,
commandSelection,
commandTestRequest,
conversationBadge,
documentIndexLabelKey,
llmActionLabelKey,
resolveCommandPipeline,
withoutHidden,
} from '../../src/renderer/pages/tools/tools-model'
const polish = { id: 'i1', name: '정중하게', prompt: '{{text}} 를 정중하게' }
const list = [polish, { id: 'i2', name: '요약', prompt: '{{text}} 요약' }]
describe('resolveCommandPipeline', () => {
it("기본 액션 'none' 이면 원문 그대로 — 활성 지시문 id 가 남아 있어도", () => {
const p = resolveCommandPipeline({ defaultLLMAction: 'none', activeInstructionId: 'i1' }, list)
expect(p.kind).toBe('raw')
expect(commandSelection(p)).toBe(COMMAND_NONE)
})
it("'custom' + 활성 지시문이면 그 지시문", () => {
const p = resolveCommandPipeline({ defaultLLMAction: 'custom', activeInstructionId: 'i1' }, list)
expect(p).toEqual({ kind: 'instruction', instruction: polish })
expect(commandSelection(p)).toBe('i1')
})
it("'custom' 인데 지시문이 지워졌으면 '선택 안 됨'으로 정직하게", () => {
const p = resolveCommandPipeline({ defaultLLMAction: 'custom', activeInstructionId: 'gone' }, list)
expect(p.kind).toBe('instruction-missing')
expect(commandSelection(p)).toBe('')
})
it('일반 액션이 기본이면 활성 지시문이 있어도 그 액션이다(main 규칙과 같다) — 라디오는 아무것도 고르지 않는다', () => {
const p = resolveCommandPipeline({ defaultLLMAction: 'refine', activeInstructionId: 'i1' }, list)
expect(p).toEqual({ kind: 'action', action: 'refine' })
expect(commandSelection(p)).toBe('')
expect(llmActionLabelKey('refine')).toBe('settings.action.refine')
})
})
describe('commandTestRequest', () => {
it('지시문 경로는 custom + 지시문 원문으로 요청한다(치환은 main 이 한다)', () => {
const r = commandTestRequest({ kind: 'instruction', instruction: polish }, ' 안녕 ')
expect(r).toEqual({ ok: true, params: { text: '안녕', action: 'custom', customPrompt: polish.prompt } })
})
it('원문 삽입 설정에서는 실행하지 않는다 — 예전엔 refine 결과를 보여 줬다', () => {
expect(commandTestRequest({ kind: 'raw' }, '안녕')).toEqual({ ok: false, reason: 'raw' })
})
it('체인은 한 번의 LLM 호출로 흉내 내지 않는다', () => {
expect(commandTestRequest({ kind: 'action', action: 'chain' }, '안녕')).toEqual({ ok: false, reason: 'chain' })
})
it('빈 입력은 실행하지 않는다', () => {
expect(commandTestRequest({ kind: 'action', action: 'refine' }, ' ')).toEqual({ ok: false, reason: 'empty' })
})
})
describe('conversationBadge', () => {
it('세션이 꺼져 있으면 표지가 없다 — 켜지지 않은 대화를 대기라 쓰지 않는다', () => {
expect(conversationBadge({ active: false, state: 'idle', connecting: false })).toBeNull()
})
it('강조색은 듣는 중에만', () => {
expect(conversationBadge({ active: true, state: 'listening', connecting: false })?.tone).toBe('accent')
expect(conversationBadge({ active: true, state: 'thinking', connecting: false })?.tone).toBe('default')
})
it('연결 중이면 세션 상태보다 먼저 연결 중이라 쓴다', () => {
expect(conversationBadge({ active: true, state: 'idle', connecting: true })?.labelKey).toBe(
'conversation.realtime.connecting',
)
})
})
describe('withoutHidden / documentIndexLabelKey', () => {
it('지연 삭제 중인 id 를 목록에서 뺀다', () => {
expect(withoutHidden(list, new Set(['i1'])).map((i) => i.id)).toEqual(['i2'])
expect(withoutHidden(list, new Set())).toHaveLength(2)
})
it('색인이 끝나지 않은 문서는 진행 중일 때만 색인 중, 아니면 색인 안 됨', () => {
expect(documentIndexLabelKey({ id: 'd', indexed: false }, 'd')).toBe('rag.indexing')
expect(documentIndexLabelKey({ id: 'd', indexed: false }, null)).toBe('tools.knowledge.notIndexed')
expect(documentIndexLabelKey({ id: 'd', indexed: true }, null)).toBe('rag.indexed')
})
})