From 8bc181214708097cd87831643f0e1d65fd7848d2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 19:21:47 +0200 Subject: [PATCH] slots: minute-of-day contract (phase 1 of 3: pure model + shared fixture) Meal-slot checkpoints move from whole hours to minute-of-day ints (0..1439) in the pure model and slot arithmetic of both languages: MealSchedule(first_minute, last_minute, count) / (firstMinute:, lastMinute:, count:), interior slots snapped to the 15-minute grid, endpoints exact, integer-only. New: nearest_slot, satisfied_slots, slot_fields / entry_slot_minute (additive "slot_min" wire field) and schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour, so whole-hour data stays byte-identical), parse_hhmm. tests/fixtures/meal_schedule_vectors.json (scripts/ build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json slot values regenerated as minutes. Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications) are intentionally broken until phase 2. patch-targets skipped: it fails only because diet_guard._gatelock no longer imports. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn --- app/lib/models/meal_schedule.dart | 180 +- app/lib/models/slot.dart | 115 +- app/pubspec.yaml | 2 +- app/test/kuchnia_fill_parity_test.dart | 2 +- app/test/kuchnia_parity_test.dart | 12 +- app/test/models/meal_schedule_test.dart | 227 +- .../models/meal_schedule_vectors_test.dart | 177 + app/test/models/slot_test.dart | 295 +- diet_guard/_meal_schedule.py | 194 +- diet_guard/_slot_wire.py | 109 + diet_guard/_slots.py | 111 +- diet_guard/tests/test_kuchnia_parity.py | 12 +- diet_guard/tests/test_meal_schedule.py | 179 +- .../tests/test_meal_schedule_vectors.py | 143 + diet_guard/tests/test_slot_wire.py | 89 + diet_guard/tests/test_slots.py | 167 +- diet_guard/tests/test_slots_future.py | 35 +- diet_guard/tests/test_slots_nearest.py | 54 + scripts/build_kuchnia_fixture.py | 29 +- scripts/build_meal_schedule_fixture.py | 184 + scripts/meal_schedule_cases.py | 125 + tests/fixtures/kuchnia_day.json | 142 +- tests/fixtures/meal_schedule_vectors.json | 4640 +++++++++++++++++ 23 files changed, 6509 insertions(+), 714 deletions(-) create mode 100644 app/test/models/meal_schedule_vectors_test.dart create mode 100644 diet_guard/_slot_wire.py create mode 100644 diet_guard/tests/test_meal_schedule_vectors.py create mode 100644 diet_guard/tests/test_slot_wire.py create mode 100644 diet_guard/tests/test_slots_nearest.py create mode 100755 scripts/build_meal_schedule_fixture.py create mode 100755 scripts/meal_schedule_cases.py create mode 100644 tests/fixtures/meal_schedule_vectors.json diff --git a/app/lib/models/meal_schedule.dart b/app/lib/models/meal_schedule.dart index 64364b3..5112ad1 100644 --- a/app/lib/models/meal_schedule.dart +++ b/app/lib/models/meal_schedule.dart @@ -1,9 +1,15 @@ -/// The user's eating window and meal count, and the slot hours they imply. +/// The user's eating window and meal count, and the slot minutes they imply. /// -/// A schedule is three numbers -- the first meal hour, the last meal hour, and -/// how many meals fall between them inclusive -- from which the intermediate -/// checkpoints are derived by even division. `MealSchedule(8, 20, 5)` yields -/// `[8, 11, 14, 17, 20]`. +/// A schedule is three numbers -- the first meal's minute of day, the last +/// meal's minute of day, and how many meals fall between them inclusive -- +/// from which the intermediate checkpoints are derived by even division and +/// rounded to the 15-minute grid. `MealSchedule(firstMinute: 480, +/// lastMinute: 1200, count: 5)` yields `[480, 660, 840, 1020, 1200]`. +/// +/// Every time here is a *minute of day*, an int in `0..1439`. The fields are +/// deliberately named `firstMinute`/`lastMinute` rather than reusing the old +/// hour-valued `first`/`last`, so `flutter analyze` enumerates every call site +/// that still thinks in hours. /// /// This file is pure: no clock, no storage, no settings lookup. Persistence /// lives in `services/meal_schedule_service.dart`, and the slot arithmetic @@ -12,13 +18,17 @@ /// KEEP IN SYNC WITH `diet_guard/_meal_schedule.py`. The two must agree on /// every input, because a device that derives different slots than its peer /// nags for checkpoints the other never offers -- and a slot that can never be -/// satisfied is a permanent lock. Two rules make that agreement checkable: +/// satisfied is a permanent lock. `tests/fixtures/meal_schedule_vectors.json` +/// is read by both test suites; three rules make that agreement checkable: /// /// * **Integer arithmetic only.** No doubles and no `round()` anywhere in the /// derivation. Dart's `round()` is half-away-from-zero (`2.5.round() == 3`) /// while Python's is banker's rounding (`round(2.5) == 2`), so any /// floating-point path is a latent cross-language split brain. `~/` here, /// `//` there. +/// * **Non-negative operands to every division.** `~/` truncates while +/// Python's `//` floors (`-7 ~/ 15 == 0`, `-7 // 15 == -1`). Every division +/// below runs on a value [MealSchedule.normalized] has pulled into the day. /// * **Clamp, don't reject.** Every out-of-range input is normalised to the /// nearest legal schedule rather than throwing, so the two languages cannot /// disagree about which inputs are errors. @@ -36,11 +46,16 @@ const int kMinMealCount = 2; /// `test/widgets/slot_selector_row_test.dart`. const int kMaxMealCount = 6; -/// Earliest hour a meal may be scheduled at. -const int kFirstHour = 0; +/// Minutes in a day; a minute of day is `0..kMinutesPerDay - 1`. +const int kMinutesPerDay = 1440; -/// Latest hour a meal may be scheduled at. -const int kLastHour = 23; +/// Minimum spacing between two checkpoints, and the grid interior checkpoints +/// are rounded onto. +/// +/// One constant for both is what makes the strict-ascent proof in +/// [MealSchedule.slots] work: points at least one grid step apart cannot round +/// onto the same grid mark. +const int kSlotGridMinutes = 15; /// Grace period after the final checkpoint, before the gate stops firing. /// @@ -48,9 +63,9 @@ const int kLastHour = 23; /// have to log a late dinner, which has nothing to do with how many meals you /// eat. Tying it to the spacing would stretch the lockout window to midnight /// at four meals, contradicting the "don't trap me overnight" intent. -const int kEnforcementTailHours = 2; +const int kEnforcementTailMinutes = 120; -const int _hoursPerDay = 24; +const int _lastMinute = kMinutesPerDay - 1; int _clamp(int value, int low, int high) => value < low ? low : (value > high ? high : value); @@ -60,95 +75,142 @@ int _clamp(int value, int low, int high) => class MealSchedule { /// Creates a [MealSchedule]. const MealSchedule({ - required this.first, - required this.last, + required this.firstMinute, + required this.lastMinute, required this.count, }); - /// Hour of the first meal, 0-23. - final int first; + /// Minute of day of the first meal, 0-1424. + final int firstMinute; - /// Hour of the last meal, strictly after [first]. - final int last; + /// Minute of day of the last meal, at least one grid step (15 minutes) after + /// [firstMinute] and at most 1439. + final int lastMinute; /// Total meals including both endpoints. final int count; /// Returns an equivalent schedule guaranteed to satisfy the invariants. /// - /// Ordering matters: [first] is clamped into the day, then [last] is clamped - /// to leave at least one hour of window, then [count] is clamped to the - /// window's width. That last clamp is the load-bearing one -- see [slots]. + /// Ordering matters: [firstMinute] is clamped into the day leaving room for + /// one grid step, then [lastMinute] is clamped to at least one grid step + /// after it, then [count] is clamped to how many grid-spaced checkpoints the + /// window holds. That last clamp is the load-bearing one -- see [slots]. MealSchedule normalized() { - final normFirst = _clamp(first, kFirstHour, kLastHour - 1); - final normLast = _clamp(last, normFirst + 1, kLastHour); - // A window of N hours holds at most N+1 whole-hour checkpoints; asking for - // more would repeat an hour (see slots()). + final first = _clamp(firstMinute, 0, _lastMinute - kSlotGridMinutes); + final last = _clamp(lastMinute, first + kSlotGridMinutes, _lastMinute); + // A window of W minutes holds at most W ~/ 15 + 1 checkpoints spaced a + // grid step apart; asking for more would round two onto one mark. + final capacity = (last - first) ~/ kSlotGridMinutes + 1; final normCount = _clamp( count, kMinMealCount, - kMaxMealCount < normLast - normFirst + 1 - ? kMaxMealCount - : normLast - normFirst + 1, + kMaxMealCount < capacity ? kMaxMealCount : capacity, ); - return MealSchedule(first: normFirst, last: normLast, count: normCount); + return MealSchedule(firstMinute: first, lastMinute: last, count: normCount); } - /// Returns the meal-slot hours, ascending, with both endpoints exact. + /// Returns the meal-slot minutes, ascending, with both endpoints exact. /// - /// Meals are spread evenly across the window and rounded to whole hours by - /// integer arithmetic: slot *i* is `first + (i*span + d ~/ 2) ~/ d` where - /// `span = last - first` and `d = count - 1`. The `d ~/ 2` term is a - /// round-half-up bias applied before the division, which is what keeps this - /// free of floating point. + /// With `span = last - first` and `d = count - 1`, interior slot *i* is + /// `raw = first + (i*span + d ~/ 2) ~/ d` (even division, the `d ~/ 2` term + /// a round-half-up bias that keeps it free of floating point), then snapped + /// to the nearest absolute quarter hour by `(raw + 7) ~/ 15 * 15`. The + /// endpoints are [firstMinute] and [lastMinute] exactly -- never snapped. /// - /// Both endpoints land exactly on [first] and [last] by construction, so the - /// eating window is always honoured even when interior spacing must round. - /// - /// The result is strictly ascending because [normalized] caps [count] at - /// `last - first + 1`. Without that cap a narrow window repeats an hour - /// (`08-12` with 6 meals would give `8, 9, 10, 10, 11, 12`), and since slot - /// hours are used as set members, map keys *and* notification ids, a repeat - /// silently drops a checkpoint. + /// Strictly ascending, provably: [normalized] caps [count] so that + /// `span / d >= 15`, hence consecutive `raw` values differ by at least 15, + /// and two integers at least 15 apart never snap onto the same grid mark. + /// Snapping moves a value by at most 7, so no interior slot can reach an + /// endpoint. That matters because slot minutes are set members, map keys + /// *and* notification ids: a repeat silently drops a checkpoint. List slots() { final schedule = normalized(); - final span = schedule.last - schedule.first; + final first = schedule.firstMinute; + final last = schedule.lastMinute; + final span = last - first; final divisions = schedule.count - 1; return [ - for (var index = 0; index < schedule.count; index++) - schedule.first + (index * span + divisions ~/ 2) ~/ divisions, + first, + for (var index = 1; index < divisions; index++) + (first + (index * span + divisions ~/ 2) ~/ divisions + 7) ~/ + kSlotGridMinutes * + kSlotGridMinutes, + last, ]; } - /// The hour at which slot enforcement stops for the day. + /// The minute at which slot enforcement stops for the day. /// - /// Clamped to the end of the day: a 23:00 last meal would otherwise put the - /// cutoff at 25, making `hour < cutoff` vacuously true so the enforcement - /// window never closes and the reminder can never stop firing. - int get enforcementEndHour { - final end = last + kEnforcementTailHours; - return end < _hoursPerDay ? end : _hoursPerDay; + /// Derived from the *normalised* last meal, so it always agrees with the + /// last entry of [slots]. Clamped to the end of the day: a 23:00 last meal + /// would otherwise put the cutoff at 1500, making `minute < cutoff` + /// vacuously true so the enforcement window never closes. + int get enforcementEndMinute { + final end = normalized().lastMinute + kEnforcementTailMinutes; + return end < kMinutesPerDay ? end : kMinutesPerDay; } @override bool operator ==(Object other) => other is MealSchedule && - other.first == first && - other.last == last && + other.firstMinute == firstMinute && + other.lastMinute == lastMinute && other.count == count; @override - int get hashCode => Object.hash(first, last, count); + int get hashCode => Object.hash(firstMinute, lastMinute, count); @override - String toString() => 'MealSchedule($first-$last x$count)'; + String toString() => 'MealSchedule($firstMinute-$lastMinute x$count)'; } /// The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, enforcement /// closing at 22:00. Still what a device uses before the user has chosen /// anything, so upgrading changes no behaviour. const MealSchedule kDefaultSchedule = MealSchedule( - first: 8, - last: 20, + firstMinute: 8 * 60, + lastMinute: 20 * 60, count: 4, ); + +/// Returns the synced `sched:` value for [schedule]. +/// +/// Mirrors `_meal_schedule.schedule_to_wire`. Backward compatible by +/// construction: `f`/`l` carry whole hours exactly as before the move to +/// minutes, and `fm`/`lm` appear only when an endpoint is off the hour, so a +/// whole-hour schedule encodes byte-identically to the old `{f, l, n}` form. +/// Normalised first, so every `~/` runs on a non-negative value. +Map scheduleToWire(MealSchedule schedule) { + final norm = schedule.normalized(); + final first = norm.firstMinute; + final last = norm.lastMinute; + return { + 'f': first ~/ 60, + 'l': last ~/ 60, + 'n': norm.count, + if (first % 60 != 0) 'fm': first, + if (last % 60 != 0) 'lm': last, + }; +} + +/// Returns the normalised schedule a `sched:` value describes, or null. +/// +/// Mirrors `_meal_schedule.schedule_from_wire`. `fm`/`lm` win when they are +/// ints; otherwise `f`/`l` are hours. Never throws: anything without int `f`, +/// `l` and `n` yields null, so one bad field from a peer cannot take out the +/// whole history. +MealSchedule? scheduleFromWire(Object? raw) { + if (raw is! Map) return null; + final first = raw['f']; + final last = raw['l']; + final count = raw['n']; + if (first is! int || last is! int || count is! int) return null; + final firstMinute = raw['fm']; + final lastMinute = raw['lm']; + return MealSchedule( + firstMinute: firstMinute is int ? firstMinute : first * 60, + lastMinute: lastMinute is int ? lastMinute : last * 60, + count: count, + ).normalized(); +} diff --git a/app/lib/models/slot.dart b/app/lib/models/slot.dart index 1e7560b..4b8adb0 100644 --- a/app/lib/models/slot.dart +++ b/app/lib/models/slot.dart @@ -1,4 +1,5 @@ -/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py`. +/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py` and +/// `_slot_wire.py`. /// /// Deliberately I/O-free and clock-free: every function is a total function of /// its `now` and `schedule` arguments, so the time-of-day edges are @@ -7,6 +8,11 @@ /// the Python original is shared between the gate dashboard and the lock /// decision. /// +/// A slot is a *minute of day* (`0..1439`): 480, 720, 960, 1200 by default. +/// A logged meal satisfies the slot nearest its recorded slot minute +/// ([satisfiedSlots]), so an entry written under an older schedule still +/// counts after the schedule moves -- nothing stored is ever rewritten. +/// /// `schedule` is a required argument on every function here, deliberately: it /// used to be read from module constants, and a default would let a call site /// that was missed during a refactor keep deriving the old fixed hours on one @@ -14,11 +20,24 @@ /// slot one device offers and the other does not is a checkpoint that can /// never be satisfied. Callers resolve the value at the impure edge, through /// `MealScheduleService.current`. +/// +/// **Entry wire rule** (mirrors `_slot_wire.py`): writers always write +/// `slot = minute ~/ 60` and add `slot_min = minute` only when the minute is +/// off the hour, so whole-hour entries stay byte-identical to the pre-minute +/// format and an older peer still reads an hour. Readers prefer an int +/// `slot_min`, then `slot * 60`, else no slot. See [slotFields] and +/// [entrySlotMinute]; `tests/fixtures/meal_schedule_vectors.json` gates both +/// languages. library; import 'package:diet_guard_app/models/meal_schedule.dart'; -/// Returns the meal-slot hours for a day, e.g. `[8, 12, 16, 20]`. +/// Returns [now]'s minute of day, `0..1439` (seconds are ignored). +/// +/// Mirrors `_slots.minute_of_day`. +int minuteOfDay(DateTime now) => now.hour * 60 + now.minute; + +/// Returns the meal-slot minutes for a day, e.g. `[480, 720, 960, 1200]`. /// /// Mirrors `_slots.day_slots`. List daySlots(MealSchedule schedule) => schedule.slots(); @@ -26,29 +45,30 @@ List daySlots(MealSchedule schedule) => schedule.slots(); /// Returns true if [now] is inside the daily slot-enforcement window. /// /// Mirrors `_slots.within_enforcement_window`. -bool withinEnforcementWindow(DateTime now, MealSchedule schedule) => - now.hour >= schedule.slots().first && - now.hour < schedule.enforcementEndHour; +bool withinEnforcementWindow(DateTime now, MealSchedule schedule) { + final minute = minuteOfDay(now); + return schedule.slots().first <= minute && + minute < schedule.enforcementEndMinute; +} -/// Returns today's slots whose hour has arrived as of [now]. +/// Returns today's slots whose minute has arrived as of [now]. /// /// Empty outside the enforcement window. Mirrors `_slots.elapsed_slots`. List elapsedSlots(DateTime now, MealSchedule schedule) { if (!withinEnforcementWindow(now, schedule)) return const []; - return daySlots(schedule).where((slot) => slot <= now.hour).toList(); + final minute = minuteOfDay(now); + return daySlots(schedule).where((slot) => slot <= minute).toList(); } -/// Returns elapsed slots not yet covered by [logged]. +/// Returns elapsed slots not yet covered by [logged] (normally +/// [satisfiedSlots] of the entries' slot minutes). /// /// Mirrors `_slots.missing_slots`. -List missingSlots( - DateTime now, - Set logged, - MealSchedule schedule, -) => elapsedSlots( - now, - schedule, -).where((slot) => !logged.contains(slot)).toList(); +List missingSlots(DateTime now, Set logged, MealSchedule schedule) => + elapsedSlots( + now, + schedule, + ).where((slot) => !logged.contains(slot)).toList(); /// Returns the most recent elapsed slot as of [now], or null. /// @@ -64,8 +84,7 @@ int? currentSlot(DateTime now, MealSchedule schedule) { /// first slot, clamp to the first slot; after the enforcement window ends, /// clamp to the last slot; behaviour inside a window is unchanged. The two /// languages must reach each answer by the *same* branch, not merely agree on -/// the value -- `test/models/slot_test.dart` sweeps every hour of the day -/// against several schedules for exactly that reason. +/// the value -- the shared fixture pins every minute edge for that reason. /// /// Unlike [currentSlot] this never returns null, which is the point: an /// off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30 @@ -74,11 +93,65 @@ int? currentSlot(DateTime now, MealSchedule schedule) { /// *those* would instead make every slot fall due at the end of the day. int slotForLog(DateTime now, MealSchedule schedule) { final slots = daySlots(schedule); - if (now.hour < slots.first) return slots.first; + if (minuteOfDay(now) < slots.first) return slots.first; return currentSlot(now, schedule) ?? slots.last; } -/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`. +/// Returns the slot closest to [minute]; an exact tie goes to the earlier. +/// +/// Mirrors `_slots.nearest_slot`, whose `min` keeps the first minimal element +/// of the ascending slots -- hence replacing the best only on a strictly +/// smaller distance here. +int nearestSlot(int minute, MealSchedule schedule) { + final slots = daySlots(schedule); + var best = slots.first; + for (final slot in slots) { + if ((slot - minute).abs() < (best - minute).abs()) best = slot; + } + return best; +} + +/// Returns the slots covered by meals recorded at [entryMinutes]. +/// +/// Mirrors `_slots.satisfied_slots`. Many-to-one is intended: two meals +/// snapping onto one slot satisfy only that slot. +Set satisfiedSlots(Iterable entryMinutes, MealSchedule schedule) => { + for (final minute in entryMinutes) nearestSlot(minute, schedule), +}; + +/// Returns the entry fields that record [minute] as its slot. +/// +/// Mirrors `_slot_wire.slot_fields`; throws [ArgumentError] for a minute +/// outside the day, as Python raises `ValueError`. +Map slotFields(int minute) { + if (minute < 0 || minute >= kMinutesPerDay) { + throw ArgumentError.value( + minute, + 'minute', + 'slot minute is outside 0..${kMinutesPerDay - 1}', + ); + } + return {'slot': minute ~/ 60, if (minute % 60 != 0) 'slot_min': minute}; +} + +/// Returns the slot minute a raw log entry records, or null when it has none. +/// +/// Mirrors `_slot_wire.entry_slot_minute`: an int `slot_min` wins, else an int +/// `slot` is an hour, else null. Values are not range-checked. +int? entrySlotMinute(Map entry) { + final slotMin = entry['slot_min']; + if (slotMin is int) return slotMin; + final slot = entry['slot']; + if (slot is int) return slot * 60; + return null; +} + +/// Returns a human `HH:MM` label for a slot minute, e.g. `"07:15"`. /// /// Mirrors `_slots.slot_label`. -String slotLabel(int slot) => '${(slot % 24).toString().padLeft(2, '0')}:00'; +String slotLabel(int minute) { + final wrapped = minute % kMinutesPerDay; + final hours = (wrapped ~/ 60).toString().padLeft(2, '0'); + final minutes = (wrapped % 60).toString().padLeft(2, '0'); + return '$hours:$minutes'; +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b2ddcf8..8843522 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.104+1 +version: 1.0.105+1 environment: sdk: ^3.12.2 diff --git a/app/test/kuchnia_fill_parity_test.dart b/app/test/kuchnia_fill_parity_test.dart index 3493ccc..5c1e909 100644 --- a/app/test/kuchnia_fill_parity_test.dart +++ b/app/test/kuchnia_fill_parity_test.dart @@ -116,7 +116,7 @@ void main() { // silently stops testing the intra-batch dedup. final testCase = cases('log_dishes')['twins_share_16'] as Map; final input = _pairs(spread(testCase.cast())); - bool isTwin16(List p) => p[0] == 'Twin dish' && p[1] == 16; + bool isTwin16(List p) => p[0] == 'Twin dish' && p[1] == 960; expect(input.where(isTwin16), hasLength(2)); expect( (testCase['expected'] as List).cast>().where(isTwin16), diff --git a/app/test/kuchnia_parity_test.dart b/app/test/kuchnia_parity_test.dart index 40c07b8..a58ffab 100644 --- a/app/test/kuchnia_parity_test.dart +++ b/app/test/kuchnia_parity_test.dart @@ -106,9 +106,9 @@ void main() { subject = dishes.take(3).toList(); key = key.substring('first_three_'.length); } - final hours = [for (final part in key.split(',')) int.parse(part)]; + final minutes = [for (final part in key.split(',')) int.parse(part)]; final actual = [ - for (final item in assignSlots(subject, hours)) item.slot, + for (final item in assignSlots(subject, minutes)) item.slot, ]; expect( actual, @@ -124,10 +124,10 @@ void main() { // total. Run repeatedly: an unstable sort need not misbehave every time. for (var attempt = 0; attempt < 5; attempt++) { final ordered = assignSlots(parseMenu(fixture['payload']), const [ - 8, - 12, - 16, - 20, + 480, + 720, + 960, + 1200, ]); expect( [for (final item in ordered) item.dish.name], diff --git a/app/test/models/meal_schedule_test.dart b/app/test/models/meal_schedule_test.dart index 51d0a5f..fdc042f 100644 --- a/app/test/models/meal_schedule_test.dart +++ b/app/test/models/meal_schedule_test.dart @@ -1,94 +1,87 @@ /// Tests for the pure meal-schedule derivation. /// -/// The vector table below is duplicated verbatim from -/// `diet_guard/tests/test_meal_schedule.py`. KEEP THE TWO IN SYNC: it is the -/// only thing that catches a Python/Dart divergence before it reaches a -/// device, and a divergence there means one device nags for a checkpoint the -/// other never offers -- a slot that can never be satisfied, i.e. a permanent -/// lock. +/// The cross-language exact values live in the shared fixture +/// `tests/fixtures/meal_schedule_vectors.json`, asserted by +/// `meal_schedule_vectors_test.dart` (and by its Python mirror). This file +/// holds the properties: the grid sweep, the hour-formula regression and the +/// clamps a human should read without opening the fixture. The sweeps are +/// duplicated loop-for-loop in `diet_guard/tests/test_meal_schedule.py`. library; import 'package:diet_guard_app/models/meal_schedule.dart'; import 'package:flutter_test/flutter_test.dart'; -/// (first, last, count, expected slots). Shared with the Python mirror. -const List<(int, int, int, List)> scheduleVectors = [ - // The eating window the user described, at every supported meal count. - (8, 20, 2, [8, 20]), - (8, 20, 3, [8, 14, 20]), - (8, 20, 4, [8, 12, 16, 20]), // today's hardcoded schedule - (8, 20, 5, [8, 11, 14, 17, 20]), // the user's stated example - (8, 20, 6, [8, 10, 13, 15, 18, 20]), - // A window that does not divide evenly: 14 hours across 3 gaps. - (7, 21, 4, [7, 12, 16, 21]), - (7, 21, 5, [7, 11, 14, 18, 21]), - (9, 19, 4, [9, 12, 16, 19]), - // Narrow windows: count is capped at the number of whole hours available, - // so the slots stay distinct instead of repeating an hour. - (8, 12, 6, [8, 9, 10, 11, 12]), - (8, 10, 5, [8, 9, 10]), - (8, 9, 4, [8, 9]), - // Whole-day extremes. - (0, 23, 6, [0, 5, 9, 14, 18, 23]), - (0, 1, 2, [0, 1]), -]; +const int _h = 60; + +/// The pre-minute whole-hour derivation, converted to minutes. +List _oldHourSlots(int first, int last, int count) { + final span = last - first; + final divisions = count - 1; + return [ + for (var index = 0; index < count; index++) + (first + (index * span + divisions ~/ 2) ~/ divisions) * _h, + ]; +} + +bool _strictlyAscending(List slots) { + for (var i = 1; i < slots.length; i++) { + if (slots[i - 1] >= slots[i]) return false; + } + return true; +} + +MealSchedule _s(int first, int last, int count) => + MealSchedule(firstMinute: first, lastMinute: last, count: count); void main() { group('slots', () { - for (final (first, last, count, expected) in scheduleVectors) { - test('$first-$last x$count derives $expected', () { + test('the default is the historical schedule', () { + expect(kDefaultSchedule.slots(), [480, 720, 960, 1200]); + }); + + // Upgrading must not move a checkpoint for the schedules people actually + // have, or every existing hour-tagged entry would satisfy a neighbour. + for (final (first, last, count) in const [ + (8, 20, 4), + (8, 20, 5), + (7, 19, 5), + ]) { + test('$first-$last x$count matches the old hour formula', () { expect( - MealSchedule(first: first, last: last, count: count).slots(), - expected, + _s(first * _h, last * _h, count).slots(), + _oldHourSlots(first, last, count), ); }); } - test('the default is the historical schedule', () { - expect(kDefaultSchedule.slots(), [8, 12, 16, 20]); + test('off-grid endpoints are exact; only the interior snaps', () { + final slots = _s(443, 1181, 5).slots(); + expect(slots.first, 443); + expect(slots.last, 1181); + for (final slot in slots.sublist(1, slots.length - 1)) { + expect(slot % kSlotGridMinutes, 0); + } }); - test('endpoints stay exact when the spacing rounds', () { - final slots = const MealSchedule(first: 8, last: 20, count: 6).slots(); - expect(slots.first, 8); - expect(slots.last, 20); + test("the user's quarter-hour schedule", () { + expect(_s(435, 1140, 5).slots(), [435, 615, 795, 960, 1140]); }); }); group('normalized', () { - const cases = <(MealSchedule, MealSchedule)>[ - ( - MealSchedule(first: 8, last: 20, count: 99), - MealSchedule(first: 8, last: 20, count: kMaxMealCount), - ), - ( - MealSchedule(first: 8, last: 20, count: 0), - MealSchedule(first: 8, last: 20, count: kMinMealCount), - ), - ( - MealSchedule(first: -5, last: 20, count: 4), - MealSchedule(first: 0, last: 20, count: 4), - ), - ( - MealSchedule(first: 8, last: 99, count: 4), - MealSchedule(first: 8, last: 23, count: 4), - ), - // last <= first is pulled forward to leave a one-hour window. - ( - MealSchedule(first: 12, last: 12, count: 4), - MealSchedule(first: 12, last: 13, count: 2), - ), - ( - MealSchedule(first: 12, last: 3, count: 4), - MealSchedule(first: 12, last: 13, count: 2), - ), - // first cannot occupy the final hour, or no window would remain. - ( - MealSchedule(first: 23, last: 23, count: 2), - MealSchedule(first: 22, last: 23, count: 2), - ), + final cases = <(MealSchedule, MealSchedule)>[ + (_s(480, 1200, 99), _s(480, 1200, kMaxMealCount)), + (_s(480, 1200, 0), _s(480, 1200, kMinMealCount)), + (_s(-5, 1200, 4), _s(0, 1200, 4)), + (_s(480, 9999, 4), _s(480, 1439, 4)), + // last <= first is pulled forward to leave one grid step of window. + (_s(720, 720, 4), _s(720, 735, 2)), + (_s(720, 3, 4), _s(720, 735, 2)), + // first cannot sit in the final grid step, or no window remains. + (_s(1439, 1439, 2), _s(1424, 1439, 2)), + // Count is capped by the grid: 60 minutes hold 5 quarter-hour marks. + (_s(480, 540, 6), _s(480, 540, 5)), ]; - for (final (input, expected) in cases) { test('$input clamps to $expected', () { expect(input.normalized(), expected); @@ -96,56 +89,82 @@ void main() { } test('garbage still yields usable slots', () { - expect( - const MealSchedule(first: 99, last: -5, count: 999).slots(), - [22, 23], - ); + expect(_s(9999, -5, 999).slots(), [1424, 1439]); }); }); - group('enforcementEndHour', () { + group('enforcementEndMinute', () { test('the default keeps the historical 22:00 cutoff', () { - expect(kDefaultSchedule.enforcementEndHour, 22); + expect(kDefaultSchedule.enforcementEndMinute, 22 * _h); }); - test('the tail follows the last meal', () { - expect( - const MealSchedule(first: 8, last: 18, count: 4).enforcementEndHour, - 20, - ); + test('the tail follows the last meal, to the minute', () { + expect(_s(480, 1095, 4).enforcementEndMinute, 1215); }); test('is clamped to the end of the day', () { - // An unclamped 23 + 2 = 25 would make `hour < cutoff` vacuously true, - // so the enforcement window would never close. - expect( - const MealSchedule(first: 8, last: 23, count: 4).enforcementEndHour, - 24, - ); + // An unclamped 1380 + 120 = 1500 would make `minute < cutoff` + // vacuously true, so the enforcement window would never close. + expect(_s(480, 1380, 4).enforcementEndMinute, kMinutesPerDay); + }); + + test('follows the normalised last meal', () { + final schedule = _s(720, 3, 4); + expect(schedule.enforcementEndMinute, schedule.slots().last + 120); }); }); - test('every input yields ascending slots with exact endpoints', () { - // The Dart half of the cross-language parity guarantee; the Python mirror - // runs the identical sweep. - for (var first = -2; first < 26; first++) { - for (var last = -2; last < 26; last++) { - for (var count = -2; count < 10; count++) { - final schedule = MealSchedule( - first: first, - last: last, - count: count, - ); + group('wire', () { + test('a whole-hour schedule encodes exactly as before', () { + expect(scheduleToWire(kDefaultSchedule), {'f': 8, 'l': 20, 'n': 4}); + }); + + test('off-hour endpoints add minute fields', () { + expect(scheduleToWire(_s(435, 1181, 5)), { + 'f': 7, + 'l': 19, + 'n': 5, + 'fm': 435, + 'lm': 1181, + }); + }); + + test('unusable values decode to null', () { + expect(scheduleFromWire({'f': true, 'l': 20, 'n': 4}), isNull); + expect(scheduleFromWire('not a map'), isNull); + }); + }); + + test('grid sweep yields ascending slots with exact endpoints', () { + // The Python mirror runs the identical loop. A wire round trip rides + // along, because it is the same sweep's worth of schedules. Plain checks + // with `fail` rather than `expect` per cell keep 46k cells fast. + for (var first = 0; first < kMinutesPerDay; first += kSlotGridMinutes) { + for (var last = 0; last < kMinutesPerDay; last += kSlotGridMinutes) { + for (var count = kMinMealCount; count <= kMaxMealCount; count++) { + final schedule = _s(first, last, count); final normalized = schedule.normalized(); final slots = schedule.slots(); + final ok = + slots.first == normalized.firstMinute && + slots.last == normalized.lastMinute && + slots.length == normalized.count && + _strictlyAscending(slots) && + slots.first >= 0 && + slots.last < kMinutesPerDay && + scheduleFromWire(scheduleToWire(schedule)) == normalized; + if (!ok) fail('$schedule derived $slots'); + } + } + } + }); - expect(slots.first, normalized.first); - expect(slots.last, normalized.last); - expect(slots.length, normalized.count); - expect(slots.toSet().length, slots.length); - expect(slots, orderedEquals(List.of(slots)..sort())); - expect(slots.length, greaterThanOrEqualTo(kMinMealCount)); - expect(slots.length, lessThanOrEqualTo(kMaxMealCount)); + test('off-grid sweep stays strictly ascending', () { + for (var first = 0; first < 120; first += 7) { + for (var width = 15; width < 200; width += 11) { + for (var count = kMinMealCount; count <= kMaxMealCount; count++) { + final slots = _s(first, first + width, count).slots(); + if (!_strictlyAscending(slots)) fail('$first+$width x$count: $slots'); } } } diff --git a/app/test/models/meal_schedule_vectors_test.dart b/app/test/models/meal_schedule_vectors_test.dart new file mode 100644 index 0000000..a590fe2 --- /dev/null +++ b/app/test/models/meal_schedule_vectors_test.dart @@ -0,0 +1,177 @@ +/// The Dart half of the cross-language meal-slot parity gate. +/// +/// `diet_guard/tests/test_meal_schedule_vectors.py` asserts the *same* +/// expectations against the *same* `tests/fixtures/meal_schedule_vectors.json`. +/// Two independently written suites from the same prose is not a gate -- one +/// shared input with one shared expected result is: a device that derives a +/// slot its peer never offers has a checkpoint that can never be satisfied, +/// i.e. a permanent lock. Regenerate with +/// `scripts/build_meal_schedule_fixture.py`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/models/meal_schedule.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The repo root, found by walking up from wherever `flutter test` runs. +Directory get _repoRoot { + var dir = Directory.current.absolute; + while (!File('${dir.path}/pubspec.yaml').existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + fail('could not locate app/ from ${Directory.current.path}'); + } + dir = parent; + } + // `dir` is app/; the fixture is shared, so it lives at the repo root. + return dir.parent; +} + +File get _fixtureFile => + File('${_repoRoot.path}/tests/fixtures/meal_schedule_vectors.json'); + +MealSchedule _schedule(Object? row) { + final values = (row! as List).cast(); + return MealSchedule( + firstMinute: values[0], + lastMinute: values[1], + count: values[2], + ); +} + +DateTime _at(int minute) => DateTime(2026, 1, 1, minute ~/ 60, minute % 60); + +List _ints(Object? value) => (value! as List).cast(); + +void main() { + late Map vectors; + + setUpAll(() { + vectors = + jsonDecode(_fixtureFile.readAsStringSync()) as Map; + }); + + List> rows(String key) => + (vectors[key] as List).cast>(); + + test('the shared fixture is present', () { + expect( + _fixtureFile.existsSync(), + isTrue, + reason: 'shared parity fixture missing at ${_fixtureFile.path}', + ); + }); + + test('schedules: normalised form, slots, cutoff and wire', () { + expect(rows('schedules'), isNotEmpty); + for (final row in rows('schedules')) { + final schedule = _schedule(row['input']); + final reason = '${row['input']}'; + expect( + schedule.normalized(), + _schedule(row['normalized']), + reason: reason, + ); + expect(schedule.slots(), _ints(row['slots']), reason: reason); + expect( + schedule.enforcementEndMinute, + row['enforcement_end_minute'], + reason: reason, + ); + expect(scheduleToWire(schedule), row['wire'], reason: reason); + expect( + scheduleFromWire(row['wire']), + schedule.normalized(), + reason: reason, + ); + } + }); + + test('time edges: every clock-reading function', () { + expect(rows('time_edges'), isNotEmpty); + for (final row in rows('time_edges')) { + final schedule = _schedule(row['schedule']); + final now = _at(row['minute'] as int); + final reason = '${row['schedule']} @ ${row['minute']}'; + expect( + withinEnforcementWindow(now, schedule), + row['within_window'], + reason: reason, + ); + expect( + elapsedSlots(now, schedule), + _ints(row['elapsed']), + reason: reason, + ); + expect(currentSlot(now, schedule), row['current_slot'], reason: reason); + expect(slotForLog(now, schedule), row['slot_for_log'], reason: reason); + } + }); + + test('missing slots', () { + for (final row in rows('missing')) { + final actual = missingSlots( + _at(row['minute'] as int), + _ints(row['logged']).toSet(), + _schedule(row['schedule']), + ); + expect(actual, _ints(row['missing']), reason: '$row'); + } + }); + + test('nearest slot', () { + for (final row in rows('nearest')) { + final actual = nearestSlot( + row['minute'] as int, + _schedule(row['schedule']), + ); + expect(actual, row['nearest'], reason: '$row'); + } + }); + + test('satisfied slots', () { + for (final row in rows('satisfied')) { + final actual = satisfiedSlots( + _ints(row['entry_minutes']), + _schedule(row['schedule']), + ).toList()..sort(); + expect(actual, _ints(row['satisfied']), reason: '${row['name']}'); + } + }); + + test('labels', () { + for (final row in rows('labels')) { + expect(slotLabel(row['minute'] as int), row['label'], reason: '$row'); + } + }); + + test('schedule wire decode', () { + for (final row in rows('schedule_wire_decode')) { + final expected = row['schedule'] == null + ? null + : _schedule(row['schedule']); + expect(scheduleFromWire(row['raw']), expected, reason: '$row'); + } + }); + + test('slot fields', () { + for (final row in rows('slot_fields')) { + final minute = row['minute'] as int; + if (row['error'] == true) { + expect(() => slotFields(minute), throwsArgumentError, reason: '$row'); + } else { + expect(slotFields(minute), row['fields'], reason: '$row'); + } + } + }); + + test('entry slot minute', () { + for (final row in rows('entry_slot_minute')) { + final entry = (row['entry'] as Map).cast(); + expect(entrySlotMinute(entry), row['minute'], reason: '$row'); + } + }); +} diff --git a/app/test/models/slot_test.dart b/app/test/models/slot_test.dart index e7d1d07..aa56e1f 100644 --- a/app/test/models/slot_test.dart +++ b/app/test/models/slot_test.dart @@ -1,229 +1,172 @@ +/// Tests for `models/slot.dart`, in minutes of day. +/// +/// Mirrors `diet_guard/tests/test_slots.py`, `test_slots_nearest.py` and +/// `test_slot_wire.py`; the exhaustive cross-language edges live in the shared +/// fixture (`meal_schedule_vectors_test.dart`). +library; + import 'package:diet_guard_app/models/meal_schedule.dart'; import 'package:diet_guard_app/models/slot.dart'; import 'package:flutter_test/flutter_test.dart'; +DateTime _at(int hour, [int minute = 0]) => DateTime(2026, 6, 22, hour, minute); + +MealSchedule _s(int first, int last, int count) => + MealSchedule(firstMinute: first, lastMinute: last, count: count); + void main() { - group('daySlots', () { - test('returns the four fixed hourly slots', () { - expect(daySlots(kDefaultSchedule), [8, 12, 16, 20]); - }); + test('minuteOfDay counts minutes and ignores seconds', () { + expect(minuteOfDay(DateTime(2026, 6, 22, 7, 15, 59)), 435); + expect(minuteOfDay(_at(23, 59)), 1439); + }); + + test('daySlots returns the four default slots', () { + expect(daySlots(kDefaultSchedule), [480, 720, 960, 1200]); }); group('withinEnforcementWindow', () { - test('false before the day start hour', () { - expect( - withinEnforcementWindow(DateTime(2026, 6, 22, 7, 59), kDefaultSchedule), - isFalse, - ); + test('false at 07:59, true at 08:00', () { + expect(withinEnforcementWindow(_at(7, 59), kDefaultSchedule), isFalse); + expect(withinEnforcementWindow(_at(8), kDefaultSchedule), isTrue); }); - test('true at the day start hour', () { - expect( - withinEnforcementWindow(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), - isTrue, - ); + test('true at 21:59, false at the 22:00 cutoff', () { + expect(withinEnforcementWindow(_at(21, 59), kDefaultSchedule), isTrue); + expect(withinEnforcementWindow(_at(22), kDefaultSchedule), isFalse); }); - test('true just before the eating end hour', () { - expect( - withinEnforcementWindow( - DateTime(2026, 6, 22, 21, 59), - kDefaultSchedule, - ), - isTrue, - ); - }); - - test('false at the eating end hour (exclusive)', () { - expect( - withinEnforcementWindow(DateTime(2026, 6, 22, 22, 0), kDefaultSchedule), - isFalse, - ); + test('a 07:15 breakfast opens the window at 07:15', () { + final schedule = _s(435, 1140, 5); + expect(withinEnforcementWindow(_at(7, 14), schedule), isFalse); + expect(withinEnforcementWindow(_at(7, 15), schedule), isTrue); }); }); group('elapsedSlots', () { test('empty outside the enforcement window', () { - expect( - elapsedSlots(DateTime(2026, 6, 22, 23, 0), kDefaultSchedule), - isEmpty, - ); + expect(elapsedSlots(_at(23), kDefaultSchedule), isEmpty); + expect(elapsedSlots(_at(7, 59), kDefaultSchedule), isEmpty); }); - test('only the 8 slot right at day start', () { - expect(elapsedSlots(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), [8]); + test('grows through the day', () { + expect(elapsedSlots(_at(8), kDefaultSchedule), [480]); + expect(elapsedSlots(_at(15, 59), kDefaultSchedule), [480, 720]); + expect(elapsedSlots(_at(21), kDefaultSchedule), [480, 720, 960, 1200]); }); - test('8 and 12 mid-afternoon before 16', () { - expect(elapsedSlots(DateTime(2026, 6, 22, 15, 59), kDefaultSchedule), [ - 8, - 12, - ]); - }); - - test('all four slots once 20:00 has passed', () { - expect(elapsedSlots(DateTime(2026, 6, 22, 21, 0), kDefaultSchedule), [ - 8, - 12, - 16, - 20, - ]); + test('a slot opens on its minute', () { + final schedule = _s(435, 1140, 5); + expect(elapsedSlots(_at(10, 14), schedule), [435]); + expect(elapsedSlots(_at(10, 15), schedule), [435, 615]); }); }); - group('missingSlots', () { - test('excludes already-logged elapsed slots', () { - expect( - missingSlots(DateTime(2026, 6, 22, 17, 0), {8}, kDefaultSchedule), - [12, 16], - ); - }); - - test('empty once every elapsed slot is logged', () { - expect( - missingSlots(DateTime(2026, 6, 22, 17, 0), { - 8, - 12, - 16, - }, kDefaultSchedule), - isEmpty, - ); - }); + test('missingSlots excludes logged slots', () { + expect(missingSlots(_at(17), {480}, kDefaultSchedule), [720, 960]); + expect(missingSlots(_at(17), {480, 720, 960}, kDefaultSchedule), isEmpty); }); - group('currentSlot', () { - test('null outside the enforcement window', () { - expect( - currentSlot(DateTime(2026, 6, 22, 6, 0), kDefaultSchedule), - isNull, - ); - }); - - test('returns the most recently elapsed slot', () { - expect(currentSlot(DateTime(2026, 6, 22, 17, 41), kDefaultSchedule), 16); - }); - - test('returns 8 right at day start', () { - expect(currentSlot(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), 8); - }); + test('currentSlot is the latest elapsed slot, or null', () { + expect(currentSlot(_at(6), kDefaultSchedule), isNull); + expect(currentSlot(_at(17, 41), kDefaultSchedule), 960); }); group('slotForLog', () { // Keep in lockstep with `diet_guard/tests/test_slots.py`'s // TestSlotForLog: a divergence means the PC and the phone disagree about // which checkpoint a meal satisfied. - DateTime at(int hour) => DateTime(2026, 6, 22, hour, 0); - - test('clamps to the first slot before the window opens', () { - expect(slotForLog(at(7), kDefaultSchedule), 8); + test('clamps before the window and after it', () { + expect(slotForLog(_at(0), kDefaultSchedule), 480); + expect(slotForLog(_at(7, 59), kDefaultSchedule), 480); + expect(slotForLog(_at(13), kDefaultSchedule), 720); + expect(slotForLog(_at(21, 59), kDefaultSchedule), 1200); + expect(slotForLog(_at(22), kDefaultSchedule), 1200); }); - test('clamps to the first slot in the small hours', () { - expect(slotForLog(at(0), kDefaultSchedule), 8); - }); - - test('is unchanged exactly at the first slot', () { - expect(slotForLog(at(8), kDefaultSchedule), 8); - }); - - test('matches currentSlot inside the window', () { - expect( - slotForLog(at(13), kDefaultSchedule), - currentSlot(at(13), kDefaultSchedule), - ); - expect(slotForLog(at(13), kDefaultSchedule), 12); - }); - - test('is unchanged at the last in-window hour', () { - expect(slotForLog(at(21), kDefaultSchedule), 20); - }); - - test('clamps to the last slot once the window closes', () { - expect(slotForLog(at(22), kDefaultSchedule), 20); - }); - - test('clamps to the last slot late in the evening', () { - expect(slotForLog(at(23), kDefaultSchedule), 20); - }); - - test('never returns a slot outside the schedule', () { - for (var hour = 0; hour < 24; hour++) { - expect( - daySlots(kDefaultSchedule), - contains(slotForLog(at(hour), kDefaultSchedule)), - ); - } - }); - - test('clamps the same way across configured schedules', () { - // Sweeps every hour against several windows. `test_slots.py` runs the - // identical sweep: the two must agree on every cell, because a device - // that attributes a meal to a different slot than its peer leaves the - // other device's checkpoint permanently unsatisfied. - const schedules = [ + test('clamps the same way at every minute across schedules', () { + // `test_slots.py` runs the identical sweep. + final schedules = [ kDefaultSchedule, - MealSchedule(first: 8, last: 20, count: 5), - MealSchedule(first: 6, last: 22, count: 6), - MealSchedule(first: 0, last: 23, count: 2), - MealSchedule(first: 10, last: 14, count: 3), + _s(480, 1200, 5), + _s(435, 1140, 5), + _s(443, 1181, 4), + _s(0, 1439, 2), + _s(600, 840, 3), ]; for (final schedule in schedules) { final slots = daySlots(schedule); - for (var hour = 0; hour < 24; hour++) { - final attributed = slotForLog(at(hour), schedule); - expect(slots, contains(attributed)); - if (hour < slots.first) { - expect(attributed, slots.first); - } else if (hour >= schedule.enforcementEndHour) { - expect(attributed, slots.last); + for (var minute = 0; minute < 1440; minute++) { + final attributed = slotForLog( + _at(minute ~/ 60, minute % 60), + schedule, + ); + final int expected; + if (minute < slots.first) { + expected = slots.first; + } else if (minute >= schedule.enforcementEndMinute) { + expected = slots.last; } else { - // Inside the window: the most recent slot that has opened. - expect(attributed, slots.where((s) => s <= hour).last); + expected = slots.where((s) => s <= minute).last; } + if (attributed != expected) fail('$schedule @ $minute: $attributed'); } } }); - }); - - group('configured schedules', () { - test('five meals shifts the checkpoints', () { - const schedule = MealSchedule(first: 8, last: 20, count: 5); - expect(daySlots(schedule), [8, 11, 14, 17, 20]); - expect(elapsedSlots(DateTime(2026, 6, 22, 14), schedule), [8, 11, 14]); - expect( - missingSlots(DateTime(2026, 6, 22, 14), {8, 11}, schedule), - [14], - ); - }); - - test('enforcement follows the last meal', () { - const schedule = MealSchedule(first: 8, last: 18, count: 3); - expect( - withinEnforcementWindow(DateTime(2026, 6, 22, 19), schedule), - isTrue, - ); - expect( - withinEnforcementWindow(DateTime(2026, 6, 22, 20), schedule), - isFalse, - ); - expect(elapsedSlots(DateTime(2026, 6, 22, 20), schedule), isEmpty); - }); test('midnight is a real slot, not a falsy absence', () { - const schedule = MealSchedule(first: 0, last: 12, count: 3); - expect(daySlots(schedule), [0, 6, 12]); - expect(currentSlot(DateTime(2026, 6, 22), schedule), 0); - expect(slotForLog(DateTime(2026, 6, 22), schedule), 0); + final schedule = _s(0, 720, 3); + expect(daySlots(schedule), [0, 360, 720]); + expect(currentSlot(_at(0), schedule), 0); + expect(slotForLog(_at(0), schedule), 0); }); }); - group('slotLabel', () { - test('pads single-digit hours', () { - expect(slotLabel(8), '08:00'); + group('nearestSlot / satisfiedSlots', () { + test('an exact halfway goes to the earlier slot', () { + expect(nearestSlot(600, kDefaultSchedule), 480); + expect(nearestSlot(601, kDefaultSchedule), 720); }); - test('formats double-digit hours', () { - expect(slotLabel(20), '20:00'); + test('outside the window clamps to the ends', () { + expect(nearestSlot(-60, kDefaultSchedule), 480); + expect(nearestSlot(1800, kDefaultSchedule), 1200); }); + + test('meals logged at 07:00 and 10:00 survive a move to 07:15', () { + expect(satisfiedSlots([420, 600], _s(435, 1140, 5)), {435, 615}); + }); + + test('two meals on one slot satisfy only that slot', () { + expect(satisfiedSlots([480, 540], kDefaultSchedule), {480}); + expect(satisfiedSlots(const [], kDefaultSchedule), isEmpty); + }); + }); + + group('slotFields / entrySlotMinute', () { + test('a whole hour is byte-identical to the old format', () { + expect(slotFields(480), {'slot': 8}); + expect(slotFields(435), {'slot': 7, 'slot_min': 435}); + }); + + test('a minute outside the day is refused', () { + expect(() => slotFields(-1), throwsArgumentError); + expect(() => slotFields(1440), throwsArgumentError); + }); + + test('slot_min wins; legacy slot is an hour; bools are not ints', () { + expect(entrySlotMinute({'slot': 8}), 480); + expect(entrySlotMinute({'slot': 5, 'slot_min': 0}), 0); + expect(entrySlotMinute({'slot': 8, 'slot_min': true}), 480); + expect(entrySlotMinute({'slot': true}), isNull); + expect(entrySlotMinute({}), isNull); + }); + }); + + test('slotLabel zero-pads and wraps into the day', () { + expect(slotLabel(435), '07:15'); + expect(slotLabel(5), '00:05'); + expect(slotLabel(1200), '20:00'); + expect(slotLabel(1440), '00:00'); + expect(slotLabel(-15), '23:45'); }); } diff --git a/diet_guard/_meal_schedule.py b/diet_guard/_meal_schedule.py index 9c4efd9..920e17a 100644 --- a/diet_guard/_meal_schedule.py +++ b/diet_guard/_meal_schedule.py @@ -1,23 +1,38 @@ -"""The user's eating window and meal count, and the slot hours they imply. +"""The user's eating window and meal count, and the slot minutes they imply. -A schedule is three numbers -- the first meal hour, the last meal hour, and how -many meals fall between them inclusive -- from which the intermediate -checkpoints are derived by even division. ``MealSchedule(8, 20, 5)`` yields -``(8, 11, 14, 17, 20)``. +A schedule is three numbers -- the first meal's minute of day, the last meal's +minute of day, and how many meals fall between them inclusive -- from which the +intermediate checkpoints are derived by even division and rounded to the +15-minute grid. ``MealSchedule(480, 1200, 5)`` (08:00-20:00, five meals) +yields ``(480, 660, 840, 1020, 1200)``: 08:00, 11:00, 14:00, 17:00, 20:00. + +Every time here is a *minute of day*, an int in ``0..1439``. The fields are +deliberately named ``first_minute``/``last_minute`` rather than reusing the old +hour-valued ``first``/``last``: renaming them makes mypy enumerate every call +site that still thinks in hours, instead of one quietly passing ``8`` and +getting a 00:08 breakfast. This module is pure: no clock, no filesystem, no configuration lookup. Storage -lives in :mod:`diet_guard._meal_schedule_store`, and the slot arithmetic that -consumes a schedule lives in :mod:`diet_guard._slots`. +lives in :mod:`diet_guard._meal_schedule_store`, the slot arithmetic that +consumes a schedule in :mod:`diet_guard._slots`, and a log entry's slot +encoding in :mod:`diet_guard._slot_wire`. The schedule's own wire encoding +(:func:`schedule_to_wire`/:func:`schedule_from_wire`) lives here. KEEP IN SYNC WITH ``app/lib/models/meal_schedule.dart``. The two must agree on every input, because a device that derives different slots than its peer nags for checkpoints the other never offers -- and a slot that can never be -satisfied is a permanent lock. Two rules make that agreement checkable: +satisfied is a permanent lock. ``tests/fixtures/meal_schedule_vectors.json`` +(regenerated by ``scripts/build_meal_schedule_fixture.py``) is read by both +test suites; three rules make that agreement checkable: * **Integer arithmetic only.** No floats and no ``round()`` anywhere in the derivation. Python's ``round`` is banker's rounding (``round(2.5) == 2``) while Dart's is half-away-from-zero (``2.5.round() == 3``), so any float path is a latent cross-language split brain. ``//`` here, ``~/`` there. +* **Non-negative operands to every division.** Python's ``//`` floors while + Dart's ``~/`` truncates, so ``-7 // 15 == -1`` but ``-7 ~/ 15 == 0``. Every + division below runs on a value :meth:`MealSchedule.normalized` has already + pulled into the day, which is what keeps the two operators equivalent. * **Clamp, don't reject.** Every out-of-range input is normalised to the nearest legal schedule rather than raising, so the two languages cannot disagree about which inputs are errors. @@ -26,6 +41,7 @@ satisfied is a permanent lock. Two rules make that agreement checkable: from __future__ import annotations from dataclasses import dataclass +from typing import TypeGuard # A day's meals must fit inside a day, and the pills that render them have to # stay legible on a phone at the widest (all-logged) size -- see @@ -33,8 +49,13 @@ from dataclasses import dataclass MIN_MEAL_COUNT = 2 MAX_MEAL_COUNT = 6 -FIRST_HOUR = 0 -LAST_HOUR = 23 +MINUTES_PER_DAY = 1440 + +# Both the minimum spacing between two checkpoints and the grid the interior +# checkpoints are rounded onto. One constant for both is what makes the +# strict-ascent proof in ``slots()`` work: points at least one grid step apart +# cannot round onto the same grid mark. +SLOT_GRID_MINUTES = 15 # Grace period after the final checkpoint, before the gate stops firing for # the day. Deliberately a constant rather than the slot spacing: it is how @@ -42,9 +63,9 @@ LAST_HOUR = 23 # meals you eat. Tying it to the spacing would stretch the lockout window to # midnight at four meals, contradicting the "don't trap me overnight" intent # this cutoff exists to serve. -ENFORCEMENT_TAIL_HOURS = 2 +ENFORCEMENT_TAIL_MINUTES = 120 -_HOURS_PER_DAY = 24 +_LAST_MINUTE = MINUTES_PER_DAY - 1 def _clamp(value: int, low: int, high: int) -> int: @@ -57,78 +78,141 @@ class MealSchedule: """An eating window and the number of meals inside it. Attributes: - first: Hour of the first meal, 0-23. - last: Hour of the last meal, strictly after ``first``. + first_minute: Minute of day of the first meal, 0-1424. + last_minute: Minute of day of the last meal, at least one grid step + (15 minutes) after ``first_minute`` and at most 1439. count: Total meals including both endpoints. """ - first: int - last: int + first_minute: int + last_minute: int count: int def normalized(self) -> MealSchedule: """Return an equivalent schedule guaranteed to satisfy the invariants. - Ordering matters: ``first`` is clamped into the day, then ``last`` is - clamped to leave at least one hour of window, then ``count`` is clamped - to the window's width. That last clamp is the load-bearing one -- see - :meth:`slots`. + Ordering matters: ``first_minute`` is clamped into the day leaving room + for one grid step, then ``last_minute`` is clamped to at least one grid + step after it, then ``count`` is clamped to how many grid-spaced + checkpoints the window holds. That last clamp is the load-bearing one + -- see :meth:`slots`. Returns: A schedule whose ``slots()`` is strictly ascending. """ - first = _clamp(self.first, FIRST_HOUR, LAST_HOUR - 1) - last = _clamp(self.last, first + 1, LAST_HOUR) - # A window of N hours holds at most N+1 whole-hour checkpoints; asking - # for more would repeat an hour (see slots()). - count = _clamp( - self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, last - first + 1) - ) + first = _clamp(self.first_minute, 0, _LAST_MINUTE - SLOT_GRID_MINUTES) + last = _clamp(self.last_minute, first + SLOT_GRID_MINUTES, _LAST_MINUTE) + # A window of W minutes holds at most W//15 + 1 checkpoints spaced a + # grid step apart; asking for more would round two onto one mark. + capacity = (last - first) // SLOT_GRID_MINUTES + 1 + count = _clamp(self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, capacity)) return MealSchedule(first, last, count) def slots(self) -> tuple[int, ...]: - """Return the meal-slot hours, ascending, endpoints exact. + """Return the meal-slot minutes, ascending, endpoints exact. - Meals are spread evenly across the window and rounded to whole hours by - integer arithmetic: slot *i* is ``first + (i*span + d//2) // d`` where - ``span = last - first`` and ``d = count - 1``. The ``d//2`` term is a - round-half-up bias applied before the division, which is what keeps - this float-free. + With ``span = last - first`` and ``d = count - 1``, interior slot *i* + is ``raw = first + (i*span + d//2) // d`` (even division, the ``d//2`` + term a round-half-up bias that keeps it float-free), then snapped to + the nearest absolute quarter hour by ``(raw + 7) // 15 * 15``. The + endpoints are ``first`` and ``last`` exactly -- never snapped -- so the + eating window the user typed is always honoured, even at 07:23. - Both endpoints land exactly on ``first`` and ``last`` by construction, - so the eating window is always honoured even when the interior spacing - has to round. - - The result is strictly ascending because :meth:`normalized` caps - ``count`` at ``last - first + 1``. Without that cap a narrow window - repeats an hour (``08-12`` with 6 meals would give - ``8, 9, 10, 10, 11, 12``), and since slot hours are used as set - members, dict keys *and* notification ids, a repeat silently drops a - checkpoint. + Strictly ascending, provably: :meth:`normalized` caps ``count`` so that + ``span / d >= 15``, hence consecutive ``raw`` values differ by at least + 15, and two integers at least 15 apart never snap onto the same grid + mark. Snapping moves a value by at most 7, so the first interior slot + is at least 8 past ``first`` and the last at least 8 before ``last``. + That matters because slot minutes are set members, dict keys *and* + notification ids: a repeat silently drops a checkpoint. Returns: - The slot hours, ascending, of length ``normalized().count``. + The slot minutes, ascending, of length ``normalized().count``. """ schedule = self.normalized() - span = schedule.last - schedule.first + first, last = schedule.first_minute, schedule.last_minute + span = last - first divisions = schedule.count - 1 - return tuple( - schedule.first + (index * span + divisions // 2) // divisions - for index in range(schedule.count) + interior = ( + (first + (index * span + divisions // 2) // divisions + 7) + // SLOT_GRID_MINUTES + * SLOT_GRID_MINUTES + for index in range(1, divisions) ) + return (first, *interior, last) @property - def enforcement_end_hour(self) -> int: - """Return the hour at which slot enforcement stops for the day. + def enforcement_end_minute(self) -> int: + """Return the minute at which slot enforcement stops for the day. - Clamped to the end of the day: a 23:00 last meal would otherwise put - the cutoff at 25, making ``hour < cutoff`` vacuously true so the - enforcement window never closes and the gate can never stop firing. + Derived from the *normalised* last meal, so it always agrees with the + last entry of :meth:`slots`. Clamped to the end of the day: a 23:00 + last meal would otherwise put the cutoff at 1500, making + ``minute < cutoff`` vacuously true so the enforcement window never + closes and the gate can never stop firing. """ - return min(self.last + ENFORCEMENT_TAIL_HOURS, _HOURS_PER_DAY) + last = self.normalized().last_minute + return min(last + ENFORCEMENT_TAIL_MINUTES, MINUTES_PER_DAY) # The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, with the # enforcement window closing at 22:00. Still what a device uses before the # user has ever chosen anything, so upgrading changes no behaviour. -DEFAULT_SCHEDULE = MealSchedule(first=8, last=20, count=4) +DEFAULT_SCHEDULE = MealSchedule(first_minute=8 * 60, last_minute=20 * 60, count=4) + + +def is_wire_int(value: object) -> TypeGuard[int]: + """Return True for a real int; ``bool`` is excluded, as Dart's ``is int`` is. + + Python's ``bool`` subclasses ``int``, so a bare ``isinstance`` would accept + ``true`` from a peer's JSON that the Dart side rejects -- two devices + disagreeing about which values are usable. + """ + return isinstance(value, int) and not isinstance(value, bool) + + +def schedule_to_wire(schedule: MealSchedule) -> dict[str, int]: + """Return the synced ``sched:`` value for ``schedule``. + + Backward compatible by construction: ``f``/``l`` carry whole hours exactly + as before the move to minutes, and ``fm``/``lm`` appear only when an + endpoint is off the hour. So a whole-hour schedule encodes byte-identically + to the old ``{"f", "l", "n"}`` form. The schedule is normalised first, so + every ``//`` runs on a non-negative value (see the module docstring). + + Returns: + ``{"f", "l", "n"}`` plus ``"fm"``/``"lm"`` for off-hour endpoints. + """ + norm = schedule.normalized() + first, last = norm.first_minute, norm.last_minute + wire = {"f": first // 60, "l": last // 60, "n": norm.count} + if first % 60: + wire["fm"] = first + if last % 60: + wire["lm"] = last + return wire + + +def schedule_from_wire(raw: object) -> MealSchedule | None: + """Return the normalised schedule a ``sched:`` value describes. + + ``fm``/``lm`` win when they are ints; otherwise ``f``/``l`` are hours. + Never raises: anything without int ``f``, ``l`` and ``n`` yields None, so + one bad field from a peer cannot take out the whole history. Normalising + on the way in means a peer running a future version with a wider range + cannot hand us a schedule we would derive differently. + + Returns: + The decoded, normalised schedule, or None when ``raw`` is unusable. + """ + if not isinstance(raw, dict): + return None + first, last, count = raw.get("f"), raw.get("l"), raw.get("n") + if not (is_wire_int(first) and is_wire_int(last) and is_wire_int(count)): + return None + first_minute, last_minute = raw.get("fm"), raw.get("lm") + return MealSchedule( + first_minute if is_wire_int(first_minute) else first * 60, + last_minute if is_wire_int(last_minute) else last * 60, + count, + ).normalized() diff --git a/diet_guard/_slot_wire.py b/diet_guard/_slot_wire.py new file mode 100644 index 0000000..839bcab --- /dev/null +++ b/diet_guard/_slot_wire.py @@ -0,0 +1,109 @@ +"""How a log entry records its meal slot on disk and on the sync wire. + +Slots are minutes of day (see :mod:`diet_guard._slots`), but every entry ever +written before that change carries an hour in ``"slot"``, and a peer running an +older build reads only that field. So the wire rule is additive: + +* **Writers** always write ``"slot" = minute // 60`` and add + ``"slot_min" = minute`` *only* when ``minute % 60 != 0``. A whole-hour slot + therefore encodes byte-identically to the pre-minute format -- no re-signing + storm, no diff on every existing entry -- and an older peer reading an + off-hour entry sees the hour it starts in. :func:`slot_fields` is that rule. +* **Readers** prefer an int ``"slot_min"``, fall back to ``"slot" * 60``, and + otherwise report no slot. :func:`entry_slot_minute` is that rule. + +``bool`` is rejected wherever an int is expected: Python's ``bool`` subclasses +``int`` but Dart's does not, and a value one device accepts while the other +ignores is the cross-device split brain the shared fixture exists to catch. + +KEEP IN SYNC WITH ``app/lib/models/slot.dart`` (``slotFields``, +``entrySlotMinute``); ``tests/fixtures/meal_schedule_vectors.json`` gates both. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int + +if TYPE_CHECKING: + from collections.abc import Mapping + +# ASCII digits only: ``\d`` would also accept Arabic-Indic and other Unicode +# digits, which ``int()`` happily parses. +_HHMM = re.compile(r"([0-9]{1,2})(?::([0-9]{2}))?") +_HOURS_PER_DAY = 24 +_MINUTES_PER_HOUR = 60 + + +def slot_fields(minute: int) -> dict[str, int]: + """Return the entry fields that record ``minute`` as its slot. + + Args: + minute: The slot's minute of day, ``0..1439``. + + Returns: + ``{"slot": hour}``, plus ``"slot_min"`` when ``minute`` is off the hour. + + Raises: + ValueError: ``minute`` is outside the day -- a writer bug, refused + rather than encoded, since ``//`` on a negative value would floor + here and truncate in Dart. + """ + if not 0 <= minute < MINUTES_PER_DAY: + msg = f"slot minute {minute} is outside 0..{MINUTES_PER_DAY - 1}" + raise ValueError(msg) + fields = {"slot": minute // 60} + if minute % 60: + fields["slot_min"] = minute + return fields + + +def entry_slot_minute(entry: Mapping[str, object]) -> int | None: + """Return the slot minute an entry records, or None when it has none. + + Args: + entry: One decoded log entry. + + Returns: + ``entry["slot_min"]`` if it is an int, else ``entry["slot"] * 60`` if + that is an int, else None. Values are not range-checked: an entry is + reported as written, and :func:`diet_guard._slots.nearest_slot` maps + anything onto a real slot. + """ + slot_min = entry.get("slot_min") + if is_wire_int(slot_min): + return slot_min + slot = entry.get("slot") + if is_wire_int(slot): + return slot * 60 + return None + + +def parse_hhmm(text: str) -> int: + """Parse a user-typed clock time into a minute of day. + + Accepts ``H``, ``HH``, ``H:MM`` and ``HH:MM`` with surrounding whitespace, + e.g. ``"7"`` -> 420, ``"07:15"`` -> 435. + + Args: + text: The time as typed. + + Returns: + The minute of day, ``0..1439``. + + Raises: + ValueError: ``text`` is not in one of those forms, or the hour is not + 0-23 or the minutes not 0-59. + """ + match = _HHMM.fullmatch(text.strip()) + if match is None: + msg = f"invalid time {text!r}, expected HH:MM (e.g. 07:15) or HH" + raise ValueError(msg) + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + if hour >= _HOURS_PER_DAY or minute >= _MINUTES_PER_HOUR: + msg = f"invalid time {text!r}: hour must be 0-23 and minutes 0-59" + raise ValueError(msg) + return hour * 60 + minute diff --git a/diet_guard/_slots.py b/diet_guard/_slots.py index 4a5a4b2..20bcd4e 100644 --- a/diet_guard/_slots.py +++ b/diet_guard/_slots.py @@ -7,10 +7,17 @@ exhaustively unit-testable without mocking the filesystem or the wall clock. The stateful "which slots have I actually logged?" question lives in :mod:`diet_guard._state`; the two are composed in :mod:`diet_guard._gate`. -A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12, -16, 20 by default). A slot is *elapsed* once its hour has arrived and we are -still inside the daily enforcement window; an elapsed slot with no logged meal -is what makes the gate fire. +A "slot" is the *minute of day* (``0..1439``) at which a meal checkpoint opens +(480, 720, 960, 1200 -- 08:00, 12:00, 16:00, 20:00 -- by default). A slot is +*elapsed* once its minute has arrived and we are still inside the daily +enforcement window; an elapsed slot with no logged meal is what makes the gate +fire. How a log entry records its slot on the wire, and how the old +hour-valued ``slot`` field is read back, lives in :mod:`diet_guard._slot_wire`. + +A logged meal satisfies the slot *nearest* its recorded slot minute +(:func:`satisfied_slots`), not only an exact match. That is how a meal logged +under an old schedule (or an old hour-valued entry) still counts after the +schedule moves by a few minutes -- nothing on disk is ever rewritten. ``schedule`` is a required argument on every function here, deliberately: it used to be read from module constants, and a default would let a call site that @@ -20,6 +27,9 @@ device offers and the other does not is a checkpoint that can never be satisfied. Making it required lets mypy enumerate the call sites instead. Callers resolve the value at the impure edge, mirroring how :mod:`diet_guard._daystatus` takes an explicit budget schedule. + +KEEP IN SYNC WITH ``app/lib/models/slot.dart``; the shared vectors in +``tests/fixtures/meal_schedule_vectors.json`` gate the two. """ from __future__ import annotations @@ -27,20 +37,27 @@ from __future__ import annotations from datetime import date, datetime, time from typing import TYPE_CHECKING +from diet_guard._meal_schedule import MINUTES_PER_DAY + if TYPE_CHECKING: + from collections.abc import Iterable + from diet_guard._meal_schedule import MealSchedule -_HOURS_PER_DAY = 24 + +def minute_of_day(now: datetime) -> int: + """Return ``now``'s minute of day, ``0..1439`` (seconds are ignored).""" + return now.hour * 60 + now.minute def day_slots(schedule: MealSchedule) -> tuple[int, ...]: - """Return the meal-slot hours for a day, e.g. ``(8, 12, 16, 20)``. + """Return the meal-slot minutes for a day, e.g. ``(480, 720, 960, 1200)``. Args: schedule: The eating window and meal count to derive slots from. Returns: - The slot hours in ascending order. + The slot minutes in ascending order. """ return schedule.slots() @@ -58,11 +75,12 @@ def within_enforcement_window(now: datetime, schedule: MealSchedule) -> bool: Returns: True if slot enforcement is active at ``now``. """ - return schedule.slots()[0] <= now.hour < schedule.enforcement_end_hour + minute = minute_of_day(now) + return schedule.slots()[0] <= minute < schedule.enforcement_end_minute def elapsed_slots(now: datetime, schedule: MealSchedule) -> tuple[int, ...]: - """Return today's slots whose hour has arrived as of ``now``. + """Return today's slots whose minute has arrived as of ``now``. Empty outside the enforcement window (before the first slot, or after the overnight cutoff), so the caller never has to special-case the night. @@ -72,11 +90,12 @@ def elapsed_slots(now: datetime, schedule: MealSchedule) -> tuple[int, ...]: schedule: The schedule in force for that day. Returns: - The elapsed slot hours, ascending (possibly empty). + The elapsed slot minutes, ascending (possibly empty). """ if not within_enforcement_window(now, schedule): return () - return tuple(slot for slot in day_slots(schedule) if slot <= now.hour) + minute = minute_of_day(now) + return tuple(slot for slot in day_slots(schedule) if slot <= minute) def missing_slots( @@ -86,11 +105,12 @@ def missing_slots( Args: now: Reference local time. - logged: The set of slot hours already covered by today's log. + logged: The slot minutes already covered by today's log -- normally + :func:`satisfied_slots` of the entries' slot minutes. schedule: The schedule in force for that day. Returns: - The unsatisfied elapsed slot hours, ascending (empty == nothing due). + The unsatisfied elapsed slot minutes, ascending (empty == nothing due). """ return tuple(slot for slot in elapsed_slots(now, schedule) if slot not in logged) @@ -107,7 +127,7 @@ def current_slot(now: datetime, schedule: MealSchedule) -> int | None: schedule: The schedule in force for that day. Returns: - The latest elapsed slot hour, or None when none have elapsed yet. + The latest elapsed slot minute, or None when none have elapsed yet. """ elapsed = elapsed_slots(now, schedule) return elapsed[-1] if elapsed else None @@ -120,8 +140,8 @@ def slot_for_log(now: datetime, schedule: MealSchedule) -> int: the first slot, clamp to the first slot; after the enforcement window ends, clamp to the last slot; behaviour inside a window is unchanged. Both languages must reach each answer by the *same* branch, not merely agree on - the value -- ``test_slots.py`` sweeps every hour of the day against several - schedules for exactly that reason. + the value -- the shared fixture pins every minute edge for exactly that + reason. Unlike :func:`current_slot` this never returns None, which is the point: an off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30 @@ -134,39 +154,61 @@ def slot_for_log(now: datetime, schedule: MealSchedule) -> int: schedule: The schedule in force for that day. Returns: - The slot hour to tag the log with. + The slot minute to tag the log with. """ slots = day_slots(schedule) - if now.hour < slots[0]: + if minute_of_day(now) < slots[0]: return slots[0] current = current_slot(now, schedule) return current if current is not None else slots[-1] +def nearest_slot(minute: int, schedule: MealSchedule) -> int: + """Return the slot closest to ``minute``; an exact tie goes to the earlier. + + The tie rule is part of the cross-language contract: ``min`` keeps the + first minimal element of the ascending slots, and the Dart loop replaces + its best only on a strictly smaller distance. + """ + return min(day_slots(schedule), key=lambda slot: abs(slot - minute)) + + +def satisfied_slots(entry_minutes: Iterable[int], schedule: MealSchedule) -> set[int]: + """Return the slots covered by meals recorded at ``entry_minutes``. + + Each recorded slot minute satisfies its :func:`nearest_slot`. Many-to-one + is intended: two meals snapping onto one slot satisfy only that slot, so a + double breakfast does not also clear lunch. This is what lets an entry + written under an older schedule count toward today's nearest checkpoint + without rewriting it on disk. + """ + return {nearest_slot(minute, schedule) for minute in entry_minutes} + + def resolve_future_when( - date_str: str, hour: int, schedule: MealSchedule, now: datetime + date_str: str, slot_minute: int, schedule: MealSchedule, now: datetime ) -> datetime: """Build a tz-aware datetime for a meal pre-logged against a future slot. - Lets a caller (the CLI's ``--date``/``--hour`` flags, or the app's future- - date picker) turn a user-picked date+hour into the ``when`` that + Lets a caller (the CLI's ``--date`` flag, or the app's future-date picker) + turn a user-picked date+slot into the ``when`` that :func:`diet_guard._state.log_meal` expects, with the same validation on both platforms. Still clock-free per the module's own rule: ``now`` is supplied by the caller rather than read here. Args: date_str: The chosen date as ``YYYY-MM-DD``. - hour: The chosen slot hour; must be one of ``schedule``'s slot hours. - schedule: The schedule in force, used to validate ``hour``. + slot_minute: The chosen slot; must be one of ``schedule``'s slots. + schedule: The schedule in force, used to validate ``slot_minute``. now: Reference local time, used to reject a non-future date. Returns: - A tz-aware datetime combining ``date_str`` and ``hour``, in ``now``'s - timezone. + A tz-aware datetime combining ``date_str`` and ``slot_minute``, in + ``now``'s timezone. Raises: - ValueError: ``date_str`` doesn't parse, ``hour`` isn't a slot hour, or - the resulting date isn't strictly after ``now``'s date. + ValueError: ``date_str`` doesn't parse, ``slot_minute`` isn't a slot, + or the resulting date isn't strictly after ``now``'s date. """ try: parsed_date = date.fromisoformat(date_str) @@ -174,15 +216,18 @@ def resolve_future_when( msg = f"invalid date {date_str!r}, expected YYYY-MM-DD" raise ValueError(msg) from exc slots = day_slots(schedule) - if hour not in slots: - msg = f"hour {hour} is not a meal slot; choose one of {slots}" + if slot_minute not in slots: + labels = ", ".join(slot_label(slot) for slot in slots) + msg = f"{slot_label(slot_minute)} is not a meal slot; choose one of {labels}" raise ValueError(msg) if parsed_date <= now.date(): msg = f"{date_str} is not a future date" raise ValueError(msg) - return datetime.combine(parsed_date, time(hour=hour), tzinfo=now.tzinfo) + clock = time(hour=slot_minute // 60, minute=slot_minute % 60) + return datetime.combine(parsed_date, clock, tzinfo=now.tzinfo) -def slot_label(slot: int) -> str: - """Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``.""" - return f"{slot % _HOURS_PER_DAY:02d}:00" +def slot_label(minute: int) -> str: + """Return a human ``HH:MM`` label for a slot minute, e.g. ``"07:15"``.""" + wrapped = minute % MINUTES_PER_DAY + return f"{wrapped // 60:02d}:{wrapped % 60:02d}" diff --git a/diet_guard/tests/test_kuchnia_parity.py b/diet_guard/tests/test_kuchnia_parity.py index 1d27d4d..2af4e5b 100644 --- a/diet_guard/tests/test_kuchnia_parity.py +++ b/diet_guard/tests/test_kuchnia_parity.py @@ -57,7 +57,7 @@ def _dishes(fixture: dict[str, Any]) -> list[Dish]: def _slots_for(key: str) -> tuple[int, ...]: - """Turn a fixture slot key such as ``"8,12,16,20"`` into slot hours.""" + """Turn a fixture slot key such as ``"480,720,960,1200"`` into slot minutes.""" return tuple(int(part) for part in key.split(",")) @@ -107,8 +107,8 @@ def test_slot_assignment_matches(fixture: dict[str, Any], dishes: list[Dish]) -> """``i * S // N`` lands each dish on the slot the Dart side also picks.""" for key, expected in fixture["expected"]["slots"].items(): subject = dishes[:3] if key.startswith("first_three_") else dishes - hours = _slots_for(key.removeprefix("first_three_")) - actual = [item.slot for item in assign_slots(subject, hours)] + minutes = _slots_for(key.removeprefix("first_three_")) + actual = [item.slot for item in assign_slots(subject, minutes)] assert actual == expected, f"slot assignment diverged for {key}" @@ -119,7 +119,7 @@ def test_slot_order_matches(fixture: dict[str, Any], dishes: list[Dish]) -> None dishes sharing both priority and name are the case that forces both comparators to be total. """ - ordered = assign_slots(dishes, (8, 12, 16, 20)) + ordered = assign_slots(dishes, (480, 720, 960, 1200)) assert [item.dish.name for item in ordered] == fixture["expected"]["slot_order"] @@ -201,5 +201,5 @@ def test_twin_case_is_not_vacuous(fixture: dict[str, Any], dishes: list[Dish]) - fill = fixture["expected"]["fill"] case = fill["log_dishes"]["twins_share_16"] spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)] - assert spread.count(("Twin dish", 16)) == 2 - assert case["expected"].count(["Twin dish", 16]) == 1 + assert spread.count(("Twin dish", 960)) == 2 + assert case["expected"].count(["Twin dish", 960]) == 1 diff --git a/diet_guard/tests/test_meal_schedule.py b/diet_guard/tests/test_meal_schedule.py index d2a806e..d833bc2 100644 --- a/diet_guard/tests/test_meal_schedule.py +++ b/diet_guard/tests/test_meal_schedule.py @@ -1,65 +1,73 @@ """Tests for the pure meal-schedule derivation. -The vector table below is duplicated verbatim in -``app/test/models/meal_schedule_test.dart``. KEEP THE TWO IN SYNC: it is the -only thing that catches a Python/Dart divergence before it reaches a device, -and a divergence there means one device nags for a checkpoint the other never -offers -- a slot that can never be satisfied, i.e. a permanent lock. +The cross-language exact values live in the shared fixture +``tests/fixtures/meal_schedule_vectors.json`` and are asserted by +``test_meal_schedule_vectors.py`` (and by its Dart mirror). This file holds +the properties: the grid sweep, the hour-formula regression and the clamps a +human should be able to read without opening the fixture. The sweep is +duplicated loop-for-loop in ``app/test/models/meal_schedule_test.dart``. """ from __future__ import annotations +from itertools import pairwise + import pytest from diet_guard._meal_schedule import ( DEFAULT_SCHEDULE, MAX_MEAL_COUNT, MIN_MEAL_COUNT, + MINUTES_PER_DAY, + SLOT_GRID_MINUTES, MealSchedule, + schedule_from_wire, + schedule_to_wire, ) -# (first, last, count) -> expected slot hours. Shared with the Dart mirror. -SCHEDULE_VECTORS: list[tuple[int, int, int, tuple[int, ...]]] = [ - # The eating window the user described, at every supported meal count. - (8, 20, 2, (8, 20)), - (8, 20, 3, (8, 14, 20)), - (8, 20, 4, (8, 12, 16, 20)), # today's hardcoded schedule - (8, 20, 5, (8, 11, 14, 17, 20)), # the user's stated example - (8, 20, 6, (8, 10, 13, 15, 18, 20)), - # A window that does not divide evenly: 14 hours across 3 gaps. - (7, 21, 4, (7, 12, 16, 21)), - (7, 21, 5, (7, 11, 14, 18, 21)), - (9, 19, 4, (9, 12, 16, 19)), - # Narrow windows: count is capped at the number of whole hours available, - # so the slots stay distinct instead of repeating an hour. - (8, 12, 6, (8, 9, 10, 11, 12)), - (8, 10, 5, (8, 9, 10)), - (8, 9, 4, (8, 9)), - # Whole-day extremes. - (0, 23, 6, (0, 5, 9, 14, 18, 23)), - (0, 1, 2, (0, 1)), -] +_H = 60 + + +def _old_hour_slots(first: int, last: int, count: int) -> tuple[int, ...]: + """Return the pre-minute whole-hour derivation, converted to minutes.""" + span, divisions = last - first, count - 1 + return tuple( + (first + (index * span + divisions // 2) // divisions) * _H + for index in range(count) + ) class TestSlots: """Slot derivation from a schedule.""" - @pytest.mark.parametrize(("first", "last", "count", "expected"), SCHEDULE_VECTORS) - def test_matches_shared_vectors( - self, first: int, last: int, count: int, expected: tuple[int, ...] - ) -> None: - """Each shared vector derives its documented slot hours.""" - assert MealSchedule(first, last, count).slots() == expected - def test_default_is_the_historical_schedule(self) -> None: """The default reproduces the hours that used to be hardcoded.""" - assert DEFAULT_SCHEDULE.slots() == (8, 12, 16, 20) + assert DEFAULT_SCHEDULE.slots() == (480, 720, 960, 1200) - def test_endpoints_are_exact_when_spacing_rounds(self) -> None: - """Rounding the interior never drags an endpoint off the window.""" - slots = MealSchedule(8, 20, 6).slots() - assert slots[0] == 8 - assert slots[-1] == 20 + @pytest.mark.parametrize( + ("first", "last", "count"), [(8, 20, 4), (8, 20, 5), (7, 19, 5)] + ) + def test_whole_hour_schedules_match_the_old_hour_formula( + self, first: int, last: int, count: int + ) -> None: + """Evenly dividing hour schedules derive exactly what they used to. + + Upgrading must not move a checkpoint for the schedules people actually + have, or every existing hour-tagged entry would satisfy a neighbour. + """ + schedule = MealSchedule(first * _H, last * _H, count) + assert schedule.slots() == _old_hour_slots(first, last, count) + + def test_off_grid_endpoints_are_exact(self) -> None: + """07:23-19:41 keeps both endpoints; only the interior snaps.""" + slots = MealSchedule(443, 1181, 5).slots() + assert slots[0] == 443 + assert slots[-1] == 1181 + assert all(slot % SLOT_GRID_MINUTES == 0 for slot in slots[1:-1]) + + def test_the_users_quarter_hour_schedule(self) -> None: + """07:15-19:00 with five meals, as the user asked for it.""" + assert MealSchedule(435, 1140, 5).slots() == (435, 615, 795, 960, 1140) class TestNormalization: @@ -68,15 +76,17 @@ class TestNormalization: @pytest.mark.parametrize( ("schedule", "expected"), [ - (MealSchedule(8, 20, 99), MealSchedule(8, 20, MAX_MEAL_COUNT)), - (MealSchedule(8, 20, 0), MealSchedule(8, 20, MIN_MEAL_COUNT)), - (MealSchedule(-5, 20, 4), MealSchedule(0, 20, 4)), - (MealSchedule(8, 99, 4), MealSchedule(8, 23, 4)), - # last <= first is pulled forward to leave a one-hour window. - (MealSchedule(12, 12, 4), MealSchedule(12, 13, 2)), - (MealSchedule(12, 3, 4), MealSchedule(12, 13, 2)), - # first cannot occupy the final hour, or no window would remain. - (MealSchedule(23, 23, 2), MealSchedule(22, 23, 2)), + (MealSchedule(480, 1200, 99), MealSchedule(480, 1200, MAX_MEAL_COUNT)), + (MealSchedule(480, 1200, 0), MealSchedule(480, 1200, MIN_MEAL_COUNT)), + (MealSchedule(-5, 1200, 4), MealSchedule(0, 1200, 4)), + (MealSchedule(480, 9999, 4), MealSchedule(480, 1439, 4)), + # last <= first is pulled forward to leave one grid step of window. + (MealSchedule(720, 720, 4), MealSchedule(720, 735, 2)), + (MealSchedule(720, 3, 4), MealSchedule(720, 735, 2)), + # first cannot sit in the final grid step, or no window remains. + (MealSchedule(1439, 1439, 2), MealSchedule(1424, 1439, 2)), + # Count is capped by the grid: 60 minutes hold 5 quarter-hour marks. + (MealSchedule(480, 540, 6), MealSchedule(480, 540, 5)), ], ) def test_clamps_to_the_nearest_legal_schedule( @@ -87,48 +97,79 @@ class TestNormalization: def test_garbage_still_yields_usable_slots(self) -> None: """Wildly invalid input degrades to a schedule, not an exception.""" - assert MealSchedule(99, -5, 999).slots() == (22, 23) + assert MealSchedule(9999, -5, 999).slots() == (1424, 1439) -class TestEnforcementEndHour: +class TestEnforcementEndMinute: """The daily cutoff derived from the last meal.""" def test_default_keeps_the_historical_cutoff(self) -> None: """The default schedule still stops enforcing at 22:00.""" - assert DEFAULT_SCHEDULE.enforcement_end_hour == 22 + assert DEFAULT_SCHEDULE.enforcement_end_minute == 22 * _H def test_tail_follows_the_last_meal(self) -> None: - """Moving the last meal moves the cutoff with it.""" - assert MealSchedule(8, 18, 4).enforcement_end_hour == 20 + """Moving the last meal moves the cutoff with it, to the minute.""" + assert MealSchedule(480, 1095, 4).enforcement_end_minute == 1215 def test_clamped_to_the_end_of_the_day(self) -> None: """A late last meal cannot push the cutoff past midnight. - An unclamped 23 + 2 = 25 would make ``hour < cutoff`` vacuously true, - so the enforcement window would never close. + An unclamped 1380 + 120 = 1500 would make ``minute < cutoff`` + vacuously true, so the enforcement window would never close. """ - assert MealSchedule(8, 23, 4).enforcement_end_hour == 24 + assert MealSchedule(480, 1380, 4).enforcement_end_minute == MINUTES_PER_DAY + + def test_follows_the_normalised_last_meal(self) -> None: + """An inverted window's cutoff agrees with its normalised last slot.""" + schedule = MealSchedule(720, 3, 4) + assert schedule.enforcement_end_minute == schedule.slots()[-1] + 120 + + +class TestWire: + """The ``sched:`` wire encoding stays readable by old devices.""" + + def test_whole_hour_schedule_encodes_exactly_as_before(self) -> None: + """No ``fm``/``lm`` keys at all, so the bytes do not change.""" + assert schedule_to_wire(DEFAULT_SCHEDULE) == {"f": 8, "l": 20, "n": 4} + + def test_off_hour_endpoints_add_minute_fields(self) -> None: + """An off-hour endpoint carries its minute alongside the floor hour.""" + wire = schedule_to_wire(MealSchedule(435, 1181, 5)) + assert wire == {"f": 7, "l": 19, "n": 5, "fm": 435, "lm": 1181} + + def test_unusable_values_decode_to_none(self) -> None: + """A bool ``f`` is not an int here, exactly as in Dart.""" + assert schedule_from_wire({"f": True, "l": 20, "n": 4}) is None + assert schedule_from_wire("not a dict") is None class TestExhaustiveInvariants: """Properties that must hold for every input the UI or sync can produce.""" - def test_every_input_yields_ascending_slots_with_exact_endpoints(self) -> None: - """Sweep the whole input space, including out-of-range values. + def test_grid_sweep_yields_ascending_slots_with_exact_endpoints(self) -> None: + """Sweep every quarter-hour first/last pair at every count. - This is the cheap half of the cross-language parity guarantee; the - Dart mirror runs the identical sweep. + The Dart mirror runs the identical loop. A wire round trip rides + along, because it is the same sweep's worth of schedules. """ - for first in range(-2, 26): - for last in range(-2, 26): - for count in range(-2, 10): + for first in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES): + for last in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES): + for count in range(MIN_MEAL_COUNT, MAX_MEAL_COUNT + 1): schedule = MealSchedule(first, last, count) normalized = schedule.normalized() slots = schedule.slots() - - assert slots[0] == normalized.first - assert slots[-1] == normalized.last + assert slots[0] == normalized.first_minute + assert slots[-1] == normalized.last_minute assert len(slots) == normalized.count - assert len(set(slots)) == len(slots) - assert list(slots) == sorted(slots) - assert MIN_MEAL_COUNT <= len(slots) <= MAX_MEAL_COUNT + assert all(a < b for a, b in pairwise(slots)) + assert slots[0] >= 0 + assert slots[-1] < MINUTES_PER_DAY + assert schedule_from_wire(schedule_to_wire(schedule)) == normalized + + def test_off_grid_sweep_stays_strictly_ascending(self) -> None: + """Odd-minute windows near the capacity cap never repeat a slot.""" + for first in range(0, 120, 7): + for width in range(15, 200, 11): + for count in range(MIN_MEAL_COUNT, MAX_MEAL_COUNT + 1): + slots = MealSchedule(first, first + width, count).slots() + assert all(a < b for a, b in pairwise(slots)) diff --git a/diet_guard/tests/test_meal_schedule_vectors.py b/diet_guard/tests/test_meal_schedule_vectors.py new file mode 100644 index 0000000..974c663 --- /dev/null +++ b/diet_guard/tests/test_meal_schedule_vectors.py @@ -0,0 +1,143 @@ +"""The Python half of the cross-language meal-slot parity gate. + +``app/test/models/meal_schedule_vectors_test.dart`` asserts the *same* +expectations against the *same* ``tests/fixtures/meal_schedule_vectors.json``. +Two independently written suites from the same prose is not a gate -- one +shared input with one shared expected result is: a device that derives a slot +its peer never offers has a checkpoint that can never be satisfied, i.e. a +permanent lock. Regenerate with ``scripts/build_meal_schedule_fixture.py``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import json +from pathlib import Path +from typing import Any + +import pytest + +from diet_guard._meal_schedule import ( + MealSchedule, + schedule_from_wire, + schedule_to_wire, +) +from diet_guard._slot_wire import entry_slot_minute, slot_fields +from diet_guard._slots import ( + current_slot, + elapsed_slots, + missing_slots, + nearest_slot, + satisfied_slots, + slot_for_log, + slot_label, + within_enforcement_window, +) + +FIXTURE = ( + Path(__file__).resolve().parents[2] / "tests/fixtures/meal_schedule_vectors.json" +) + + +@pytest.fixture(name="vectors", scope="module") +def _vectors() -> dict[str, Any]: + """The shared fixture. ``Any``: decoded JSON whose shape is the contract.""" + with FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def _schedule(row: list[int]) -> MealSchedule: + """Return the schedule a ``[first, last, count]`` fixture row encodes.""" + return MealSchedule(*row) + + +def _at(minute: int) -> datetime: + """Return a fixed datetime at ``minute`` of day (the date is irrelevant).""" + return datetime(2026, 1, 1, minute // 60, minute % 60, tzinfo=UTC) + + +def test_fixture_is_present() -> None: + """A relocation fails here with a clear reason, not as a KeyError later.""" + assert FIXTURE.is_file(), f"shared parity fixture missing at {FIXTURE}" + + +def test_schedules(vectors: dict[str, Any]) -> None: + """Normalisation, slots, cutoff and wire encoding, row by row.""" + assert vectors["schedules"] + for row in vectors["schedules"]: + schedule = _schedule(row["input"]) + normalized = schedule.normalized() + assert [ + normalized.first_minute, + normalized.last_minute, + normalized.count, + ] == row["normalized"], row["input"] + assert list(schedule.slots()) == row["slots"], row["input"] + assert schedule.enforcement_end_minute == row["enforcement_end_minute"] + assert schedule_to_wire(schedule) == row["wire"], row["input"] + assert schedule_from_wire(row["wire"]) == normalized, row["input"] + + +def test_time_edges(vectors: dict[str, Any]) -> None: + """Every clock-reading function agrees at each minute edge.""" + assert vectors["time_edges"] + for row in vectors["time_edges"]: + schedule, now = _schedule(row["schedule"]), _at(row["minute"]) + where = (row["schedule"], row["minute"]) + assert within_enforcement_window(now, schedule) == row["within_window"], where + assert list(elapsed_slots(now, schedule)) == row["elapsed"], where + assert current_slot(now, schedule) == row["current_slot"], where + assert slot_for_log(now, schedule) == row["slot_for_log"], where + + +def test_missing(vectors: dict[str, Any]) -> None: + """Missing slots given a set of satisfied slot minutes.""" + for row in vectors["missing"]: + schedule, now = _schedule(row["schedule"]), _at(row["minute"]) + actual = missing_slots(now, set(row["logged"]), schedule) + assert list(actual) == row["missing"], row + + +def test_nearest(vectors: dict[str, Any]) -> None: + """Nearest slot, including exact-halfway ties and out-of-day minutes.""" + for row in vectors["nearest"]: + actual = nearest_slot(row["minute"], _schedule(row["schedule"])) + assert actual == row["nearest"], row + + +def test_satisfied(vectors: dict[str, Any]) -> None: + """Satisfied slots, many-to-one included.""" + for row in vectors["satisfied"]: + actual = satisfied_slots(row["entry_minutes"], _schedule(row["schedule"])) + assert sorted(actual) == row["satisfied"], row["name"] + + +def test_labels(vectors: dict[str, Any]) -> None: + """``HH:MM`` labels, wrapped into the day.""" + for row in vectors["labels"]: + assert slot_label(row["minute"]) == row["label"], row + + +def test_schedule_wire_decode(vectors: dict[str, Any]) -> None: + """Malformed and legacy ``sched:`` values decode identically.""" + for row in vectors["schedule_wire_decode"]: + triple = row["schedule"] + expected = None if triple is None else _schedule(triple) + assert schedule_from_wire(row["raw"]) == expected, row + + +def test_slot_fields(vectors: dict[str, Any]) -> None: + """The entry writer's fields, or a refusal for a minute outside the day.""" + for row in vectors["slot_fields"]: + if row.get("error"): + with pytest.raises(ValueError, match="outside"): + slot_fields(row["minute"]) + else: + assert slot_fields(row["minute"]) == row["fields"], row + + +def test_entry_slot_minute(vectors: dict[str, Any]) -> None: + """The entry reader: ``slot_min`` wins, legacy hours, bools rejected.""" + for row in vectors["entry_slot_minute"]: + assert entry_slot_minute(row["entry"]) == row["minute"], row diff --git a/diet_guard/tests/test_slot_wire.py b/diet_guard/tests/test_slot_wire.py new file mode 100644 index 0000000..2bed31b --- /dev/null +++ b/diet_guard/tests/test_slot_wire.py @@ -0,0 +1,89 @@ +"""Tests for _slot_wire.py — a log entry's slot fields and clock parsing. + +The cross-language cases live in the shared fixture +(``test_meal_schedule_vectors.py``); these pin the rule's intent readably, +plus ``parse_hhmm``, which is Python-only. +""" + +from __future__ import annotations + +import pytest + +from diet_guard._slot_wire import entry_slot_minute, parse_hhmm, slot_fields + + +class TestSlotFields: + """The writer half of the entry wire rule.""" + + def test_whole_hour_is_byte_identical_to_the_old_format(self) -> None: + """No ``slot_min`` key at all for 08:00.""" + assert slot_fields(480) == {"slot": 8} + + def test_off_hour_adds_the_minute(self) -> None: + """07:15 keeps ``slot`` 7 for old readers and adds ``slot_min``.""" + assert slot_fields(435) == {"slot": 7, "slot_min": 435} + + @pytest.mark.parametrize("minute", [-1, 1440]) + def test_refuses_minutes_outside_the_day(self, minute: int) -> None: + """A writer bug is refused rather than encoded.""" + with pytest.raises(ValueError, match=r"outside 0\.\.1439"): + slot_fields(minute) + + +class TestEntrySlotMinute: + """The reader half of the entry wire rule.""" + + def test_legacy_hour_entry(self) -> None: + """A pre-minute entry's ``slot`` is an hour.""" + assert entry_slot_minute({"slot": 8}) == 480 + + def test_slot_min_wins(self) -> None: + """``slot_min`` is authoritative, even when it is zero.""" + assert entry_slot_minute({"slot": 7, "slot_min": 435}) == 435 + assert entry_slot_minute({"slot": 5, "slot_min": 0}) == 0 + + def test_bools_are_not_ints(self) -> None: + """``True`` is rejected in both fields, as Dart's ``is int`` rejects it.""" + assert entry_slot_minute({"slot": 8, "slot_min": True}) == 480 + assert entry_slot_minute({"slot": True}) is None + + def test_no_slot(self) -> None: + """An entry without an int slot reports none.""" + assert entry_slot_minute({}) is None + assert entry_slot_minute({"slot": "8"}) is None + + +class TestParseHhmm: + """User-typed clock times.""" + + @pytest.mark.parametrize( + ("text", "minute"), + [ + ("7", 420), + ("07", 420), + ("7:15", 435), + ("07:15", 435), + (" 19:41 ", 1181), + ("0", 0), + ("23:59", 1439), + ], + ) + def test_accepted_forms(self, text: str, minute: int) -> None: + """H, HH, H:MM and HH:MM, whitespace stripped.""" + assert parse_hhmm(text) == minute + + @pytest.mark.parametrize( + "text", + # chr(0x667) is ARABIC-INDIC DIGIT SEVEN, which ``\d`` and int() accept. + ["", "7:5", "7:", ":15", "123", "07:15:00", "7.15", "x", chr(0x667)], + ) + def test_rejects_malformed_text(self, text: str) -> None: + """Anything else is a clear error, including non-ASCII digits.""" + with pytest.raises(ValueError, match="expected HH:MM"): + parse_hhmm(text) + + @pytest.mark.parametrize("text", ["24", "24:00", "7:60", "99:99"]) + def test_rejects_out_of_range_values(self, text: str) -> None: + """Hours must be 0-23 and minutes 0-59.""" + with pytest.raises(ValueError, match="hour must be 0-23"): + parse_hhmm(text) diff --git a/diet_guard/tests/test_slots.py b/diet_guard/tests/test_slots.py index 6fda04c..9c8fd71 100644 --- a/diet_guard/tests/test_slots.py +++ b/diet_guard/tests/test_slots.py @@ -1,9 +1,12 @@ -"""Tests for _slots.py — pure meal-slot arithmetic. +"""Tests for _slots.py — pure meal-slot arithmetic, in minutes of day. Every function is a total function of ``now`` and an explicit schedule, so the time-of-day edges are exercised directly with fixed ``datetime`` values. The assertions below use ``DEFAULT_SCHEDULE`` (08/12/16/20, cutoff 22:00) so they still pin the behaviour these functions had when those hours were hardcoded. +The exhaustive cross-language edges live in the shared fixture +(``test_meal_schedule_vectors.py``); nearest/satisfied in +``test_slots_nearest.py``. """ from __future__ import annotations @@ -15,6 +18,7 @@ from diet_guard._slots import ( current_slot, day_slots, elapsed_slots, + minute_of_day, missing_slots, slot_for_log, slot_label, @@ -22,45 +26,64 @@ from diet_guard._slots import ( ) -def _at(hour: int) -> datetime: - """Return a fixed local datetime at ``hour`` (date is irrelevant here).""" - return datetime(2026, 1, 1, hour, 0, tzinfo=UTC) +def _at(hour: int, minute: int = 0) -> datetime: + """Return a fixed local datetime at ``hour:minute`` (date is irrelevant).""" + return datetime(2026, 1, 1, hour, minute, tzinfo=UTC) + + +class TestMinuteOfDay: + """The clock reading every other function compares against.""" + + def test_counts_minutes_and_ignores_seconds(self) -> None: + """07:15:59 is minute 435.""" + assert minute_of_day(datetime(2026, 1, 1, 7, 15, 59, tzinfo=UTC)) == 435 + + def test_day_ends(self) -> None: + """Midnight is 0 and 23:59 is 1439.""" + assert minute_of_day(_at(0)) == 0 + assert minute_of_day(_at(23, 59)) == 1439 class TestDaySlots: - """The fixed slot schedule derived from the constants.""" + """The slot schedule derived from a schedule.""" def test_default_schedule(self) -> None: """Slots open every 4h from 08:00 up to (not past) the 22:00 cutoff.""" - assert day_slots(DEFAULT_SCHEDULE) == (8, 12, 16, 20) + assert day_slots(DEFAULT_SCHEDULE) == (480, 720, 960, 1200) class TestEnforcementWindow: - """The [day_start, eating_end) active window.""" + """The [first slot, enforcement end) active window, to the minute.""" def test_before_window(self) -> None: - """An hour before the first slot is outside the window.""" - assert not within_enforcement_window(_at(7), DEFAULT_SCHEDULE) + """07:59 is outside the window.""" + assert not within_enforcement_window(_at(7, 59), DEFAULT_SCHEDULE) def test_first_slot_is_inside(self) -> None: - """The day-start hour is inside (inclusive lower bound).""" + """08:00 is inside (inclusive lower bound).""" assert within_enforcement_window(_at(8), DEFAULT_SCHEDULE) - def test_last_active_hour_inside(self) -> None: - """21:00 is still inside; the cutoff is exclusive at 22:00.""" - assert within_enforcement_window(_at(21), DEFAULT_SCHEDULE) + def test_last_active_minute_inside(self) -> None: + """21:59 is still inside; the cutoff is exclusive at 22:00.""" + assert within_enforcement_window(_at(21, 59), DEFAULT_SCHEDULE) def test_cutoff_is_outside(self) -> None: - """The cutoff hour itself is outside (exclusive upper bound).""" + """22:00 itself is outside (exclusive upper bound).""" assert not within_enforcement_window(_at(22), DEFAULT_SCHEDULE) + def test_quarter_hour_first_slot(self) -> None: + """A 07:15 breakfast opens the window at 07:15, not 07:00.""" + schedule = MealSchedule(435, 1140, 5) + assert not within_enforcement_window(_at(7, 14), schedule) + assert within_enforcement_window(_at(7, 15), schedule) + class TestElapsedSlots: """Which slots have arrived as of now.""" def test_empty_before_window(self) -> None: """Before the first slot, nothing has elapsed.""" - assert elapsed_slots(_at(7), DEFAULT_SCHEDULE) == () + assert elapsed_slots(_at(7, 59), DEFAULT_SCHEDULE) == () def test_empty_after_cutoff(self) -> None: """After the overnight cutoff, slots lapse to empty.""" @@ -68,15 +91,21 @@ class TestElapsedSlots: def test_first_slot_only(self) -> None: """At 08:00 exactly, only the 08:00 slot has elapsed.""" - assert elapsed_slots(_at(8), DEFAULT_SCHEDULE) == (8,) + assert elapsed_slots(_at(8), DEFAULT_SCHEDULE) == (480,) def test_midday(self) -> None: """At 13:00, the 08:00 and 12:00 slots have elapsed.""" - assert elapsed_slots(_at(13), DEFAULT_SCHEDULE) == (8, 12) + assert elapsed_slots(_at(13), DEFAULT_SCHEDULE) == (480, 720) def test_all_elapsed_late(self) -> None: """At 21:00, every slot for the day has elapsed.""" - assert elapsed_slots(_at(21), DEFAULT_SCHEDULE) == (8, 12, 16, 20) + assert elapsed_slots(_at(21), DEFAULT_SCHEDULE) == (480, 720, 960, 1200) + + def test_a_slot_opens_on_its_minute(self) -> None: + """10:14 has not reached a 10:15 slot; 10:15 has.""" + schedule = MealSchedule(435, 1140, 5) + assert elapsed_slots(_at(10, 14), schedule) == (435,) + assert elapsed_slots(_at(10, 15), schedule) == (435, 615) class TestMissingSlots: @@ -84,11 +113,11 @@ class TestMissingSlots: def test_none_missing_when_all_logged(self) -> None: """All elapsed slots logged -> nothing due.""" - assert missing_slots(_at(13), {8, 12}, DEFAULT_SCHEDULE) == () + assert missing_slots(_at(13), {480, 720}, DEFAULT_SCHEDULE) == () def test_reports_unlogged(self) -> None: """Only the unlogged elapsed slots are returned, ascending.""" - assert missing_slots(_at(17), {8}, DEFAULT_SCHEDULE) == (12, 16) + assert missing_slots(_at(17), {480}, DEFAULT_SCHEDULE) == (720, 960) class TestCurrentSlot: @@ -100,7 +129,7 @@ class TestCurrentSlot: def test_latest_elapsed(self) -> None: """At 13:00 the current slot is 12:00 (the latest elapsed).""" - assert current_slot(_at(13), DEFAULT_SCHEDULE) == 12 + assert current_slot(_at(13), DEFAULT_SCHEDULE) == 720 class TestSlotForLog: @@ -113,108 +142,78 @@ class TestSlotForLog: def test_before_the_first_window_clamps_to_the_first_slot(self) -> None: """An early breakfast counts toward 08:00 rather than nothing.""" - assert slot_for_log(_at(7), DEFAULT_SCHEDULE) == 8 + assert slot_for_log(_at(7, 59), DEFAULT_SCHEDULE) == 480 def test_midnight_clamps_to_the_first_slot(self) -> None: """The small hours are still "before the first window".""" - assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 8 - - def test_first_slot_boundary_is_unchanged(self) -> None: - """08:00 exactly is inside the window, so no clamp applies.""" - assert slot_for_log(_at(8), DEFAULT_SCHEDULE) == 8 + assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 480 def test_inside_the_window_matches_current_slot(self) -> None: """Within the window attribution is just the latest elapsed slot.""" - assert ( - slot_for_log(_at(13), DEFAULT_SCHEDULE) - == current_slot(_at(13), DEFAULT_SCHEDULE) - == 12 - ) + assert slot_for_log(_at(13), DEFAULT_SCHEDULE) == 720 - def test_last_in_window_hour_is_unchanged(self) -> None: + def test_last_in_window_minute_is_unchanged(self) -> None: """21:59 is still inside the window and lands on 20:00.""" - assert slot_for_log(_at(21), DEFAULT_SCHEDULE) == 20 + assert slot_for_log(_at(21, 59), DEFAULT_SCHEDULE) == 1200 def test_after_the_last_window_clamps_to_the_last_slot(self) -> None: """A late dinner counts toward 20:00 rather than nothing.""" - assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 20 - - def test_late_evening_clamps_to_the_last_slot(self) -> None: - """23:00 clamps the same way as the 22:00 boundary.""" - assert slot_for_log(_at(23), DEFAULT_SCHEDULE) == 20 - - def test_never_returns_none(self) -> None: - """Every hour of the day attributes to some slot.""" - assert all( - slot_for_log(_at(hour), DEFAULT_SCHEDULE) in day_slots(DEFAULT_SCHEDULE) - for hour in range(24) - ) + assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 1200 def test_clamps_across_configured_schedules(self) -> None: - """The clamp rule holds for any schedule, not just the default. + """The clamp rule holds at every minute for several windows. - Sweeps every hour against several windows. ``slot.dart`` runs the - identical sweep: the two must agree on every cell, because a device - that attributes a meal to a different slot than its peer leaves the - other device's checkpoint permanently unsatisfied. + ``slot_test.dart`` runs the identical sweep: a device that attributes + a meal to a different slot than its peer leaves the other device's + checkpoint permanently unsatisfied. """ for schedule in ( DEFAULT_SCHEDULE, - MealSchedule(8, 20, 5), - MealSchedule(6, 22, 6), - MealSchedule(0, 23, 2), - MealSchedule(10, 14, 3), + MealSchedule(480, 1200, 5), + MealSchedule(435, 1140, 5), + MealSchedule(443, 1181, 4), + MealSchedule(0, 1439, 2), + MealSchedule(600, 840, 3), ): slots = day_slots(schedule) - for hour in range(24): - attributed = slot_for_log(_at(hour), schedule) - assert attributed in slots - if hour < slots[0]: + for minute in range(1440): + attributed = slot_for_log(_at(minute // 60, minute % 60), schedule) + if minute < slots[0]: assert attributed == slots[0] - elif hour >= schedule.enforcement_end_hour: + elif minute >= schedule.enforcement_end_minute: assert attributed == slots[-1] else: - # Inside the window: the most recent slot that has opened. - assert attributed == max(s for s in slots if s <= hour) + assert attributed == max(s for s in slots if s <= minute) class TestSlotLabel: - """Human HH:00 labels.""" + """Human HH:MM labels.""" - def test_morning_zero_padded(self) -> None: - """A single-digit hour is zero-padded.""" - assert slot_label(8) == "08:00" + def test_zero_padded(self) -> None: + """Hours and minutes are both zero-padded.""" + assert slot_label(435) == "07:15" + assert slot_label(5) == "00:05" def test_evening(self) -> None: """A two-digit hour formats plainly.""" - assert slot_label(20) == "20:00" + assert slot_label(1200) == "20:00" + + def test_wraps_into_the_day(self) -> None: + """Out-of-day values wrap, as the hour label used to with ``% 24``.""" + assert slot_label(1440) == "00:00" + assert slot_label(-15) == "23:45" class TestConfiguredSchedules: """Slot arithmetic against schedules other than the default.""" - def test_five_meals_shifts_the_checkpoints(self) -> None: - """The user's example schedule drives elapsed/missing correctly.""" - schedule = MealSchedule(8, 20, 5) - assert day_slots(schedule) == (8, 11, 14, 17, 20) - assert elapsed_slots(_at(14), schedule) == (8, 11, 14) - assert missing_slots(_at(14), {8, 11}, schedule) == (14,) - - def test_enforcement_follows_the_last_meal(self) -> None: - """A window ending at 18:00 stops enforcing at 20:00, not 22:00.""" - schedule = MealSchedule(8, 18, 3) - assert within_enforcement_window(_at(19), schedule) - assert not within_enforcement_window(_at(20), schedule) - assert elapsed_slots(_at(20), schedule) == () - def test_midnight_first_slot_is_not_treated_as_absent(self) -> None: """Slot 0 is a real slot, not a falsy stand-in for "nothing elapsed". Guards the ``or`` bug this used to have in ``_gatelock._pending_slots``: ``current_slot(...) or day_slots()[0]`` silently swallowed slot 0. """ - schedule = MealSchedule(0, 12, 3) - assert day_slots(schedule) == (0, 6, 12) + schedule = MealSchedule(0, 720, 3) + assert day_slots(schedule) == (0, 360, 720) assert current_slot(_at(0), schedule) == 0 - assert current_slot(_at(0), schedule) is not None assert slot_for_log(_at(0), schedule) == 0 diff --git a/diet_guard/tests/test_slots_future.py b/diet_guard/tests/test_slots_future.py index e1a088b..e4c43aa 100644 --- a/diet_guard/tests/test_slots_future.py +++ b/diet_guard/tests/test_slots_future.py @@ -21,44 +21,49 @@ def _at(hour: int) -> datetime: class TestResolveFutureWhen: """Validation and construction for a pre-logged future meal's datetime.""" - def test_valid_future_date_and_slot_hour(self) -> None: + def test_valid_future_date_and_slot(self) -> None: """A future date on a real slot hour resolves to that datetime.""" now = _at(10) - when = resolve_future_when("2026-01-02", 12, DEFAULT_SCHEDULE, now) + when = resolve_future_when("2026-01-02", 720, DEFAULT_SCHEDULE, now) assert when == datetime(2026, 1, 2, 12, 0, tzinfo=UTC) def test_preserves_nows_timezone(self) -> None: """The returned datetime carries ``now``'s tzinfo, not a bare one.""" now = _at(10) - when = resolve_future_when("2026-01-02", 8, DEFAULT_SCHEDULE, now) + when = resolve_future_when("2026-01-02", 480, DEFAULT_SCHEDULE, now) assert when.tzinfo == now.tzinfo def test_rejects_malformed_date(self) -> None: """A date string that doesn't parse raises ValueError.""" with pytest.raises(ValueError, match="invalid date"): - resolve_future_when("not-a-date", 8, DEFAULT_SCHEDULE, _at(10)) + resolve_future_when("not-a-date", 480, DEFAULT_SCHEDULE, _at(10)) - def test_rejects_hour_not_a_slot(self) -> None: - """An hour that isn't one of the schedule's slots raises ValueError.""" + def test_rejects_minute_not_a_slot(self) -> None: + """A minute that is not one of the schedule's slots raises ValueError.""" with pytest.raises(ValueError, match="not a meal slot"): - resolve_future_when("2026-01-02", 13, DEFAULT_SCHEDULE, _at(10)) + resolve_future_when("2026-01-02", 780, DEFAULT_SCHEDULE, _at(10)) def test_rejects_todays_date(self) -> None: """Today's own date is not "future", even on a real slot hour.""" now = datetime(2026, 1, 1, 10, 0, tzinfo=UTC) with pytest.raises(ValueError, match="not a future date"): - resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now) + resolve_future_when("2026-01-01", 720, DEFAULT_SCHEDULE, now) def test_rejects_past_date(self) -> None: """A date before today raises ValueError.""" now = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) with pytest.raises(ValueError, match="not a future date"): - resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now) + resolve_future_when("2026-01-01", 720, DEFAULT_SCHEDULE, now) - def test_accepts_every_configured_slot_hour(self) -> None: - """Every hour the schedule actually opens is accepted, not just one.""" - schedule = MealSchedule(8, 20, 5) + def test_accepts_every_configured_slot(self) -> None: + """Every slot the schedule actually opens is accepted, to the minute.""" + schedule = MealSchedule(435, 1140, 5) now = _at(10) - for hour in day_slots(schedule): - when = resolve_future_when("2026-01-02", hour, schedule, now) - assert when.hour == hour + for slot in day_slots(schedule): + when = resolve_future_when("2026-01-02", slot, schedule, now) + assert when.hour * 60 + when.minute == slot + + def test_error_lists_the_slots_as_clock_times(self) -> None: + """The rejection names the offered slots readably, not as raw minutes.""" + with pytest.raises(ValueError, match=r"07:15, 10:15"): + resolve_future_when("2026-01-02", 420, MealSchedule(435, 1140, 5), _at(9)) diff --git a/diet_guard/tests/test_slots_nearest.py b/diet_guard/tests/test_slots_nearest.py new file mode 100644 index 0000000..08523f5 --- /dev/null +++ b/diet_guard/tests/test_slots_nearest.py @@ -0,0 +1,54 @@ +"""Tests for _slots.nearest_slot / satisfied_slots. + +How a recorded slot minute -- possibly written under an older schedule, or as a +legacy whole hour -- counts toward today's checkpoints. Split out of +test_slots.py for the repo's 250-line cap. +""" + +from __future__ import annotations + +from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule +from diet_guard._slots import nearest_slot, satisfied_slots + + +class TestNearestSlot: + """The closest slot, ties to the earlier.""" + + def test_exact_slot_is_itself(self) -> None: + """A minute that is a slot maps to that slot.""" + assert nearest_slot(720, DEFAULT_SCHEDULE) == 720 + + def test_exact_halfway_goes_to_the_earlier_slot(self) -> None: + """10:00 is equidistant from 08:00 and 12:00; the earlier wins.""" + assert nearest_slot(600, DEFAULT_SCHEDULE) == 480 + assert nearest_slot(601, DEFAULT_SCHEDULE) == 720 + + def test_outside_the_window_clamps_to_the_ends(self) -> None: + """Before the first slot and after the last, the end slots win.""" + assert nearest_slot(-60, DEFAULT_SCHEDULE) == 480 + assert nearest_slot(1800, DEFAULT_SCHEDULE) == 1200 + + +class TestSatisfiedSlots: + """Which checkpoints a day's recorded slot minutes cover.""" + + def test_empty_input_satisfies_nothing(self) -> None: + """No meals, no satisfied slots.""" + assert satisfied_slots([], DEFAULT_SCHEDULE) == set() + + def test_meals_survive_a_schedule_move(self) -> None: + """The user's case: logged at 07:00 and 10:00, then moved to 07:15. + + Under 07:15-19:00 x5 the slots are 07:15, 10:15, 13:15, 16:00, 19:00; + each old meal lands on its own nearest slot, so both still count. + """ + moved = MealSchedule(435, 1140, 5) + assert satisfied_slots([420, 600], moved) == {435, 615} + + def test_two_meals_on_one_slot_satisfy_only_that_slot(self) -> None: + """A double breakfast does not also clear lunch.""" + assert satisfied_slots([480, 540], DEFAULT_SCHEDULE) == {480} + + def test_legacy_hour_entries_map_onto_five_meals(self) -> None: + """Entries tagged 08 and 12 under four meals, read under five.""" + assert satisfied_slots([480, 720], MealSchedule(480, 1200, 5)) == {480, 660} diff --git a/scripts/build_kuchnia_fixture.py b/scripts/build_kuchnia_fixture.py index b55bfb3..c79cd97 100755 --- a/scripts/build_kuchnia_fixture.py +++ b/scripts/build_kuchnia_fixture.py @@ -146,18 +146,21 @@ def _dish_json(dish: Dish) -> dict[str, object]: #: re-spread over ``slots`` (never a slice of the full spread). The full day #: spreads the twins to 16 and 20, so ``twins_share_16`` drops the first dish: #: 7 on 4 slots puts both twins on 16, the one place intra-batch dedup bites. +#: Slots are MINUTES OF DAY, ``occupied``/``today[].slot`` too: resolved slot +#: minutes, not the on-disk hour (seed real entries via ``slot_fields``). _ALL = list(range(8)) -_SLOTS = [8, 12, 16, 20] +_SLOTS = [480, 720, 960, 1200] _PLAN_CASES = { "empty_day": {"take": _ALL, "occupied": []}, - "occupied_12": {"take": _ALL, "occupied": [12]}, - # First five in slot order: pancakes and hummus double into 8, both kept. - "five_on_four": {"take": _ALL[:5], "occupied": [16]}, + "occupied_12": {"take": _ALL, "occupied": [720]}, + # First five in slot order: pancakes and hummus double into 08:00, both kept. + "five_on_four": {"take": _ALL[:5], "occupied": [960]}, "full_day": {"take": _ALL, "occupied": _SLOTS}, } # Case/space-insensitive name match, and the same name in another slot is no # match: log_dishes dedups by (name, slot), it does not test occupancy. -_SEEN = [{"desc": " twin DISH ", "slot": 16}, {"desc": "No priority, kept", "slot": 8}] +_TWIN_SEEN = {"desc": " twin DISH ", "slot": 960} +_SEEN = [_TWIN_SEEN, {"desc": "No priority, kept", "slot": 480}] _LOG_CASES = { "empty_day": {"take": _ALL, "today": []}, "twins_share_16": {"take": _ALL[1:], "today": []}, @@ -201,7 +204,10 @@ def build() -> dict[str, object]: "showNutrition": True, } dishes = parse_menu(payload) - default_slots = (8, 12, 16, 20) + spreads = { # slot-minute keys, comma-joined; both tests parse them back + ",".join(map(str, slots)): [s.slot for s in assign_slots(dishes, slots)] + for slots in (_SLOTS, (*_SLOTS, 1320)) + } return { "_comment": ( "Shared parity fixture for the Kuchnia Wikinga catering import. " @@ -214,16 +220,13 @@ def build() -> dict[str, object]: "dishes": [_dish_json(dish) for dish in dishes], "dropped_count": len(payload["deliveryMenuMeal"]) - len(dishes), "slots": { - "8,12,16,20": [s.slot for s in assign_slots(dishes, default_slots)], - "8,12,16,20,22": [ - s.slot for s in assign_slots(dishes, (8, 12, 16, 20, 22)) - ], + **spreads, # The other direction: fewer dishes than slots. - "first_three_8,12,16,20": [ - s.slot for s in assign_slots(dishes[:3], default_slots) + "first_three_480,720,960,1200": [ + s.slot for s in assign_slots(dishes[:3], _SLOTS) ], }, - "slot_order": [s.dish.name for s in assign_slots(dishes, default_slots)], + "slot_order": [s.dish.name for s in assign_slots(dishes, _SLOTS)], "bank_keys": [dish.name.strip().casefold() for dish in dishes], "bank_records": [dish_to_record(dish) for dish in dishes], "fill": _fill_cases(dishes), diff --git a/scripts/build_meal_schedule_fixture.py b/scripts/build_meal_schedule_fixture.py new file mode 100755 index 0000000..69cefbc --- /dev/null +++ b/scripts/build_meal_schedule_fixture.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Regenerate the shared cross-language meal-schedule parity fixture. + +The fixture (``tests/fixtures/meal_schedule_vectors.json``) is read by *both* +``diet_guard/tests/test_meal_schedule_vectors.py`` and +``app/test/models/meal_schedule_vectors_test.dart``: one shared input, one +shared expected result, so the PC and the phone derive the same slot minutes, +attribute a meal to the same slot and read the same wire fields. A slot one +device offers while the other does not is a checkpoint that can never be +satisfied -- a permanent lock. The inputs live in +``scripts/meal_schedule_cases.py``. + +``expected`` values come from the Python implementation, the reference the Dart +port must reproduce -- so regenerating blesses whatever Python does now. Only +run it after an intended change, and re-read the diff before committing. + +Run it from the repo root with the repo on the path, so ``diet_guard`` +resolves to *this* tree rather than wherever the editable install +points: + + PYTHONPATH="$PWD" python3 scripts/build_meal_schedule_fixture.py +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from itertools import pairwise +import json +from pathlib import Path + +import meal_schedule_cases as cases + +from diet_guard._meal_schedule import ( + MINUTES_PER_DAY, + MealSchedule, + schedule_from_wire, + schedule_to_wire, +) +from diet_guard._slot_wire import entry_slot_minute, slot_fields +from diet_guard._slots import ( + current_slot, + elapsed_slots, + missing_slots, + nearest_slot, + satisfied_slots, + slot_for_log, + slot_label, + within_enforcement_window, +) + +FIXTURE = ( + Path(__file__).resolve().parent.parent / "tests/fixtures/meal_schedule_vectors.json" +) + + +def _triple(schedule: MealSchedule | None) -> list[int] | None: + """Return a schedule as the ``[first, last, count]`` row it is stored as.""" + if schedule is None: + return None + return [schedule.first_minute, schedule.last_minute, schedule.count] + + +def _at(minute: int) -> datetime: + """Return a fixed datetime at ``minute`` of day (the date is irrelevant).""" + return datetime(2026, 1, 1, minute // 60, minute % 60, tzinfo=UTC) + + +def _schedule_row(triple: cases.Triple) -> dict[str, object]: + """Return one schedule's normalised form, slots, cutoff and wire encoding.""" + schedule = MealSchedule(*triple) + return { + "input": list(triple), + "normalized": _triple(schedule.normalized()), + "slots": list(schedule.slots()), + "enforcement_end_minute": schedule.enforcement_end_minute, + "wire": schedule_to_wire(schedule), + } + + +def _edge_minutes(schedule: MealSchedule) -> list[int]: + """Return every minute around a slot or the cutoff, plus the day's ends.""" + around = [schedule.enforcement_end_minute, *schedule.slots()] + minutes = {0, 1439} | {m + d for m in around for d in (-1, 0, 1)} + return sorted(m for m in minutes if 0 <= m < MINUTES_PER_DAY) + + +def _edge_row(triple: cases.Triple, minute: int) -> dict[str, object]: + """Return what every clock-reading function says at ``minute``.""" + schedule, now = MealSchedule(*triple), _at(minute) + return { + "schedule": list(triple), + "minute": minute, + "within_window": within_enforcement_window(now, schedule), + "elapsed": list(elapsed_slots(now, schedule)), + "current_slot": current_slot(now, schedule), + "slot_for_log": slot_for_log(now, schedule), + } + + +def _nearest_minutes(schedule: MealSchedule) -> list[int]: + """Return each slot, each midpoint +/- 1, and out-of-day minutes.""" + slots = schedule.slots() + mids = [(a + b) // 2 for a, b in pairwise(slots)] + minutes = {-60, 0, 1439, 1800, *slots} + minutes |= {m + d for m in mids for d in (-1, 0, 1)} + return sorted(minutes) + + +def _slot_field_row(minute: int) -> dict[str, object]: + """Return ``slot_fields(minute)``, or an error marker when it raises.""" + try: + return {"minute": minute, "fields": slot_fields(minute)} + except ValueError: + return {"minute": minute, "error": True} + + +def _missing_row(triple: cases.Triple, minute: int, logged: list[int]) -> object: + """Return the unsatisfied elapsed slots at ``minute`` given ``logged``.""" + missing = missing_slots(_at(minute), set(logged), MealSchedule(*triple)) + return { + "schedule": list(triple), + "minute": minute, + "logged": logged, + "missing": list(missing), + } + + +def _satisfied_row(name: str, triple: cases.Triple, minutes: list[int]) -> object: + """Return the slots ``minutes`` satisfy under ``triple``, sorted.""" + satisfied = satisfied_slots(minutes, MealSchedule(*triple)) + return { + "name": name, + "schedule": list(triple), + "entry_minutes": minutes, + "satisfied": sorted(satisfied), + } + + +def build() -> dict[str, object]: + """Return the whole fixture: every input with its expected result.""" + edge = [(t, MealSchedule(*t)) for t in cases.EDGE_SCHEDULES] + return { + "_comment": ( + "Shared parity fixture for meal-slot minutes. Read by BOTH " + "diet_guard/tests/test_meal_schedule_vectors.py and " + "app/test/models/meal_schedule_vectors_test.dart. Regenerate with " + "scripts/build_meal_schedule_fixture.py." + ), + "schedules": [_schedule_row(t) for t in cases.SCHEDULES], + "time_edges": [_edge_row(t, m) for t, s in edge for m in _edge_minutes(s)], + "missing": [_missing_row(*case) for case in cases.MISSING], + "nearest": [ + {"schedule": list(t), "minute": m, "nearest": nearest_slot(m, s)} + for t, s in edge + for m in _nearest_minutes(s) + ], + "satisfied": [_satisfied_row(*case) for case in cases.SATISFIED], + "labels": [{"minute": m, "label": slot_label(m)} for m in cases.LABELS], + "schedule_wire_decode": [ + {"raw": raw, "schedule": _triple(schedule_from_wire(raw))} + for raw in cases.WIRE_DECODE + ], + "slot_fields": [_slot_field_row(m) for m in cases.SLOT_FIELD_MINUTES], + "entry_slot_minute": [ + {"entry": entry, "minute": entry_slot_minute(entry)} + for entry in cases.ENTRIES + ], + } + + +def main() -> None: + """Write the fixture to disk. + + Deliberately silent: ``T201`` bans ``print`` here, and the script's output + is the file itself -- ``git diff`` is how you check what changed. + """ + FIXTURE.parent.mkdir(parents=True, exist_ok=True) + with FIXTURE.open("w", encoding="utf-8") as handle: + json.dump(build(), handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/meal_schedule_cases.py b/scripts/meal_schedule_cases.py new file mode 100755 index 0000000..51c3fc4 --- /dev/null +++ b/scripts/meal_schedule_cases.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Input cases for ``scripts/build_meal_schedule_fixture.py``. + +Split out of the builder for the repo's 250-line cap. Inputs only: every +expected value is computed by the builder from the Python implementation. +A script-style file like its siblings (hence the shebang); running it +directly does nothing -- run the builder. +""" + +from __future__ import annotations + +H = 60 +Triple = tuple[int, int, int] + + +def window(first: int, last: int) -> list[Triple]: + """Return ``(first, last, count)`` for counts 2..6 plus a clamp either side.""" + return [(first, last, count) for count in (0, 2, 3, 4, 5, 6, 7)] + + +#: Curated (first_minute, last_minute, count) inputs. Both suites also sweep +#: the 15-minute grid in code; this table holds exact values a human can check. +SCHEDULES: list[Triple] = [ + *window(8 * H, 20 * H), # the default window + *window(7 * H + 15, 19 * H), # the user's quarter-hour breakfast + *window(7 * H + 23, 19 * H + 41), # off-grid endpoints, never snapped + *window(0, 1439), # the whole day, last at 23:59 + (7 * H, 19 * H, 5), + (7 * H, 21 * H, 4), # uneven: the old hour formula said 12:00 here + (7 * H, 21 * H, 5), + (9 * H, 19 * H, 4), + (0, 12 * H, 3), # a midnight first slot is a real slot + (0, H, 2), + (0, H, 6), + (22 * H, 1439, 3), + (23 * H, 1439, 6), + # Narrow windows: count is capped at window // 15 + 1. + (8 * H, 8 * H + 15, 6), + (8 * H, 8 * H + 30, 6), + (8 * H, 8 * H + 45, 6), + (8 * H, 9 * H, 6), + (8 * H, 9 * H + 14, 6), + (8 * H, 9 * H + 15, 6), + (481, 496, 3), + (8 * H + 7, 9 * H + 22, 6), + # Every normalized() clamp. + (-5, 20 * H, 4), + (1430, 1439, 2), + (1439, 1439, 2), + (2000, -5, 999), + (8 * H, 5000, 4), + (12 * H, 12 * H, 4), + (12 * H, 100, 4), + (12 * H, 12 * H + 14, 2), + (8 * H, 20 * H, -3), + (8 * H, 20 * H, 99), + (8, 20, 4), # an hour-valued caller that missed the rename: 00:08-00:23 +] + +#: Schedules whose every slot +/- 1 minute and cutoff edge is pinned. +EDGE_SCHEDULES: list[Triple] = [ + (8 * H, 20 * H, 4), + (8 * H, 20 * H, 5), + (7 * H + 15, 19 * H, 5), + (7 * H + 23, 19 * H + 41, 4), + (0, 12 * H, 3), + (0, 1439, 6), # cutoff clamps to 1440: the window closes at midnight + (22 * H, 1439, 3), +] + +DEFAULT: Triple = (8 * H, 20 * H, 4) +SATISFIED: list[tuple[str, Triple, list[int]]] = [ + ("empty input satisfies nothing", DEFAULT, []), + # Logged 07:00 and 10:00 under 07:00-19:00 x5, then the schedule moved. + ("old 07:00/10:00 meals after moving to 07:15", (435, 1140, 5), [420, 600]), + ("two meals snap onto one slot", DEFAULT, [480, 540]), + ("legacy hour entries under five meals", (8 * H, 20 * H, 5), [480, 720]), + ("exact halfway goes to the earlier slot", DEFAULT, [600, 840]), + ("out-of-window minutes clamp to the ends", DEFAULT, [0, 1439]), + ("every slot exactly", DEFAULT, [480, 720, 960, 1200]), +] + +#: (schedule, minute, logged slot minutes) for missing_slots. +MISSING: list[tuple[Triple, int, list[int]]] = [ + (DEFAULT, 13 * H, [480, 720]), + (DEFAULT, 17 * H, [480]), + (DEFAULT, 23 * H, []), + ((435, 1140, 5), 615, [435]), + ((435, 1140, 5), 614, []), +] + +LABELS = [0, 5, 59, 60, 435, 480, 615, 1200, 1439, 1440, -15] + +WIRE_DECODE: list[object] = [ + {"f": 8, "l": 20, "n": 4}, + {"f": 7, "l": 19, "n": 5, "fm": 435}, + {"f": 7, "l": 19, "n": 5, "fm": 443, "lm": 1181}, + {"f": 7, "l": 19, "n": 5, "fm": 600}, # fm wins even if it disagrees with f + {"f": 7, "l": 19, "n": 5, "fm": True}, # bool is not an int: f wins + {"f": 7, "l": 19, "n": 5, "fm": 435.0}, # nor is a float + {"f": 7, "l": 19, "n": 5, "lm": None}, + {"f": True, "l": 20, "n": 4}, + {"f": 8, "l": 20}, + {"f": "8", "l": 20, "n": 4}, + {"f": 99, "l": -5, "n": 999}, # garbage from a peer is normalised + [8, 20, 4], + None, +] + +SLOT_FIELD_MINUTES = [0, 59, 60, 435, 480, 1200, 1439, -1, 1440] + +ENTRIES: list[dict[str, object]] = [ + {"slot": 8}, + {"slot": 0}, + {"slot": 7, "slot_min": 435}, + {"slot_min": 435}, + {"slot": 5, "slot_min": 0}, # a zero slot_min still wins + {"slot": 8, "slot_min": True}, + {"slot": 8, "slot_min": False}, + {"slot": True}, + {"slot": "8"}, + {"slot": 8.0}, + {"slot": None}, + {}, +] diff --git a/tests/fixtures/kuchnia_day.json b/tests/fixtures/kuchnia_day.json index 24fe342..04774bf 100644 --- a/tests/fixtures/kuchnia_day.json +++ b/tests/fixtures/kuchnia_day.json @@ -174,35 +174,35 @@ "expected": [ [ "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", - 8 + 480 ], [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", - 12 + 720 ], [ "Fit ciasto, jak twix white", - 12 + 720 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 16 + 960 ], [ "Twin dish", - 16 + 960 ], [ "Twin dish", - 20 + 1200 ], [ "No priority, kept", - 20 + 1200 ] ], "occupied": [], @@ -221,23 +221,23 @@ "expected": [ [ "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", - 8 + 480 ], [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", - 12 + 720 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 20 + 1200 ] ], "occupied": [ - 16 + 960 ], "take": [ 0, @@ -250,10 +250,10 @@ "full_day": { "expected": [], "occupied": [ - 8, - 12, - 16, - 20 + 480, + 720, + 960, + 1200 ], "take": [ 0, @@ -270,31 +270,31 @@ "expected": [ [ "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", - 8 + 480 ], [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 16 + 960 ], [ "Twin dish", - 16 + 960 ], [ "Twin dish", - 20 + 1200 ], [ "No priority, kept", - 20 + 1200 ] ], "occupied": [ - 12 + 720 ], "take": [ 0, @@ -313,35 +313,35 @@ "expected": [ [ "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", - 8 + 480 ], [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", - 12 + 720 ], [ "Fit ciasto, jak twix white", - 12 + 720 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 16 + 960 ], [ "Twin dish", - 16 + 960 ], [ "Twin dish", - 20 + 1200 ], [ "No priority, kept", - 20 + 1200 ] ], "take": [ @@ -360,23 +360,23 @@ "expected": [ [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", - 8 + 480 ], [ "Fit ciasto, jak twix white", - 12 + 720 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 12 + 720 ], [ "No priority, kept", - 20 + 1200 ] ], "take": [ @@ -391,11 +391,11 @@ "today": [ { "desc": " twin DISH ", - "slot": 16 + "slot": 960 }, { "desc": "No priority, kept", - "slot": 8 + "slot": 480 } ] }, @@ -403,27 +403,27 @@ "expected": [ [ "Hummus paprykowy, mini wafle kukurydziane, warzywa", - 8 + 480 ], [ "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", - 8 + 480 ], [ "Fit ciasto, jak twix white", - 12 + 720 ], [ "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", - 12 + 720 ], [ "Twin dish", - 16 + 960 ], [ "No priority, kept", - 20 + 1200 ] ], "take": [ @@ -439,10 +439,10 @@ } }, "slots": [ - 8, - 12, - 16, - 20 + 480, + 720, + 960, + 1200 ] }, "slot_order": [ @@ -456,30 +456,30 @@ "No priority, kept" ], "slots": { - "8,12,16,20": [ - 8, - 8, - 12, - 12, - 16, - 16, - 20, - 20 + "480,720,960,1200": [ + 480, + 480, + 720, + 720, + 960, + 960, + 1200, + 1200 ], - "8,12,16,20,22": [ - 8, - 8, - 12, - 12, - 16, - 20, - 20, - 22 + "480,720,960,1200,1320": [ + 480, + 480, + 720, + 720, + 960, + 1200, + 1200, + 1320 ], - "first_three_8,12,16,20": [ - 8, - 12, - 16 + "first_three_480,720,960,1200": [ + 480, + 720, + 960 ] } }, diff --git a/tests/fixtures/meal_schedule_vectors.json b/tests/fixtures/meal_schedule_vectors.json new file mode 100644 index 0000000..0f50aa7 --- /dev/null +++ b/tests/fixtures/meal_schedule_vectors.json @@ -0,0 +1,4640 @@ +{ + "_comment": "Shared parity fixture for meal-slot minutes. Read by BOTH diet_guard/tests/test_meal_schedule_vectors.py and app/test/models/meal_schedule_vectors_test.dart. Regenerate with scripts/build_meal_schedule_fixture.py.", + "entry_slot_minute": [ + { + "entry": { + "slot": 8 + }, + "minute": 480 + }, + { + "entry": { + "slot": 0 + }, + "minute": 0 + }, + { + "entry": { + "slot": 7, + "slot_min": 435 + }, + "minute": 435 + }, + { + "entry": { + "slot_min": 435 + }, + "minute": 435 + }, + { + "entry": { + "slot": 5, + "slot_min": 0 + }, + "minute": 0 + }, + { + "entry": { + "slot": 8, + "slot_min": true + }, + "minute": 480 + }, + { + "entry": { + "slot": 8, + "slot_min": false + }, + "minute": 480 + }, + { + "entry": { + "slot": true + }, + "minute": null + }, + { + "entry": { + "slot": "8" + }, + "minute": null + }, + { + "entry": { + "slot": 8.0 + }, + "minute": null + }, + { + "entry": { + "slot": null + }, + "minute": null + }, + { + "entry": {}, + "minute": null + } + ], + "labels": [ + { + "label": "00:00", + "minute": 0 + }, + { + "label": "00:05", + "minute": 5 + }, + { + "label": "00:59", + "minute": 59 + }, + { + "label": "01:00", + "minute": 60 + }, + { + "label": "07:15", + "minute": 435 + }, + { + "label": "08:00", + "minute": 480 + }, + { + "label": "10:15", + "minute": 615 + }, + { + "label": "20:00", + "minute": 1200 + }, + { + "label": "23:59", + "minute": 1439 + }, + { + "label": "00:00", + "minute": 1440 + }, + { + "label": "23:45", + "minute": -15 + } + ], + "missing": [ + { + "logged": [ + 480, + 720 + ], + "minute": 780, + "missing": [], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "logged": [ + 480 + ], + "minute": 1020, + "missing": [ + 720, + 960 + ], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "logged": [], + "minute": 1380, + "missing": [], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "logged": [ + 435 + ], + "minute": 615, + "missing": [ + 615 + ], + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "logged": [], + "minute": 614, + "missing": [ + 435 + ], + "schedule": [ + 435, + 1140, + 5 + ] + } + ], + "nearest": [ + { + "minute": -60, + "nearest": 480, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 0, + "nearest": 480, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 480, + "nearest": 480, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 599, + "nearest": 480, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 600, + "nearest": 480, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 601, + "nearest": 720, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 720, + "nearest": 720, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 839, + "nearest": 720, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 840, + "nearest": 720, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 841, + "nearest": 960, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 960, + "nearest": 960, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1079, + "nearest": 960, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1080, + "nearest": 960, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1081, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1200, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1439, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": 1800, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "minute": -60, + "nearest": 480, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 0, + "nearest": 480, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 480, + "nearest": 480, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 569, + "nearest": 480, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 570, + "nearest": 480, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 571, + "nearest": 660, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 660, + "nearest": 660, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 749, + "nearest": 660, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 750, + "nearest": 660, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 751, + "nearest": 840, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 840, + "nearest": 840, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 929, + "nearest": 840, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 930, + "nearest": 840, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 931, + "nearest": 1020, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1020, + "nearest": 1020, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1109, + "nearest": 1020, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1110, + "nearest": 1020, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1111, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1200, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1439, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": 1800, + "nearest": 1200, + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "minute": -60, + "nearest": 435, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 0, + "nearest": 435, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 435, + "nearest": 435, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 524, + "nearest": 435, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 525, + "nearest": 435, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 526, + "nearest": 615, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 615, + "nearest": 615, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 704, + "nearest": 615, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 705, + "nearest": 615, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 706, + "nearest": 795, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 795, + "nearest": 795, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 876, + "nearest": 795, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 877, + "nearest": 795, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 878, + "nearest": 960, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 960, + "nearest": 960, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1049, + "nearest": 960, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1050, + "nearest": 960, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1051, + "nearest": 1140, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1140, + "nearest": 1140, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1439, + "nearest": 1140, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": 1800, + "nearest": 1140, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "minute": -60, + "nearest": 443, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 0, + "nearest": 443, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 443, + "nearest": 443, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 565, + "nearest": 443, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 566, + "nearest": 443, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 567, + "nearest": 690, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 690, + "nearest": 690, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 809, + "nearest": 690, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 810, + "nearest": 690, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 811, + "nearest": 930, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 930, + "nearest": 930, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1054, + "nearest": 930, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1055, + "nearest": 930, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1056, + "nearest": 1181, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1181, + "nearest": 1181, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1439, + "nearest": 1181, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": 1800, + "nearest": 1181, + "schedule": [ + 443, + 1181, + 4 + ] + }, + { + "minute": -60, + "nearest": 0, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 0, + "nearest": 0, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 179, + "nearest": 0, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 180, + "nearest": 0, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 181, + "nearest": 360, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 360, + "nearest": 360, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 539, + "nearest": 360, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 540, + "nearest": 360, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 541, + "nearest": 720, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 720, + "nearest": 720, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 1439, + "nearest": 720, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": 1800, + "nearest": 720, + "schedule": [ + 0, + 720, + 3 + ] + }, + { + "minute": -60, + "nearest": 0, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 0, + "nearest": 0, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 141, + "nearest": 0, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 142, + "nearest": 0, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 143, + "nearest": 285, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 285, + "nearest": 285, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 426, + "nearest": 285, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 427, + "nearest": 285, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 428, + "nearest": 570, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 570, + "nearest": 570, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 719, + "nearest": 570, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 720, + "nearest": 570, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 721, + "nearest": 870, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 870, + "nearest": 870, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1011, + "nearest": 870, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1012, + "nearest": 870, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1013, + "nearest": 1155, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1155, + "nearest": 1155, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1296, + "nearest": 1155, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1297, + "nearest": 1155, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1298, + "nearest": 1439, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1439, + "nearest": 1439, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": 1800, + "nearest": 1439, + "schedule": [ + 0, + 1439, + 6 + ] + }, + { + "minute": -60, + "nearest": 1320, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 0, + "nearest": 1320, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1320, + "nearest": 1320, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1349, + "nearest": 1320, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1350, + "nearest": 1320, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1351, + "nearest": 1380, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1380, + "nearest": 1380, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1408, + "nearest": 1380, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1409, + "nearest": 1380, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1410, + "nearest": 1439, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1439, + "nearest": 1439, + "schedule": [ + 1320, + 1439, + 3 + ] + }, + { + "minute": 1800, + "nearest": 1439, + "schedule": [ + 1320, + 1439, + 3 + ] + } + ], + "satisfied": [ + { + "entry_minutes": [], + "name": "empty input satisfies nothing", + "satisfied": [], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "entry_minutes": [ + 420, + 600 + ], + "name": "old 07:00/10:00 meals after moving to 07:15", + "satisfied": [ + 435, + 615 + ], + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "entry_minutes": [ + 480, + 540 + ], + "name": "two meals snap onto one slot", + "satisfied": [ + 480 + ], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "entry_minutes": [ + 480, + 720 + ], + "name": "legacy hour entries under five meals", + "satisfied": [ + 480, + 660 + ], + "schedule": [ + 480, + 1200, + 5 + ] + }, + { + "entry_minutes": [ + 600, + 840 + ], + "name": "exact halfway goes to the earlier slot", + "satisfied": [ + 480, + 720 + ], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "entry_minutes": [ + 0, + 1439 + ], + "name": "out-of-window minutes clamp to the ends", + "satisfied": [ + 480, + 1200 + ], + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "entry_minutes": [ + 480, + 720, + 960, + 1200 + ], + "name": "every slot exactly", + "satisfied": [ + 480, + 720, + 960, + 1200 + ], + "schedule": [ + 480, + 1200, + 4 + ] + } + ], + "schedule_wire_decode": [ + { + "raw": { + "f": 8, + "l": 20, + "n": 4 + }, + "schedule": [ + 480, + 1200, + 4 + ] + }, + { + "raw": { + "f": 7, + "fm": 435, + "l": 19, + "n": 5 + }, + "schedule": [ + 435, + 1140, + 5 + ] + }, + { + "raw": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 5 + }, + "schedule": [ + 443, + 1181, + 5 + ] + }, + { + "raw": { + "f": 7, + "fm": 600, + "l": 19, + "n": 5 + }, + "schedule": [ + 600, + 1140, + 5 + ] + }, + { + "raw": { + "f": 7, + "fm": true, + "l": 19, + "n": 5 + }, + "schedule": [ + 420, + 1140, + 5 + ] + }, + { + "raw": { + "f": 7, + "fm": 435.0, + "l": 19, + "n": 5 + }, + "schedule": [ + 420, + 1140, + 5 + ] + }, + { + "raw": { + "f": 7, + "l": 19, + "lm": null, + "n": 5 + }, + "schedule": [ + 420, + 1140, + 5 + ] + }, + { + "raw": { + "f": true, + "l": 20, + "n": 4 + }, + "schedule": null + }, + { + "raw": { + "f": 8, + "l": 20 + }, + "schedule": null + }, + { + "raw": { + "f": "8", + "l": 20, + "n": 4 + }, + "schedule": null + }, + { + "raw": { + "f": 99, + "l": -5, + "n": 999 + }, + "schedule": [ + 1424, + 1439, + 2 + ] + }, + { + "raw": [ + 8, + 20, + 4 + ], + "schedule": null + }, + { + "raw": null, + "schedule": null + } + ], + "schedules": [ + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 0 + ], + "normalized": [ + 480, + 1200, + 2 + ], + "slots": [ + 480, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 2 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 2 + ], + "normalized": [ + 480, + 1200, + 2 + ], + "slots": [ + 480, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 2 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 3 + ], + "normalized": [ + 480, + 1200, + 3 + ], + "slots": [ + 480, + 840, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 3 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 4 + ], + "normalized": [ + 480, + 1200, + 4 + ], + "slots": [ + 480, + 720, + 960, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 4 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 5 + ], + "normalized": [ + 480, + 1200, + 5 + ], + "slots": [ + 480, + 660, + 840, + 1020, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 5 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 6 + ], + "normalized": [ + 480, + 1200, + 6 + ], + "slots": [ + 480, + 630, + 765, + 915, + 1050, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 6 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 7 + ], + "normalized": [ + 480, + 1200, + 6 + ], + "slots": [ + 480, + 630, + 765, + 915, + 1050, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 6 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 0 + ], + "normalized": [ + 435, + 1140, + 2 + ], + "slots": [ + 435, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 2 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 2 + ], + "normalized": [ + 435, + 1140, + 2 + ], + "slots": [ + 435, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 2 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 3 + ], + "normalized": [ + 435, + 1140, + 3 + ], + "slots": [ + 435, + 795, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 3 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 4 + ], + "normalized": [ + 435, + 1140, + 4 + ], + "slots": [ + 435, + 675, + 900, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 4 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 5 + ], + "normalized": [ + 435, + 1140, + 5 + ], + "slots": [ + 435, + 615, + 795, + 960, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 5 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 6 + ], + "normalized": [ + 435, + 1140, + 6 + ], + "slots": [ + 435, + 570, + 720, + 855, + 1005, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 6 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 435, + 1140, + 7 + ], + "normalized": [ + 435, + 1140, + 6 + ], + "slots": [ + 435, + 570, + 720, + 855, + 1005, + 1140 + ], + "wire": { + "f": 7, + "fm": 435, + "l": 19, + "n": 6 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 0 + ], + "normalized": [ + 443, + 1181, + 2 + ], + "slots": [ + 443, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 2 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 2 + ], + "normalized": [ + 443, + 1181, + 2 + ], + "slots": [ + 443, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 2 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 3 + ], + "normalized": [ + 443, + 1181, + 3 + ], + "slots": [ + 443, + 810, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 3 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 4 + ], + "normalized": [ + 443, + 1181, + 4 + ], + "slots": [ + 443, + 690, + 930, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 4 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 5 + ], + "normalized": [ + 443, + 1181, + 5 + ], + "slots": [ + 443, + 630, + 810, + 990, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 5 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 6 + ], + "normalized": [ + 443, + 1181, + 6 + ], + "slots": [ + 443, + 585, + 735, + 885, + 1035, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 6 + } + }, + { + "enforcement_end_minute": 1301, + "input": [ + 443, + 1181, + 7 + ], + "normalized": [ + 443, + 1181, + 6 + ], + "slots": [ + 443, + 585, + 735, + 885, + 1035, + 1181 + ], + "wire": { + "f": 7, + "fm": 443, + "l": 19, + "lm": 1181, + "n": 6 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 0 + ], + "normalized": [ + 0, + 1439, + 2 + ], + "slots": [ + 0, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 2 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 2 + ], + "normalized": [ + 0, + 1439, + 2 + ], + "slots": [ + 0, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 2 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 3 + ], + "normalized": [ + 0, + 1439, + 3 + ], + "slots": [ + 0, + 720, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 3 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 4 + ], + "normalized": [ + 0, + 1439, + 4 + ], + "slots": [ + 0, + 480, + 960, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 4 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 5 + ], + "normalized": [ + 0, + 1439, + 5 + ], + "slots": [ + 0, + 360, + 720, + 1080, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 5 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 6 + ], + "normalized": [ + 0, + 1439, + 6 + ], + "slots": [ + 0, + 285, + 570, + 870, + 1155, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 6 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 0, + 1439, + 7 + ], + "normalized": [ + 0, + 1439, + 6 + ], + "slots": [ + 0, + 285, + 570, + 870, + 1155, + 1439 + ], + "wire": { + "f": 0, + "l": 23, + "lm": 1439, + "n": 6 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 420, + 1140, + 5 + ], + "normalized": [ + 420, + 1140, + 5 + ], + "slots": [ + 420, + 600, + 780, + 960, + 1140 + ], + "wire": { + "f": 7, + "l": 19, + "n": 5 + } + }, + { + "enforcement_end_minute": 1380, + "input": [ + 420, + 1260, + 4 + ], + "normalized": [ + 420, + 1260, + 4 + ], + "slots": [ + 420, + 705, + 975, + 1260 + ], + "wire": { + "f": 7, + "l": 21, + "n": 4 + } + }, + { + "enforcement_end_minute": 1380, + "input": [ + 420, + 1260, + 5 + ], + "normalized": [ + 420, + 1260, + 5 + ], + "slots": [ + 420, + 630, + 840, + 1050, + 1260 + ], + "wire": { + "f": 7, + "l": 21, + "n": 5 + } + }, + { + "enforcement_end_minute": 1260, + "input": [ + 540, + 1140, + 4 + ], + "normalized": [ + 540, + 1140, + 4 + ], + "slots": [ + 540, + 735, + 945, + 1140 + ], + "wire": { + "f": 9, + "l": 19, + "n": 4 + } + }, + { + "enforcement_end_minute": 840, + "input": [ + 0, + 720, + 3 + ], + "normalized": [ + 0, + 720, + 3 + ], + "slots": [ + 0, + 360, + 720 + ], + "wire": { + "f": 0, + "l": 12, + "n": 3 + } + }, + { + "enforcement_end_minute": 180, + "input": [ + 0, + 60, + 2 + ], + "normalized": [ + 0, + 60, + 2 + ], + "slots": [ + 0, + 60 + ], + "wire": { + "f": 0, + "l": 1, + "n": 2 + } + }, + { + "enforcement_end_minute": 180, + "input": [ + 0, + 60, + 6 + ], + "normalized": [ + 0, + 60, + 5 + ], + "slots": [ + 0, + 15, + 30, + 45, + 60 + ], + "wire": { + "f": 0, + "l": 1, + "n": 5 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 1320, + 1439, + 3 + ], + "normalized": [ + 1320, + 1439, + 3 + ], + "slots": [ + 1320, + 1380, + 1439 + ], + "wire": { + "f": 22, + "l": 23, + "lm": 1439, + "n": 3 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 1380, + 1439, + 6 + ], + "normalized": [ + 1380, + 1439, + 4 + ], + "slots": [ + 1380, + 1395, + 1425, + 1439 + ], + "wire": { + "f": 23, + "l": 23, + "lm": 1439, + "n": 4 + } + }, + { + "enforcement_end_minute": 615, + "input": [ + 480, + 495, + 6 + ], + "normalized": [ + 480, + 495, + 2 + ], + "slots": [ + 480, + 495 + ], + "wire": { + "f": 8, + "l": 8, + "lm": 495, + "n": 2 + } + }, + { + "enforcement_end_minute": 630, + "input": [ + 480, + 510, + 6 + ], + "normalized": [ + 480, + 510, + 3 + ], + "slots": [ + 480, + 495, + 510 + ], + "wire": { + "f": 8, + "l": 8, + "lm": 510, + "n": 3 + } + }, + { + "enforcement_end_minute": 645, + "input": [ + 480, + 525, + 6 + ], + "normalized": [ + 480, + 525, + 4 + ], + "slots": [ + 480, + 495, + 510, + 525 + ], + "wire": { + "f": 8, + "l": 8, + "lm": 525, + "n": 4 + } + }, + { + "enforcement_end_minute": 660, + "input": [ + 480, + 540, + 6 + ], + "normalized": [ + 480, + 540, + 5 + ], + "slots": [ + 480, + 495, + 510, + 525, + 540 + ], + "wire": { + "f": 8, + "l": 9, + "n": 5 + } + }, + { + "enforcement_end_minute": 674, + "input": [ + 480, + 554, + 6 + ], + "normalized": [ + 480, + 554, + 5 + ], + "slots": [ + 480, + 495, + 510, + 540, + 554 + ], + "wire": { + "f": 8, + "l": 9, + "lm": 554, + "n": 5 + } + }, + { + "enforcement_end_minute": 675, + "input": [ + 480, + 555, + 6 + ], + "normalized": [ + 480, + 555, + 6 + ], + "slots": [ + 480, + 495, + 510, + 525, + 540, + 555 + ], + "wire": { + "f": 8, + "l": 9, + "lm": 555, + "n": 6 + } + }, + { + "enforcement_end_minute": 616, + "input": [ + 481, + 496, + 3 + ], + "normalized": [ + 481, + 496, + 2 + ], + "slots": [ + 481, + 496 + ], + "wire": { + "f": 8, + "fm": 481, + "l": 8, + "lm": 496, + "n": 2 + } + }, + { + "enforcement_end_minute": 682, + "input": [ + 487, + 562, + 6 + ], + "normalized": [ + 487, + 562, + 6 + ], + "slots": [ + 487, + 495, + 510, + 525, + 540, + 562 + ], + "wire": { + "f": 8, + "fm": 487, + "l": 9, + "lm": 562, + "n": 6 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + -5, + 1200, + 4 + ], + "normalized": [ + 0, + 1200, + 4 + ], + "slots": [ + 0, + 405, + 795, + 1200 + ], + "wire": { + "f": 0, + "l": 20, + "n": 4 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 1430, + 1439, + 2 + ], + "normalized": [ + 1424, + 1439, + 2 + ], + "slots": [ + 1424, + 1439 + ], + "wire": { + "f": 23, + "fm": 1424, + "l": 23, + "lm": 1439, + "n": 2 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 1439, + 1439, + 2 + ], + "normalized": [ + 1424, + 1439, + 2 + ], + "slots": [ + 1424, + 1439 + ], + "wire": { + "f": 23, + "fm": 1424, + "l": 23, + "lm": 1439, + "n": 2 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 2000, + -5, + 999 + ], + "normalized": [ + 1424, + 1439, + 2 + ], + "slots": [ + 1424, + 1439 + ], + "wire": { + "f": 23, + "fm": 1424, + "l": 23, + "lm": 1439, + "n": 2 + } + }, + { + "enforcement_end_minute": 1440, + "input": [ + 480, + 5000, + 4 + ], + "normalized": [ + 480, + 1439, + 4 + ], + "slots": [ + 480, + 795, + 1125, + 1439 + ], + "wire": { + "f": 8, + "l": 23, + "lm": 1439, + "n": 4 + } + }, + { + "enforcement_end_minute": 855, + "input": [ + 720, + 720, + 4 + ], + "normalized": [ + 720, + 735, + 2 + ], + "slots": [ + 720, + 735 + ], + "wire": { + "f": 12, + "l": 12, + "lm": 735, + "n": 2 + } + }, + { + "enforcement_end_minute": 855, + "input": [ + 720, + 100, + 4 + ], + "normalized": [ + 720, + 735, + 2 + ], + "slots": [ + 720, + 735 + ], + "wire": { + "f": 12, + "l": 12, + "lm": 735, + "n": 2 + } + }, + { + "enforcement_end_minute": 855, + "input": [ + 720, + 734, + 2 + ], + "normalized": [ + 720, + 735, + 2 + ], + "slots": [ + 720, + 735 + ], + "wire": { + "f": 12, + "l": 12, + "lm": 735, + "n": 2 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + -3 + ], + "normalized": [ + 480, + 1200, + 2 + ], + "slots": [ + 480, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 2 + } + }, + { + "enforcement_end_minute": 1320, + "input": [ + 480, + 1200, + 99 + ], + "normalized": [ + 480, + 1200, + 6 + ], + "slots": [ + 480, + 630, + 765, + 915, + 1050, + 1200 + ], + "wire": { + "f": 8, + "l": 20, + "n": 6 + } + }, + { + "enforcement_end_minute": 143, + "input": [ + 8, + 20, + 4 + ], + "normalized": [ + 8, + 23, + 2 + ], + "slots": [ + 8, + 23 + ], + "wire": { + "f": 0, + "fm": 8, + "l": 0, + "lm": 23, + "n": 2 + } + } + ], + "slot_fields": [ + { + "fields": { + "slot": 0 + }, + "minute": 0 + }, + { + "fields": { + "slot": 0, + "slot_min": 59 + }, + "minute": 59 + }, + { + "fields": { + "slot": 1 + }, + "minute": 60 + }, + { + "fields": { + "slot": 7, + "slot_min": 435 + }, + "minute": 435 + }, + { + "fields": { + "slot": 8 + }, + "minute": 480 + }, + { + "fields": { + "slot": 20 + }, + "minute": 1200 + }, + { + "fields": { + "slot": 23, + "slot_min": 1439 + }, + "minute": 1439 + }, + { + "error": true, + "minute": -1 + }, + { + "error": true, + "minute": 1440 + } + ], + "time_edges": [ + { + "current_slot": null, + "elapsed": [], + "minute": 0, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 480, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 479, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 480, + "within_window": false + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 480, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 481, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 719, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 480, + 720 + ], + "minute": 720, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 480, + 720 + ], + "minute": 721, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 480, + 720 + ], + "minute": 959, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 480, + 720, + 960 + ], + "minute": 960, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 480, + 720, + 960 + ], + "minute": 961, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 480, + 720, + 960 + ], + "minute": 1199, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 720, + 960, + 1200 + ], + "minute": 1200, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 720, + 960, + 1200 + ], + "minute": 1201, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 720, + 960, + 1200 + ], + "minute": 1319, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1320, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1321, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1439, + "schedule": [ + 480, + 1200, + 4 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 0, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 480, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 479, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 480, + "within_window": false + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 480, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 481, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 480, + "elapsed": [ + 480 + ], + "minute": 659, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 480, + "within_window": true + }, + { + "current_slot": 660, + "elapsed": [ + 480, + 660 + ], + "minute": 660, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 660, + "within_window": true + }, + { + "current_slot": 660, + "elapsed": [ + 480, + 660 + ], + "minute": 661, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 660, + "within_window": true + }, + { + "current_slot": 660, + "elapsed": [ + 480, + 660 + ], + "minute": 839, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 660, + "within_window": true + }, + { + "current_slot": 840, + "elapsed": [ + 480, + 660, + 840 + ], + "minute": 840, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 840, + "within_window": true + }, + { + "current_slot": 840, + "elapsed": [ + 480, + 660, + 840 + ], + "minute": 841, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 840, + "within_window": true + }, + { + "current_slot": 840, + "elapsed": [ + 480, + 660, + 840 + ], + "minute": 1019, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 840, + "within_window": true + }, + { + "current_slot": 1020, + "elapsed": [ + 480, + 660, + 840, + 1020 + ], + "minute": 1020, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1020, + "within_window": true + }, + { + "current_slot": 1020, + "elapsed": [ + 480, + 660, + 840, + 1020 + ], + "minute": 1021, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1020, + "within_window": true + }, + { + "current_slot": 1020, + "elapsed": [ + 480, + 660, + 840, + 1020 + ], + "minute": 1199, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1020, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 660, + 840, + 1020, + 1200 + ], + "minute": 1200, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 660, + 840, + 1020, + 1200 + ], + "minute": 1201, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": 1200, + "elapsed": [ + 480, + 660, + 840, + 1020, + 1200 + ], + "minute": 1319, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1320, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1321, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1439, + "schedule": [ + 480, + 1200, + 5 + ], + "slot_for_log": 1200, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 0, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 435, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 434, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 435, + "within_window": false + }, + { + "current_slot": 435, + "elapsed": [ + 435 + ], + "minute": 435, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 435, + "within_window": true + }, + { + "current_slot": 435, + "elapsed": [ + 435 + ], + "minute": 436, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 435, + "within_window": true + }, + { + "current_slot": 435, + "elapsed": [ + 435 + ], + "minute": 614, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 435, + "within_window": true + }, + { + "current_slot": 615, + "elapsed": [ + 435, + 615 + ], + "minute": 615, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 615, + "within_window": true + }, + { + "current_slot": 615, + "elapsed": [ + 435, + 615 + ], + "minute": 616, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 615, + "within_window": true + }, + { + "current_slot": 615, + "elapsed": [ + 435, + 615 + ], + "minute": 794, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 615, + "within_window": true + }, + { + "current_slot": 795, + "elapsed": [ + 435, + 615, + 795 + ], + "minute": 795, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 795, + "within_window": true + }, + { + "current_slot": 795, + "elapsed": [ + 435, + 615, + 795 + ], + "minute": 796, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 795, + "within_window": true + }, + { + "current_slot": 795, + "elapsed": [ + 435, + 615, + 795 + ], + "minute": 959, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 795, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 435, + 615, + 795, + 960 + ], + "minute": 960, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 435, + 615, + 795, + 960 + ], + "minute": 961, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 960, + "elapsed": [ + 435, + 615, + 795, + 960 + ], + "minute": 1139, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 960, + "within_window": true + }, + { + "current_slot": 1140, + "elapsed": [ + 435, + 615, + 795, + 960, + 1140 + ], + "minute": 1140, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": true + }, + { + "current_slot": 1140, + "elapsed": [ + 435, + 615, + 795, + 960, + 1140 + ], + "minute": 1141, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": true + }, + { + "current_slot": 1140, + "elapsed": [ + 435, + 615, + 795, + 960, + 1140 + ], + "minute": 1259, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1260, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1261, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1439, + "schedule": [ + 435, + 1140, + 5 + ], + "slot_for_log": 1140, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 0, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 443, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 442, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 443, + "within_window": false + }, + { + "current_slot": 443, + "elapsed": [ + 443 + ], + "minute": 443, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 443, + "within_window": true + }, + { + "current_slot": 443, + "elapsed": [ + 443 + ], + "minute": 444, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 443, + "within_window": true + }, + { + "current_slot": 443, + "elapsed": [ + 443 + ], + "minute": 689, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 443, + "within_window": true + }, + { + "current_slot": 690, + "elapsed": [ + 443, + 690 + ], + "minute": 690, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 690, + "within_window": true + }, + { + "current_slot": 690, + "elapsed": [ + 443, + 690 + ], + "minute": 691, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 690, + "within_window": true + }, + { + "current_slot": 690, + "elapsed": [ + 443, + 690 + ], + "minute": 929, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 690, + "within_window": true + }, + { + "current_slot": 930, + "elapsed": [ + 443, + 690, + 930 + ], + "minute": 930, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 930, + "within_window": true + }, + { + "current_slot": 930, + "elapsed": [ + 443, + 690, + 930 + ], + "minute": 931, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 930, + "within_window": true + }, + { + "current_slot": 930, + "elapsed": [ + 443, + 690, + 930 + ], + "minute": 1180, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 930, + "within_window": true + }, + { + "current_slot": 1181, + "elapsed": [ + 443, + 690, + 930, + 1181 + ], + "minute": 1181, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": true + }, + { + "current_slot": 1181, + "elapsed": [ + 443, + 690, + 930, + 1181 + ], + "minute": 1182, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": true + }, + { + "current_slot": 1181, + "elapsed": [ + 443, + 690, + 930, + 1181 + ], + "minute": 1300, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1301, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1302, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1439, + "schedule": [ + 443, + 1181, + 4 + ], + "slot_for_log": 1181, + "within_window": false + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 0, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 1, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 359, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 360, + "elapsed": [ + 0, + 360 + ], + "minute": 360, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 360, + "within_window": true + }, + { + "current_slot": 360, + "elapsed": [ + 0, + 360 + ], + "minute": 361, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 360, + "within_window": true + }, + { + "current_slot": 360, + "elapsed": [ + 0, + 360 + ], + "minute": 719, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 360, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 0, + 360, + 720 + ], + "minute": 720, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 0, + 360, + 720 + ], + "minute": 721, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": 720, + "elapsed": [ + 0, + 360, + 720 + ], + "minute": 839, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 840, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 841, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1439, + "schedule": [ + 0, + 720, + 3 + ], + "slot_for_log": 720, + "within_window": false + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 0, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 1, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 0, + "elapsed": [ + 0 + ], + "minute": 284, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 0, + "within_window": true + }, + { + "current_slot": 285, + "elapsed": [ + 0, + 285 + ], + "minute": 285, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 285, + "within_window": true + }, + { + "current_slot": 285, + "elapsed": [ + 0, + 285 + ], + "minute": 286, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 285, + "within_window": true + }, + { + "current_slot": 285, + "elapsed": [ + 0, + 285 + ], + "minute": 569, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 285, + "within_window": true + }, + { + "current_slot": 570, + "elapsed": [ + 0, + 285, + 570 + ], + "minute": 570, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 570, + "within_window": true + }, + { + "current_slot": 570, + "elapsed": [ + 0, + 285, + 570 + ], + "minute": 571, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 570, + "within_window": true + }, + { + "current_slot": 570, + "elapsed": [ + 0, + 285, + 570 + ], + "minute": 869, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 570, + "within_window": true + }, + { + "current_slot": 870, + "elapsed": [ + 0, + 285, + 570, + 870 + ], + "minute": 870, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 870, + "within_window": true + }, + { + "current_slot": 870, + "elapsed": [ + 0, + 285, + 570, + 870 + ], + "minute": 871, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 870, + "within_window": true + }, + { + "current_slot": 870, + "elapsed": [ + 0, + 285, + 570, + 870 + ], + "minute": 1154, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 870, + "within_window": true + }, + { + "current_slot": 1155, + "elapsed": [ + 0, + 285, + 570, + 870, + 1155 + ], + "minute": 1155, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 1155, + "within_window": true + }, + { + "current_slot": 1155, + "elapsed": [ + 0, + 285, + 570, + 870, + 1155 + ], + "minute": 1156, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 1155, + "within_window": true + }, + { + "current_slot": 1155, + "elapsed": [ + 0, + 285, + 570, + 870, + 1155 + ], + "minute": 1438, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 1155, + "within_window": true + }, + { + "current_slot": 1439, + "elapsed": [ + 0, + 285, + 570, + 870, + 1155, + 1439 + ], + "minute": 1439, + "schedule": [ + 0, + 1439, + 6 + ], + "slot_for_log": 1439, + "within_window": true + }, + { + "current_slot": null, + "elapsed": [], + "minute": 0, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1320, + "within_window": false + }, + { + "current_slot": null, + "elapsed": [], + "minute": 1319, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1320, + "within_window": false + }, + { + "current_slot": 1320, + "elapsed": [ + 1320 + ], + "minute": 1320, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1320, + "within_window": true + }, + { + "current_slot": 1320, + "elapsed": [ + 1320 + ], + "minute": 1321, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1320, + "within_window": true + }, + { + "current_slot": 1320, + "elapsed": [ + 1320 + ], + "minute": 1379, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1320, + "within_window": true + }, + { + "current_slot": 1380, + "elapsed": [ + 1320, + 1380 + ], + "minute": 1380, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1380, + "within_window": true + }, + { + "current_slot": 1380, + "elapsed": [ + 1320, + 1380 + ], + "minute": 1381, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1380, + "within_window": true + }, + { + "current_slot": 1380, + "elapsed": [ + 1320, + 1380 + ], + "minute": 1438, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1380, + "within_window": true + }, + { + "current_slot": 1439, + "elapsed": [ + 1320, + 1380, + 1439 + ], + "minute": 1439, + "schedule": [ + 1320, + 1439, + 3 + ], + "slot_for_log": 1439, + "within_window": true + } + ] +}