init commit
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
49
tests/test_config.py
Normal file
49
tests/test_config.py
Normal 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
230
tests/test_ffmpeg.py
Normal 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
270
tests/test_manager.py
Normal 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
|
||||
Reference in New Issue
Block a user