mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Booleaness asserts, hoisted imports, fixture(name=...) instead of shadowing arguments, explicit encodings, snake_case names, comparison of bound methods, dead protected-access disables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
"""Tests for _stream_cache: merge, tombstones, own-device rule and crediting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync import Hlc, Record
|
|
import pytest
|
|
|
|
from screen_locker import _stream_cache
|
|
from screen_locker._poke_credit import BatchOutcome
|
|
from screen_locker._rtdb_stream import SseEvent
|
|
from screen_locker._stream_cache import DeviceLogCache
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
_LEAF = "log~2Ejson"
|
|
OWN = "pc-own"
|
|
_PAYLOAD = {"exercises": [], "date": "2026-10-09"}
|
|
|
|
|
|
def log_blob(
|
|
rid: str, *, wall: int = 1, deleted: bool = False, payload: object = None
|
|
) -> str:
|
|
"""One device-log blob holding a single (possibly tombstoned) record."""
|
|
hlc = Hlc(wall, 0, "n")
|
|
record = Record(
|
|
rid,
|
|
{"payload": (_PAYLOAD if payload is None else payload, hlc)},
|
|
deleted=deleted,
|
|
deleted_hlc=hlc if deleted else None,
|
|
)
|
|
return json.dumps({rid: record.to_dict()})
|
|
|
|
|
|
def snapshot(logs: Mapping[str, str]) -> SseEvent:
|
|
"""A ``put /`` carrying every device log."""
|
|
data = {dev: {_LEAF: text} for dev, text in logs.items()}
|
|
return SseEvent("put", json.dumps({"path": "/", "data": data}))
|
|
|
|
|
|
def patch_event(logs: Mapping[str, str | None]) -> SseEvent:
|
|
"""A ``patch /`` rewriting some device logs (``None`` deletes one)."""
|
|
data = {f"{dev}/{_LEAF}": text for dev, text in logs.items()}
|
|
return SseEvent("patch", json.dumps({"path": "/", "data": data}))
|
|
|
|
|
|
class Credit:
|
|
"""Stand-in for ``credit_sessions`` that records every batch it is handed."""
|
|
|
|
def __init__(self) -> None:
|
|
self.batches: list[list[str]] = []
|
|
self.error: Exception | None = None
|
|
self.gate_held: list[bool] = []
|
|
self.gate: threading.Lock | None = None
|
|
|
|
def __call__(
|
|
self, records: Iterable[tuple[str, Mapping[str, object]]]
|
|
) -> BatchOutcome:
|
|
if self.gate is not None:
|
|
self.gate_held.append(self.gate.locked())
|
|
if self.error is not None:
|
|
raise self.error
|
|
ids = [rid for rid, _payload in records]
|
|
self.batches.append(ids)
|
|
return BatchOutcome(ingested=tuple(ids), credited=len(ids))
|
|
|
|
|
|
@pytest.fixture(name="credit")
|
|
def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> Credit:
|
|
"""Replace the real (locker-building) credit step with a recorder."""
|
|
fake = Credit()
|
|
monkeypatch.setattr(_stream_cache._poke_credit, "credit_sessions", fake)
|
|
return fake
|
|
|
|
|
|
@pytest.fixture(name="cache")
|
|
def cache_fixture(credit: Credit) -> DeviceLogCache:
|
|
"""A cache whose own device dir is ``pc-own``."""
|
|
gate = threading.Lock()
|
|
credit.gate = gate
|
|
return DeviceLogCache("sync/devices", frozenset({OWN}), gate)
|
|
|
|
|
|
class TestCredit:
|
|
"""Which events credit, and how."""
|
|
|
|
def test_snapshot_credits_foreign_sessions_under_the_gate(
|
|
self, cache: DeviceLogCache, credit: Credit
|
|
) -> None:
|
|
"""A phone session in the snapshot is credited while the lock is held."""
|
|
result = cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
assert result.snapshot is True
|
|
assert credit.batches == [["s1"]]
|
|
assert credit.gate_held == [True]
|
|
assert (
|
|
"1 session(s) checked, 1 newly logged ['s1'], 1 credited" in result.summary
|
|
)
|
|
|
|
def test_patch_credits_a_new_session(
|
|
self, cache: DeviceLogCache, credit: Credit
|
|
) -> None:
|
|
"""A later patch for the phone credits only what is new."""
|
|
cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
result = cache.handle(patch_event({"phone": log_blob("s2")}))
|
|
assert result.snapshot is False
|
|
assert credit.batches == [["s1"], ["s2"]]
|
|
|
|
def test_already_handed_sessions_are_a_no_op(
|
|
self, cache: DeviceLogCache, credit: Credit
|
|
) -> None:
|
|
"""Re-streaming the same snapshot costs no second credit call."""
|
|
cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
result = cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
assert credit.batches == [["s1"]]
|
|
assert "no-op, no file I/O" in result.summary
|
|
assert result.snapshot is True
|
|
|
|
def test_a_newer_hlc_of_the_same_record_is_credited_again(
|
|
self, cache: DeviceLogCache, credit: Credit
|
|
) -> None:
|
|
"""The seen-set is keyed on (id, hlc): an edit is a new thing to check."""
|
|
cache.handle(snapshot({"phone": log_blob("s1", wall=1)}))
|
|
cache.handle(patch_event({"phone": log_blob("s1", wall=2)}))
|
|
assert credit.batches == [["s1"], ["s1"]]
|
|
|
|
def test_non_session_records_are_not_credited(
|
|
self, cache: DeviceLogCache, credit: Credit
|
|
) -> None:
|
|
"""A record without ``exercises`` (e.g. runnerup_verified) is skipped."""
|
|
cache.handle(snapshot({"phone": log_blob("v1", payload={"kind": "x"})}))
|
|
assert credit.batches == []
|
|
|
|
|
|
class TestFailures:
|
|
"""Nothing is swallowed silently; nothing is marked seen on failure."""
|
|
|
|
def test_credit_failure_is_logged_and_retried_next_event(
|
|
self,
|
|
cache: DeviceLogCache,
|
|
credit: Credit,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A crediting error logs at ERROR, keeps the stream, retries later."""
|
|
credit.error = RuntimeError("disk full")
|
|
with caplog.at_level(logging.WARNING):
|
|
result = cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
assert result.summary == "crediting failed (see above)"
|
|
assert result.snapshot is True
|
|
failures = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
|
assert failures
|
|
assert failures[0].exc_info is not None
|
|
assert "Crediting 1 streamed session(s) failed" in failures[0].getMessage()
|
|
credit.error = None
|
|
cache.handle(snapshot({"phone": log_blob("s1")}))
|
|
assert credit.batches == [["s1"]]
|
|
|
|
def test_bad_json_propagates_for_the_caller_to_warn(
|
|
self, cache: DeviceLogCache
|
|
) -> None:
|
|
"""Undecodable events are the connection's to report, not ours to eat."""
|
|
with pytest.raises(json.JSONDecodeError):
|
|
cache.handle(SseEvent("put", "{nope"))
|
|
with pytest.raises(TypeError, match="without a path"):
|
|
cache.handle(SseEvent("put", "[]"))
|