// 기록 화면(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 { 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 => ({ ...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() { let resolve!: (v: T) => void const promise = new Promise((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() 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\)/) }) })