"""Tests for cht.stream.manager — StreamManager orchestration.""" import json import os import time from pathlib import Path from unittest.mock import patch, MagicMock, call import pytest from cht.stream.manager import StreamManager @pytest.fixture def manager(tmp_path): """StreamManager with session dir in 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 # should be a timestamp string 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 TestStartRecorder: @patch("cht.stream.manager.ff.run_async") @patch("cht.stream.manager.ff.receive_and_segment") def test_calls_ffmpeg_module(self, mock_segment, mock_async, manager): manager.setup_dirs() mock_node = MagicMock() mock_segment.return_value = mock_node manager.start_recorder() mock_segment.assert_called_once_with( manager.stream_url, manager.stream_dir, 60, ) mock_async.assert_called_once_with(mock_node, pipe_stderr=True) assert "recorder" in manager._procs class TestStartRecorderWithMonitor: @patch("cht.stream.manager.ff.run_async") @patch("cht.stream.manager.ff.receive_and_segment_with_monitor") def test_creates_fifo_and_starts(self, mock_monitor, mock_async, manager): mock_node = MagicMock() mock_monitor.return_value = mock_node fifo = manager.start_recorder_with_monitor() assert fifo == manager.session_dir / "monitor.pipe" mock_monitor.assert_called_once() mock_async.assert_called_once_with(mock_node, pipe_stderr=True) @patch("cht.stream.manager.ff.run_async") @patch("cht.stream.manager.ff.receive_and_segment_with_monitor") def test_setup_dirs_called(self, mock_monitor, mock_async, manager): mock_monitor.return_value = MagicMock() manager.start_recorder_with_monitor() assert manager.stream_dir.is_dir() 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 TestParseFrameTimestamps: def test_parses_showinfo_output(self, manager): manager.setup_dirs() # Create fake frame files for i in range(1, 4): (manager.frames_dir / f"F{i:04d}.jpg").touch() stderr = ( "[Parsed_showinfo_1 @ 0x1234] n:0 pts:1000 pts_time:10.5 other stuff\n" "some other line\n" "[Parsed_showinfo_1 @ 0x1234] n:1 pts:2000 pts_time:20.0 other stuff\n" "[Parsed_showinfo_1 @ 0x1234] n:2 pts:3000 pts_time:35.7 other stuff\n" ) manager._parse_frame_timestamps(stderr, start_num=1) index_path = manager.frames_dir / "index.json" assert index_path.exists() with open(index_path) as f: index = json.load(f) assert len(index) == 3 assert index[0]["id"] == "F0001" assert index[0]["timestamp"] == 10.5 assert index[0]["sent_to_agent"] is False assert index[1]["id"] == "F0002" assert index[1]["timestamp"] == 20.0 assert index[2]["id"] == "F0003" assert index[2]["timestamp"] == 35.7 def test_appends_to_existing_index(self, manager): manager.setup_dirs() index_path = manager.frames_dir / "index.json" # Pre-existing index existing = [{"id": "F0001", "timestamp": 5.0, "path": "/old", "sent_to_agent": True}] with open(index_path, "w") as f: json.dump(existing, f) # New frame (manager.frames_dir / "F0002.jpg").touch() stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:15.0 stuff\n" manager._parse_frame_timestamps(stderr, start_num=2) with open(index_path) as f: index = json.load(f) assert len(index) == 2 assert index[0]["id"] == "F0001" # preserved assert index[1]["id"] == "F0002" # new def test_skips_missing_frame_files(self, manager): manager.setup_dirs() # Don't create the frame file stderr = "[Parsed_showinfo_1 @ 0x1] n:0 pts:100 pts_time:10.0 stuff\n" manager._parse_frame_timestamps(stderr, start_num=1) index_path = manager.frames_dir / "index.json" with open(index_path) as f: index = json.load(f) assert len(index) == 0 def test_ignores_non_showinfo_lines(self, manager): manager.setup_dirs() (manager.frames_dir / "F0001.jpg").touch() stderr = ( "frame= 100 fps=30 q=28.0 size= 1024kB\n" "video:500kB audio:200kB subtitle:0kB other streams:0kB\n" ) manager._parse_frame_timestamps(stderr, start_num=1) index_path = manager.frames_dir / "index.json" with open(index_path) as f: index = json.load(f) assert len(index) == 0 class TestExtractFramesFromFile: @patch("cht.stream.manager.ff.extract_scene_frames") def test_calls_ffmpeg_with_correct_args(self, mock_extract, manager): manager.setup_dirs() seg = manager.stream_dir / "segment_0001.ts" seg.touch() mock_extract.return_value = ("", "") manager._extract_frames_from_file(seg) mock_extract.assert_called_once_with( seg, manager.frames_dir, scene_threshold=0.3, max_interval=30, start_number=1, ) @patch("cht.stream.manager.ff.extract_scene_frames") def test_continues_numbering(self, mock_extract, manager): manager.setup_dirs() # Pre-existing frames (manager.frames_dir / "F0001.jpg").touch() (manager.frames_dir / "F0002.jpg").touch() seg = manager.stream_dir / "segment_0002.ts" seg.touch() mock_extract.return_value = ("", "") manager._extract_frames_from_file(seg) assert mock_extract.call_args.kwargs["start_number"] == 3 @patch("cht.stream.manager.ff.extract_scene_frames") def test_handles_ffmpeg_failure(self, mock_extract, manager): manager.setup_dirs() seg = manager.stream_dir / "segment_0001.ts" seg.touch() mock_extract.side_effect = RuntimeError("ffmpeg died") # Should not raise manager._extract_frames_from_file(seg) class TestFrameWatcher: @patch("cht.stream.manager.ff.extract_scene_frames") def test_detects_new_segments(self, mock_extract, manager): manager.setup_dirs() mock_extract.return_value = ("", "") manager.start_frame_extractor() # Create a segment file seg = manager.stream_dir / "segment_0001.ts" seg.write_bytes(b"\x00" * 100) # Wait for watcher to pick it up time.sleep(3) manager.stop_all() mock_extract.assert_called() @patch("cht.stream.manager.ff.extract_scene_frames") def test_skips_already_seen(self, mock_extract, manager): manager.setup_dirs() mock_extract.return_value = ("", "") # Pre-create segment seg = manager.stream_dir / "segment_0001.ts" seg.write_bytes(b"\x00" * 100) manager.start_frame_extractor() time.sleep(3) call_count = mock_extract.call_count # Wait another cycle — should not re-process time.sleep(3) manager.stop_all() assert mock_extract.call_count == call_count