fix(history): call memo tag API with params objects, render stored summary, and cancel stale audio playback
This commit is contained in:
parent
6347115ae7
commit
ed8b585804
3 changed files with 462 additions and 60 deletions
|
|
@ -10,6 +10,33 @@ import { useI18n } from '@d3ro/i18n'
|
||||||
import { formatDuration } from '../../utils/formatters'
|
import { formatDuration } from '../../utils/formatters'
|
||||||
import { isImeComposingEvent } from '../../utils/keyboard'
|
import { isImeComposingEvent } from '../../utils/keyboard'
|
||||||
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
|
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 {
|
export interface HistoryEntryCardProps {
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
|
|
@ -40,66 +67,48 @@ export function HistoryEntryCard({
|
||||||
const [summaryLoading, setSummaryLoading] = useState(false)
|
const [summaryLoading, setSummaryLoading] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const hasSummary = !!entry.summaryText
|
const hasSummary = !!entry.summaryText
|
||||||
const [audioState, setAudioState] = useState<'idle' | 'loading' | 'playing' | 'unavailable'>('idle')
|
const [audioState, setAudioState] = useState<HistoryAudioState>('idle')
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const [tagFailure, setTagFailure] = useState<TagFailure | null>(null)
|
||||||
const objectUrlRef = useRef<string | null>(null)
|
const playerRef = useRef<HistoryAudioPlayer | null>(null)
|
||||||
|
|
||||||
const stopAudio = useCallback((next: 'idle' | 'unavailable' = 'idle') => {
|
const getPlayer = useCallback((): HistoryAudioPlayer => {
|
||||||
audioRef.current?.pause()
|
if (!playerRef.current) playerRef.current = new HistoryAudioPlayer(browserHistoryAudioPort, setAudioState)
|
||||||
audioRef.current = null
|
return playerRef.current
|
||||||
if (objectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(objectUrlRef.current)
|
|
||||||
objectUrlRef.current = null
|
|
||||||
}
|
|
||||||
setAudioState(next)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => stopAudio(), [stopAudio])
|
// 언마운트되면 받아 오는 중인 요청까지 끊는다. StrictMode 재마운트를 위해 참조도 비운다.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
playerRef.current?.dispose()
|
||||||
|
playerRef.current = null
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
// 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다.
|
// 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다.
|
||||||
const handleToggleAudio = useCallback(
|
const handleToggleAudio = useCallback(
|
||||||
async (e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const player = getPlayer()
|
||||||
if (audioState === 'playing') {
|
if (audioState === 'playing') {
|
||||||
stopAudio()
|
player.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAudioState('loading')
|
void player.play(entry.id)
|
||||||
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')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[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(() => {
|
useEffect(() => {
|
||||||
loadTags()
|
if (!showTags) return
|
||||||
}, [loadTags])
|
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) => {
|
const handleCopyClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -109,26 +118,41 @@ export function HistoryEntryCard({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddTag = useCallback(async () => {
|
const handleAddTag = useCallback(async () => {
|
||||||
const trimmed = tagInput.trim()
|
const outcome = await addEntryTag(window.electronAPI.memo, entry.id, tagInput)
|
||||||
if (!trimmed) return
|
if (outcome.status === 'empty') return
|
||||||
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
|
if (outcome.status === 'failed') {
|
||||||
if (result.success) {
|
setTagFailure({ kind: 'add', tag: outcome.tag })
|
||||||
setTags((prev) => [...prev, result.data])
|
return
|
||||||
|
}
|
||||||
|
setTagFailure(null)
|
||||||
|
setTags((prev) => [...prev.filter((t) => t.tag !== outcome.tag.tag), outcome.tag])
|
||||||
setTagInput('')
|
setTagInput('')
|
||||||
setShowTagInput(false)
|
setShowTagInput(false)
|
||||||
}
|
|
||||||
}, [entry.id, tagInput])
|
}, [entry.id, tagInput])
|
||||||
|
|
||||||
const handleRemoveTag = useCallback(
|
const handleRemoveTag = useCallback(
|
||||||
async (tag: string) => {
|
async (tag: string) => {
|
||||||
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
|
const removed = await removeEntryTag(window.electronAPI.memo, entry.id, tag)
|
||||||
if (result.success) {
|
if (!removed) {
|
||||||
setTags((prev) => prev.filter((t) => t.tag !== tag))
|
setTagFailure({ kind: 'remove', tag })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setTagFailure(null)
|
||||||
|
setTags((prev) => prev.filter((t) => t.tag !== tag))
|
||||||
},
|
},
|
||||||
[entry.id],
|
[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(
|
const handleTagKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (isImeComposingEvent(e)) return
|
if (isImeComposingEvent(e)) return
|
||||||
|
|
@ -394,15 +418,19 @@ export function HistoryEntryCard({
|
||||||
<Box
|
<Box
|
||||||
component="input"
|
component="input"
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTagInput(e.target.value)
|
||||||
|
if (tagFailure?.kind === 'add') setTagFailure(null)
|
||||||
|
}}
|
||||||
onKeyDown={handleTagKeyDown}
|
onKeyDown={handleTagKeyDown}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!tagInput.trim()) setShowTagInput(false)
|
if (!tagInput.trim()) setShowTagInput(false)
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t('memo.tagPlaceholder')}
|
placeholder={t('memo.tagPlaceholder')}
|
||||||
|
aria-invalid={tagFailure?.kind === 'add'}
|
||||||
sx={{
|
sx={{
|
||||||
border: `1px solid ${d3roPalette.accent.main}`,
|
border: `1px solid ${tagFailure?.kind === 'add' ? d3roPalette.tag.red : d3roPalette.accent.main}`,
|
||||||
bgcolor: d3roPalette.bg.input,
|
bgcolor: d3roPalette.bg.input,
|
||||||
color: d3roPalette.text.primary,
|
color: d3roPalette.text.primary,
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
|
|
@ -429,6 +457,16 @@ export function HistoryEntryCard({
|
||||||
<Tag size={11} />
|
<Tag size={11} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
{tagFailure && (
|
||||||
|
<TactileBadge
|
||||||
|
tone="error"
|
||||||
|
role="alert"
|
||||||
|
onClick={handleRetryTag}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{`#${tagFailure.tag} · ${t('common.retry')}`}
|
||||||
|
</TactileBadge>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -459,12 +497,12 @@ export function HistoryEntryCard({
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{summary.summaryText}
|
{summaryDisplayText(summary) || entry.summaryText || t('meetingSummary.noSummary')}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.inactive }}>
|
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.inactive }}>
|
||||||
{entry.summaryText || t('meetingSummary.empty')}
|
{entry.summaryText || t('meetingSummary.noSummary')}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -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<IPCResult<MemoTag[]>>
|
||||||
|
addTag(params: AddTagParams): Promise<IPCResult<MemoTag>>
|
||||||
|
removeTag(params: RemoveTagParams): Promise<IPCResult<void>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddEntryTagOutcome =
|
||||||
|
| { status: 'empty' }
|
||||||
|
| { status: 'added'; tag: MemoTag }
|
||||||
|
| { status: 'failed'; tag: string }
|
||||||
|
|
||||||
|
/** 항목의 태그를 불러온다. 실패하면 null — 호출자가 기존 목록을 유지한다. */
|
||||||
|
export async function loadEntryTags(port: MemoTagPort, historyId: string): Promise<MemoTag[] | null> {
|
||||||
|
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<AddEntryTagOutcome> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await port.removeTag({ historyId, tag })
|
||||||
|
return result.success
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 회의 요약 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장된 요약에서 보여 줄 본문. `summary`가 비었으면 `rawMarkdown`,
|
||||||
|
* 둘 다 비었으면 빈 문자열(호출자가 대체 문구를 고른다).
|
||||||
|
*/
|
||||||
|
export function summaryDisplayText(summary: Pick<MeetingSummaryResult, 'summary' | 'rawMarkdown'>): 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<void>
|
||||||
|
pause(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryAudioPort {
|
||||||
|
fetchAudio(historyId: string): Promise<IPCResult<HistoryAudioSource | null>>
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts
Normal file
187
apps/desktop/tests/unit/history-entry-card-redteam-r2-33.test.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue