import { describe, it, expect } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { getHistoryService } from '../../src/main/services/HistoryService' import { registerHistoryHandlers } from '../../src/main/ipc/history-handlers' import { historyInput, invokeIpc, useRedHarness } from './harness' import { USER_TEXT } from './fixtures' useRedHarness() describe('유스케이스: 히스토리 CRUD / 검색 / 통계 / IPC', () => { it('대시보드에서 빈 히스토리를 열면 0건이다', () => { const page = getHistoryService().list({ page: 0, pageSize: 20 }) expect(page.total).toBe(0) expect(page.entries).toEqual([]) expect(page.totalPages).toBe(0) }) it('받아쓰기 완료 후 히스토리에 원문이 저장된다', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i) expect(entry.originalText).toBe(USER_TEXT.KO) expect(entry.status).toBe('completed') expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.KO) }) it('영문 받아쓰기도 같은 경로로 저장된다', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.EN })) expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.EN) }) it('유니코드/이모지 원문이 그대로 저장된다', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.UNICODE })) expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.UNICODE) }) it('긴 원문(4000자)이 잘리지 않고 저장된다', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.LONG, wordCount: 4000 })) expect(getHistoryService().getById(entry.id)?.originalText.length).toBe(4000) }) it('빈 원문도 오류 상태 엔트리로 저장할 수 있다', () => { const entry = getHistoryService().create( historyInput({ originalText: USER_TEXT.EMPTY, status: 'error', errorCode: 'STTNoAudioData', wordCount: 0 }), ) expect(entry.status).toBe('error') expect(entry.errorCode).toBe('STTNoAudioData') expect(getHistoryService().getById(entry.id)?.errorCode).toBe('STTNoAudioData') }) it('취소된 세션은 cancelled 상태로 남는다', () => { const entry = getHistoryService().create(historyInput({ status: 'cancelled', originalText: '' })) expect(getHistoryService().getById(entry.id)?.status).toBe('cancelled') }) it('같은 원문을 두 번 저장하면 서로 다른 id의 두 건이 된다', () => { const a = getHistoryService().create(historyInput({ originalText: USER_TEXT.DUP })) const b = getHistoryService().create(historyInput({ originalText: USER_TEXT.DUP })) expect(a.id).not.toBe(b.id) expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(2) }) it('존재하지 않는 id 조회는 null이다', () => { expect(getHistoryService().getById('missing-id')).toBeNull() }) it('한 건 삭제 후 목록에서 사라진다', () => { const a = getHistoryService().create(historyInput()) const b = getHistoryService().create(historyInput({ originalText: USER_TEXT.EN })) expect(getHistoryService().delete(a.id)).toBe(true) expect(getHistoryService().getById(a.id)).toBeNull() expect(getHistoryService().getById(b.id)).not.toBeNull() }) it('없는 id 삭제는 false를 반환한다', () => { expect(getHistoryService().delete('no-such')).toBe(false) }) it('전체 삭제는 목록과 통계 조회가 가능한 빈 상태를 만든다', () => { getHistoryService().create(historyInput()) getHistoryService().create(historyInput()) getHistoryService().deleteAll() expect(getHistoryService().list({ page: 0, pageSize: 20 }).total).toBe(0) const stats = getHistoryService().getStats() expect(stats.todaySessionCount).toBe(0) }) it('원문 검색은 부분 일치한다', () => { getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) getHistoryService().create(historyInput({ originalText: USER_TEXT.EN })) const found = getHistoryService().search({ query: '회의', page: 0, pageSize: 10 }) expect(found.total).toBe(1) expect(found.entries[0].originalText).toBe(USER_TEXT.KO) }) it('검색어가 없으면 매칭 0건이다', () => { getHistoryService().create(historyInput()) const found = getHistoryService().search({ query: 'zzzz-no-hit', page: 0, pageSize: 10 }) expect(found.total).toBe(0) expect(found.entries).toEqual([]) }) it('페이지네이션 두 번째 페이지는 첫 페이지와 겹치지 않는다', () => { for (let i = 0; i < 5; i++) { getHistoryService().create(historyInput({ originalText: `item-${i}` })) } const p0 = getHistoryService().list({ page: 0, pageSize: 2 }) const p1 = getHistoryService().list({ page: 1, pageSize: 2 }) expect(p0.entries).toHaveLength(2) expect(p1.entries).toHaveLength(2) const ids = new Set([...p0.entries, ...p1.entries].map((e) => e.id)) expect(ids.size).toBe(4) expect(p0.totalPages).toBe(3) }) it('완료 세션은 오늘 통계에 반영된다', () => { getHistoryService().create(historyInput({ duration: 2, wordCount: 4 })) getHistoryService().create(historyInput({ duration: 3, wordCount: 6 })) const stats = getHistoryService().getStats() expect(stats.todaySessionCount).toBe(2) expect(stats.todayWordCount).toBe(10) expect(stats.totalSessionCount).toBe(2) expect(stats.totalWordCount).toBe(10) }) it('error 상태 세션은 오늘 완료 통계에 포함되지 않는다', () => { getHistoryService().create(historyInput({ status: 'error', duration: 10, wordCount: 99 })) const stats = getHistoryService().getStats() expect(stats.todaySessionCount).toBe(0) }) it('30일 이내 항목은 보존 정리에서 삭제되지 않는다', () => { const entry = getHistoryService().create(historyInput()) expect(getHistoryService().runRetentionCleanup()).toBe(0) expect(getHistoryService().getById(entry.id)).not.toBeNull() }) it('IPC history:getAll 은 서비스 list 결과를 감싼다', async () => { registerHistoryHandlers() getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) const res = await invokeIpc(IPC_CHANNELS.HISTORY.GET_ALL, { page: 0, pageSize: 10 }) expect(res.success).toBe(true) if (res.success) { expect(res.data.total).toBe(1) expect(res.data.entries[0].originalText).toBe(USER_TEXT.KO) } }) it('IPC history:getById 없는 id 는 성공+null 이 아니라 조회 결과를 그대로 전달한다', async () => { registerHistoryHandlers() const res = await invokeIpc(IPC_CHANNELS.HISTORY.GET_BY_ID, { id: 'missing' }) expect(res.success).toBe(true) if (res.success) expect(res.data).toBeNull() }) it('IPC history:delete 없는 id 는 HistoryNotFound 로 실패한다', async () => { registerHistoryHandlers() const res = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: 'missing' }) expect(res.success).toBe(false) if (!res.success) expect(res.error.code).toBe(ErrorCode.HistoryNotFound) }) it('IPC history:delete 있는 id 는 성공하고 목록에서 사라진다', async () => { registerHistoryHandlers() const entry = getHistoryService().create(historyInput()) const res = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: entry.id }) expect(res.success).toBe(true) expect(getHistoryService().getById(entry.id)).toBeNull() }) it('IPC history:search 는 사용자 검색어로 필터한다', async () => { registerHistoryHandlers() getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) const res = await invokeIpc(IPC_CHANNELS.HISTORY.SEARCH, { query: '오후', page: 0, pageSize: 10 }) expect(res.success).toBe(true) if (res.success) expect(res.data.total).toBe(1) }) it('IPC history:deleteAll 후 목록이 비고 stats:getSummary 가 동작한다', async () => { registerHistoryHandlers() getHistoryService().create(historyInput()) const del = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE_ALL) expect(del.success).toBe(true) const stats = await invokeIpc(IPC_CHANNELS.STATS.GET_SUMMARY) expect(stats.success).toBe(true) if (stats.success) expect(stats.data.todaySessionCount).toBe(0) }) it('짧은 원문(<10자)은 자동 제목을 만들지 않고 title 이 null 이다', async () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.SHORT })) const title = await getHistoryService().generateTitle(entry.id) expect(title).toBeNull() expect(getHistoryService().getById(entry.id)?.title ?? null).toBeNull() }) it('없는 엔트리 generateTitle 은 null 이다', async () => { expect(await getHistoryService().generateTitle('missing')).toBeNull() }) it('LLM 제목 생성 실패 시 create 는 성공하고 title 은 비어 있다', async () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) expect(entry.id).toBeTruthy() await new Promise((r) => setTimeout(r, 20)) const stored = getHistoryService().getById(entry.id) expect(stored).not.toBeNull() expect(stored?.title ?? null).toBeNull() }) })