mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +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>
236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
"""Tests for the typed index operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from python_pkg.wsg_grabber import db, store, store_threads, store_verdicts
|
|
from python_pkg.wsg_grabber.models import RemoteFile, ThreadRef
|
|
from python_pkg.wsg_grabber.states import CLAIMABLE, FileState
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
import sqlite3
|
|
|
|
_EXPECTED_CLAIMABLE_STATES = 3
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(tmp_path: Path) -> Iterator[sqlite3.Connection]:
|
|
"""Open a migrated index for one test and close it afterwards.
|
|
|
|
Closing matters: pytest runs with ``filterwarnings = ["error", ...]`` and a
|
|
leaked connection surfaces as a ResourceWarning, i.e. a test failure.
|
|
|
|
Args:
|
|
tmp_path: Per-test temporary directory.
|
|
|
|
Yields:
|
|
sqlite3.Connection: Ready connection.
|
|
"""
|
|
connection = db.open_index(tmp_path / "index.db")
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _file(md5: str = "AAAA", tim: int = 111, thread_no: int = 9) -> RemoteFile:
|
|
"""Build a RemoteFile with sensible defaults.
|
|
|
|
Args:
|
|
md5: Identity.
|
|
tim: CDN filename stem.
|
|
thread_no: Owning thread.
|
|
|
|
Returns:
|
|
RemoteFile: Test fixture value.
|
|
"""
|
|
return RemoteFile(
|
|
md5=md5,
|
|
tim=tim,
|
|
ext=".webm",
|
|
orig_name="clip",
|
|
fsize=1234,
|
|
width=480,
|
|
height=360,
|
|
thread_no=thread_no,
|
|
post_no=tim,
|
|
)
|
|
|
|
|
|
def test_claimable_placeholder_count_matches_the_state_set() -> None:
|
|
"""The SQL spells out one '?' per claimable state; keep them in step."""
|
|
assert len(CLAIMABLE) == _EXPECTED_CLAIMABLE_STATES
|
|
assert len(store.claimable_values()) == _EXPECTED_CLAIMABLE_STATES
|
|
|
|
|
|
def test_local_name_ignores_the_poster_supplied_filename() -> None:
|
|
name = store.local_name("ab/cd+ef==", 555, ".webm")
|
|
assert name.startswith("555-")
|
|
assert name.endswith(".webm")
|
|
assert "/" not in name
|
|
assert "+" not in name
|
|
|
|
|
|
def test_record_files_counts_only_new_rows(conn: sqlite3.Connection) -> None:
|
|
assert store.record_files(conn, [_file("A"), _file("B", tim=2)]) == 2
|
|
assert store.record_files(conn, [_file("B", tim=2), _file("C", tim=3)]) == 1
|
|
assert store.known_md5s(conn) == {"A", "B", "C"}
|
|
|
|
|
|
def test_record_files_with_nothing_to_do(conn: sqlite3.Connection) -> None:
|
|
assert store.record_files(conn, []) == 0
|
|
|
|
|
|
def test_a_repost_of_identical_bytes_never_becomes_a_second_row(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""Same md5 in another thread must not cost a second download."""
|
|
store.record_files(conn, [_file("SAME", tim=1, thread_no=100)])
|
|
added = store.record_files(conn, [_file("SAME", tim=999, thread_no=200)])
|
|
assert added == 0
|
|
assert store.pending_downloads(conn) == 1
|
|
|
|
|
|
def test_claim_next_takes_the_oldest_and_marks_it_in_flight(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A", tim=1)])
|
|
claimed = store.claim_next(conn)
|
|
assert claimed is not None
|
|
assert claimed.md5 == "A"
|
|
assert store.attempts_for(conn, "A") == 1
|
|
assert store.counts(conn)[FileState.DOWNLOADING.value] == 1
|
|
|
|
|
|
def test_claim_next_returns_none_when_idle(conn: sqlite3.Connection) -> None:
|
|
assert store.claim_next(conn) is None
|
|
|
|
|
|
def test_claim_next_rolls_back_on_failure() -> None:
|
|
"""A mid-transaction failure must not leave the claim half-applied.
|
|
|
|
``sqlite3.Connection.execute`` is read-only so it cannot be patched on a
|
|
real connection; a mock stands in purely to inject the failure.
|
|
"""
|
|
fake = MagicMock()
|
|
# BEGIN IMMEDIATE, then the SELECT blows up, then ROLLBACK must still run.
|
|
fake.execute.side_effect = [MagicMock(), RuntimeError("simulated"), MagicMock()]
|
|
|
|
with pytest.raises(RuntimeError, match="simulated"):
|
|
store.claim_next(fake)
|
|
|
|
executed = [call.args[0] for call in fake.execute.call_args_list]
|
|
assert executed[0] == "BEGIN IMMEDIATE"
|
|
assert executed[-1] == "ROLLBACK"
|
|
|
|
|
|
def test_attempts_for_unknown_file_is_zero(conn: sqlite3.Connection) -> None:
|
|
assert store.attempts_for(conn, "nope") == 0
|
|
|
|
|
|
def test_set_state_records_the_error(conn: sqlite3.Connection) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
store.set_state(conn, "A", FileState.GONE, error="404")
|
|
row = conn.execute("SELECT state, last_error FROM files").fetchone()
|
|
assert row["state"] == FileState.GONE.value
|
|
assert row["last_error"] == "404"
|
|
|
|
|
|
def test_mark_downloaded_makes_the_file_reviewable(
|
|
conn: sqlite3.Connection,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A", tim=42)])
|
|
store.mark_downloaded(conn, "A", "42-x.webm")
|
|
items = store.ready_items(conn, tmp_path)
|
|
assert len(items) == 1
|
|
assert items[0].md5 == "A"
|
|
assert items[0].path == tmp_path / "42-x.webm"
|
|
assert items[0].orig_name == "clip.webm"
|
|
|
|
|
|
def test_ready_items_is_empty_before_any_download(
|
|
conn: sqlite3.Connection,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
assert store.ready_items(conn, tmp_path) == []
|
|
|
|
|
|
def test_record_progress_persists_partial_bytes(conn: sqlite3.Connection) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
store.record_progress(conn, "A", 4096)
|
|
assert conn.execute("SELECT bytes_done FROM files").fetchone()[0] == 4096
|
|
|
|
|
|
def test_record_verdict_is_terminal_and_stamps_the_time(
|
|
conn: sqlite3.Connection,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
store.mark_downloaded(conn, "A", "a.webm")
|
|
store_verdicts.record_verdict(conn, "A", FileState.PASSED)
|
|
assert store.ready_items(conn, tmp_path) == []
|
|
assert conn.execute("SELECT reviewed_at FROM files").fetchone()[0] is not None
|
|
assert store.pending_downloads(conn) == 0
|
|
|
|
|
|
def test_a_reviewed_file_is_still_remembered_so_it_never_returns(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
store_verdicts.record_verdict(conn, "A", FileState.PASSED)
|
|
assert "A" in store.known_md5s(conn)
|
|
assert store.record_files(conn, [_file("A")]) == 0
|
|
|
|
|
|
def test_reset_in_flight_recovers_an_interrupted_download(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
store.record_files(conn, [_file("A")])
|
|
store.claim_next(conn)
|
|
assert store.reset_in_flight(conn) == 1
|
|
assert store.pending_downloads(conn) == 1
|
|
assert store.reset_in_flight(conn) == 0
|
|
|
|
|
|
def test_counts_reports_every_state_and_a_total(conn: sqlite3.Connection) -> None:
|
|
store.record_files(conn, [_file("A"), _file("B", tim=2)])
|
|
store.set_state(conn, "A", FileState.GONE)
|
|
result = store.counts(conn)
|
|
assert result["total"] == 2
|
|
assert result[FileState.GONE.value] == 1
|
|
assert result[FileState.NEW.value] == 1
|
|
|
|
|
|
def test_thread_bookkeeping_round_trip(conn: sqlite3.Connection) -> None:
|
|
stamp = "Mon, 27 Jul 2026 08:00:00 GMT"
|
|
store_threads.record_thread(conn, ThreadRef(7, 1000), stamp)
|
|
assert store_threads.known_threads(conn) == {7: 1000}
|
|
stamp = "Mon, 27 Jul 2026 08:00:00 GMT"
|
|
assert store_threads.http_last_modified(conn, 7) == stamp
|
|
|
|
store_threads.record_thread(conn, ThreadRef(7, 2000), None)
|
|
assert store_threads.known_threads(conn) == {7: 2000}
|
|
stamp = "Mon, 27 Jul 2026 08:00:00 GMT"
|
|
assert store_threads.http_last_modified(conn, 7) == stamp
|
|
|
|
|
|
def test_http_last_modified_absent_cases(conn: sqlite3.Connection) -> None:
|
|
assert store_threads.http_last_modified(conn, 404) is None
|
|
store_threads.record_thread(conn, ThreadRef(8, 1), None)
|
|
assert store_threads.http_last_modified(conn, 8) is None
|
|
|
|
|
|
def test_mark_thread_gone(conn: sqlite3.Connection) -> None:
|
|
store_threads.record_thread(conn, ThreadRef(7, 1000), None)
|
|
store_threads.mark_thread_gone(conn, 7)
|
|
row = conn.execute("SELECT status FROM threads WHERE thread_no = 7").fetchone()
|
|
assert row["status"] == "gone"
|