180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
"""Tests for cht.stream.manager — StreamManager orchestration."""
|
|
|
|
import json
|
|
import time
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from cht.stream.manager import StreamManager
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(tmp_path):
|
|
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
|
mgr = StreamManager(session_id="test_session")
|
|
yield mgr
|
|
mgr.stop_all()
|
|
|
|
|
|
class TestInit:
|
|
def test_session_id_default(self, tmp_path):
|
|
with patch("cht.stream.manager.SESSIONS_DIR", tmp_path):
|
|
mgr = StreamManager()
|
|
assert mgr.session_id
|
|
|
|
def test_session_id_custom(self, manager):
|
|
assert manager.session_id == "test_session"
|
|
|
|
def test_dirs_not_created_on_init(self, manager):
|
|
assert not manager.stream_dir.exists()
|
|
|
|
|
|
class TestSetupDirs:
|
|
def test_creates_all_subdirs(self, manager):
|
|
manager.setup_dirs()
|
|
assert manager.stream_dir.is_dir()
|
|
assert manager.frames_dir.is_dir()
|
|
assert manager.transcript_dir.is_dir()
|
|
assert manager.agent_dir.is_dir()
|
|
|
|
def test_idempotent(self, manager):
|
|
manager.setup_dirs()
|
|
manager.setup_dirs()
|
|
assert manager.stream_dir.is_dir()
|
|
|
|
|
|
class TestStreamUrl:
|
|
def test_default_url(self, manager):
|
|
assert "0.0.0.0" in manager.stream_url
|
|
assert "4444" in manager.stream_url
|
|
assert "listen" in manager.stream_url
|
|
|
|
|
|
class TestRecordingPath:
|
|
def test_is_in_stream_dir(self, manager):
|
|
assert manager.recording_path.parent == manager.stream_dir
|
|
assert manager.recording_path.name == "recording.ts"
|
|
|
|
|
|
class TestStartRecorder:
|
|
@patch("cht.stream.manager.ff.run_async")
|
|
@patch("cht.stream.manager.ff.receive_and_record")
|
|
def test_calls_ffmpeg_module(self, mock_record, mock_async, manager):
|
|
manager.setup_dirs()
|
|
mock_node = MagicMock()
|
|
mock_record.return_value = mock_node
|
|
|
|
manager.start_recorder()
|
|
|
|
mock_record.assert_called_once_with(
|
|
manager.stream_url, manager.recording_path,
|
|
)
|
|
mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
|
|
assert "recorder" in manager._procs
|
|
|
|
|
|
class TestStopAll:
|
|
@patch("cht.stream.manager.ff.stop_proc")
|
|
def test_stops_all_procs(self, mock_stop, manager):
|
|
proc1, proc2 = MagicMock(), MagicMock()
|
|
manager._procs = {"a": proc1, "b": proc2}
|
|
|
|
manager.stop_all()
|
|
|
|
mock_stop.assert_any_call(proc1)
|
|
mock_stop.assert_any_call(proc2)
|
|
assert len(manager._procs) == 0
|
|
|
|
def test_sets_stop_flag(self, manager):
|
|
manager.stop_all()
|
|
assert "stop" in manager._stop_flags
|
|
|
|
|
|
class TestExtractNewFrames:
|
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
def test_calls_ffmpeg_with_start_time(self, mock_extract, manager):
|
|
manager.setup_dirs()
|
|
rec = manager.recording_path
|
|
rec.touch()
|
|
|
|
mock_extract.return_value = ("", "")
|
|
manager._extract_new_frames(rec, start_time=10.0, start_number=5)
|
|
|
|
mock_extract.assert_called_once_with(
|
|
rec,
|
|
manager.frames_dir,
|
|
scene_threshold=0.3,
|
|
max_interval=30,
|
|
start_number=5,
|
|
start_time=10.0,
|
|
)
|
|
|
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
def test_indexes_new_frames(self, mock_extract, manager):
|
|
manager.setup_dirs()
|
|
rec = manager.recording_path
|
|
rec.touch()
|
|
|
|
# Simulate ffmpeg creating a frame file during extraction
|
|
def create_frame_and_return(*args, **kwargs):
|
|
(manager.frames_dir / "F0001.jpg").touch()
|
|
return ("", "[Parsed_showinfo_1 @ 0x1] n:0 pts:1000 pts_time:10.5 stuff\n")
|
|
|
|
mock_extract.side_effect = create_frame_and_return
|
|
|
|
count, max_ts = manager._extract_new_frames(rec, start_number=1)
|
|
assert count == 1
|
|
assert max_ts == 10.5
|
|
|
|
index_path = manager.frames_dir / "index.json"
|
|
with open(index_path) as f:
|
|
index = json.load(f)
|
|
assert len(index) == 1
|
|
assert index[0]["id"] == "F0001"
|
|
assert index[0]["timestamp"] == 10.5
|
|
|
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
def test_handles_ffmpeg_failure(self, mock_extract, manager):
|
|
manager.setup_dirs()
|
|
rec = manager.recording_path
|
|
rec.touch()
|
|
|
|
mock_extract.side_effect = RuntimeError("ffmpeg died")
|
|
count, max_ts = manager._extract_new_frames(rec)
|
|
assert count == 0
|
|
|
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
def test_skips_preexisting_frames(self, mock_extract, manager):
|
|
manager.setup_dirs()
|
|
rec = manager.recording_path
|
|
rec.touch()
|
|
|
|
# Pre-existing frame
|
|
(manager.frames_dir / "F0001.jpg").touch()
|
|
|
|
# ffmpeg "creates" no new files, just returns showinfo for existing
|
|
stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:5.0 stuff\n"
|
|
mock_extract.return_value = ("", stderr)
|
|
|
|
count, _ = manager._extract_new_frames(rec, start_number=1)
|
|
# F0001 already existed before extraction, should not be counted
|
|
assert count == 0
|
|
|
|
|
|
class TestSceneDetector:
|
|
@patch("cht.stream.manager.ff.extract_scene_frames")
|
|
def test_detects_growing_file(self, mock_extract, manager):
|
|
manager.setup_dirs()
|
|
mock_extract.return_value = ("", "")
|
|
|
|
# Create recording with some data
|
|
rec = manager.recording_path
|
|
rec.write_bytes(b"\x00" * 200_000)
|
|
|
|
manager.start_scene_detector()
|
|
time.sleep(12) # wait for one cycle
|
|
manager.stop_all()
|
|
|
|
mock_extract.assert_called()
|