mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
ee7e380278
commit
597901dc08
@ -6,7 +6,11 @@ day. Read its README for the rules and the RTDB wire format. This file covers
|
|||||||
the phone half.
|
the phone half.
|
||||||
|
|
||||||
On a sick day the phone does not ring, the stick does not judge the day, and
|
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
|
separate RTDB node (`sickdays-sync/devices`), separate native cache, separate
|
||||||
screen, and every log line and event says `sick_day`.
|
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 |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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`
|
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
|
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**
|
icon. **Today is tappable** (unlike the free-day grid); the past is inert.
|
||||||
(unlike the free-day grid); the past is inert. No count, no confirmation.
|
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
|
## A date is never in both pools
|
||||||
|
|
||||||
@ -40,6 +53,15 @@ exactly as the Python README prescribes.
|
|||||||
|
|
||||||
## The ring
|
## 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
|
`AlarmReceiver` asks `RingDecision.skip(free, sick)`. On a sick day it logs
|
||||||
`sick day: not ringing; re-armed for tomorrow`, appends `alarm_fired` with
|
`sick day: not ringing; re-armed for tomorrow`, appends `alarm_fired` with
|
||||||
`reason: "sick_day"`, re-arms and returns: no `AlarmService`, no
|
`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
|
`PoolSyncScheduleTest`, `StickCheckSickTest`. Run with
|
||||||
`cd phone_app/android && ./gradlew :app:testDebugUnitTest`, after one
|
`cd phone_app/android && ./gradlew :app:testDebugUnitTest`, after one
|
||||||
`flutter build apk` has written `gradlew` and `local.properties`.
|
`flutter build apk` has written `gradlew` and `local.properties`.
|
||||||
- Cross-language: `phone_app/scripts/sick_days_interop/check.sh` writes a
|
- Keep: `test/services/sick_day_keep_test.dart` (wire, read rules 1-5, a
|
||||||
pool through Dart and reads it with `sickdays` (Python), failing on any
|
keep edit after a future PC tick, concurrent clear), `test/sick_day_sheet_test.dart`,
|
||||||
disagreement. Needs `SICKDAYS_PYTHON` or the sickdays venv.
|
`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
|
## Do NOT
|
||||||
|
|
||||||
- Don't fold sick days into `FreeDays`/`isFree`, or log them as `free_day`.
|
- 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 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 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.
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# 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
|
# ~/src/utils/sickdays (Python) owns. Both directions, Python as the oracle:
|
||||||
# rules and reads it back through the Python ones, and fails on any
|
# 1. Dart writes a pool; Python reads it and replays the same edits.
|
||||||
# disagreement about which days are sick.
|
# 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
|
# Requires a Python with sickdays 0.2.0 importable: SICKDAYS_PYTHON, else
|
||||||
# sickdays/install.sh creates.
|
# 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
|
# 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; }
|
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() {
|
main() {
|
||||||
validate_requirements
|
validate_requirements
|
||||||
TEMP_DIR="$(mktemp -d)"
|
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 =="
|
echo "== sick-day interop: Dart writes, Python reads =="
|
||||||
(
|
dart_test test/sick_day_interop_writer_test.dart \
|
||||||
cd "$APP_DIR"
|
"SICK_DAYS_INTEROP_OUT=$dart_pool"
|
||||||
SICK_DAYS_INTEROP_OUT="$pool" flutter test test/sick_day_interop_writer_test.dart \
|
[[ -s "$dart_pool" ]] || { echo "Error: Dart wrote no fixture" >&2; exit 1; }
|
||||||
| tail -1
|
oracle dart "$dart_pool"
|
||||||
)
|
|
||||||
[[ -s "$pool" ]] || { echo "Error: Dart wrote no fixture" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "== sick-day interop: Python reads =="
|
echo "== sick-day interop: Python writes, Dart reads =="
|
||||||
"$SICKDAYS_PYTHON" -I "$SCRIPT_DIR/verify.py" "$pool"
|
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 "$@"
|
main "$@"
|
||||||
|
|||||||
@ -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
|
``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
|
``~/src/utils/sickdays`` owns (0.2.0, with the per-locker ``keep`` set). The
|
||||||
the writer test's edits into a scratch pool, and every reading a gate relies
|
Python package is the oracle in both directions:
|
||||||
on (``is_sick_day`` per date, ``sick_days``) has to agree between the two
|
|
||||||
files. Reads only; writes nothing outside a temporary directory.
|
``dart <dart.json>``
|
||||||
|
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 <out.json>``
|
||||||
|
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 <python.json> <dart-reading.json>``
|
||||||
|
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
|
from __future__ import annotations
|
||||||
@ -15,70 +30,147 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from crdt_sync import Hlc
|
||||||
import sickdays
|
import sickdays
|
||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
EXPECTED_ARGC = 2
|
|
||||||
TODAY = date(2026, 10, 16)
|
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 <dart.json> | write <out.json> | "
|
||||||
|
"read <python.json> <dart-reading.json>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _never_free(_day: date) -> bool:
|
def _never_free(_day: date) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def python_pool(root: Path) -> Path:
|
def _add(paths: Paths, day: date, keep: tuple[str, ...] = ()) -> None:
|
||||||
"""Write the oracle pool with the edits the Dart writer made."""
|
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)
|
paths = Paths.under(root)
|
||||||
sickdays.add_sick_day(TODAY, is_free=_never_free, paths=paths, now=TODAY)
|
_add(paths, TODAY)
|
||||||
sickdays.add_sick_day(
|
_add(paths, date(2026, 10, 20), (sickdays.LOCKER_WORKOUT, sickdays.LOCKER_ALARM))
|
||||||
date(2026, 10, 20), is_free=_never_free, paths=paths, now=TODAY
|
_add(paths, date(2026, 10, 21), (sickdays.LOCKER_DIET,))
|
||||||
)
|
|
||||||
sickdays.add_sick_day(
|
|
||||||
date(2026, 10, 21), is_free=_never_free, paths=paths, now=TODAY
|
|
||||||
)
|
|
||||||
sickdays.remove_sick_day(date(2026, 10, 21), paths=paths, now=TODAY)
|
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
|
return paths.log
|
||||||
|
|
||||||
|
|
||||||
def reading(pool: Path) -> dict[str, object]:
|
def write_python(out: Path) -> None:
|
||||||
"""Every view of a pool a gate relies on."""
|
"""A Python pool for the Dart reader, then the legacy shapes by hand."""
|
||||||
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 <dart sick_days.json>\n")
|
|
||||||
return 2
|
|
||||||
dart = reading(Path(argv[1]))
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
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")
|
sys.stdout.write(f"dart: {json.dumps(dart)}\npython: {json.dumps(python)}\n")
|
||||||
if dart != python:
|
if dart != python:
|
||||||
sys.stderr.write("MISMATCH: Dart and Python read different pools\n")
|
sys.stderr.write("MISMATCH: Dart and Python read different pools\n")
|
||||||
return 1
|
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")
|
sys.stdout.write("OK: Dart and Python agree\n")
|
||||||
return 0
|
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__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main(sys.argv))
|
sys.exit(main(sys.argv))
|
||||||
|
|||||||
47
phone_app/test/sick_day_interop_reader_test.dart
Normal file
47
phone_app/test/sick_day_interop_reader_test.dart
Normal file
@ -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 = <String, Object?>{
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,14 +2,15 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:flutter_test/flutter_test.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';
|
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
|
||||||
|
|
||||||
/// Half of the cross-language check (`scripts/sick_days_interop/check.sh`).
|
/// 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
|
/// 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
|
/// 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
|
/// there. The other direction is `sick_day_interop_reader_test.dart`.
|
||||||
/// nothing.
|
/// Under a plain `flutter test` the variable is unset and this does nothing.
|
||||||
class _FilePersistence implements LogPersistence {
|
class _FilePersistence implements LogPersistence {
|
||||||
_FilePersistence(this.file);
|
_FilePersistence(this.file);
|
||||||
|
|
||||||
@ -33,11 +34,25 @@ void main() {
|
|||||||
);
|
);
|
||||||
final now = DateTime(2026, 10, 16);
|
final now = DateTime(2026, 10, 16);
|
||||||
bool never(DateTime _) => false;
|
bool never(DateTime _) => false;
|
||||||
// Today sick; a future day sick; a future day marked then cleared;
|
// Today sick keeping nothing; 2026-10-20 sick keeping workout + alarm
|
||||||
// 2026-10-23 untouched.
|
// (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(now, isFree: never, now: now);
|
||||||
await pool.add(DateTime(2026, 10, 20), isFree: never, now: now);
|
await pool.add(
|
||||||
await pool.add(DateTime(2026, 10, 21), isFree: never, now: now);
|
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.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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user