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.
335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
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)/)
|
|
})
|
|
})
|