init commit

This commit is contained in:
2026-04-01 13:53:09 -03:00
commit 453601c072
22 changed files with 1525 additions and 0 deletions

0
tests/__init__.py Normal file
View File

49
tests/test_config.py Normal file
View File

@@ -0,0 +1,49 @@
"""Tests for cht.config — verify defaults are sane."""
from pathlib import Path
from cht.config import (
APP_ID,
APP_NAME,
DATA_DIR,
SESSIONS_DIR,
STREAM_HOST,
STREAM_PORT,
SCENE_THRESHOLD,
MAX_FRAME_INTERVAL,
SEGMENT_DURATION,
)
def test_app_id_format():
assert "." in APP_ID
assert APP_ID.startswith("com.")
def test_data_dir_under_home():
assert str(DATA_DIR).startswith(str(Path.home()))
def test_sessions_dir_under_data():
assert str(SESSIONS_DIR).startswith(str(DATA_DIR))
def test_stream_host_binds_all():
assert STREAM_HOST == "0.0.0.0"
def test_stream_port_is_int():
assert isinstance(STREAM_PORT, int)
assert 1024 <= STREAM_PORT <= 65535
def test_scene_threshold_range():
assert 0 < SCENE_THRESHOLD < 1
def test_max_frame_interval_positive():
assert MAX_FRAME_INTERVAL > 0
def test_segment_duration_positive():
assert SEGMENT_DURATION > 0

230
tests/test_ffmpeg.py Normal file
View File

@@ -0,0 +1,230 @@
"""Tests for cht.stream.ffmpeg — command compilation and pipeline construction."""
import os
import signal
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from cht.stream import ffmpeg as ff
class TestReceiveAndSegment:
def test_compiles_to_valid_cmd(self, tmp_path):
node = ff.receive_and_segment(
"tcp://0.0.0.0:4444?listen", tmp_path, segment_duration=30
)
cmd = ff.compile_cmd(node)
assert cmd[0] == "ffmpeg"
assert "-hide_banner" in cmd
assert "-loglevel" in cmd
# Input options
assert "tcp://0.0.0.0:4444?listen" in cmd
# Segment output options
assert "segment" in cmd
assert "30" in cmd or 30 in cmd
assert str(tmp_path / "segment_%04d.ts") in cmd
def test_input_has_low_latency_flags(self, tmp_path):
node = ff.receive_and_segment(
"tcp://0.0.0.0:4444?listen", tmp_path
)
cmd = ff.compile_cmd(node)
cmd_str = " ".join(str(c) for c in cmd)
assert "nobuffer" in cmd_str
assert "low_delay" in cmd_str
def test_copy_codec(self, tmp_path):
node = ff.receive_and_segment(
"tcp://0.0.0.0:4444?listen", tmp_path
)
cmd = ff.compile_cmd(node)
assert "copy" in cmd
class TestReceiveAndSegmentWithMonitor:
def test_creates_fifo(self, tmp_path):
fifo = tmp_path / "monitor.pipe"
ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
)
assert fifo.exists()
assert os.path.isfile(fifo) or os.stat(fifo).st_mode & 0o010000 # is fifo
def test_does_not_recreate_existing_fifo(self, tmp_path):
fifo = tmp_path / "monitor.pipe"
os.mkfifo(str(fifo))
inode_before = fifo.stat().st_ino
ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
)
assert fifo.stat().st_ino == inode_before
def test_has_two_outputs(self, tmp_path):
fifo = tmp_path / "monitor.pipe"
node = ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
)
cmd = ff.compile_cmd(node)
cmd_str = " ".join(str(c) for c in cmd)
# Should have segment output and fifo output
assert "segment_%04d.ts" in cmd_str
assert "monitor.pipe" in cmd_str
def test_both_outputs_use_copy(self, tmp_path):
fifo = tmp_path / "monitor.pipe"
node = ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
)
cmd = ff.compile_cmd(node)
# Should have copy codec for both outputs
assert cmd.count("copy") >= 2
class TestExtractSceneFrames:
def test_compiles_select_filter(self, tmp_path):
# We can't run ffmpeg without a real file, but we can test compilation
import ffmpeg
stream = ffmpeg.input(str(tmp_path / "test.ts"))
stream = stream.filter("select", "gt(scene\\,0.3)+gte(t-prev_selected_t\\,30)")
stream = stream.filter("showinfo")
output = ffmpeg.output(
stream,
str(tmp_path / "F%04d.jpg"),
vsync="vfr",
**{"q:v": "2"},
start_number=1,
)
cmd = ff.compile_cmd(output)
cmd_str = " ".join(str(c) for c in cmd)
assert "select" in cmd_str
assert "scene" in cmd_str
assert "showinfo" in cmd_str
assert "vfr" in cmd_str
@patch("cht.stream.ffmpeg.subprocess.run")
def test_calls_subprocess_with_timeout(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(stdout="", stderr="")
ff.extract_scene_frames(
tmp_path / "test.ts", tmp_path,
scene_threshold=0.4, max_interval=20, start_number=5,
)
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["timeout"] == 120
assert call_kwargs.kwargs["capture_output"] is True
@patch("cht.stream.ffmpeg.subprocess.run")
def test_returns_stdout_stderr(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(stdout="out", stderr="err")
stdout, stderr = ff.extract_scene_frames(
tmp_path / "test.ts", tmp_path,
)
assert stdout == "out"
assert stderr == "err"
@patch("cht.stream.ffmpeg.subprocess.run")
def test_start_number_in_cmd(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(stdout="", stderr="")
ff.extract_scene_frames(
tmp_path / "test.ts", tmp_path, start_number=42
)
cmd = mock_run.call_args.args[0]
assert "42" in [str(c) for c in cmd]
class TestExtractAudioPcm:
def test_compiles_audio_extraction(self, tmp_path):
node = ff.extract_audio_pcm(tmp_path / "test.ts")
cmd = ff.compile_cmd(node)
cmd_str = " ".join(str(c) for c in cmd)
assert "pcm_s16le" in cmd_str
assert "16000" in cmd_str
assert "pipe:" in cmd_str
assert "wav" in cmd_str
class TestCompileCmd:
def test_inserts_global_flags_after_ffmpeg(self, tmp_path):
import ffmpeg
node = ffmpeg.input(str(tmp_path / "test.ts")).output(str(tmp_path / "out.ts"))
cmd = ff.compile_cmd(node)
assert cmd[0] == "ffmpeg"
assert cmd[1] == "-hide_banner"
assert cmd[2] == "-loglevel"
assert cmd[3] == "warning"
class TestRunAsync:
@patch("cht.stream.ffmpeg.subprocess.Popen")
def test_default_no_pipes(self, mock_popen, tmp_path):
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_async(node)
call_kwargs = mock_popen.call_args
assert call_kwargs.kwargs["stdout"] == subprocess.DEVNULL
assert call_kwargs.kwargs["stderr"] == subprocess.DEVNULL
@patch("cht.stream.ffmpeg.subprocess.Popen")
def test_pipe_stdout(self, mock_popen, tmp_path):
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_async(node, pipe_stdout=True)
call_kwargs = mock_popen.call_args
assert call_kwargs.kwargs["stdout"] == subprocess.PIPE
@patch("cht.stream.ffmpeg.subprocess.Popen")
def test_pipe_stderr(self, mock_popen, tmp_path):
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_async(node, pipe_stderr=True)
call_kwargs = mock_popen.call_args
assert call_kwargs.kwargs["stderr"] == subprocess.PIPE
class TestRunSync:
@patch("cht.stream.ffmpeg.subprocess.run")
def test_returns_tuple(self, mock_run):
mock_run.return_value = MagicMock(stdout="hello", stderr="world")
import ffmpeg
node = ffmpeg.input("test").output("out")
stdout, stderr = ff.run_sync(node)
assert stdout == "hello"
assert stderr == "world"
@patch("cht.stream.ffmpeg.subprocess.run")
def test_passes_timeout(self, mock_run):
mock_run.return_value = MagicMock(stdout="", stderr="")
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_sync(node, timeout=30)
assert mock_run.call_args.kwargs["timeout"] == 30
class TestStopProc:
def test_sends_sigint_then_waits(self):
proc = MagicMock()
proc.poll.return_value = None
ff.stop_proc(proc, timeout=3)
proc.send_signal.assert_called_once_with(signal.SIGINT)
proc.wait.assert_called_once_with(timeout=3)
def test_kills_on_timeout(self):
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = subprocess.TimeoutExpired("ffmpeg", 3)
ff.stop_proc(proc, timeout=3)
proc.kill.assert_called_once()
def test_noop_if_already_exited(self):
proc = MagicMock()
proc.poll.return_value = 0
ff.stop_proc(proc)
proc.send_signal.assert_not_called()
def test_noop_if_none(self):
ff.stop_proc(None) # should not raise

270
tests/test_manager.py Normal file
View File

@@ -0,0 +1,270 @@
"""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)
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)
@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