"""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()