d3ro-voice/apps/desktop/tests/unit/records-view.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

281 lines
12 KiB
TypeScript

// 기록 화면(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\)/)
})
})