testsAndMisc/python_pkg/wsg_grabber/tests/test_cli.py
Krzysztof kuhy Rudnicki 89ed9bc95d
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass
New package. Walks every thread on 4chan's /wsg/, downloads each video it has
not seen, and reviews them one at a time: mpv embedded in a Tk window via --wid
with Keep/Pass/Undo directly beneath the picture.

Dedupe is structural rather than a convention. Every post carrying a file also
carries md5 -- base64 MD5 of the whole file -- so it is the sqlite primary key.
An already-seen video costs zero bytes to skip and a byte-identical repost in
another thread collapses onto the existing row. Verified against a live file:
the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.

Undo is unbounded and survives quitting: the verdict trail lives in the index,
not in memory, so yesterday's decisions can still be taken back.

No downloaded video is ever deleted. A pass moves the file to trash/ and leaves
it there; files.apply_move refuses to overwrite an existing destination, so the
guarantee holds by construction rather than per caller.

Three bugs only running it could find, each caught from evidence rather than
guesswork:
  - the video stayed black: a <Configure> handler set mpv's geometry, fighting
    the automatic sizing --wid already does
  - playback never advanced: refresh() ran every tick and re-sent loadfile ~7x
    a second, restarting the video continuously
  - it froze on one video around the 26th while filenames kept advancing: mpv
    pushes an event stream down the same socket and nothing drained it, so the
    buffer filled and mpv stopped reading commands. Isolated to command #35
    without a drain thread; all 60 honoured with one.

A five-lens review then found nine more, all reproduced before fixing: a failed
mpv start stranded the non-daemon worker forever; undo could clobber a file in
incoming/; a NUL-bearing md5 poisoned the index permanently; an oversized int
killed the worker with no event; gzip bombs on both hosts were accepted; the
board endpoints were fetched unconditionally forever; and Session.missing could
corrupt the undo trail.

mpv runs as a subprocess over its JSON IPC socket rather than via python-mpv,
which resolves libmpv at import time and would fail on CI while passing here.
Third-party surface is requests alone, already declared.

277 package tests, 100% branch coverage on all 22 modules, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:55:30 +02:00

242 lines
7.2 KiB
Python

"""Tests for the command-line interface."""
from __future__ import annotations
import queue
import threading
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.wsg_grabber import app, cli, db, logs, paths, store
from python_pkg.wsg_grabber.models import RemoteFile
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture
def fake_session() -> Iterator[app.Session]:
"""Build a session over a sandboxed index with no worker.
Yields:
app.Session: Session closed afterwards.
"""
paths.ensure_dirs()
conn = db.open_index(paths.db_path())
built = app.Session(conn=conn, events=queue.SimpleQueue(), stop=threading.Event())
try:
yield built
finally:
conn.close()
def test_parser_defaults_to_the_reviewer() -> None:
assert cli.build_parser().parse_args([]).command is None
assert cli.build_parser().parse_args(["review"]).command == "review"
def test_parser_exposes_the_subcommands() -> None:
assert cli.build_parser().parse_args(["stats"]).command == "stats"
parsed = cli.build_parser().parse_args(["scrape", "--seconds", "5"])
assert parsed.command == "scrape"
assert parsed.seconds == pytest.approx(5.0)
def test_help_mentions_that_nothing_is_deleted() -> None:
text = cli.build_parser().format_help()
assert "Nothing is ever deleted" in text
assert "trash" in text
def test_stats_reports_every_state(capsys: pytest.CaptureFixture[str]) -> None:
paths.ensure_dirs()
conn = db.open_index(paths.db_path())
store.record_files(
conn,
[
RemoteFile(
md5="A" * 24,
tim=1,
ext=".webm",
orig_name="x",
fsize=1,
width=1,
height=1,
thread_no=1,
post_no=1,
),
],
)
conn.close()
assert cli.main(["stats"]) == 0
out = capsys.readouterr().out
assert "known files 1" in out
assert "new 1" in out
assert "never emptied automatically" in out
def test_scrape_runs_and_shuts_down(
fake_session: app.Session,
capsys: pytest.CaptureFixture[str],
) -> None:
ticks = {"n": 0}
def fake_sleep(_seconds: float) -> None:
ticks["n"] += 1
with (
patch.object(app, "open_session", return_value=fake_session),
patch.object(fake_session, "shutdown") as stopped,
patch(
"python_pkg.wsg_grabber.cli.time.monotonic",
side_effect=[0.0, 1.0, 99.0, 99.0],
),
):
assert cli.scrape(5.0, sleeper=fake_sleep) == 0
assert ticks["n"] >= 1
stopped.assert_called_once()
assert "scraping /wsg/" in capsys.readouterr().out
def test_scrape_reports_progress_periodically(
fake_session: app.Session,
capsys: pytest.CaptureFixture[str],
) -> None:
with (
patch.object(app, "open_session", return_value=fake_session),
patch.object(fake_session, "shutdown"),
patch(
"python_pkg.wsg_grabber.cli.time.monotonic",
side_effect=[0.0, 999.0, 999.0, 999.0],
),
):
cli.scrape(1.0, sleeper=lambda _s: None)
assert "known 0" in capsys.readouterr().out
def test_scrape_handles_an_interrupt(
fake_session: app.Session,
capsys: pytest.CaptureFixture[str],
) -> None:
def interrupt(_seconds: float) -> None:
raise KeyboardInterrupt
with (
patch.object(app, "open_session", return_value=fake_session),
patch.object(fake_session, "shutdown") as stopped,
):
assert cli.scrape(0.0, sleeper=interrupt) == 0
stopped.assert_called_once()
assert "stopping" in capsys.readouterr().out
def test_main_dispatches_to_the_reviewer() -> None:
with patch.object(cli, "open_reviewer", return_value=0) as opened:
assert cli.main([]) == 0
opened.assert_called_once()
def test_main_dispatches_to_scrape() -> None:
with patch.object(cli, "scrape", return_value=0) as scraped:
assert cli.main(["scrape", "--seconds", "2"]) == 0
assert scraped.call_args.args[0] == pytest.approx(2.0)
def test_open_reviewer_wires_the_window_to_the_player(
fake_session: app.Session,
) -> None:
window = MagicMock()
window.video_wid.return_value = 4321
with (
patch.object(app, "open_session", return_value=fake_session),
patch(
"python_pkg.wsg_grabber.cli.ui.ReviewWindow",
return_value=window,
) as built,
patch("python_pkg.wsg_grabber.cli.player.MpvPlayer") as made_player,
):
assert cli.open_reviewer() == 0
made_player.assert_called_once_with(4321, paths.ipc_socket_path())
window.attach_player.assert_called_once()
window.run.assert_called_once()
callbacks = built.call_args.args[2]
assert callbacks.commit == fake_session.commit
assert callbacks.shutdown == fake_session.shutdown
def test_log_level_flags_are_exposed() -> None:
parsed = cli.build_parser().parse_args(["--log-level", "debug", "--echo-log"])
assert parsed.log_level == "debug"
assert parsed.echo_log
assert cli.build_parser().parse_args([]).log_level == "info"
def test_running_a_command_writes_a_session_log() -> None:
assert cli.main(["stats"]) == 0
written = sorted(logs.logs_dir().glob("session-*.jsonl"))
assert written
assert "session.start" in written[-1].read_text(encoding="utf-8")
def test_logs_command_reports_where_logs_live(
capsys: pytest.CaptureFixture[str],
) -> None:
cli.main(["stats"])
capsys.readouterr()
assert cli.main(["logs"]) == 0
out = capsys.readouterr().out
assert str(logs.logs_dir()) in out
assert "newest session" in out
assert "jq" in out
def test_logs_command_before_anything_has_run(
capsys: pytest.CaptureFixture[str],
) -> None:
logs.logs_dir().mkdir(parents=True, exist_ok=True)
for stale in logs.logs_dir().glob("*.jsonl"):
stale.unlink()
assert cli.show_logs() == 0
assert "no session logs yet" in capsys.readouterr().out
def test_summarise_counts_events_and_skips_junk(tmp_path: Path) -> None:
sample = tmp_path / "s.jsonl"
sample.write_text(
'{"event":"a","level":"info"}\n'
"not json at all\n"
'{"event":"a","level":"error"}\n'
'{"event":"b","level":"info"}\n',
encoding="utf-8",
)
text = cli.summarise(sample)
assert "error=1" in text
assert "info=2" in text
assert "a" in text
def test_a_failed_player_start_does_not_strand_the_worker(
fake_session: app.Session,
) -> None:
"""The worker is non-daemon; without cleanup the process never exits."""
window = MagicMock()
window.video_wid.return_value = 1
with (
patch.object(app, "open_session", return_value=fake_session),
patch("python_pkg.wsg_grabber.cli.ui.ReviewWindow", return_value=window),
patch(
"python_pkg.wsg_grabber.cli.player.MpvPlayer",
side_effect=TimeoutError("mpv never came up"),
),
patch.object(fake_session, "shutdown") as stopped,
pytest.raises(TimeoutError),
):
cli.open_reviewer()
stopped.assert_called_once()