204 lines
7.4 KiB
Python
204 lines
7.4 KiB
Python
"""
|
|
Unit tests for the CallRecorder ring buffer and per-call slicing.
|
|
|
|
No FFmpeg and no PulseAudio: the buffer is filled directly with timestamped
|
|
chunks, which is exactly what _ingest() produces at runtime.
|
|
"""
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.internal.call_recorder import (
|
|
CallRecorder,
|
|
MAX_RECORDING_SECONDS,
|
|
PRE_ROLL_SECONDS,
|
|
RING_BUFFER_SECONDS,
|
|
)
|
|
|
|
T0 = 1_700_000_000.0
|
|
CHUNK_INTERVAL = 0.1 # seconds of audio per synthetic chunk
|
|
|
|
|
|
@pytest.fixture
|
|
def recorder(tmp_path):
|
|
r = CallRecorder()
|
|
r._recordings_dir = tmp_path
|
|
r._capturing = True
|
|
return r
|
|
|
|
|
|
def fill(recorder, start: float, end: float, marker: bytes = b"A"):
|
|
"""Append one chunk every CHUNK_INTERVAL seconds over [start, end)."""
|
|
ts = start
|
|
index = 0
|
|
while ts < end:
|
|
recorder._buffer.append((ts, marker + str(index).encode() + b";"))
|
|
index += 1
|
|
ts = round(ts + CHUNK_INTERVAL, 6)
|
|
|
|
|
|
def timestamps(recorder):
|
|
return [ts for ts, _ in recorder._buffer]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ring buffer trimming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_idle_buffer_keeps_only_the_rolling_window(recorder):
|
|
with patch("app.internal.call_recorder.time.time", return_value=T0):
|
|
for offset in range(0, int(RING_BUFFER_SECONDS) + 20):
|
|
with patch("app.internal.call_recorder.time.time", return_value=T0 + offset):
|
|
recorder._ingest(b"x" * 16)
|
|
|
|
assert len(recorder._buffer) <= RING_BUFFER_SECONDS + 1
|
|
assert min(timestamps(recorder)) >= (T0 + RING_BUFFER_SECONDS + 19) - RING_BUFFER_SECONDS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_buffer_is_not_trimmed_below_the_active_slice(recorder):
|
|
fill(recorder, T0, T0 + 5.0)
|
|
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
|
|
|
|
# Ingest far past the normal rolling window; the slice start must survive.
|
|
with patch("app.internal.call_recorder.time.time", return_value=T0 + RING_BUFFER_SECONDS + 10):
|
|
recorder._ingest(b"z")
|
|
|
|
assert min(timestamps(recorder)) <= (T0 + 1.0) - PRE_ROLL_SECONDS + CHUNK_INTERVAL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pre-roll and slicing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slice_starts_pre_roll_before_the_op25_timestamp(recorder):
|
|
fill(recorder, T0, T0 + 10.0)
|
|
grant_time = T0 + 5.0
|
|
|
|
await recorder.start_recording("call-1", start_epoch=grant_time)
|
|
assert recorder._slice_start == pytest.approx(grant_time - PRE_ROLL_SECONDS)
|
|
|
|
path = await recorder.stop_recording(end_epoch=grant_time + 2.0)
|
|
assert path is not None and path.exists()
|
|
|
|
# Reconstruct which chunks landed in the file.
|
|
kept = path.read_bytes().decode().strip(";").split(";")
|
|
first_index = int(kept[0][1:])
|
|
first_ts = T0 + first_index * CHUNK_INTERVAL
|
|
|
|
# A chunk stamped `ts` holds the audio that arrived over [ts - interval, ts],
|
|
# so the audio actually covered must begin at or before the requested slice
|
|
# start — erring early is the safe direction, erring late loses speech.
|
|
assert first_ts - CHUNK_INTERVAL <= grant_time - PRE_ROLL_SECONDS + 1e-6
|
|
# ...and no more than one chunk of extra pre-roll is dragged in.
|
|
assert first_ts >= grant_time - PRE_ROLL_SECONDS - 1e-6
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tail_chunk_straddling_the_end_is_included(recorder):
|
|
fill(recorder, T0, T0 + 10.0)
|
|
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
|
|
|
|
# End halfway through a chunk interval.
|
|
path = await recorder.stop_recording(end_epoch=T0 + 3.05)
|
|
kept = path.read_bytes().decode().strip(";").split(";")
|
|
last_ts = T0 + int(kept[-1][1:]) * CHUNK_INTERVAL
|
|
|
|
assert last_ts >= T0 + 3.05, "the chunk covering the end instant must be kept"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_roll_earlier_than_buffer_start_is_clamped(recorder, caplog):
|
|
"""A grant older than anything buffered must still produce a file."""
|
|
fill(recorder, T0 + 5.0, T0 + 10.0) # buffer only covers T0+5 onwards
|
|
|
|
await recorder.start_recording("call-1", start_epoch=T0) # 5 s before the head
|
|
path = await recorder.stop_recording(end_epoch=T0 + 8.0)
|
|
|
|
assert path is not None and path.stat().st_size > 0
|
|
kept = path.read_bytes().decode().strip(";").split(";")
|
|
assert kept[0] == "A0", "slice should begin at the buffer head, not fail"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_buffered_audio_returns_none(recorder):
|
|
await recorder.start_recording("call-1", start_epoch=T0)
|
|
assert await recorder.stop_recording(end_epoch=T0 + 2.0) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_epoch_omitted_falls_back_to_now(recorder):
|
|
now = time.time()
|
|
fill(recorder, now - 5.0, now)
|
|
|
|
await recorder.start_recording("call-1")
|
|
assert recorder._slice_start == pytest.approx(now - PRE_ROLL_SECONDS, abs=1.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_recording_seconds_caps_the_slice(recorder):
|
|
fill(recorder, T0, T0 + MAX_RECORDING_SECONDS + 60, marker=b"A")
|
|
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
|
|
|
|
path = await recorder.stop_recording(end_epoch=T0 + MAX_RECORDING_SECONDS + 50)
|
|
kept = path.read_bytes().decode().strip(";").split(";")
|
|
last_ts = T0 + int(kept[-1][1:]) * CHUNK_INTERVAL
|
|
|
|
assert last_ts <= T0 + 1.0 + MAX_RECORDING_SECONDS + CHUNK_INTERVAL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recording lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_second_start_is_rejected_while_recording(recorder):
|
|
fill(recorder, T0, T0 + 5.0)
|
|
assert await recorder.start_recording("call-1", start_epoch=T0 + 1.0) is True
|
|
assert await recorder.start_recording("call-2", start_epoch=T0 + 2.0) is False
|
|
assert recorder.is_recording
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_without_start_is_a_noop(recorder):
|
|
assert await recorder.stop_recording() is None
|
|
assert not recorder.is_recording
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_then_immediate_restart_keeps_both_slices(recorder):
|
|
"""
|
|
A tgid change closes one recording and opens the next at the same instant —
|
|
the second must still find its pre-roll in the buffer.
|
|
"""
|
|
fill(recorder, T0, T0 + 10.0)
|
|
split = T0 + 5.0
|
|
|
|
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
|
|
first = await recorder.stop_recording(end_epoch=split)
|
|
|
|
await recorder.start_recording("call-2", start_epoch=split)
|
|
second = await recorder.stop_recording(end_epoch=T0 + 8.0)
|
|
|
|
assert first is not None and first.stat().st_size > 0
|
|
assert second is not None and second.stat().st_size > 0
|
|
assert first != second
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FFmpeg invocation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ffmpeg_command_reads_pulse_and_flushes_packets(recorder):
|
|
cmd = recorder._ffmpeg_command()
|
|
joined = " ".join(cmd)
|
|
|
|
assert "-f pulse" in joined
|
|
assert "drb_sink.monitor" in joined, "must address the monitor explicitly, not 'default'"
|
|
# Without -flush_packets the mp3 muxer buffers 32 KB (~16 s at 16 kbps) before
|
|
# writing, which would destroy the ring buffer's timestamp resolution.
|
|
assert "-flush_packets" in cmd
|
|
assert cmd[-1] == "-" and cmd[-2] == "mp3", "must emit MP3 on stdout for /upload"
|