mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:07:03 +01:00
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>
430 lines
14 KiB
Python
430 lines
14 KiB
Python
"""Tests for the composition root and verdict application."""
|
|
|
|
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,
|
|
db,
|
|
downloader,
|
|
paths,
|
|
review,
|
|
store,
|
|
store_verdicts,
|
|
)
|
|
from python_pkg.wsg_grabber.models import RemoteFile, Verdict
|
|
from python_pkg.wsg_grabber.states import FileState
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterator
|
|
|
|
_MD5 = "Nz9OEKdMuMZEdYE6eLTKmA=="
|
|
|
|
|
|
@pytest.fixture
|
|
def session() -> Iterator[app.Session]:
|
|
"""Build a session over a sandboxed index, with no worker running.
|
|
|
|
Yields:
|
|
app.Session: Session whose connection is 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 _downloaded(session: app.Session, name: str = "clip") -> None:
|
|
"""Put one downloaded, unreviewed file into the index and on disk.
|
|
|
|
Args:
|
|
session: The session to populate.
|
|
name: Local filename stem.
|
|
"""
|
|
store.record_files(
|
|
session.conn,
|
|
[
|
|
RemoteFile(
|
|
md5=_MD5,
|
|
tim=1,
|
|
ext=".webm",
|
|
orig_name=name,
|
|
fsize=5,
|
|
width=4,
|
|
height=3,
|
|
thread_no=7,
|
|
post_no=1,
|
|
),
|
|
],
|
|
)
|
|
local = f"{name}.webm"
|
|
(paths.incoming_dir() / local).write_bytes(b"video")
|
|
store.mark_downloaded(session.conn, _MD5, local)
|
|
|
|
|
|
def test_keep_moves_the_file_and_records_it(session: app.Session) -> None:
|
|
_downloaded(session)
|
|
state = app.initial_state(session)
|
|
result = session.commit(state, Verdict.KEEP)
|
|
|
|
assert (paths.keep_dir() / "clip.webm").exists()
|
|
assert not (paths.incoming_dir() / "clip.webm").exists()
|
|
assert store.state_of(session.conn, _MD5) is FileState.KEPT
|
|
assert result.kept == 1
|
|
|
|
|
|
def test_pass_moves_to_trash_and_never_deletes(session: app.Session) -> None:
|
|
"""The whole point of trash/: a pass is reversible until the user says so."""
|
|
_downloaded(session)
|
|
state = app.initial_state(session)
|
|
result = session.commit(state, Verdict.SKIP)
|
|
|
|
assert (paths.trash_dir() / "clip.webm").exists()
|
|
assert (paths.trash_dir() / "clip.webm").read_bytes() == b"video"
|
|
assert store.state_of(session.conn, _MD5) is FileState.PASSED
|
|
assert result.passed == 1
|
|
|
|
|
|
def test_a_name_collision_in_the_destination_is_resolved(
|
|
session: app.Session,
|
|
) -> None:
|
|
_downloaded(session)
|
|
(paths.keep_dir() / "clip.webm").write_bytes(b"older")
|
|
session.commit(app.initial_state(session), Verdict.KEEP)
|
|
|
|
assert (paths.keep_dir() / "clip.webm").read_bytes() == b"older"
|
|
assert (paths.keep_dir() / "clip-2.webm").read_bytes() == b"video"
|
|
|
|
|
|
def test_a_verdict_on_a_vanished_file_writes_it_off(session: app.Session) -> None:
|
|
_downloaded(session)
|
|
state = app.initial_state(session)
|
|
(paths.incoming_dir() / "clip.webm").unlink()
|
|
|
|
result = session.commit(state, Verdict.KEEP)
|
|
assert store.state_of(session.conn, _MD5) is FileState.GONE
|
|
assert result.kept == 0
|
|
assert result.current is None
|
|
|
|
|
|
def test_a_verdict_with_nothing_showing_is_ignored(session: app.Session) -> None:
|
|
state = review.initial_state(0, [])
|
|
assert session.commit(state, Verdict.KEEP) is state
|
|
assert session.missing(state) is state
|
|
|
|
|
|
def test_initial_state_resumes_a_previous_run(session: app.Session) -> None:
|
|
_downloaded(session)
|
|
state = app.initial_state(session)
|
|
assert state.current is not None
|
|
assert state.current.md5 == _MD5
|
|
assert state.indexed == 1
|
|
|
|
|
|
def test_shutdown_stops_the_worker_and_closes_the_index() -> None:
|
|
paths.ensure_dirs()
|
|
conn = db.open_index(paths.db_path())
|
|
worker = MagicMock()
|
|
worker.is_alive.return_value = False
|
|
built = app.Session(
|
|
conn=conn,
|
|
events=queue.SimpleQueue(),
|
|
stop=threading.Event(),
|
|
worker=worker,
|
|
)
|
|
built.shutdown()
|
|
assert built.stop.is_set()
|
|
worker.join.assert_called_once()
|
|
assert built.worker is None
|
|
|
|
|
|
def test_shutdown_without_a_worker() -> None:
|
|
paths.ensure_dirs()
|
|
conn = db.open_index(paths.db_path())
|
|
built = app.Session(conn=conn, events=queue.SimpleQueue(), stop=threading.Event())
|
|
built.shutdown()
|
|
assert built.stop.is_set()
|
|
|
|
|
|
def test_open_session_starts_a_worker_owning_its_own_connection() -> None:
|
|
"""sqlite forbids sharing a connection across threads, so the factory must
|
|
build one on the worker's side of the boundary."""
|
|
captured: list[downloader.WorkerDeps] = []
|
|
|
|
def fake_start(
|
|
build: Callable[[], downloader.WorkerDeps],
|
|
_on_failure: Callable[[str], None] = lambda _m: None,
|
|
) -> MagicMock:
|
|
deps = build()
|
|
captured.append(deps)
|
|
deps.conn.close()
|
|
deps.session.close()
|
|
return MagicMock()
|
|
|
|
with patch.object(downloader, "start", side_effect=fake_start):
|
|
built = app.open_session(include_archive=False)
|
|
try:
|
|
assert captured
|
|
assert captured[0].incoming == paths.incoming_dir()
|
|
assert not captured[0].include_archive
|
|
assert paths.incoming_dir().is_dir()
|
|
assert paths.trash_dir().is_dir()
|
|
finally:
|
|
built.shutdown()
|
|
|
|
|
|
def test_open_session_recovers_interrupted_downloads() -> None:
|
|
paths.ensure_dirs()
|
|
conn = db.open_index(paths.db_path())
|
|
store.record_files(
|
|
conn,
|
|
[
|
|
RemoteFile(
|
|
md5=_MD5,
|
|
tim=1,
|
|
ext=".webm",
|
|
orig_name="x",
|
|
fsize=1,
|
|
width=1,
|
|
height=1,
|
|
thread_no=1,
|
|
post_no=1,
|
|
),
|
|
],
|
|
)
|
|
store.claim_next(conn)
|
|
conn.close()
|
|
|
|
with patch.object(downloader, "start", return_value=MagicMock()):
|
|
built = app.open_session()
|
|
try:
|
|
assert store.pending_downloads(built.conn) == 1
|
|
finally:
|
|
built.shutdown()
|
|
|
|
|
|
def test_undo_puts_a_kept_file_back_in_the_queue(session: app.Session) -> None:
|
|
"""The misclick this exists for: kept something you meant to pass."""
|
|
_downloaded(session)
|
|
state = session.commit(app.initial_state(session), Verdict.KEEP)
|
|
assert (paths.keep_dir() / "clip.webm").exists()
|
|
|
|
state = session.undo(state)
|
|
assert not (paths.keep_dir() / "clip.webm").exists()
|
|
assert (paths.incoming_dir() / "clip.webm").exists()
|
|
assert store.state_of(session.conn, _MD5) is FileState.READY
|
|
assert state.kept == 0
|
|
assert state.current is not None
|
|
assert state.current.md5 == _MD5
|
|
|
|
|
|
def test_undo_puts_a_passed_file_back(session: app.Session) -> None:
|
|
_downloaded(session)
|
|
state = session.commit(app.initial_state(session), Verdict.SKIP)
|
|
assert (paths.trash_dir() / "clip.webm").exists()
|
|
|
|
state = session.undo(state)
|
|
assert (paths.incoming_dir() / "clip.webm").exists()
|
|
assert not (paths.trash_dir() / "clip.webm").exists()
|
|
assert state.passed == 0
|
|
|
|
|
|
def test_undo_survives_a_collision_rename(session: app.Session) -> None:
|
|
"""A verdict may have renamed the file; undo must follow the real name."""
|
|
_downloaded(session)
|
|
(paths.trash_dir() / "clip.webm").write_bytes(b"older")
|
|
state = session.commit(app.initial_state(session), Verdict.SKIP)
|
|
assert (paths.trash_dir() / "clip-2.webm").exists()
|
|
|
|
state = session.undo(state)
|
|
assert (paths.incoming_dir() / "clip.webm").read_bytes() == b"video"
|
|
assert (paths.trash_dir() / "clip.webm").read_bytes() == b"older"
|
|
assert not (paths.trash_dir() / "clip-2.webm").exists()
|
|
|
|
|
|
def test_undo_steps_back_through_several_verdicts(session: app.Session) -> None:
|
|
for index in range(3):
|
|
store.record_files(
|
|
session.conn,
|
|
[
|
|
RemoteFile(
|
|
md5=f"M{index}",
|
|
tim=index,
|
|
ext=".webm",
|
|
orig_name=f"v{index}",
|
|
fsize=5,
|
|
width=1,
|
|
height=1,
|
|
thread_no=1,
|
|
post_no=index,
|
|
),
|
|
],
|
|
)
|
|
(paths.incoming_dir() / f"v{index}.webm").write_bytes(b"video")
|
|
store.mark_downloaded(session.conn, f"M{index}", f"v{index}.webm")
|
|
|
|
state = app.initial_state(session)
|
|
for _ in range(3):
|
|
state = session.commit(state, Verdict.SKIP)
|
|
assert state.passed == 3
|
|
|
|
for expected in ("M2", "M1", "M0"):
|
|
state = session.undo(state)
|
|
assert state.current is not None
|
|
assert state.current.md5 == expected
|
|
assert state.passed == 0
|
|
assert not review.can_undo(state)
|
|
|
|
|
|
def test_undo_with_nothing_to_undo_is_a_no_op(session: app.Session) -> None:
|
|
state = app.initial_state(session)
|
|
assert session.undo(state) is state
|
|
|
|
|
|
def test_undo_gives_up_gracefully_when_the_file_was_removed(
|
|
session: app.Session,
|
|
) -> None:
|
|
"""You may have emptied trash/ by hand; undo must not wedge on it."""
|
|
_downloaded(session)
|
|
state = session.commit(app.initial_state(session), Verdict.SKIP)
|
|
(paths.trash_dir() / "clip.webm").unlink()
|
|
|
|
state = session.undo(state)
|
|
assert not review.can_undo(state)
|
|
assert store.state_of(session.conn, _MD5) is FileState.PASSED
|
|
|
|
|
|
def test_undo_survives_quitting_and_reopening(session: app.Session) -> None:
|
|
"""The whole point of persisting the trail: undo is not lost on exit."""
|
|
_downloaded(session)
|
|
session.commit(app.initial_state(session), Verdict.SKIP)
|
|
assert (paths.trash_dir() / "clip.webm").exists()
|
|
session.conn.close()
|
|
|
|
# a brand new session, as if the reviewer had been restarted
|
|
reopened = app.Session(
|
|
conn=db.open_index(paths.db_path()),
|
|
events=queue.SimpleQueue(),
|
|
stop=threading.Event(),
|
|
)
|
|
try:
|
|
fresh = app.initial_state(reopened)
|
|
assert fresh.undoable == 1
|
|
restored = reopened.undo(fresh)
|
|
assert (paths.incoming_dir() / "clip.webm").exists()
|
|
assert store.state_of(reopened.conn, _MD5) is FileState.READY
|
|
assert restored.current is not None
|
|
assert restored.current.md5 == _MD5
|
|
assert restored.undoable == 0
|
|
finally:
|
|
reopened.conn.close()
|
|
|
|
|
|
def test_the_undo_trail_is_unbounded(session: app.Session) -> None:
|
|
"""Any number of last verdicts, not a fixed window."""
|
|
total = 25
|
|
for index in range(total):
|
|
store.record_files(
|
|
session.conn,
|
|
[
|
|
RemoteFile(
|
|
md5=f"U{index:02d}",
|
|
tim=index,
|
|
ext=".webm",
|
|
orig_name=f"u{index:02d}",
|
|
fsize=5,
|
|
width=1,
|
|
height=1,
|
|
thread_no=1,
|
|
post_no=index,
|
|
),
|
|
],
|
|
)
|
|
(paths.incoming_dir() / f"u{index:02d}.webm").write_bytes(b"video")
|
|
store.mark_downloaded(session.conn, f"U{index:02d}", f"u{index:02d}.webm")
|
|
|
|
state = app.initial_state(session)
|
|
for _ in range(total):
|
|
state = session.commit(state, Verdict.SKIP)
|
|
assert state.passed == total
|
|
assert store_verdicts.reviewed_count(session.conn) == total
|
|
|
|
for index in reversed(range(total)):
|
|
state = session.undo(state)
|
|
assert state.current is not None
|
|
assert state.current.md5 == f"U{index:02d}"
|
|
assert state.passed == 0
|
|
assert store_verdicts.reviewed_count(session.conn) == 0
|
|
assert len(list(paths.trash_dir().iterdir())) == 0
|
|
|
|
|
|
def test_undo_never_overwrites_a_file_sitting_in_incoming(
|
|
session: app.Session,
|
|
) -> None:
|
|
"""A verdict must never be reversed on top of another video.
|
|
|
|
Path.replace clobbers silently, so undo picks a collision-free name the
|
|
same way commit does.
|
|
"""
|
|
_downloaded(session)
|
|
state = session.commit(app.initial_state(session), Verdict.KEEP)
|
|
# something else now occupies the name the file used to have
|
|
(paths.incoming_dir() / "clip.webm").write_bytes(b"DO NOT DESTROY ME")
|
|
|
|
session.undo(state)
|
|
|
|
assert (paths.incoming_dir() / "clip.webm").read_bytes() == b"DO NOT DESTROY ME"
|
|
assert (paths.incoming_dir() / "clip-2.webm").read_bytes() == b"video"
|
|
row = session.conn.execute(
|
|
"SELECT local_name FROM files WHERE md5 = ?",
|
|
(_MD5,),
|
|
).fetchone()
|
|
assert row["local_name"] == "clip-2.webm"
|
|
|
|
|
|
def test_a_missing_file_never_clobbers_an_existing_verdict(
|
|
session: app.Session,
|
|
) -> None:
|
|
"""Two reviewers on one index must not corrupt each other's undo trail."""
|
|
_downloaded(session)
|
|
state = app.initial_state(session)
|
|
session.commit(state, Verdict.KEEP)
|
|
assert store_verdicts.reviewed_count(session.conn) == 1
|
|
|
|
# the same item, still queued in a second reviewer, whose file is now gone
|
|
session.missing(state)
|
|
|
|
assert store.state_of(session.conn, _MD5) is FileState.KEPT
|
|
assert store_verdicts.reviewed_count(session.conn) == 1
|
|
|
|
|
|
def test_open_session_closes_the_index_if_the_worker_cannot_start() -> None:
|
|
with (
|
|
patch.object(downloader, "start", side_effect=RuntimeError("no thread")),
|
|
pytest.raises(RuntimeError),
|
|
):
|
|
app.open_session()
|
|
# a second open must succeed, which it cannot if the first leaked a handle
|
|
with patch.object(downloader, "start", return_value=MagicMock()):
|
|
again = app.open_session()
|
|
again.shutdown()
|
|
|
|
|
|
def test_a_worker_that_outlives_the_join_is_not_silently_forgotten(
|
|
session: app.Session,
|
|
) -> None:
|
|
worker = MagicMock()
|
|
worker.is_alive.return_value = True
|
|
session.worker = worker
|
|
session.shutdown()
|
|
assert session.worker is worker # handle kept so the failure is visible
|