testsAndMisc/python_pkg/phone_lease/tests/test_scopes.py
Krzysztof kuhy Rudnicki c0c51036fc phone_lease: per-app leases, a self-cleaning whole-phone lease, one serial
Several sessions can now share one phone: a lease covers one package (the
real screen stays the default scope, so existing callers keep their
meaning), and only phone-wide actions take `--whole-phone`, which is capped
at 300 s and runs its `--cleanup` commands exactly once on release or, via a
detached reaper, on expiry. The serial is always resolved, never the
"default" placeholder that let android_ui and phone_deploy lease different
keys for the same phone (2026-09-25). Adds status --json, holds and whoami
for phone_vd.sh and the raw-adb hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ajzz98xksbfak8CaunZWC
2026-09-25 15:05:57 +02:00

201 lines
6.3 KiB
Python

"""Scoped leases: packages, the whole phone, cleanup, holds, and the reaper."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.phone_lease import (
WHOLE_PHONE_MAX_TTL,
Clock,
Lease,
PhoneBusyError,
Terms,
_cleanup,
acquire,
holds,
reap,
release,
scope_of,
status,
status_all,
)
from python_pkg.phone_lease import lease as lease_mod
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
WHOLE = scope_of(whole_phone=True)
@dataclass
class Mocks:
"""The two subprocess-touching hooks, replaced for every test."""
run: MagicMock
spawn: MagicMock
@pytest.fixture(autouse=True)
def mocks(tmp_path: Path) -> Iterator[Mocks]:
"""Own lease directory; cleanup commands and reapers never really run."""
with (
patch.object(lease_mod, "LEASE_DIR", tmp_path / "leases"),
patch.object(_cleanup, "run", return_value=[]) as run,
patch.object(_cleanup, "spawn_reaper") as spawn,
):
yield Mocks(run=run, spawn=spawn)
@pytest.fixture
def lease_dir(tmp_path: Path) -> Path:
"""Where this test's lease files live."""
return tmp_path / "leases"
def _take(owner: str, scope: str, cleanup: tuple[str, ...] = ()) -> Lease:
terms = Terms(wait=0, cleanup=cleanup)
return acquire("P", owner, scope=scope, terms=terms, owner=owner)
class TestScopeOf:
def test_names(self) -> None:
assert scope_of() == "pkg:__screen__"
assert scope_of("a.b") == "pkg:a.b"
assert scope_of("a.b", whole_phone=True) == "whole-phone"
class TestConflicts:
def test_different_packages_coexist(self) -> None:
_take("A", "pkg:a")
_take("B", "pkg:b")
assert {x.owner for x in status_all("P")} == {"A", "B"}
def test_same_package_is_exclusive(self) -> None:
_take("A", "pkg:a")
with pytest.raises(PhoneBusyError, match=r"\[pkg:a\] held by A"):
_take("B", "pkg:a")
def test_whole_phone_waits_for_every_foreign_lease(self) -> None:
_take("A", "pkg:a")
with pytest.raises(PhoneBusyError):
_take("B", WHOLE)
def test_whole_phone_blocks_every_foreign_lease(self) -> None:
_take("A", WHOLE)
with pytest.raises(PhoneBusyError, match="whole-phone"):
_take("B", "pkg:z")
def test_own_leases_never_conflict(self) -> None:
_take("A", "pkg:a")
assert _take("A", WHOLE).scope == WHOLE
class TestWholePhone:
def test_ttl_is_capped(self) -> None:
clock = Clock(now=lambda: 100.0, sleep=lambda _s: None)
got = acquire("P", scope=WHOLE, terms=Terms(ttl=9999), owner="A", clock=clock)
assert got.expires == 100.0 + WHOLE_PHONE_MAX_TTL
def test_reaper_only_for_a_new_lease_with_cleanup(self, mocks: Mocks) -> None:
_take("A", WHOLE, cleanup=("undo",))
_take("A", WHOLE, cleanup=("undo",))
_take("A", "pkg:b")
mocks.spawn.assert_called_once_with("P", "A", WHOLE)
def test_release_runs_cleanup_once(self, mocks: Mocks) -> None:
_take("A", WHOLE, cleanup=("undo",))
assert release("P", scope=WHOLE, owner="A") is True
assert release("P", scope=WHOLE, owner="A") is False
mocks.run.assert_called_once_with(("undo",))
def test_expired_lease_is_cleaned_by_the_next_caller(self, mocks: Mocks) -> None:
early = Clock(now=lambda: 0.0, sleep=lambda _s: None)
acquire(
"P",
scope=WHOLE,
terms=Terms(ttl=5, cleanup=("undo",)),
owner="A",
clock=early,
)
_take("B", "pkg:b")
mocks.run.assert_any_call(("undo",))
assert [x.owner for x in status_all("P")] == ["B"]
class TestHoldsAndStatus:
def test_holds_scope_or_whole_phone(self) -> None:
_take("A", "pkg:a")
assert holds("P", "A", "pkg:a") is True
assert holds("P", "A", "pkg:b") is False
assert holds("P", "B", "pkg:a") is False
_take("C", "pkg:c")
release("P", scope="pkg:a", owner="A")
release("P", scope="pkg:c", owner="C")
_take("A", WHOLE)
assert holds("P", "A", "pkg:anything") is True
def test_status_by_scope(self) -> None:
_take("A", "pkg:a")
assert status("P", scope="pkg:a") is not None
assert status("P") is None
def test_reads_a_pre_scope_lease_file(self, lease_dir: Path) -> None:
lease_dir.mkdir()
(lease_dir / "P.json").write_text(
json.dumps({"serial": "P", "owner": "old", "expires": 9e12, "note": "n"})
)
(live,) = status_all("P")
assert (live.owner, live.scope) == ("old", "pkg:__screen__")
def test_skips_entries_that_are_not_objects(self, lease_dir: Path) -> None:
lease_dir.mkdir()
good = {"serial": "P", "owner": "o", "expires": 9e12, "note": "n"}
(lease_dir / "P.json").write_text(json.dumps({"leases": [1, "x", good]}))
assert [x.owner for x in status_all("P")] == ["o"]
def test_describe_names_non_screen_scopes(self) -> None:
assert "[pkg:a]" in _take("A", "pkg:a").describe()
assert "[" not in _take("B", "pkg:__screen__").describe()
class TestReap:
def _clock(self, start: float) -> tuple[Clock, list[float]]:
now = [start]
slept: list[float] = []
def sleep(secs: float) -> None:
slept.append(secs)
now[0] += secs
return Clock(now=lambda: now[0], sleep=sleep), slept
def test_runs_cleanup_after_expiry(self, mocks: Mocks) -> None:
clock, slept = self._clock(0.0)
acquire(
"P",
scope=WHOLE,
terms=Terms(ttl=12, cleanup=("undo",)),
owner="A",
clock=clock,
)
assert reap("P", "A", WHOLE, clock=clock) is True
assert slept == [5.0, 5.0, 2.0]
mocks.run.assert_called_once_with(("undo",))
def test_released_first_means_nothing_to_do(self) -> None:
clock, _ = self._clock(0.0)
assert reap("P", "A", WHOLE, clock=clock) is False
def test_stops_when_told(self) -> None:
_take("A", WHOLE)
assert reap("P", "A", WHOLE, alive=lambda: False) is False
def test_defaults_to_the_real_clock(self) -> None:
assert reap("P", "nobody", WHOLE) is False