mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22: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>
164 lines
4.3 KiB
Python
164 lines
4.3 KiB
Python
"""Tests for the structured event log."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from python_pkg.wsg_grabber import logs
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _closed() -> Iterator[None]:
|
|
"""Make sure no test leaves the log file open.
|
|
|
|
Yields:
|
|
None: Control returns to the test.
|
|
"""
|
|
try:
|
|
yield
|
|
finally:
|
|
logs.stop()
|
|
|
|
|
|
def _records(path: Path) -> list[dict[str, object]]:
|
|
"""Parse a session log.
|
|
|
|
Args:
|
|
path: The JSONL file.
|
|
|
|
Returns:
|
|
list[dict[str, object]]: One dict per line.
|
|
"""
|
|
return [
|
|
json.loads(line)
|
|
for line in path.read_text(encoding="utf-8").splitlines()
|
|
if line
|
|
]
|
|
|
|
|
|
def test_starting_creates_a_log_and_announces_itself(tmp_path: Path) -> None:
|
|
path = logs.start()
|
|
assert path.parent == logs.logs_dir()
|
|
assert str(path).startswith(str(tmp_path))
|
|
assert logs.current_path() == path
|
|
|
|
first = _records(path)[0]
|
|
assert first["event"] == "session.start"
|
|
assert first["seq"] == 1
|
|
assert first["level"] == "info"
|
|
|
|
|
|
def test_every_record_carries_the_standard_fields() -> None:
|
|
path = logs.start()
|
|
logs.event("thing.happened", detail="x", count=3)
|
|
record = _records(path)[-1]
|
|
assert set(record) >= {"ts", "seq", "level", "event", "thread"}
|
|
assert record["event"] == "thing.happened"
|
|
assert record["detail"] == "x"
|
|
assert record["count"] == 3
|
|
|
|
|
|
def test_sequence_numbers_are_monotonic() -> None:
|
|
path = logs.start()
|
|
for index in range(5):
|
|
logs.event("tick", index=index)
|
|
seqs = [int(str(record["seq"])) for record in _records(path)]
|
|
assert seqs == sorted(seqs)
|
|
assert len(set(seqs)) == len(seqs)
|
|
|
|
|
|
def test_level_helpers_set_the_level() -> None:
|
|
path = logs.start("debug")
|
|
logs.debug("a")
|
|
logs.warning("b")
|
|
logs.error("c")
|
|
levels = [record["level"] for record in _records(path)[1:]]
|
|
assert levels == ["debug", "warning", "error"]
|
|
|
|
|
|
def test_records_below_the_threshold_are_dropped() -> None:
|
|
path = logs.start("warning")
|
|
logs.debug("dropped")
|
|
logs.event("also-dropped")
|
|
logs.warning("kept")
|
|
events = [record["event"] for record in _records(path)]
|
|
assert "dropped" not in events
|
|
assert "also-dropped" not in events
|
|
assert "kept" in events
|
|
|
|
|
|
def test_an_unknown_level_name_falls_back_to_info() -> None:
|
|
path = logs.start("nonsense")
|
|
logs.event("recorded", level="nonsense")
|
|
assert any(record["event"] == "recorded" for record in _records(path))
|
|
|
|
|
|
def test_values_that_cannot_be_serialised_are_stringified() -> None:
|
|
path = logs.start()
|
|
logs.event("odd", value=object(), where=tmp_marker())
|
|
record = _records(path)[-1]
|
|
assert isinstance(record["value"], str)
|
|
|
|
|
|
def tmp_marker() -> object:
|
|
"""Return a non-JSON-encodable value.
|
|
|
|
Returns:
|
|
object: Something json cannot encode directly.
|
|
"""
|
|
return {1, 2, 3}
|
|
|
|
|
|
def test_events_before_start_go_nowhere_and_do_not_raise() -> None:
|
|
logs.stop()
|
|
logs.event("ignored")
|
|
assert logs.current_path() is None
|
|
|
|
|
|
def test_echo_mirrors_records_to_stderr(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
logs.start(echo=True)
|
|
logs.event("mirrored", detail="yes")
|
|
captured = capsys.readouterr().err
|
|
assert "mirrored" in captured
|
|
assert json.loads(captured.strip().splitlines()[-1])["detail"] == "yes"
|
|
|
|
|
|
def test_starting_twice_rotates_to_a_new_file() -> None:
|
|
first = logs.start()
|
|
logs.event("one")
|
|
second = logs.start()
|
|
logs.event("two")
|
|
assert logs.current_path() == second
|
|
assert "one" in first.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_stop_is_idempotent() -> None:
|
|
logs.start()
|
|
logs.stop()
|
|
logs.stop()
|
|
assert logs.current_path() is None
|
|
|
|
|
|
def test_the_log_lands_inside_the_sandbox(tmp_path: Path) -> None:
|
|
"""The suite must never write a log into the real data directory."""
|
|
assert str(logs.logs_dir()).startswith(str(tmp_path))
|
|
|
|
|
|
def test_each_session_numbers_its_own_records_from_one() -> None:
|
|
"""seq orders records within one file, so it must restart per session."""
|
|
first = logs.start()
|
|
logs.event("a")
|
|
logs.event("b")
|
|
second = logs.start()
|
|
assert _records(second)[0]["seq"] == 1
|
|
assert len(_records(first)) == 3
|