187 lines
6.9 KiB
TypeScript
187 lines
6.9 KiB
TypeScript
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<T>(): IPCResult<T> {
|
|
return { success: false, error: { code: 'UNKNOWN', message: 'Failed' } } as unknown as IPCResult<T>
|
|
}
|
|
|
|
/** 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<MemoTag[]>()
|
|
}),
|
|
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<MemoTag>()
|
|
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<void>()
|
|
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<MemoTag[]>(),
|
|
addTag: async () => {
|
|
throw new Error('ipc down')
|
|
},
|
|
removeTag: async () => fail<void>(),
|
|
}
|
|
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<T>() {
|
|
let resolve!: (v: T) => void
|
|
const promise = new Promise<T>((r) => {
|
|
resolve = r
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function audioHarness() {
|
|
const pending = deferred<IPCResult<HistoryAudioSource | null>>()
|
|
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<HistoryAudioSource | null> = {
|
|
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'])
|
|
})
|
|
})
|