d3ro-voice/apps/desktop/tests/unit/meeting-page-models-wave2.test.ts
Yun Chan f064c93197 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.
2026-09-28 20:46:08 +09:00

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')
})
})