some changes
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
"""Tests for cht.stream.manager — StreamManager orchestration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,7 +11,6 @@ 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
|
||||
@@ -24,7 +21,7 @@ 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
|
||||
assert mgr.session_id
|
||||
|
||||
def test_session_id_custom(self, manager):
|
||||
assert manager.session_id == "test_session"
|
||||
@@ -54,44 +51,29 @@ class TestStreamUrl:
|
||||
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_segment")
|
||||
def test_calls_ffmpeg_module(self, mock_segment, mock_async, manager):
|
||||
@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_segment.return_value = mock_node
|
||||
mock_record.return_value = mock_node
|
||||
|
||||
manager.start_recorder()
|
||||
|
||||
mock_segment.assert_called_once_with(
|
||||
manager.stream_url, manager.stream_dir, 60,
|
||||
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 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):
|
||||
@@ -109,162 +91,89 @@ class TestStopAll:
|
||||
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:
|
||||
class TestExtractNewFrames:
|
||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||
def test_calls_ffmpeg_with_correct_args(self, mock_extract, manager):
|
||||
def test_calls_ffmpeg_with_start_time(self, mock_extract, manager):
|
||||
manager.setup_dirs()
|
||||
seg = manager.stream_dir / "segment_0001.ts"
|
||||
seg.touch()
|
||||
rec = manager.recording_path
|
||||
rec.touch()
|
||||
|
||||
mock_extract.return_value = ("", "")
|
||||
manager._extract_frames_from_file(seg)
|
||||
manager._extract_new_frames(rec, start_time=10.0, start_number=5)
|
||||
|
||||
mock_extract.assert_called_once_with(
|
||||
seg,
|
||||
rec,
|
||||
manager.frames_dir,
|
||||
scene_threshold=0.3,
|
||||
max_interval=30,
|
||||
start_number=1,
|
||||
start_number=5,
|
||||
start_time=10.0,
|
||||
)
|
||||
|
||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||
def test_continues_numbering(self, mock_extract, manager):
|
||||
def test_indexes_new_frames(self, mock_extract, manager):
|
||||
manager.setup_dirs()
|
||||
# Pre-existing frames
|
||||
(manager.frames_dir / "F0001.jpg").touch()
|
||||
(manager.frames_dir / "F0002.jpg").touch()
|
||||
rec = manager.recording_path
|
||||
rec.touch()
|
||||
|
||||
seg = manager.stream_dir / "segment_0002.ts"
|
||||
seg.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.return_value = ("", "")
|
||||
manager._extract_frames_from_file(seg)
|
||||
mock_extract.side_effect = create_frame_and_return
|
||||
|
||||
assert mock_extract.call_args.kwargs["start_number"] == 3
|
||||
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()
|
||||
seg = manager.stream_dir / "segment_0001.ts"
|
||||
seg.touch()
|
||||
rec = manager.recording_path
|
||||
rec.touch()
|
||||
|
||||
mock_extract.side_effect = RuntimeError("ffmpeg died")
|
||||
# Should not raise
|
||||
manager._extract_frames_from_file(seg)
|
||||
count, max_ts = manager._extract_new_frames(rec)
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestFrameWatcher:
|
||||
@patch("cht.stream.manager.ff.extract_scene_frames")
|
||||
def test_detects_new_segments(self, mock_extract, manager):
|
||||
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 = ("", "")
|
||||
|
||||
manager.start_frame_extractor()
|
||||
# Create recording with some data
|
||||
rec = manager.recording_path
|
||||
rec.write_bytes(b"\x00" * 200_000)
|
||||
|
||||
# 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.start_scene_detector()
|
||||
time.sleep(12) # wait for one cycle
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user