mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
feat: 회의 오디오 원본 보관 — 전사가 놓친 발화를 되살릴 수 있게
46분 대면 회의 실측에서 Web Speech 포착률이 7~10%에 그쳤다(489어절 / 분당 10.6어절, 30초 이상 공백 26곳 26분 29초). 빈 텍스트 청크 19개는 Chrome이 `isFinal: true`에 `transcript: ""`를 준 것으로, 소리는 감지했으나 인식에 실패했다는 신호다. 지금까지는 오디오를 어디에도 남기지 않아 놓친 발화를 복구할 수도, 얼마나 놓쳤는지 잴 수도 없었다. 전사와 독립적으로 원음을 파일로 남긴다. ## 유실 방지 설계 메모리에 모았다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가 죽는 순간 회의 전체가 사라진다. 15초 조각마다 디스크에 append 하므로 어느 시점에 중단되든 그때까지의 오디오는 남는다. - 서버 업로드가 실패해도 녹음을 멈추지 않고 브라우저 사본으로 회수한다 - 조각이 3회 재시도 후에도 실패하면 뒤를 이어 붙이지 않고 멈춘다. 중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다 - 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄운다 - 서버 사본이 로컬보다 짧으면 경고한다 ## 캡처 제약 변경 `noiseSuppression: false, echoCancellation: false, autoGainControl: true`. 브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를 노이즈로 지운다. AGC는 조용한 화자를 끌어올려 주므로 남긴다. 실측으로 검증할 가설이며 회귀 방지 테스트를 걸어 뒀다. ## 검증 - 서버 파이프라인: 184조각 755KB 업로드 → 다운로드 SHA256 바이트 일치 - 클라이언트 전 경로: 합성 스트림으로 48초 녹음 → 로컬·서버 크기 일치, 서버 파일이 decodeAudioData로 48.12초 실제 오디오로 디코딩됨 - 경로 조작 400 / 없는 세션 404 / 빈 조각 400 / 미허용 mimeType 400 - 테스트 162 → 206 통과, 신규 타입 에러 0, 린트 baseline과 동일 실제 마이크 경로는 테스트하지 못했다. Web Speech와 getUserMedia가 같은 마이크를 두고 경합하는지 실사용 확인이 필요하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
This commit is contained in:
1 parent
9af00c3016
commit
cf3ce3f40c
17 files changed
+1696
-7
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, rm, readFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
|
||||
type Store = typeof import('@/lib/recording-store')
|
||||
|
||||
let dir: string
|
||||
let store: Store
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'recordings-'))
|
||||
process.env.RECORDINGS_DIR = dir
|
||||
vi.resetModules()
|
||||
store = await import('@/lib/recording-store')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.RECORDINGS_DIR
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('createRecording', () => {
|
||||
it('세션 메타를 만들고 확장자를 붙인다', async () => {
|
||||
const meta = await store.createRecording('audio/webm;codecs=opus')
|
||||
|
||||
expect(meta.id).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(meta.fileName).toBe(`${meta.id}.webm`)
|
||||
expect(meta.finalizedAt).toBeNull()
|
||||
expect(meta.durationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('매번 다른 id를 준다', async () => {
|
||||
const a = await store.createRecording('audio/webm')
|
||||
const b = await store.createRecording('audio/webm')
|
||||
expect(a.id).not.toBe(b.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendChunk', () => {
|
||||
it('조각을 받은 순서대로 이어 붙인다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
|
||||
await store.appendChunk(id, Buffer.from('AAA'))
|
||||
await store.appendChunk(id, Buffer.from('BBB'))
|
||||
const last = await store.appendChunk(id, Buffer.from('CC'))
|
||||
|
||||
expect(last).toEqual({ ok: true, bytes: 8 })
|
||||
|
||||
const meta = await store.getRecording(id)
|
||||
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||
expect(written).toBe('AAABBBCC')
|
||||
})
|
||||
|
||||
it('동시에 들어와도 순서가 섞이지 않는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
|
||||
// 클라이언트가 직렬로 보내도 서버에서 겹칠 수 있다. 겹쳐도 파일이
|
||||
// 깨지지 않아야 한다 — 순서가 어긋나면 컨테이너를 못 읽는다.
|
||||
await Promise.all([
|
||||
store.appendChunk(id, Buffer.from('1')),
|
||||
store.appendChunk(id, Buffer.from('2')),
|
||||
store.appendChunk(id, Buffer.from('3')),
|
||||
store.appendChunk(id, Buffer.from('4')),
|
||||
])
|
||||
|
||||
const meta = await store.getRecording(id)
|
||||
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||
expect(written).toHaveLength(4)
|
||||
expect(written.split('').sort().join('')).toBe('1234')
|
||||
})
|
||||
|
||||
it('없는 세션은 404로 거절한다', async () => {
|
||||
const result = await store.appendChunk('f'.repeat(32), Buffer.from('x'))
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: '녹음 세션을 찾을 수 없습니다.',
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('누적 크기를 정확히 보고한다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('keep'))
|
||||
|
||||
const result = await store.appendChunk(id, Buffer.alloc(1024))
|
||||
expect(result).toEqual({ ok: true, bytes: 4 + 1024 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalizeRecording', () => {
|
||||
it('길이를 확정하고 최종 크기를 돌려준다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('0123456789'))
|
||||
|
||||
const status = await store.finalizeRecording(id, 46_000)
|
||||
|
||||
expect(status?.bytes).toBe(10)
|
||||
expect(status?.durationMs).toBe(46_000)
|
||||
expect(status?.finalizedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('확정 정보가 디스크에도 남는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.finalizeRecording(id, 1_234)
|
||||
|
||||
const reloaded = await store.getRecording(id)
|
||||
expect(reloaded?.durationMs).toBe(1_234)
|
||||
})
|
||||
|
||||
it('길이가 숫자가 아니면 null로 둔다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
const status = await store.finalizeRecording(id, null)
|
||||
expect(status?.durationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('없는 세션은 null', async () => {
|
||||
expect(await store.finalizeRecording('e'.repeat(32), 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecording', () => {
|
||||
it('잘못된 id는 파일을 찾아보지도 않는다', async () => {
|
||||
expect(await store.getRecording('../../etc/passwd')).toBeNull()
|
||||
expect(await store.getRecording('..')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openRecording', () => {
|
||||
it('조각이 하나도 없으면 null', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
expect(await store.openRecording(id)).toBeNull()
|
||||
})
|
||||
|
||||
it('저장된 바이트를 그대로 읽어준다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('hello'))
|
||||
|
||||
const opened = await store.openRecording(id)
|
||||
expect(opened?.bytes).toBe(5)
|
||||
expect(opened?.meta.mimeType).toBe('audio/webm')
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of opened!.stream) {
|
||||
chunks.push(Buffer.from(chunk as Buffer))
|
||||
}
|
||||
expect(Buffer.concat(chunks).toString()).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('크래시 내성', () => {
|
||||
it('종료 처리 없이 중단돼도 그때까지의 오디오는 남는다', async () => {
|
||||
const { id } = await store.createRecording('audio/webm')
|
||||
await store.appendChunk(id, Buffer.from('part1'))
|
||||
await store.appendChunk(id, Buffer.from('part2'))
|
||||
// finalize 없이 프로세스가 죽었다고 치고 모듈을 새로 읽는다.
|
||||
vi.resetModules()
|
||||
const reloaded: Store = await import('@/lib/recording-store')
|
||||
|
||||
const status = await reloaded.getRecordingStatus(id)
|
||||
expect(status?.bytes).toBe(10)
|
||||
expect(status?.finalizedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
PREFERRED_MIME_TYPES,
|
||||
RECORDING_AUDIO_CONSTRAINTS,
|
||||
extensionForMimeType,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
isValidRecordingId,
|
||||
pickRecorderMimeType,
|
||||
recordingDownloadName,
|
||||
} from '@/lib/recording'
|
||||
|
||||
describe('pickRecorderMimeType', () => {
|
||||
it('가장 앞선 후보를 고른다', () => {
|
||||
const picked = pickRecorderMimeType(() => true)
|
||||
expect(picked).toBe(PREFERRED_MIME_TYPES[0])
|
||||
})
|
||||
|
||||
it('지원하지 않는 후보는 건너뛴다', () => {
|
||||
const picked = pickRecorderMimeType((type) => type === 'audio/mp4')
|
||||
expect(picked).toBe('audio/mp4')
|
||||
})
|
||||
|
||||
it('아무것도 지원하지 않으면 null', () => {
|
||||
expect(pickRecorderMimeType(() => false)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensionForMimeType', () => {
|
||||
it('코덱 파라미터를 떼고 판단한다', () => {
|
||||
expect(extensionForMimeType('audio/webm;codecs=opus')).toBe('webm')
|
||||
expect(extensionForMimeType('audio/ogg; codecs=opus')).toBe('ogg')
|
||||
})
|
||||
|
||||
it('주요 컨테이너를 매핑한다', () => {
|
||||
expect(extensionForMimeType('audio/webm')).toBe('webm')
|
||||
expect(extensionForMimeType('audio/mp4')).toBe('m4a')
|
||||
expect(extensionForMimeType('audio/mpeg')).toBe('mp3')
|
||||
expect(extensionForMimeType('audio/wav')).toBe('wav')
|
||||
})
|
||||
|
||||
it('대소문자를 가리지 않는다', () => {
|
||||
expect(extensionForMimeType('AUDIO/WEBM')).toBe('webm')
|
||||
})
|
||||
|
||||
it('모르는 형식은 bin으로 떨어진다', () => {
|
||||
expect(extensionForMimeType('application/octet-stream')).toBe('bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidRecordingId', () => {
|
||||
const valid = 'a'.repeat(32)
|
||||
|
||||
it('32자 hex만 통과시킨다', () => {
|
||||
expect(isValidRecordingId(valid)).toBe(true)
|
||||
expect(isValidRecordingId('0123456789abcdef0123456789abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('경로 조작 시도를 막는다', () => {
|
||||
expect(isValidRecordingId('../../etc/passwd')).toBe(false)
|
||||
expect(isValidRecordingId(`${valid}/../x`)).toBe(false)
|
||||
expect(isValidRecordingId('..')).toBe(false)
|
||||
expect(isValidRecordingId(`../${valid}`)).toBe(false)
|
||||
})
|
||||
|
||||
it('길이나 문자셋이 어긋나면 거부한다', () => {
|
||||
expect(isValidRecordingId('a'.repeat(31))).toBe(false)
|
||||
expect(isValidRecordingId('a'.repeat(33))).toBe(false)
|
||||
expect(isValidRecordingId('A'.repeat(32))).toBe(false)
|
||||
expect(isValidRecordingId('g'.repeat(32))).toBe(false)
|
||||
expect(isValidRecordingId('')).toBe(false)
|
||||
})
|
||||
|
||||
it('문자열이 아니면 거부한다', () => {
|
||||
expect(isValidRecordingId(null)).toBe(false)
|
||||
expect(isValidRecordingId(undefined)).toBe(false)
|
||||
expect(isValidRecordingId(123)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordingDownloadName', () => {
|
||||
const at = new Date(2026, 8, 8, 14, 5)
|
||||
|
||||
it('날짜와 제목을 붙인다', () => {
|
||||
expect(recordingDownloadName('주간 회의', at, 'audio/webm')).toBe(
|
||||
'20260908-1405_주간_회의.webm',
|
||||
)
|
||||
})
|
||||
|
||||
it('제목이 없으면 날짜만 쓴다', () => {
|
||||
expect(recordingDownloadName(' ', at, 'audio/webm')).toBe(
|
||||
'20260908-1405.webm',
|
||||
)
|
||||
})
|
||||
|
||||
it('파일명에 못 쓰는 문자를 지운다', () => {
|
||||
const name = recordingDownloadName('a/b\\c:d*e?f"g<h>i|j', at, 'audio/webm')
|
||||
expect(name).toBe('20260908-1405_abcdefghij.webm')
|
||||
expect(name).not.toMatch(/[\\/:*?"<>|]/)
|
||||
})
|
||||
|
||||
it('아주 긴 제목을 자른다', () => {
|
||||
const name = recordingDownloadName('가'.repeat(200), at, 'audio/webm')
|
||||
expect(name.length).toBeLessThan(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('단위를 바꿔가며 표기한다', () => {
|
||||
expect(formatBytes(512)).toBe('512B')
|
||||
expect(formatBytes(2048)).toBe('2KB')
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0MB')
|
||||
})
|
||||
|
||||
it('비정상 값은 0으로 떨어진다', () => {
|
||||
expect(formatBytes(-1)).toBe('0B')
|
||||
expect(formatBytes(NaN)).toBe('0B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('한 시간 미만은 분:초', () => {
|
||||
expect(formatDuration(0)).toBe('00:00')
|
||||
expect(formatDuration(65_000)).toBe('01:05')
|
||||
expect(formatDuration(46 * 60_000 + 6_000)).toBe('46:06')
|
||||
})
|
||||
|
||||
it('한 시간 이상은 시:분:초', () => {
|
||||
expect(formatDuration(3_600_000)).toBe('1:00:00')
|
||||
expect(formatDuration(3_725_000)).toBe('1:02:05')
|
||||
})
|
||||
|
||||
it('음수는 0으로 본다', () => {
|
||||
expect(formatDuration(-5_000)).toBe('00:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RECORDING_AUDIO_CONSTRAINTS', () => {
|
||||
it('원거리 화자를 지우는 전처리를 끈다', () => {
|
||||
// 46분 대면 회의에서 포착률이 10% 안팎에 그친 원인 중 하나.
|
||||
// 이 값이 다시 true로 돌아가면 회귀다.
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.noiseSuppression).toBe(false)
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.echoCancellation).toBe(false)
|
||||
})
|
||||
|
||||
it('조용한 화자를 끌어올리는 AGC는 남긴다', () => {
|
||||
expect(RECORDING_AUDIO_CONSTRAINTS.autoGainControl).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAudioRecorder } from '@/hooks/useAudioRecorder'
|
||||
|
||||
/**
|
||||
* jsdom에는 MediaRecorder도 getUserMedia도 없다. 훅이 다루는 것은
|
||||
* "조각이 언제 오고 어디로 가는가"이므로 그 둘만 흉내 내면 충분하다.
|
||||
*/
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = () => true
|
||||
|
||||
state: 'inactive' | 'recording' = 'inactive'
|
||||
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
constructor(
|
||||
public stream: MediaStream,
|
||||
public options?: MediaRecorderOptions,
|
||||
) {
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = 'recording'
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.state = 'inactive'
|
||||
// 실제 MediaRecorder는 stop() 시 남은 버퍼를 한 번 더 내보낸 뒤 stop을 쏜다.
|
||||
for (const fn of this.listeners.stop ?? []) fn()
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: () => void) {
|
||||
;(this.listeners[type] ??= []).push(fn)
|
||||
}
|
||||
|
||||
emit(bytes: number) {
|
||||
this.ondataavailable?.({ data: new Blob([new Uint8Array(bytes)]) })
|
||||
}
|
||||
}
|
||||
|
||||
let instances: FakeMediaRecorder[] = []
|
||||
let stopTracks: ReturnType<typeof vi.fn>
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200) {
|
||||
return { ok, status, json: async () => body }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
instances = []
|
||||
stopTracks = vi.fn()
|
||||
|
||||
vi.stubGlobal('MediaRecorder', FakeMediaRecorder)
|
||||
vi.stubGlobal('navigator', {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [{ stop: stopTracks }],
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32), mimeType: 'audio/webm' }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 1024 })
|
||||
return jsonResponse({ bytes: 1024 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function startedHook() {
|
||||
const view = renderHook(() => useAudioRecorder())
|
||||
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||
await act(async () => {
|
||||
await view.result.current.startRecording()
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
describe('useAudioRecorder — 캡처 제약', () => {
|
||||
it('원거리 화자를 지우는 전처리를 끄고 마이크를 연다', async () => {
|
||||
await startedHook()
|
||||
|
||||
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
|
||||
audio: expect.objectContaining({
|
||||
noiseSuppression: false,
|
||||
echoCancellation: false,
|
||||
autoGainControl: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 조각 업로드', () => {
|
||||
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(2048)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
)
|
||||
expect(chunkCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(view.result.current.localBytes).toBe(2048)
|
||||
})
|
||||
|
||||
it('빈 조각은 올리지 않는다', async () => {
|
||||
await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(0)
|
||||
})
|
||||
|
||||
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
)
|
||||
expect(chunkCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('업로드가 끝내 실패하면 뒤 조각을 이어 붙이지 않고 경고한다', async () => {
|
||||
// 중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다.
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ error: 'nope' }, false, 500)
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
|
||||
// 500은 일시 장애일 수 있으므로 백오프를 두고 3회까지 재시도한다.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(view.result.current.uploadWarning).toContain('서버 저장이 중단')
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
|
||||
const callsAfterBreak = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).endsWith('/chunk'),
|
||||
).length
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/chunk')).length,
|
||||
).toBe(callsAfterBreak)
|
||||
})
|
||||
|
||||
it('세션 생성이 실패해도 녹음은 계속된다', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ error: 'down' }, false, 500)
|
||||
}
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
expect(view.result.current.isRecording).toBe(true)
|
||||
expect(view.result.current.uploadWarning).toContain('내려받아')
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(4096)
|
||||
})
|
||||
expect(view.result.current.localBytes).toBe(4096)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAudioRecorder — 종료', () => {
|
||||
it('오디오를 한 조각도 못 받았으면 보관됐다고 말하지 않는다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.recording).toBeNull()
|
||||
expect(view.result.current.error).toContain('한 조각도 캡처되지 않았습니다')
|
||||
expect(view.result.current.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('받은 조각이 있으면 결과를 넘기고 마이크를 놓는다', async () => {
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(1024)
|
||||
})
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.recording?.blob.size).toBe(1024)
|
||||
expect(view.result.current.recording?.recordingId).toBe('a'.repeat(32))
|
||||
expect(stopTracks).toHaveBeenCalled()
|
||||
expect(view.result.current.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('서버 사본이 로컬보다 짧으면 경고한다', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/recordings') {
|
||||
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||
}
|
||||
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 10 })
|
||||
return jsonResponse({ bytes: 10 })
|
||||
})
|
||||
|
||||
const view = await startedHook()
|
||||
|
||||
await act(async () => {
|
||||
instances[0].emit(4096)
|
||||
})
|
||||
await act(async () => {
|
||||
await view.result.current.stopRecording()
|
||||
})
|
||||
|
||||
expect(view.result.current.uploadWarning).toContain('서버 사본이 로컬보다 짧습니다')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user