fix(history): call memo tag API with params objects, render stored summary, and cancel stale audio playback

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent 6347115ae7
commit ed8b585804
3 changed files with 462 additions and 60 deletions

View file

@ -0,0 +1,187 @@
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'])
})
})