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.
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
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')
|
|
})
|
|
})
|