mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:07:41 +01:00
manual:2026-09-13T14:30 -- the synced copy of the football that this
morning's day-key rekey let back in under a second id -- made every
workout-sync tick log "Manual-workout budget exhausted -- not ingested".
Tombstoning it once did not stick: sync_log pulls only the OTHER devices'
logs and _manual_push re-derives the PC's log from log.json each tick, so
the next push overwrote the tombstone ("tombstoned", then "LIVE" five
seconds later). Deletion is monotonic only for records still in the merge.
- New _sync_tombstones: {id: {deleted_hlc, reason, tombstoned_at}} in
screen_locker/sync_tombstones.json (gitignored with its siblings). The
HLC is minted once and stored so re-pushing does not churn the store.
The ledger path is resolved at call time, not as a default argument, so
the test suite's redirect actually applies -- verified by hashing the
real ledger before and after the run.
- _manual_push.records_from_workout_log merges the ledger over the live
derivation, so every push carries the tombstones.
- scripts/tombstone_sync_record.py <id> [--apply --reason ...]: dry run
reports live/tombstoned; --apply writes the ledger, runs one tick and
reports what the store holds afterwards. Rerunnable.
- tests/_isolated_state.py redirects SYNC_TOMBSTONES_FILE (both bindings).
Verified: --apply -> "tombstoned"; a second ordinary tick -> "already
tombstoned" with the same HLC; workout-sync.service now logs "Sync record
manual:2026-09-13T14:30 is tombstoned -- skipping it" and the budget line
for that id is gone. log.json untouched (the local entry stays; it shares
the original's credit per duplicate_credits.json).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
"""Tests for the durable sync-tombstone ledger and its place in every push.
|
|
|
|
Guards the 2026-09-13 finding: a tombstone written once to the PC's device log
|
|
was gone by the next tick, because ``sync_log`` never reads this device's own
|
|
remote log and ``_manual_push`` re-derives it from ``log.json`` every time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync import Hlc, Record
|
|
|
|
from screen_locker import _sync_tombstones
|
|
from screen_locker._manual_push import records_from_workout_log
|
|
from screen_locker._sync_tombstones import add_tombstone, tombstone_records
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_DEVICE = "57582b3b-01c6-45cc-9674-ad2b95bebf29"
|
|
_RID = "manual:2026-09-13T14:30"
|
|
_MANUAL = {"type": "manual_workout", "source": "football 11v11", "start_time": "14:30"}
|
|
|
|
|
|
class TestAddTombstone:
|
|
"""The ledger is written once per id, with a clock minted at that moment."""
|
|
|
|
def test_creates_the_ledger_with_hlc_reason_and_time(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
assert add_tombstone(_RID, _DEVICE, "duplicate", path) is True
|
|
entry = json.loads(path.read_text())[_RID]
|
|
assert Hlc.from_str(entry["deleted_hlc"]).node_id == _DEVICE
|
|
assert entry["reason"] == "duplicate"
|
|
assert entry["tombstoned_at"].startswith("20")
|
|
|
|
def test_second_add_is_a_no_op_and_keeps_the_original_clock(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
add_tombstone(_RID, _DEVICE, "first", path)
|
|
before = path.read_text()
|
|
assert add_tombstone(_RID, _DEVICE, "second", path) is False
|
|
assert path.read_text() == before
|
|
|
|
def test_adds_alongside_existing_ids(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
add_tombstone(_RID, _DEVICE, "a", path)
|
|
add_tombstone("manual:2026-01-01T08:00", _DEVICE, "b", path)
|
|
assert set(json.loads(path.read_text())) == {_RID, "manual:2026-01-01T08:00"}
|
|
|
|
|
|
class TestTombstoneRecords:
|
|
"""Ledger → crdt_sync Records, loud about anything it cannot push."""
|
|
|
|
def test_missing_ledger_is_empty(self, tmp_path: Path) -> None:
|
|
assert tombstone_records(tmp_path / "absent.json") == {}
|
|
|
|
def test_ledgered_id_becomes_a_deleted_record(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
add_tombstone(_RID, _DEVICE, "duplicate", path)
|
|
record = tombstone_records(path)[_RID]
|
|
assert record == Record(
|
|
id=_RID,
|
|
fields={},
|
|
deleted=True,
|
|
deleted_hlc=Hlc.from_str(json.loads(path.read_text())[_RID]["deleted_hlc"]),
|
|
)
|
|
|
|
def test_same_clock_on_every_read(self, tmp_path: Path) -> None:
|
|
"""Re-pushing must not churn the store: the HLC is stored, not re-minted."""
|
|
path = tmp_path / "sync_tombstones.json"
|
|
add_tombstone(_RID, _DEVICE, "duplicate", path)
|
|
assert tombstone_records(path) == tombstone_records(path)
|
|
|
|
def test_corrupt_ledger_pushes_nothing_and_warns(
|
|
self, tmp_path: Path, caplog
|
|
) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
path.write_text("{not json")
|
|
assert tombstone_records(path) == {}
|
|
assert "unreadable" in caplog.text
|
|
|
|
def test_non_object_ledger_is_ignored_and_warns(
|
|
self, tmp_path: Path, caplog
|
|
) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
path.write_text("[]")
|
|
assert tombstone_records(path) == {}
|
|
assert "not a JSON object" in caplog.text
|
|
|
|
def test_non_dict_entries_are_dropped(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
path.write_text(json.dumps({_RID: "garbage"}))
|
|
assert tombstone_records(path) == {}
|
|
|
|
def test_entry_without_usable_hlc_is_skipped_and_warns(
|
|
self, tmp_path: Path, caplog
|
|
) -> None:
|
|
path = tmp_path / "sync_tombstones.json"
|
|
path.write_text(json.dumps({_RID: {"reason": "x"}}))
|
|
assert tombstone_records(path) == {}
|
|
assert "no usable deleted_hlc" in caplog.text
|
|
|
|
|
|
class TestPushCarriesTombstones:
|
|
"""records_from_workout_log replaces the live copy with the tombstone."""
|
|
|
|
def _log(self, tmp_path: Path) -> Path:
|
|
log_file = tmp_path / "log.json"
|
|
log_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"2026-09-13": [
|
|
{
|
|
"timestamp": "2026-09-13T07:40:06+00:00",
|
|
"workout_data": _MANUAL,
|
|
"workout_id": _RID,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
return log_file
|
|
|
|
def test_live_entry_is_pushed_when_not_ledgered(self, tmp_path: Path) -> None:
|
|
log = records_from_workout_log(self._log(tmp_path))
|
|
assert log[_RID].deleted is False
|
|
|
|
def test_ledgered_entry_is_pushed_as_a_tombstone(self, tmp_path: Path) -> None:
|
|
"""The isolated SYNC_TOMBSTONES_FILE (conftest) is what the push reads."""
|
|
add_tombstone(_RID, _DEVICE, "duplicate", _sync_tombstones.SYNC_TOMBSTONES_FILE)
|
|
log = records_from_workout_log(self._log(tmp_path))
|
|
assert log[_RID].deleted is True
|
|
assert log[_RID].fields == {}
|
|
|
|
def test_tombstone_for_an_id_not_in_the_log_is_still_pushed(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
add_tombstone(
|
|
"manual:2026-01-01T08:00",
|
|
_DEVICE,
|
|
"x",
|
|
_sync_tombstones.SYNC_TOMBSTONES_FILE,
|
|
)
|
|
log = records_from_workout_log(self._log(tmp_path))
|
|
assert log["manual:2026-01-01T08:00"].deleted is True
|
|
assert log[_RID].deleted is False
|