import fs from 'fs' import path from 'path' import { describe, expect, it, vi } from 'vitest' import type { IPCResult } from '@d3ro/core/errors' import type { HistoryAudioSource, MemoTag } from '@d3ro/core/types' import { HistoryAudioPlayer, addEntryTag, loadEntryTags, removeEntryTag, summaryDisplayText, type HistoryAudioPort, type HistoryAudioState, type MemoTagPort, } from '../../src/renderer/components/shared/history-entry-card-actions' const CARD = path.resolve(__dirname, '../../src/renderer/components/shared/HistoryEntryCard.tsx') function fail(): IPCResult { return { success: false, error: { code: 'UNKNOWN', message: 'Failed' } } as unknown as IPCResult } /** preload 계약을 흉내 낸다 — 인자 하나(params)만 main으로 넘긴다. */ function memoPort(tags: MemoTag[] = []) { const seen: unknown[] = [] const port: MemoTagPort = { getTags: vi.fn(async (...args: unknown[]) => { const params = args[0] as { historyId?: unknown } seen.push(params) return typeof params?.historyId === 'string' ? { success: true as const, data: tags } : fail() }), addTag: vi.fn(async (...args: unknown[]) => { const params = args[0] as { historyId?: unknown; tag?: unknown } seen.push(params) if (typeof params?.historyId !== 'string' || typeof params?.tag !== 'string') return fail() return { success: true as const, data: { id: 't1', historyId: params.historyId, tag: params.tag } as MemoTag } }), removeTag: vi.fn(async (...args: unknown[]) => { const params = args[0] as { historyId?: unknown; tag?: unknown } seen.push(params) if (typeof params?.historyId !== 'string' || typeof params?.tag !== 'string') return fail() return { success: true as const, data: undefined } }), } return { port, seen } } describe('HistoryEntryCard — 메모 태그 계약', () => { it('getTags/addTag/removeTag는 { historyId, tag } params 객체 하나로 부른다', async () => { const existing = { id: 'x', historyId: 'h1', tag: 'work' } as MemoTag const { port, seen } = memoPort([existing]) expect(await loadEntryTags(port, 'h1')).toEqual([existing]) const added = await addEntryTag(port, 'h1', ' meeting ') expect(added).toEqual({ status: 'added', tag: { id: 't1', historyId: 'h1', tag: 'meeting' } }) expect(await removeEntryTag(port, 'h1', 'work')).toBe(true) expect(seen).toEqual([{ historyId: 'h1' }, { historyId: 'h1', tag: 'meeting' }, { historyId: 'h1', tag: 'work' }]) }) it('빈 입력은 호출하지 않고, 실패·예외는 failed/false/null로 알린다', async () => { const { port } = memoPort() expect(await addEntryTag(port, 'h1', ' ')).toEqual({ status: 'empty' }) expect(port.addTag).not.toHaveBeenCalled() const broken: MemoTagPort = { getTags: async () => fail(), addTag: async () => { throw new Error('ipc down') }, removeTag: async () => fail(), } expect(await loadEntryTags(broken, 'h1')).toBeNull() expect(await addEntryTag(broken, 'h1', 'x')).toEqual({ status: 'failed', tag: 'x' }) expect(await removeEntryTag(broken, 'h1', 'x')).toBe(false) }) it('카드는 memo API에 위치 인자를 넘기지 않는다', () => { const src = fs.readFileSync(CARD, 'utf8') expect(src).not.toMatch(/memo\.(getTags|addTag|removeTag)\(\s*entry\.id/) expect(src).not.toMatch(/summary\.summaryText/) }) }) describe('HistoryEntryCard — 요약 표시', () => { it('MeetingSummaryResult.summary를 보여 주고, 비었으면 rawMarkdown', () => { expect(summaryDisplayText({ summary: '결정: 예산 확정', rawMarkdown: '# md' })).toBe('결정: 예산 확정') expect(summaryDisplayText({ summary: ' ', rawMarkdown: '# md' })).toBe('# md') expect(summaryDisplayText({ summary: '', rawMarkdown: '' })).toBe('') }) }) function deferred() { let resolve!: (v: T) => void const promise = new Promise((r) => { resolve = r }) return { promise, resolve } } function audioHarness() { const pending = deferred>() const played: string[] = [] const paused: string[] = [] const created: string[] = [] const revoked: string[] = [] const events: Array<{ onEnded(): void; onError(): void }> = [] const port: HistoryAudioPort = { fetchAudio: () => pending.promise, createObjectUrl: () => { const url = `blob:${created.length}` created.push(url) return url }, revokeObjectUrl: (url) => revoked.push(url), createAudio: (src, ev) => { events.push(ev) return { play: async () => { played.push(src) }, pause: () => paused.push(src), } }, } const states: HistoryAudioState[] = [] const player = new HistoryAudioPlayer(port, (s) => states.push(s)) return { pending, player, played, paused, created, revoked, events, states } } const localAudio: IPCResult = { success: true, data: { kind: 'local', bytes: new Uint8Array([1, 2]), mimeType: 'audio/webm' }, } describe('HistoryEntryCard — 녹음 재생 수명', () => { it('받아 오는 중에 언마운트(dispose)되면 늦은 응답을 재생하지 않는다', async () => { const h = audioHarness() const run = h.player.play('h1') h.player.dispose() h.pending.resolve({ success: true, data: { kind: 'remote', url: 'https://signed' } }) await run expect(h.played).toEqual([]) expect(h.created).toEqual([]) expect(h.states).toEqual(['loading']) }) it('받아 오는 중에 멈추면 로컬 녹음도 object URL을 만들지 않는다', async () => { const h = audioHarness() const run = h.player.play('h1') h.player.stop() h.pending.resolve(localAudio) await run expect(h.played).toEqual([]) expect(h.created).toEqual([]) expect(h.states).toEqual(['loading', 'idle']) }) it('정상 재생 → 끝나면 URL 해제, 재생 중 dispose는 멈추고 해제한다', async () => { const h = audioHarness() h.pending.resolve(localAudio) await h.player.play('h1') expect(h.played).toEqual(['blob:0']) expect(h.states).toEqual(['loading', 'playing']) h.events[0].onEnded() expect(h.revoked).toEqual(['blob:0']) expect(h.states.at(-1)).toBe('idle') const h2 = audioHarness() h2.pending.resolve(localAudio) await h2.player.play('h1') h2.player.dispose() expect(h2.paused).toEqual(['blob:0']) expect(h2.revoked).toEqual(['blob:0']) // 이전 세대 오디오의 늦은 onended는 상태를 건드리지 않는다 const before = h2.states.length h2.events[0].onEnded() expect(h2.states.length).toBe(before) }) it('녹음이 없으면 unavailable', async () => { const h = audioHarness() h.pending.resolve({ success: true, data: null }) await h.player.play('h1') expect(h.states).toEqual(['loading', 'unavailable']) }) })