From ed8b585804c0cd995571396a630ceb1ac3799e5e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:25 +0900 Subject: [PATCH] fix(history): call memo tag API with params objects, render stored summary, and cancel stale audio playback --- .../components/shared/HistoryEntryCard.tsx | 158 +++++++++------ .../shared/history-entry-card-actions.ts | 177 +++++++++++++++++ .../history-entry-card-redteam-r2-33.test.ts | 187 ++++++++++++++++++ 3 files changed, 462 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/renderer/components/shared/history-entry-card-actions.ts create mode 100644 apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts diff --git a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx index 346e609..484905a 100644 --- a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx +++ b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx @@ -10,6 +10,33 @@ import { useI18n } from '@d3ro/i18n' import { formatDuration } from '../../utils/formatters' import { isImeComposingEvent } from '../../utils/keyboard' import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types' +import { + HistoryAudioPlayer, + addEntryTag, + loadEntryTags, + removeEntryTag, + summaryDisplayText, + type HistoryAudioPort, + type HistoryAudioState, +} from './history-entry-card-actions' + +/** 렌더러 전역(IPC·Blob URL·HTMLAudioElement)을 재생 포트로 잇는 어댑터 */ +const browserHistoryAudioPort: HistoryAudioPort = { + fetchAudio: (historyId) => window.electronAPI.history.getAudio({ id: historyId }), + createObjectUrl: (bytes, mimeType) => URL.createObjectURL(new Blob([bytes.slice()], { type: mimeType })), + revokeObjectUrl: (url) => URL.revokeObjectURL(url), + createAudio: (src, events) => { + const audio = new Audio(src) + audio.onended = () => events.onEnded() + audio.onerror = () => events.onError() + return audio + }, +} + +interface TagFailure { + kind: 'add' | 'remove' + tag: string +} export interface HistoryEntryCardProps { entry: HistoryEntry @@ -40,66 +67,48 @@ export function HistoryEntryCard({ const [summaryLoading, setSummaryLoading] = useState(false) const [copied, setCopied] = useState(false) const hasSummary = !!entry.summaryText - const [audioState, setAudioState] = useState<'idle' | 'loading' | 'playing' | 'unavailable'>('idle') - const audioRef = useRef(null) - const objectUrlRef = useRef(null) + const [audioState, setAudioState] = useState('idle') + const [tagFailure, setTagFailure] = useState(null) + const playerRef = useRef(null) - const stopAudio = useCallback((next: 'idle' | 'unavailable' = 'idle') => { - audioRef.current?.pause() - audioRef.current = null - if (objectUrlRef.current) { - URL.revokeObjectURL(objectUrlRef.current) - objectUrlRef.current = null - } - setAudioState(next) + const getPlayer = useCallback((): HistoryAudioPlayer => { + if (!playerRef.current) playerRef.current = new HistoryAudioPlayer(browserHistoryAudioPort, setAudioState) + return playerRef.current }, []) - useEffect(() => () => stopAudio(), [stopAudio]) + // 언마운트되면 받아 오는 중인 요청까지 끊는다. StrictMode 재마운트를 위해 참조도 비운다. + useEffect( + () => () => { + playerRef.current?.dispose() + playerRef.current = null + }, + [], + ) // 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다. const handleToggleAudio = useCallback( - async (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation() + const player = getPlayer() if (audioState === 'playing') { - stopAudio() + player.stop() return } - setAudioState('loading') - try { - const result = await window.electronAPI.history.getAudio({ id: entry.id }) - if (!result.success || !result.data) { - stopAudio('unavailable') - return - } - let src: string - if (result.data.kind === 'local') { - src = URL.createObjectURL(new Blob([result.data.bytes.slice()], { type: result.data.mimeType })) - objectUrlRef.current = src - } else { - src = result.data.url - } - const audio = new Audio(src) - audioRef.current = audio - audio.onended = () => stopAudio() - audio.onerror = () => stopAudio('unavailable') - await audio.play() - setAudioState('playing') - } catch { - stopAudio('unavailable') - } + void player.play(entry.id) }, - [audioState, entry.id, stopAudio], + [audioState, entry.id, getPlayer], ) - const loadTags = useCallback(async () => { - if (!showTags) return - const result = await window.electronAPI.memo.getTags(entry.id) - if (result.success) setTags(result.data) - }, [entry.id, showTags]) - useEffect(() => { - loadTags() - }, [loadTags]) + if (!showTags) return + let active = true + void loadEntryTags(window.electronAPI.memo, entry.id).then((loaded) => { + if (active && loaded) setTags(loaded) + }) + return () => { + active = false + } + }, [entry.id, showTags]) const handleCopyClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -109,26 +118,41 @@ export function HistoryEntryCard({ } const handleAddTag = useCallback(async () => { - const trimmed = tagInput.trim() - if (!trimmed) return - const result = await window.electronAPI.memo.addTag(entry.id, trimmed) - if (result.success) { - setTags((prev) => [...prev, result.data]) - setTagInput('') - setShowTagInput(false) + const outcome = await addEntryTag(window.electronAPI.memo, entry.id, tagInput) + if (outcome.status === 'empty') return + if (outcome.status === 'failed') { + setTagFailure({ kind: 'add', tag: outcome.tag }) + return } + setTagFailure(null) + setTags((prev) => [...prev.filter((t) => t.tag !== outcome.tag.tag), outcome.tag]) + setTagInput('') + setShowTagInput(false) }, [entry.id, tagInput]) const handleRemoveTag = useCallback( async (tag: string) => { - const result = await window.electronAPI.memo.removeTag(entry.id, tag) - if (result.success) { - setTags((prev) => prev.filter((t) => t.tag !== tag)) + const removed = await removeEntryTag(window.electronAPI.memo, entry.id, tag) + if (!removed) { + setTagFailure({ kind: 'remove', tag }) + return } + setTagFailure(null) + setTags((prev) => prev.filter((t) => t.tag !== tag)) }, [entry.id], ) + const handleRetryTag = useCallback(() => { + if (!tagFailure) return + if (tagFailure.kind === 'remove') { + void handleRemoveTag(tagFailure.tag) + return + } + setShowTagInput(true) + void handleAddTag() + }, [tagFailure, handleAddTag, handleRemoveTag]) + const handleTagKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isImeComposingEvent(e)) return @@ -394,15 +418,19 @@ export function HistoryEntryCard({ ) => setTagInput(e.target.value)} + onChange={(e: React.ChangeEvent) => { + setTagInput(e.target.value) + if (tagFailure?.kind === 'add') setTagFailure(null) + }} onKeyDown={handleTagKeyDown} onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }} autoFocus placeholder={t('memo.tagPlaceholder')} + aria-invalid={tagFailure?.kind === 'add'} sx={{ - border: `1px solid ${d3roPalette.accent.main}`, + border: `1px solid ${tagFailure?.kind === 'add' ? d3roPalette.tag.red : d3roPalette.accent.main}`, bgcolor: d3roPalette.bg.input, color: d3roPalette.text.primary, fontFamily: d3roFontMono, @@ -429,6 +457,16 @@ export function HistoryEntryCard({ )} + {tagFailure && ( + + {`#${tagFailure.tag} · ${t('common.retry')}`} + + )} )} @@ -459,12 +497,12 @@ export function HistoryEntryCard({ whiteSpace: 'pre-wrap', }} > - {summary.summaryText} + {summaryDisplayText(summary) || entry.summaryText || t('meetingSummary.noSummary')} ) : ( - {entry.summaryText || t('meetingSummary.empty')} + {entry.summaryText || t('meetingSummary.noSummary')} )} diff --git a/apps/desktop/src/renderer/components/shared/history-entry-card-actions.ts b/apps/desktop/src/renderer/components/shared/history-entry-card-actions.ts new file mode 100644 index 0000000..afbd848 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/history-entry-card-actions.ts @@ -0,0 +1,177 @@ +// src/renderer/components/shared/history-entry-card-actions.ts +// HistoryEntryCard의 IO 정책 — 메모 태그 계약, 요약 표시 텍스트, 녹음 재생 수명. +// 렌더러 전역(window.electronAPI, URL, Audio)에 직접 닿지 않고 포트를 주입받아 +// 노드 테스트에서 그대로 검증할 수 있다. 어댑터는 컴포넌트(구성 루트)가 만든다. + +import type { IPCResult } from '@d3ro/core/errors' +import type { + AddTagParams, + GetTagsParams, + HistoryAudioSource, + MeetingSummaryResult, + MemoTag, + RemoveTagParams, +} from '@d3ro/core/types' + +// ── 메모 태그 ───────────────────────────────────────────── + +/** preload `memo` API 중 카드가 쓰는 부분. 인자는 반드시 params 객체 하나다. */ +export interface MemoTagPort { + getTags(params: GetTagsParams): Promise> + addTag(params: AddTagParams): Promise> + removeTag(params: RemoveTagParams): Promise> +} + +export type AddEntryTagOutcome = + | { status: 'empty' } + | { status: 'added'; tag: MemoTag } + | { status: 'failed'; tag: string } + +/** 항목의 태그를 불러온다. 실패하면 null — 호출자가 기존 목록을 유지한다. */ +export async function loadEntryTags(port: MemoTagPort, historyId: string): Promise { + try { + const result = await port.getTags({ historyId }) + return result.success ? result.data : null + } catch { + return null + } +} + +/** 입력값을 다듬어 태그를 붙인다. 빈 입력은 호출하지 않는다. */ +export async function addEntryTag( + port: MemoTagPort, + historyId: string, + rawTag: string, +): Promise { + const tag = rawTag.trim() + if (!tag) return { status: 'empty' } + try { + const result = await port.addTag({ historyId, tag }) + return result.success ? { status: 'added', tag: result.data } : { status: 'failed', tag } + } catch { + return { status: 'failed', tag } + } +} + +/** 태그를 뗀다. 성공 여부만 돌려준다. */ +export async function removeEntryTag(port: MemoTagPort, historyId: string, tag: string): Promise { + try { + const result = await port.removeTag({ historyId, tag }) + return result.success + } catch { + return false + } +} + +// ── 회의 요약 ───────────────────────────────────────────── + +/** + * 저장된 요약에서 보여 줄 본문. `summary`가 비었으면 `rawMarkdown`, + * 둘 다 비었으면 빈 문자열(호출자가 대체 문구를 고른다). + */ +export function summaryDisplayText(summary: Pick): string { + const body = summary.summary?.trim() + if (body) return body + return summary.rawMarkdown?.trim() ?? '' +} + +// ── 녹음 재생 ───────────────────────────────────────────── + +export type HistoryAudioState = 'idle' | 'loading' | 'playing' | 'unavailable' + +export interface PlayableAudio { + play(): Promise + pause(): void +} + +export interface HistoryAudioPort { + fetchAudio(historyId: string): Promise> + createObjectUrl(bytes: Uint8Array, mimeType: string): string + revokeObjectUrl(url: string): void + createAudio(src: string, events: { onEnded(): void; onError(): void }): PlayableAudio +} + +/** + * 카드 하나의 녹음 재생 수명을 소유한다. 요청마다 세대 번호를 올려, + * 오디오를 받아 오는 동안 멈춤·언마운트(dispose)가 일어나면 늦게 도착한 응답은 + * 재생하지 않고 만든 object URL도 바로 해제한다. + */ +export class HistoryAudioPlayer { + private generation = 0 + private audio: PlayableAudio | null = null + private objectUrl: string | null = null + private disposed = false + + constructor( + private readonly port: HistoryAudioPort, + private readonly onState: (state: HistoryAudioState) => void, + ) {} + + async play(historyId: string): Promise { + if (this.disposed) return + const gen = this.release() + this.emit('loading') + try { + const result = await this.port.fetchAudio(historyId) + if (!this.isCurrent(gen)) return + if (!result.success || !result.data) { + this.stop('unavailable') + return + } + const source = result.data + let src: string + if (source.kind === 'local') { + src = this.port.createObjectUrl(source.bytes, source.mimeType) + this.objectUrl = src + } else { + src = source.url + } + const audio = this.port.createAudio(src, { + onEnded: () => { + if (this.isCurrent(gen)) this.stop() + }, + onError: () => { + if (this.isCurrent(gen)) this.stop('unavailable') + }, + }) + this.audio = audio + await audio.play() + // play()가 끝나기 전에 멈췄다면 release()가 이미 pause·revoke했다. + if (!this.isCurrent(gen)) return + this.emit('playing') + } catch { + if (this.isCurrent(gen)) this.stop('unavailable') + } + } + + stop(next: 'idle' | 'unavailable' = 'idle'): void { + this.release() + this.emit(next) + } + + /** 언마운트 시 호출. 재생 중이든 받아 오는 중이든 모두 끊고 이후 상태 알림을 막는다. */ + dispose(): void { + this.disposed = true + this.release() + } + + private isCurrent(gen: number): boolean { + return !this.disposed && gen === this.generation + } + + /** 현재 오디오를 멈추고 URL을 해제한 뒤 새 세대 번호를 돌려준다. */ + private release(): number { + this.generation += 1 + this.audio?.pause() + this.audio = null + if (this.objectUrl) { + this.port.revokeObjectUrl(this.objectUrl) + this.objectUrl = null + } + return this.generation + } + + private emit(state: HistoryAudioState): void { + if (!this.disposed) this.onState(state) + } +} diff --git a/apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts b/apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts new file mode 100644 index 0000000..aea84d0 --- /dev/null +++ b/apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts @@ -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(): 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']) + }) +})