screen-locker/screen_locker/tests/test_runnerup_retire.py
Krzysztof kuhy Rudnicki 0411116f24 Harden RunnerUp retirement after review
Silent-failure checker, mypy, pylint now clean. Hidden packages count
as installed (pm list -u), pull gets a long timeout, wifi entries are
matched by ro.serialno, and a missing log/drop dir is a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:08:40 +01:00

217 lines
8.0 KiB
Python

"""RunnerUp retirement: when it fires, what it refuses, and what it records."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING
import pytest
from screen_locker import _runnerup_retire as rr
from screen_locker._runnerup_retire import Outcome, Paths, run_retire
from screen_locker._runnerup_retire_adb import AdbError, Device
from screen_locker._runnerup_retire_state import RetireState, load_state, save_state
from screen_locker.tests._retire_helpers import (
ANCHOR,
DUE,
RUN,
FakePhone,
put_marker,
world,
)
if TYPE_CHECKING:
from pathlib import Path
def go(paths: Paths, phone: FakePhone, now: datetime = DUE, *, dry_run: bool = False):
"""Go."""
return run_retire(paths, phone, now, dry_run=dry_run)
def test_no_anchor_never_touches_anything(tmp_path: Path) -> None:
"""Test no anchor never touches anything."""
paths = world(tmp_path, anchored=False)
phone = FakePhone()
result = go(paths, phone, DUE + timedelta(days=900))
assert result.outcome is Outcome.NO_ANCHOR
assert result.ok
assert not phone.calls
assert not paths.state_file.exists()
def test_missing_sources_are_a_warning_not_a_quiet_nothing(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test missing sources are a warning not a quiet nothing."""
paths = Paths(tmp_path / "s.json", tmp_path / "nodrop", tmp_path / "nolog.json")
with caplog.at_level(logging.INFO):
result = go(paths, FakePhone())
assert result.outcome is Outcome.SOURCES_MISSING
assert result.ok
assert "nodrop" in result.reason
assert "nolog.json" in result.reason
assert caplog.records[-1].levelno == logging.WARNING
def test_no_anchor_says_how_many_app_runs_it_saw(tmp_path: Path) -> None:
"""Test no anchor says how many app runs it saw."""
paths = world(tmp_path, anchored=False)
assert "(1 app TCX on disk)" in go(paths, FakePhone()).reason
def test_not_due_persists_the_anchor_and_says_when(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test not due persists the anchor and says when."""
paths = world(tmp_path)
with caplog.at_level(logging.INFO):
result = go(paths, FakePhone(), DUE - timedelta(minutes=1))
assert result.outcome is Outcome.NOT_DUE
assert "2026-11-08 10:45" in result.reason
assert load_state(paths.state_file).anchor == ANCHOR
assert [r.levelno for r in caplog.records] == [logging.INFO]
def test_stored_anchor_wins_over_the_log(tmp_path: Path) -> None:
"""Test stored anchor wins over the log."""
paths = world(tmp_path, anchored=False)
save_state(paths.state_file, RetireState(anchor_at=ANCHOR.isoformat()))
assert go(paths, FakePhone(), ANCHOR).outcome is Outcome.NOT_DUE
def test_dry_run_writes_nothing_and_stops_before_the_phone_changes(
tmp_path: Path,
) -> None:
"""Test dry run writes nothing and stops before the phone changes."""
paths = world(tmp_path)
phone = FakePhone()
result = go(paths, phone, dry_run=True)
assert result.outcome is Outcome.DRY_RUN
assert phone.calls == ["installed S"]
assert not paths.state_file.exists()
gone = go(paths, FakePhone(present=False), dry_run=True)
assert gone.outcome is Outcome.ALREADY_GONE
assert not paths.state_file.exists()
@pytest.mark.parametrize("variant", ["missing", "mismatch"])
def test_invalid_marker_blocks_the_uninstall_loudly(
tmp_path: Path, caplog: pytest.LogCaptureFixture, variant: str
) -> None:
"""Test invalid marker blocks the uninstall loudly."""
paths = world(tmp_path, marker=variant == "mismatch")
if variant == "mismatch":
put_marker(paths.watch_dir, [RUN], count=2)
phone = FakePhone()
with caplog.at_level(logging.WARNING):
result = go(paths, phone)
assert result.outcome is Outcome.MARKER_INVALID
assert not result.ok
assert "NOT uninstalling" in result.reason
assert not phone.calls
assert caplog.records[-1].levelno == logging.WARNING
def test_adb_missing_is_a_failure(tmp_path: Path) -> None:
"""Test adb missing is a failure."""
result = go(world(tmp_path), FakePhone(adb=False))
assert result.outcome is Outcome.ADB_MISSING
assert not result.ok
def test_absent_phone_retries_quietly_but_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test absent phone retries quietly but warns."""
phone = FakePhone(device=Device(None, "phone S is not attached"))
with caplog.at_level(logging.WARNING):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.DEVICE_ABSENT
assert result.ok
assert "not attached" in caplog.text
assert not phone.calls
def test_adb_error_while_asking_is_a_logged_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test adb error while asking is a logged failure."""
phone = FakePhone(ask_error=AdbError("server died"))
with caplog.at_level(logging.ERROR):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.PHONE_ERROR
assert not result.ok
assert len(caplog.records) == 1 # logged once, where it was caught
def test_already_uninstalled_is_recorded_done(tmp_path: Path) -> None:
"""Test already uninstalled is recorded done."""
paths = world(tmp_path)
result = go(paths, FakePhone(present=False))
assert result.outcome is Outcome.ALREADY_GONE
assert load_state(paths.state_file).done_at
assert go(paths, FakePhone()).outcome is Outcome.ALREADY_DONE
def test_full_run_backs_up_then_uninstalls_then_never_again(tmp_path: Path) -> None:
"""Test full run backs up then uninstalls then never again."""
paths = world(tmp_path)
phone = FakePhone()
result = go(paths, phone)
assert result.outcome is Outcome.UNINSTALLED
assert phone.calls == ["installed S", "backup", "uninstall"]
backup = paths.watch_dir / "runnerup_phone_backup_2026-11-08"
assert backup.is_dir()
assert not list(paths.watch_dir.glob("*.tcx")) # nothing top-level
state = load_state(paths.state_file)
assert state.anchor == ANCHOR
assert state.backup_path == str(backup)
assert state.done_at
again = FakePhone()
assert go(paths, again).outcome is Outcome.ALREADY_DONE
assert not again.calls
@pytest.mark.parametrize("error", [AdbError("short pull"), OSError("disk full")])
def test_failed_backup_blocks_the_uninstall(
tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception
) -> None:
"""Test failed backup blocks the uninstall."""
phone = FakePhone(backup_error=error)
with caplog.at_level(logging.ERROR):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.BACKUP_FAILED
assert not result.ok
assert "uninstall" not in phone.calls
assert str(error) in caplog.text
def test_failed_uninstall_keeps_the_backup_for_the_retry(tmp_path: Path) -> None:
"""Test failed uninstall keeps the backup for the retry."""
paths = world(tmp_path)
first = FakePhone(uninstall_error=AdbError("DELETE_FAILED_DEVICE_POLICY_MANAGER"))
result = go(paths, first)
assert result.outcome is Outcome.UNINSTALL_FAILED
assert not result.ok
assert not load_state(paths.state_file).done_at
retry = FakePhone()
assert go(paths, retry, DUE + timedelta(days=1)).outcome is Outcome.UNINSTALLED
assert retry.calls == ["installed S", "uninstall"] # no second backup
def test_default_paths_are_the_module_constants() -> None:
"""Test default paths are the module constants."""
paths = Paths.default()
assert paths.state_file == rr.STATE_FILE
assert paths.marker == rr.WATCH_DIR / rr.MARKER_NAME
assert paths.processed == rr.WATCH_DIR / "processed"
assert paths.anchor_dirs() == (rr.WATCH_DIR, rr.WATCH_DIR / "processed")
def test_run_retire_defaults_to_the_real_clock(tmp_path: Path) -> None:
"""Test run retire defaults to the real clock."""
paths = world(tmp_path)
assert run_retire(paths, FakePhone(), dry_run=True).outcome is Outcome.DRY_RUN