mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 17:13:59 +01:00
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The app said "manual budget exhausted for this window" (2/2w, 4/10m) while
the PC read 1/2w, 2/10m for the same records. The PC was right; two
independent Dart defects each inflated a window by one.
`_readMergedPayloads` never looked at `Record.deleted`, so the tombstoned
duplicate `manual:2026-08-21T21:00` -- the second of two logged on 08-21,
already deleted -- kept consuming a weekly slot. Suppression is collected
across the whole device union and applied after the merge, not per file:
one device tombstoning a record while another still holds it live must
delete it, and a per-file skip would just let the live copy win. This
mirrors the PC's `_tombstoned_ids`.
`countManualBudget` also counted `manual_workout:2026-08-15`, a bare
`{type, kind, date}` stub with no evidence fields. The PC refuses to ingest
those ("no workout data to ingest; skipping permanently"), so the phone was
charging budget for a workout neither device ever accepted. The filter lives
in the counter rather than the shared reader because `runnerup_verified` and
`pc_workout_verified` payloads legitimately carry no `start_time`, and the
history view must keep showing them.
Covered by a shared literal fixture both languages read, rather than two
independent round-trips: each side's own tests passed while the pair
disagreed, which is exactly how this shipped. Verified by reverting each
guard in turn and watching the twin fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
97 lines
3.7 KiB
Python
97 lines
3.7 KiB
Python
"""Cross-language fixture test for manual-workout budget counting.
|
|
|
|
The Dart app mirrors this accounting in ``lib/models/manual_workout_budget.dart``
|
|
and ``lib/services/workout_sync_service_read.dart``. Each language's own
|
|
round-trip test passed happily while the two sides disagreed -- on 2026-08-27
|
|
the phone read ``2/2w 4/10m (exhausted)`` for the very records this side read
|
|
as ``1/2w 2/10m`` -- so the only thing that catches the drift is a shared
|
|
literal both read: ``fixtures/manual_budget_merge.json``. The Dart twin of
|
|
this file is ``test/models/manual_budget_merge_fixture_test.dart``; they must
|
|
stay in step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from screen_locker._log_mixin import write_signed_entry
|
|
from screen_locker._manual_workout import (
|
|
MANUAL_WORKOUT_TYPE,
|
|
count_in_window,
|
|
is_budget_exhausted,
|
|
)
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "manual_budget_merge.json"
|
|
|
|
|
|
def _fixture() -> dict[str, Any]:
|
|
"""Load the shared cross-language budget fixture."""
|
|
loaded: dict[str, Any] = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
return loaded
|
|
|
|
|
|
def _live_payloads(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Drop tombstoned records, as the sync reader does across the union."""
|
|
return [r["payload"] for r in records if not r["deleted"]]
|
|
|
|
|
|
def _log_from(payloads: list[dict[str, Any]], log_file: Path) -> Path:
|
|
"""Write the ingestable payloads into a real signed log file.
|
|
|
|
Stubs without ``start_time`` are skipped exactly as ``_manual_sync``
|
|
refuses them ("no workout data to ingest"), so the fixture exercises the
|
|
same admission rule the live ingest applies.
|
|
"""
|
|
for payload in payloads:
|
|
if not isinstance(payload.get("start_time"), str):
|
|
continue
|
|
workout_data = {k: v for k, v in payload.items() if k != "kind"}
|
|
workout_data["type"] = MANUAL_WORKOUT_TYPE
|
|
workout_data["workout_id"] = f"manual:{payload['date']}T{payload['start_time']}"
|
|
write_signed_entry(log_file, str(payload["date"]), workout_data)
|
|
return log_file
|
|
|
|
|
|
def test_budget_matches_the_phone_for_the_2026_08_27_divergence(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The window counts must equal the numbers the Dart twin asserts."""
|
|
fixture = _fixture()
|
|
expected = fixture["expected"]
|
|
today = str(fixture["today"])
|
|
log_file = _log_from(_live_payloads(fixture["records"]), tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == expected["week"]
|
|
assert count_in_window(log_file, 30, today=today) == expected["month"]
|
|
assert is_budget_exhausted(log_file, today=today) is expected["exhausted"]
|
|
|
|
|
|
def test_a_tombstoned_duplicate_frees_its_weekly_slot(tmp_path: Path) -> None:
|
|
"""Resurrecting the deleted duplicate must exhaust the week.
|
|
|
|
This is the pre-fix behaviour the phone exhibited; asserting it proves the
|
|
tombstone is load-bearing rather than incidentally outside the window.
|
|
"""
|
|
fixture = _fixture()
|
|
today = str(fixture["today"])
|
|
resurrected = [r["payload"] for r in fixture["records"]]
|
|
log_file = _log_from(resurrected, tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == 2
|
|
assert is_budget_exhausted(log_file, today=today) is True
|
|
|
|
|
|
def test_an_evidence_less_stub_never_consumes_budget(tmp_path: Path) -> None:
|
|
"""The `{type, kind, date}` stub must cost nothing in either window."""
|
|
fixture = _fixture()
|
|
today = str(fixture["today"])
|
|
stub = next(
|
|
r["payload"] for r in fixture["records"] if "start_time" not in r["payload"]
|
|
)
|
|
log_file = _log_from([stub], tmp_path / "log.json")
|
|
|
|
assert count_in_window(log_file, 7, today=today) == 0
|
|
assert count_in_window(log_file, 30, today=today) == 0
|