From 597901dc089ab3ee519bd851deceb9fc44bfe690 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 16 Oct 2026 15:31:11 +0200 Subject: [PATCH] phone: sick-day interop both ways with keep; docs check.sh now runs Dart->Python (keep, unsorted/duplicate input, a cleared keep day, a keep-only edit that must not re-stamp state) and Python->Dart (0.2.0 keep sets plus a 0.1.0 record with no keep, a stale keep and a malformed one), Python 0.2.0 as the oracle with explicit paths and HOME pointed at a scratch dir. DOCS-sick-days.md and the SickDays.kt contract describe the alarm-only native cache. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp --- DOCS-sick-days.md | 45 ++++- phone_app/scripts/sick_days_interop/check.sh | 53 +++-- phone_app/scripts/sick_days_interop/verify.py | 184 +++++++++++++----- .../test/sick_day_interop_reader_test.dart | 47 +++++ .../test/sick_day_interop_writer_test.dart | 27 ++- 5 files changed, 281 insertions(+), 75 deletions(-) create mode 100644 phone_app/test/sick_day_interop_reader_test.dart diff --git a/DOCS-sick-days.md b/DOCS-sick-days.md index e5ea572..5ec711a 100644 --- a/DOCS-sick-days.md +++ b/DOCS-sick-days.md @@ -6,7 +6,11 @@ day. Read its README for the rules and the RTDB wire format. This file covers the phone half. On a sick day the phone does not ring, the stick does not judge the day, and -nothing ring-related is recorded. It is **not** a free day: separate pool, +nothing ring-related is recorded -- **unless the day keeps the alarm**. +Since sickdays 0.2.0 a sick day carries a per-locker `keep` set (README +"keep (0.2.0)"): a kept locker behaves exactly as on a normal day, every other +one stands down. This app is the `alarm` locker (ring, PC fallback ring, RTC +self-wake, missed-alarm penalty / stick, morning routine). It is **not** a free day: separate pool, separate RTDB node (`sickdays-sync/devices`), separate native cache, separate screen, and every log line and event says `sick_day`. @@ -15,7 +19,8 @@ screen, and every log line and event says `sick_day`. | | | |---|---| | Picker | `phone_app/lib/screens/sick_days_screen.dart` (+ `sick_days_body.dart`, the shared `day_calendar.dart`); the sick-face icon on the home screen | -| Rules, Dart port | `phone_app/lib/services/sick_day_pool.dart`: records `{state: sick/cleared, actor}`, one HLC per write, never a tombstone | +| Rules, Dart port | `phone_app/lib/services/sick_day_pool.dart`: records `{state: sick/cleared, actor, keep}`, one HLC per mark/clear, never a tombstone; keep read rules in `sick_day_keep.dart` | +| Per-locker sheet | `phone_app/lib/screens/sick_day_sheet.dart` (+ the dot in `day_cell.dart`) | | Sync | `phone_app/lib/services/sick_day_sync.dart` (shared tick: `day_pool_sync.dart`), Firebase only | | Ring path | `android/.../SickDays.kt` (+ `DayCache.kt`), read in `AlarmReceiver` through `RingDecision.kt` | | Stick | `StickCheck.kt`: verdict `sick_day`; the streak skips sick days like free days | @@ -25,8 +30,16 @@ screen, and every log line and event says `sick_day`. Same Monday-first grid as the free days, in `cat-2` blue (`DayMarkColors` in `ui/theme.dart`) rather than the accent gold, under its own title and -icon. One tap marks a day sick, another clears it. **Today is tappable** -(unlike the free-day grid); the past is inert. No count, no confirmation. +icon. **Today is tappable** (unlike the free-day grid); the past is inert. +No count, no reason. + +Tapping a normal day opens a bottom sheet: one switch per locker (Workout +lock, LeetCode, Book, Diet, Wake alarm), **all off** (off = stands down), and +"Mark sick". Tapping a sick day reopens it with the day's switches, "Save" +and "Clear sick day". Save is a keep-only edit: it re-stamps `keep` and +`actor`, never `state`, so it cannot re-assert "sick" over a concurrent +clear; an unchanged Save writes nothing. A sick day keeping any locker shows +a dot (in `on-fill` on the `cat-2` cell) and a `Kept: ...` line in the list. ## A date is never in both pools @@ -40,6 +53,15 @@ exactly as the Python README prescribes. ## The ring +**The native cache holds only the days on which the alarm stands down** -- +sick and `alarm` not kept, past days included (`SickDaySync.publishToNative` +-> `SickDayPool.standDownIsoDates(SickLocker.alarm)`, the one place the keep +set is applied for Kotlin). A sick day keeping the alarm is simply absent, so +it rings, the stick judges it and the stakes card shows its stakes; Kotlin +never sees the keep set. The pre-alarm sync goes through the same publish. +The `Alarm cache:` line on the picker reads this cache back, so a kept-alarm +sick day is (correctly) not listed there. + `AlarmReceiver` asks `RingDecision.skip(free, sick)`. On a sick day it logs `sick day: not ringing; re-armed for tomorrow`, appends `alarm_fired` with `reason: "sick_day"`, re-arms and returns: no `AlarmService`, no @@ -94,12 +116,21 @@ sync runs between A and the session's end anyway. `PoolSyncScheduleTest`, `StickCheckSickTest`. Run with `cd phone_app/android && ./gradlew :app:testDebugUnitTest`, after one `flutter build apk` has written `gradlew` and `local.properties`. -- Cross-language: `phone_app/scripts/sick_days_interop/check.sh` writes a - pool through Dart and reads it with `sickdays` (Python), failing on any - disagreement. Needs `SICKDAYS_PYTHON` or the sickdays venv. +- Keep: `test/services/sick_day_keep_test.dart` (wire, read rules 1-5, a + keep edit after a future PC tick, concurrent clear), `test/sick_day_sheet_test.dart`, + `test/sick_day_apply_test.dart`. +- Cross-language, both ways: `phone_app/scripts/sick_days_interop/check.sh`. + Dart writes (incl. keep and a keep-only edit) and Python 0.2.0 reads and + replays it; then Python writes (incl. a 0.1.0 record with no keep, a stale + keep and a malformed one) and Dart's verdicts are compared with Python's. + Needs `SICKDAYS_PYTHON` or the sickdays venv; Python runs with `HOME` + pointed at a scratch dir. ## Do NOT - Don't fold sick days into `FreeDays`/`isFree`, or log them as `free_day`. - Don't sync inside `AlarmReceiver`. Don't tombstone a date to clear it. - Don't add a count, a reason prompt or a warning. There is no budget. +- Don't put a non-alarm-stand-down date in the native cache, and don't teach + Kotlin the keep set: filter in `publishToNative` only. +- Don't re-stamp `state` on a keep edit. diff --git a/phone_app/scripts/sick_days_interop/check.sh b/phone_app/scripts/sick_days_interop/check.sh index d81cd45..634e450 100755 --- a/phone_app/scripts/sick_days_interop/check.sh +++ b/phone_app/scripts/sick_days_interop/check.sh @@ -1,15 +1,19 @@ #!/bin/bash # ============================================================================ -# Cross-language check for the sick-day pool. +# Cross-language check for the sick-day pool (sickdays 0.2.0, with keep). # # phone_app/lib/services/sick_day_pool.dart writes the wire format that -# ~/src/utils/sickdays (Python) owns. This writes a pool through the Dart -# rules and reads it back through the Python ones, and fails on any -# disagreement about which days are sick. +# ~/src/utils/sickdays (Python) owns. Both directions, Python as the oracle: +# 1. Dart writes a pool; Python reads it and replays the same edits. +# 2. Python writes a pool (incl. a 0.1.0 record with no keep, a stale keep +# and a malformed one); Dart reads it; Python compares the verdicts. +# Fails on any disagreement about which days are sick or which lockers +# stand down. # -# Requires a Python with sickdays importable: SICKDAYS_PYTHON, else the venv -# sickdays/install.sh creates. +# Requires a Python with sickdays 0.2.0 importable: SICKDAYS_PYTHON, else +# the venv sickdays/install.sh creates. Python runs with HOME pointed at a +# scratch directory, so no default path can ever reach the real pool. # # Usage: phone_app/scripts/sick_days_interop/check.sh # ============================================================================ @@ -37,21 +41,38 @@ validate_requirements() { command -v flutter >/dev/null || { echo "Error: flutter not on PATH" >&2; exit 1; } } +# The oracle, isolated from the real pool under ~/.local/share/sickdays. +oracle() { + HOME="$TEMP_DIR/home" "$SICKDAYS_PYTHON" -I "$SCRIPT_DIR/verify.py" "$@" +} + +# Runs one Dart interop test with the given VAR=value environment. +dart_test() { + local test_file="$1" + shift + (cd "$APP_DIR" && env "$@" flutter test "$test_file" | tail -1) +} + main() { validate_requirements TEMP_DIR="$(mktemp -d)" - local pool="$TEMP_DIR/sick_days.json" + mkdir -p "$TEMP_DIR/home" + local dart_pool="$TEMP_DIR/dart_sick_days.json" + local python_pool="$TEMP_DIR/python_sick_days.json" + local dart_reading="$TEMP_DIR/dart_reading.json" - echo "== sick-day interop: Dart writes ==" - ( - cd "$APP_DIR" - SICK_DAYS_INTEROP_OUT="$pool" flutter test test/sick_day_interop_writer_test.dart \ - | tail -1 - ) - [[ -s "$pool" ]] || { echo "Error: Dart wrote no fixture" >&2; exit 1; } + echo "== sick-day interop: Dart writes, Python reads ==" + dart_test test/sick_day_interop_writer_test.dart \ + "SICK_DAYS_INTEROP_OUT=$dart_pool" + [[ -s "$dart_pool" ]] || { echo "Error: Dart wrote no fixture" >&2; exit 1; } + oracle dart "$dart_pool" - echo "== sick-day interop: Python reads ==" - "$SICKDAYS_PYTHON" -I "$SCRIPT_DIR/verify.py" "$pool" + echo "== sick-day interop: Python writes, Dart reads ==" + oracle write "$python_pool" + dart_test test/sick_day_interop_reader_test.dart \ + "SICK_DAYS_INTEROP_IN=$python_pool" "SICK_DAYS_INTEROP_READING=$dart_reading" + [[ -s "$dart_reading" ]] || { echo "Error: Dart wrote no reading" >&2; exit 1; } + oracle read "$python_pool" "$dart_reading" } main "$@" diff --git a/phone_app/scripts/sick_days_interop/verify.py b/phone_app/scripts/sick_days_interop/verify.py index 8040916..6d43ad1 100644 --- a/phone_app/scripts/sick_days_interop/verify.py +++ b/phone_app/scripts/sick_days_interop/verify.py @@ -1,10 +1,25 @@ -"""Read a Dart-written sick-day pool with the Python package and compare. +"""Cross-check the Dart sick-day pool against the Python package, both ways. ``phone_app/lib/services/sick_day_pool.dart`` writes the wire format that -``~/src/utils/sickdays`` owns. The Python package is the oracle: it replays -the writer test's edits into a scratch pool, and every reading a gate relies -on (``is_sick_day`` per date, ``sick_days``) has to agree between the two -files. Reads only; writes nothing outside a temporary directory. +``~/src/utils/sickdays`` owns (0.2.0, with the per-locker ``keep`` set). The +Python package is the oracle in both directions: + +``dart `` + Replays the Dart writer test's edits into a scratch pool and fails on any + disagreement in a reading a gate relies on (``is_sick_day``, + ``kept_lockers``, ``stands_down`` per locker, ``sick_days``), or on a + wire-format slip: an unsorted keep, a mark/clear with mixed ticks, or a + keep-only edit that re-stamped ``state``. +``write `` + Writes a Python pool for the Dart reader test, including the records a + 0.2.0 writer can no longer produce: a 0.1.0 record with no ``keep``, a + stale ``keep`` (an old client re-added the day) and a malformed one. +``read `` + Compares the Dart reader's verdicts with Python's for that same pool. + +Every sickdays call gets an explicit ``paths=`` / ``log_path=``: a default +would resolve the real pool under ``~/.local/share/sickdays``. ``check.sh`` +also points ``HOME`` at a scratch directory, as a second line of defence. """ from __future__ import annotations @@ -15,70 +30,147 @@ from pathlib import Path import sys import tempfile +from crdt_sync import Hlc import sickdays from sickdays._paths import Paths -EXPECTED_ARGC = 2 TODAY = date(2026, 10, 16) -DAYS = [TODAY, date(2026, 10, 20), date(2026, 10, 21), date(2026, 10, 23)] +DART_DAYS = [TODAY, *(date(2026, 10, d) for d in (20, 21, 22, 23))] +PY_DAYS = [date(2026, 10, d) for d in (20, 22, 23, 24, 25, 26)] +#: Dart-written records that were keep-only edited after the mark. +KEEP_EDITED = {"2026-10-22"} +USAGE = ( + "usage: verify.py dart | write | " + "read \n" +) def _never_free(_day: date) -> bool: return False -def python_pool(root: Path) -> Path: - """Write the oracle pool with the edits the Dart writer made.""" +def _add(paths: Paths, day: date, keep: tuple[str, ...] = ()) -> None: + sickdays.add_sick_day(day, keep=keep, is_free=_never_free, paths=paths, now=TODAY) + + +def dart_oracle(root: Path) -> Path: + """The Dart writer test's edits, made by the Python package.""" paths = Paths.under(root) - sickdays.add_sick_day(TODAY, is_free=_never_free, paths=paths, now=TODAY) - sickdays.add_sick_day( - date(2026, 10, 20), is_free=_never_free, paths=paths, now=TODAY - ) - sickdays.add_sick_day( - date(2026, 10, 21), is_free=_never_free, paths=paths, now=TODAY - ) + _add(paths, TODAY) + _add(paths, date(2026, 10, 20), (sickdays.LOCKER_WORKOUT, sickdays.LOCKER_ALARM)) + _add(paths, date(2026, 10, 21), (sickdays.LOCKER_DIET,)) sickdays.remove_sick_day(date(2026, 10, 21), paths=paths, now=TODAY) + _add(paths, date(2026, 10, 22)) + sickdays.set_kept_lockers( + date(2026, 10, 22), [sickdays.LOCKER_LEETCODE], paths=paths, now=TODAY + ) return paths.log -def reading(pool: Path) -> dict[str, object]: - """Every view of a pool a gate relies on.""" - return { - "is_sick_day": { - d.isoformat(): sickdays.is_sick_day(d, log_path=pool) for d in DAYS - }, - "sick_days": sorted(d.isoformat() for d in sickdays.sick_days(log_path=pool)), - } - - -def one_tick_per_write(pool: Path) -> bool: - """Whether every record's fields share one HLC, as the format requires.""" - log = json.loads(pool.read_text(encoding="utf-8")) - return all( - len({hlc for _value, hlc in record["fields"].values()}) == 1 - and not record["deleted"] - for record in log.values() - ) - - -def main(argv: list[str]) -> int: - """Compare the Dart fixture at ``argv[1]`` with the Python oracle.""" - if len(argv) != EXPECTED_ARGC: - sys.stderr.write("usage: verify.py \n") - return 2 - dart = reading(Path(argv[1])) +def write_python(out: Path) -> None: + """A Python pool for the Dart reader, then the legacy shapes by hand.""" with tempfile.TemporaryDirectory() as tmp: - python = reading(python_pool(Path(tmp))) + paths = Paths.under(Path(tmp)) + _add( + paths, + date(2026, 10, 20), + (sickdays.LOCKER_LEETCODE, sickdays.LOCKER_WORKOUT), + ) + _add(paths, date(2026, 10, 22)) + sickdays.set_kept_lockers( + date(2026, 10, 22), [sickdays.LOCKER_ALARM], paths=paths, now=TODAY + ) + _add(paths, date(2026, 10, 23)) + for day in (24, 25, 26): + _add(paths, date(2026, 10, day), (sickdays.LOCKER_ALARM,)) + log = json.loads(paths.log.read_text(encoding="utf-8")) + fields = log["2026-10-24"]["fields"] + del fields["keep"] # a 0.1.0 record: keep nothing + fields = log["2026-10-25"]["fields"] + re_add = Hlc.new_tick("old-client", Hlc.from_str(fields["keep"][1])).to_str() + fields["state"] = ["sick", re_add] # a 0.1.0 re-add over a 0.2.0 keep: stale + fields["actor"] = ["old-client", re_add] + log["2026-10-26"]["fields"]["keep"][0] = "alarm" # malformed: keep all + out.write_text(json.dumps(log), encoding="utf-8") + + +def reading(pool: Path, days: list[date]) -> dict[str, object]: + """Every per-day verdict a gate relies on.""" + return { + d.isoformat(): { + "sick": sickdays.is_sick_day(d, log_path=pool), + "kept": sorted(sickdays.kept_lockers(d, log_path=pool)), + "stands_down": { + locker: sickdays.stands_down(locker, d, log_path=pool) + for locker in sickdays.LOCKERS + }, + } + for d in days + } | {"sick_days": sorted(d.isoformat() for d in sickdays.sick_days(log_path=pool))} + + +def wire_problems(pool: Path) -> list[str]: + """Wire-format slips in a Dart-written pool (empty when clean).""" + problems = [] + for iso, record in json.loads(pool.read_text(encoding="utf-8")).items(): + fields = record["fields"] + keep = fields.get("keep") + if keep is None: + problems.append(f"{iso}: no keep field") + continue + if keep[0] != sorted(set(keep[0])): + problems.append(f"{iso}: keep {keep[0]} not sorted/deduplicated") + if record["deleted"]: + problems.append(f"{iso}: tombstoned") + state, actor = fields["state"][1], fields["actor"][1] + if iso in KEEP_EDITED: + if not (keep[1] == actor and Hlc.from_str(keep[1]) > Hlc.from_str(state)): + problems.append(f"{iso}: keep-only edit re-stamped state") + elif len({state, actor, keep[1]}) != 1: + problems.append(f"{iso}: mark/clear fields carry mixed ticks") + return problems + + +def _compare(dart: object, python: object) -> int: sys.stdout.write(f"dart: {json.dumps(dart)}\npython: {json.dumps(python)}\n") if dart != python: sys.stderr.write("MISMATCH: Dart and Python read different pools\n") return 1 - if not one_tick_per_write(Path(argv[1])): - sys.stderr.write("MISMATCH: a Dart record has mixed HLCs or a tombstone\n") - return 1 sys.stdout.write("OK: Dart and Python agree\n") return 0 +def check_dart(dart_pool: Path) -> int: + """Compare the Dart-written fixture with the Python oracle.""" + with tempfile.TemporaryDirectory() as tmp: + python = reading(dart_oracle(Path(tmp)), DART_DAYS) + problems = wire_problems(dart_pool) + if problems: + sys.stderr.write("MISMATCH: " + "; ".join(problems) + "\n") + return 1 + return _compare(reading(dart_pool, DART_DAYS), python) + + +def check_read(python_pool: Path, dart_reading: Path) -> int: + """Compare the Dart reader's verdicts on a Python pool with Python's.""" + dart = json.loads(dart_reading.read_text(encoding="utf-8")) + return _compare(dart, reading(python_pool, PY_DAYS)) + + +def main(argv: list[str]) -> int: + """Dispatch ``dart`` / ``write`` / ``read`` (see the module docstring).""" + match argv[1:]: + case ["dart", dart_pool]: + return check_dart(Path(dart_pool)) + case ["write", out]: + write_python(Path(out)) + return 0 + case ["read", python_pool, dart_reading]: + return check_read(Path(python_pool), Path(dart_reading)) + case _: + sys.stderr.write(USAGE) + return 2 + + if __name__ == "__main__": sys.exit(main(sys.argv)) diff --git a/phone_app/test/sick_day_interop_reader_test.dart b/phone_app/test/sick_day_interop_reader_test.dart new file mode 100644 index 0000000..465528e --- /dev/null +++ b/phone_app/test/sick_day_interop_reader_test.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_keep.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +import 'fake_day_pool_store.dart'; + +/// The Python -> Dart half of `scripts/sick_days_interop/check.sh`. +/// +/// Reads the pool `verify.py write` made with `sickdays` (Python) from +/// `SICK_DAYS_INTEROP_IN` -- 0.2.0 keep sets plus a 0.1.0 record with no +/// keep, a stale keep and a malformed one -- and writes the Dart verdicts to +/// `SICK_DAYS_INTEROP_READING` for `verify.py read` to compare with +/// Python's. Under a plain `flutter test` both are unset and this does +/// nothing. +void main() { + test('writes the Dart reading of a Python pool when asked', () async { + final input = Platform.environment['SICK_DAYS_INTEROP_IN']; + final output = Platform.environment['SICK_DAYS_INTEROP_READING']; + if (input == null || output == null) return; + final pool = await SickDayPool.open( + persistence: MemPersistence(), + deviceId: 'dart-interop', + ); + await pool.replaceAll(logFromJson(File(input).readAsStringSync())); + final days = [ + for (final d in [20, 22, 23, 24, 25, 26]) DateTime(2026, 10, d), + ]; + final reading = { + for (final day in days) + FreeDayPool.toIso(day): { + 'sick': pool.isSick(day), + 'kept': pool.keptLockers(day).toList()..sort(), + 'stands_down': { + for (final locker in SickLocker.all) + locker: pool.standsDown(locker, day), + }, + }, + 'sick_days': pool.sickIsoDates(), + }; + File(output).writeAsStringSync(jsonEncode(reading)); + }); +} diff --git a/phone_app/test/sick_day_interop_writer_test.dart b/phone_app/test/sick_day_interop_writer_test.dart index 00a3ab8..ddc7caa 100644 --- a/phone_app/test/sick_day_interop_writer_test.dart +++ b/phone_app/test/sick_day_interop_writer_test.dart @@ -2,14 +2,15 @@ import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/sick_day_keep.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; /// Half of the cross-language check (`scripts/sick_days_interop/check.sh`). /// /// Writes a pool through the Dart rules to `SICK_DAYS_INTEROP_OUT`; the /// script reads it back with `sickdays` (Python) and replays the same edits -/// there. Under a plain `flutter test` the variable is unset and this does -/// nothing. +/// there. The other direction is `sick_day_interop_reader_test.dart`. +/// Under a plain `flutter test` the variable is unset and this does nothing. class _FilePersistence implements LogPersistence { _FilePersistence(this.file); @@ -33,11 +34,25 @@ void main() { ); final now = DateTime(2026, 10, 16); bool never(DateTime _) => false; - // Today sick; a future day sick; a future day marked then cleared; - // 2026-10-23 untouched. + // Today sick keeping nothing; 2026-10-20 sick keeping workout + alarm + // (given unsorted, with a duplicate); 2026-10-21 marked keeping diet, + // then cleared; 2026-10-22 marked, then a keep-only edit to leetcode; + // 2026-10-23 untouched. `verify.py dart` replays exactly this. await pool.add(now, isFree: never, now: now); - await pool.add(DateTime(2026, 10, 20), isFree: never, now: now); - await pool.add(DateTime(2026, 10, 21), isFree: never, now: now); + await pool.add( + DateTime(2026, 10, 20), + isFree: never, + now: now, + keep: [SickLocker.workout, SickLocker.alarm, SickLocker.workout], + ); + await pool.add( + DateTime(2026, 10, 21), + isFree: never, + now: now, + keep: [SickLocker.diet], + ); await pool.remove(DateTime(2026, 10, 21), now: now); + await pool.add(DateTime(2026, 10, 22), isFree: never, now: now); + await pool.setKept(DateTime(2026, 10, 22), [SickLocker.leetcode], now: now); }); }