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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 19:21:47 +02:00
parent 5cd89a4e99
commit 8bc1812147
23 changed files with 6509 additions and 714 deletions

View File

@ -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 /// A schedule is three numbers -- the first meal's minute of day, the last
/// how many meals fall between them inclusive -- from which the intermediate /// meal's minute of day, and how many meals fall between them inclusive --
/// checkpoints are derived by even division. `MealSchedule(8, 20, 5)` yields /// from which the intermediate checkpoints are derived by even division and
/// `[8, 11, 14, 17, 20]`. /// 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 /// This file is pure: no clock, no storage, no settings lookup. Persistence
/// lives in `services/meal_schedule_service.dart`, and the slot arithmetic /// 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 /// 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 /// 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 /// 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 /// * **Integer arithmetic only.** No doubles and no `round()` anywhere in the
/// derivation. Dart's `round()` is half-away-from-zero (`2.5.round() == 3`) /// 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 /// while Python's is banker's rounding (`round(2.5) == 2`), so any
/// floating-point path is a latent cross-language split brain. `~/` here, /// floating-point path is a latent cross-language split brain. `~/` here,
/// `//` there. /// `//` 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 /// * **Clamp, don't reject.** Every out-of-range input is normalised to the
/// nearest legal schedule rather than throwing, so the two languages cannot /// nearest legal schedule rather than throwing, so the two languages cannot
/// disagree about which inputs are errors. /// disagree about which inputs are errors.
@ -36,11 +46,16 @@ const int kMinMealCount = 2;
/// `test/widgets/slot_selector_row_test.dart`. /// `test/widgets/slot_selector_row_test.dart`.
const int kMaxMealCount = 6; const int kMaxMealCount = 6;
/// Earliest hour a meal may be scheduled at. /// Minutes in a day; a minute of day is `0..kMinutesPerDay - 1`.
const int kFirstHour = 0; const int kMinutesPerDay = 1440;
/// Latest hour a meal may be scheduled at. /// Minimum spacing between two checkpoints, and the grid interior checkpoints
const int kLastHour = 23; /// 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. /// 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 /// 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 /// eat. Tying it to the spacing would stretch the lockout window to midnight
/// at four meals, contradicting the "don't trap me overnight" intent. /// 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) => int _clamp(int value, int low, int high) =>
value < low ? low : (value > high ? high : value); value < low ? low : (value > high ? high : value);
@ -60,95 +75,142 @@ int _clamp(int value, int low, int high) =>
class MealSchedule { class MealSchedule {
/// Creates a [MealSchedule]. /// Creates a [MealSchedule].
const MealSchedule({ const MealSchedule({
required this.first, required this.firstMinute,
required this.last, required this.lastMinute,
required this.count, required this.count,
}); });
/// Hour of the first meal, 0-23. /// Minute of day of the first meal, 0-1424.
final int first; final int firstMinute;
/// Hour of the last meal, strictly after [first]. /// Minute of day of the last meal, at least one grid step (15 minutes) after
final int last; /// [firstMinute] and at most 1439.
final int lastMinute;
/// Total meals including both endpoints. /// Total meals including both endpoints.
final int count; final int count;
/// Returns an equivalent schedule guaranteed to satisfy the invariants. /// Returns an equivalent schedule guaranteed to satisfy the invariants.
/// ///
/// Ordering matters: [first] is clamped into the day, then [last] is clamped /// Ordering matters: [firstMinute] is clamped into the day leaving room for
/// to leave at least one hour of window, then [count] is clamped to the /// one grid step, then [lastMinute] is clamped to at least one grid step
/// window's width. That last clamp is the load-bearing one -- see [slots]. /// 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() { MealSchedule normalized() {
final normFirst = _clamp(first, kFirstHour, kLastHour - 1); final first = _clamp(firstMinute, 0, _lastMinute - kSlotGridMinutes);
final normLast = _clamp(last, normFirst + 1, kLastHour); final last = _clamp(lastMinute, first + kSlotGridMinutes, _lastMinute);
// A window of N hours holds at most N+1 whole-hour checkpoints; asking for // A window of W minutes holds at most W ~/ 15 + 1 checkpoints spaced a
// more would repeat an hour (see slots()). // grid step apart; asking for more would round two onto one mark.
final capacity = (last - first) ~/ kSlotGridMinutes + 1;
final normCount = _clamp( final normCount = _clamp(
count, count,
kMinMealCount, kMinMealCount,
kMaxMealCount < normLast - normFirst + 1 kMaxMealCount < capacity ? kMaxMealCount : capacity,
? kMaxMealCount
: normLast - normFirst + 1,
); );
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 /// With `span = last - first` and `d = count - 1`, interior slot *i* is
/// integer arithmetic: slot *i* is `first + (i*span + d ~/ 2) ~/ d` where /// `raw = first + (i*span + d ~/ 2) ~/ d` (even division, the `d ~/ 2` term
/// `span = last - first` and `d = count - 1`. The `d ~/ 2` term is a /// a round-half-up bias that keeps it free of floating point), then snapped
/// round-half-up bias applied before the division, which is what keeps this /// to the nearest absolute quarter hour by `(raw + 7) ~/ 15 * 15`. The
/// free of floating point. /// endpoints are [firstMinute] and [lastMinute] exactly -- never snapped.
/// ///
/// Both endpoints land exactly on [first] and [last] by construction, so the /// Strictly ascending, provably: [normalized] caps [count] so that
/// eating window is always honoured even when interior spacing must round. /// `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.
/// The result is strictly ascending because [normalized] caps [count] at /// Snapping moves a value by at most 7, so no interior slot can reach an
/// `last - first + 1`. Without that cap a narrow window repeats an hour /// endpoint. That matters because slot minutes are set members, map keys
/// (`08-12` with 6 meals would give `8, 9, 10, 10, 11, 12`), and since slot /// *and* notification ids: a repeat silently drops a checkpoint.
/// hours are used as set members, map keys *and* notification ids, a repeat
/// silently drops a checkpoint.
List<int> slots() { List<int> slots() {
final schedule = normalized(); 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; final divisions = schedule.count - 1;
return [ return [
for (var index = 0; index < schedule.count; index++) first,
schedule.first + (index * span + divisions ~/ 2) ~/ divisions, 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 /// Derived from the *normalised* last meal, so it always agrees with the
/// cutoff at 25, making `hour < cutoff` vacuously true so the enforcement /// last entry of [slots]. Clamped to the end of the day: a 23:00 last meal
/// window never closes and the reminder can never stop firing. /// would otherwise put the cutoff at 1500, making `minute < cutoff`
int get enforcementEndHour { /// vacuously true so the enforcement window never closes.
final end = last + kEnforcementTailHours; int get enforcementEndMinute {
return end < _hoursPerDay ? end : _hoursPerDay; final end = normalized().lastMinute + kEnforcementTailMinutes;
return end < kMinutesPerDay ? end : kMinutesPerDay;
} }
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
other is MealSchedule && other is MealSchedule &&
other.first == first && other.firstMinute == firstMinute &&
other.last == last && other.lastMinute == lastMinute &&
other.count == count; other.count == count;
@override @override
int get hashCode => Object.hash(first, last, count); int get hashCode => Object.hash(firstMinute, lastMinute, count);
@override @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 /// 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 /// closing at 22:00. Still what a device uses before the user has chosen
/// anything, so upgrading changes no behaviour. /// anything, so upgrading changes no behaviour.
const MealSchedule kDefaultSchedule = MealSchedule( const MealSchedule kDefaultSchedule = MealSchedule(
first: 8, firstMinute: 8 * 60,
last: 20, lastMinute: 20 * 60,
count: 4, count: 4,
); );
/// Returns the synced `sched:<date>` 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<String, int> 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:<date>` 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();
}

View File

@ -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 /// 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 /// 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 /// the Python original is shared between the gate dashboard and the lock
/// decision. /// 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 /// `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 /// 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 /// 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 /// 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 /// never be satisfied. Callers resolve the value at the impure edge, through
/// `MealScheduleService.current`. /// `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; library;
import 'package:diet_guard_app/models/meal_schedule.dart'; 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`. /// Mirrors `_slots.day_slots`.
List<int> daySlots(MealSchedule schedule) => schedule.slots(); List<int> daySlots(MealSchedule schedule) => schedule.slots();
@ -26,29 +45,30 @@ List<int> daySlots(MealSchedule schedule) => schedule.slots();
/// Returns true if [now] is inside the daily slot-enforcement window. /// Returns true if [now] is inside the daily slot-enforcement window.
/// ///
/// Mirrors `_slots.within_enforcement_window`. /// Mirrors `_slots.within_enforcement_window`.
bool withinEnforcementWindow(DateTime now, MealSchedule schedule) => bool withinEnforcementWindow(DateTime now, MealSchedule schedule) {
now.hour >= schedule.slots().first && final minute = minuteOfDay(now);
now.hour < schedule.enforcementEndHour; 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`. /// Empty outside the enforcement window. Mirrors `_slots.elapsed_slots`.
List<int> elapsedSlots(DateTime now, MealSchedule schedule) { List<int> elapsedSlots(DateTime now, MealSchedule schedule) {
if (!withinEnforcementWindow(now, schedule)) return const []; 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`. /// Mirrors `_slots.missing_slots`.
List<int> missingSlots( List<int> missingSlots(DateTime now, Set<int> logged, MealSchedule schedule) =>
DateTime now, elapsedSlots(
Set<int> logged, now,
MealSchedule schedule, schedule,
) => elapsedSlots( ).where((slot) => !logged.contains(slot)).toList();
now,
schedule,
).where((slot) => !logged.contains(slot)).toList();
/// Returns the most recent elapsed slot as of [now], or null. /// 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, /// 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 /// 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 /// 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 /// the value -- the shared fixture pins every minute edge for that reason.
/// against several schedules for exactly that reason.
/// ///
/// Unlike [currentSlot] this never returns null, which is the point: an /// 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 /// 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. /// *those* would instead make every slot fall due at the end of the day.
int slotForLog(DateTime now, MealSchedule schedule) { int slotForLog(DateTime now, MealSchedule schedule) {
final slots = daySlots(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; 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<int> satisfiedSlots(Iterable<int> 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<String, int> 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<String, Object?> 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`. /// 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';
}

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.104+1 version: 1.0.105+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -116,7 +116,7 @@ void main() {
// silently stops testing the intra-batch dedup. // silently stops testing the intra-batch dedup.
final testCase = cases('log_dishes')['twins_share_16'] as Map; final testCase = cases('log_dishes')['twins_share_16'] as Map;
final input = _pairs(spread(testCase.cast<String, dynamic>())); final input = _pairs(spread(testCase.cast<String, dynamic>()));
bool isTwin16(List<Object?> p) => p[0] == 'Twin dish' && p[1] == 16; bool isTwin16(List<Object?> p) => p[0] == 'Twin dish' && p[1] == 960;
expect(input.where(isTwin16), hasLength(2)); expect(input.where(isTwin16), hasLength(2));
expect( expect(
(testCase['expected'] as List).cast<List<Object?>>().where(isTwin16), (testCase['expected'] as List).cast<List<Object?>>().where(isTwin16),

View File

@ -106,9 +106,9 @@ void main() {
subject = dishes.take(3).toList(); subject = dishes.take(3).toList();
key = key.substring('first_three_'.length); 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 = [ final actual = [
for (final item in assignSlots(subject, hours)) item.slot, for (final item in assignSlots(subject, minutes)) item.slot,
]; ];
expect( expect(
actual, actual,
@ -124,10 +124,10 @@ void main() {
// total. Run repeatedly: an unstable sort need not misbehave every time. // total. Run repeatedly: an unstable sort need not misbehave every time.
for (var attempt = 0; attempt < 5; attempt++) { for (var attempt = 0; attempt < 5; attempt++) {
final ordered = assignSlots(parseMenu(fixture['payload']), const [ final ordered = assignSlots(parseMenu(fixture['payload']), const [
8, 480,
12, 720,
16, 960,
20, 1200,
]); ]);
expect( expect(
[for (final item in ordered) item.dish.name], [for (final item in ordered) item.dish.name],

View File

@ -1,94 +1,87 @@
/// Tests for the pure meal-schedule derivation. /// Tests for the pure meal-schedule derivation.
/// ///
/// The vector table below is duplicated verbatim from /// The cross-language exact values live in the shared fixture
/// `diet_guard/tests/test_meal_schedule.py`. KEEP THE TWO IN SYNC: it is the /// `tests/fixtures/meal_schedule_vectors.json`, asserted by
/// only thing that catches a Python/Dart divergence before it reaches a /// `meal_schedule_vectors_test.dart` (and by its Python mirror). This file
/// device, and a divergence there means one device nags for a checkpoint the /// holds the properties: the grid sweep, the hour-formula regression and the
/// other never offers -- a slot that can never be satisfied, i.e. a permanent /// clamps a human should read without opening the fixture. The sweeps are
/// lock. /// duplicated loop-for-loop in `diet_guard/tests/test_meal_schedule.py`.
library; library;
import 'package:diet_guard_app/models/meal_schedule.dart'; import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// (first, last, count, expected slots). Shared with the Python mirror. const int _h = 60;
const List<(int, int, int, List<int>)> scheduleVectors = [
// The eating window the user described, at every supported meal count. /// The pre-minute whole-hour derivation, converted to minutes.
(8, 20, 2, [8, 20]), List<int> _oldHourSlots(int first, int last, int count) {
(8, 20, 3, [8, 14, 20]), final span = last - first;
(8, 20, 4, [8, 12, 16, 20]), // today's hardcoded schedule final divisions = count - 1;
(8, 20, 5, [8, 11, 14, 17, 20]), // the user's stated example return [
(8, 20, 6, [8, 10, 13, 15, 18, 20]), for (var index = 0; index < count; index++)
// A window that does not divide evenly: 14 hours across 3 gaps. (first + (index * span + divisions ~/ 2) ~/ divisions) * _h,
(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, bool _strictlyAscending(List<int> slots) {
// so the slots stay distinct instead of repeating an hour. for (var i = 1; i < slots.length; i++) {
(8, 12, 6, [8, 9, 10, 11, 12]), if (slots[i - 1] >= slots[i]) return false;
(8, 10, 5, [8, 9, 10]), }
(8, 9, 4, [8, 9]), return true;
// Whole-day extremes. }
(0, 23, 6, [0, 5, 9, 14, 18, 23]),
(0, 1, 2, [0, 1]), MealSchedule _s(int first, int last, int count) =>
]; MealSchedule(firstMinute: first, lastMinute: last, count: count);
void main() { void main() {
group('slots', () { group('slots', () {
for (final (first, last, count, expected) in scheduleVectors) { test('the default is the historical schedule', () {
test('$first-$last x$count derives $expected', () { 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( expect(
MealSchedule(first: first, last: last, count: count).slots(), _s(first * _h, last * _h, count).slots(),
expected, _oldHourSlots(first, last, count),
); );
}); });
} }
test('the default is the historical schedule', () { test('off-grid endpoints are exact; only the interior snaps', () {
expect(kDefaultSchedule.slots(), [8, 12, 16, 20]); 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', () { test("the user's quarter-hour schedule", () {
final slots = const MealSchedule(first: 8, last: 20, count: 6).slots(); expect(_s(435, 1140, 5).slots(), [435, 615, 795, 960, 1140]);
expect(slots.first, 8);
expect(slots.last, 20);
}); });
}); });
group('normalized', () { group('normalized', () {
const cases = <(MealSchedule, MealSchedule)>[ final cases = <(MealSchedule, MealSchedule)>[
( (_s(480, 1200, 99), _s(480, 1200, kMaxMealCount)),
MealSchedule(first: 8, last: 20, count: 99), (_s(480, 1200, 0), _s(480, 1200, kMinMealCount)),
MealSchedule(first: 8, last: 20, count: kMaxMealCount), (_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.
MealSchedule(first: 8, last: 20, count: 0), (_s(720, 720, 4), _s(720, 735, 2)),
MealSchedule(first: 8, last: 20, count: kMinMealCount), (_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)),
MealSchedule(first: -5, last: 20, count: 4), // Count is capped by the grid: 60 minutes hold 5 quarter-hour marks.
MealSchedule(first: 0, last: 20, count: 4), (_s(480, 540, 6), _s(480, 540, 5)),
),
(
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),
),
]; ];
for (final (input, expected) in cases) { for (final (input, expected) in cases) {
test('$input clamps to $expected', () { test('$input clamps to $expected', () {
expect(input.normalized(), expected); expect(input.normalized(), expected);
@ -96,56 +89,82 @@ void main() {
} }
test('garbage still yields usable slots', () { test('garbage still yields usable slots', () {
expect( expect(_s(9999, -5, 999).slots(), [1424, 1439]);
const MealSchedule(first: 99, last: -5, count: 999).slots(),
[22, 23],
);
}); });
}); });
group('enforcementEndHour', () { group('enforcementEndMinute', () {
test('the default keeps the historical 22:00 cutoff', () { 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', () { test('the tail follows the last meal, to the minute', () {
expect( expect(_s(480, 1095, 4).enforcementEndMinute, 1215);
const MealSchedule(first: 8, last: 18, count: 4).enforcementEndHour,
20,
);
}); });
test('is clamped to the end of the day', () { test('is clamped to the end of the day', () {
// An unclamped 23 + 2 = 25 would make `hour < cutoff` vacuously true, // An unclamped 1380 + 120 = 1500 would make `minute < cutoff`
// so the enforcement window would never close. // vacuously true, so the enforcement window would never close.
expect( expect(_s(480, 1380, 4).enforcementEndMinute, kMinutesPerDay);
const MealSchedule(first: 8, last: 23, count: 4).enforcementEndHour, });
24,
); 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', () { group('wire', () {
// The Dart half of the cross-language parity guarantee; the Python mirror test('a whole-hour schedule encodes exactly as before', () {
// runs the identical sweep. expect(scheduleToWire(kDefaultSchedule), {'f': 8, 'l': 20, 'n': 4});
for (var first = -2; first < 26; first++) { });
for (var last = -2; last < 26; last++) {
for (var count = -2; count < 10; count++) { test('off-hour endpoints add minute fields', () {
final schedule = MealSchedule( expect(scheduleToWire(_s(435, 1181, 5)), {
first: first, 'f': 7,
last: last, 'l': 19,
count: count, '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 normalized = schedule.normalized();
final slots = schedule.slots(); 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); test('off-grid sweep stays strictly ascending', () {
expect(slots.last, normalized.last); for (var first = 0; first < 120; first += 7) {
expect(slots.length, normalized.count); for (var width = 15; width < 200; width += 11) {
expect(slots.toSet().length, slots.length); for (var count = kMinMealCount; count <= kMaxMealCount; count++) {
expect(slots, orderedEquals(List<int>.of(slots)..sort())); final slots = _s(first, first + width, count).slots();
expect(slots.length, greaterThanOrEqualTo(kMinMealCount)); if (!_strictlyAscending(slots)) fail('$first+$width x$count: $slots');
expect(slots.length, lessThanOrEqualTo(kMaxMealCount));
} }
} }
} }

View File

@ -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<int>();
return MealSchedule(
firstMinute: values[0],
lastMinute: values[1],
count: values[2],
);
}
DateTime _at(int minute) => DateTime(2026, 1, 1, minute ~/ 60, minute % 60);
List<int> _ints(Object? value) => (value! as List).cast<int>();
void main() {
late Map<String, dynamic> vectors;
setUpAll(() {
vectors =
jsonDecode(_fixtureFile.readAsStringSync()) as Map<String, dynamic>;
});
List<Map<String, dynamic>> rows(String key) =>
(vectors[key] as List).cast<Map<String, dynamic>>();
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<String, Object?>();
expect(entrySlotMinute(entry), row['minute'], reason: '$row');
}
});
}

View File

@ -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/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/models/slot.dart';
import 'package:flutter_test/flutter_test.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() { void main() {
group('daySlots', () { test('minuteOfDay counts minutes and ignores seconds', () {
test('returns the four fixed hourly slots', () { expect(minuteOfDay(DateTime(2026, 6, 22, 7, 15, 59)), 435);
expect(daySlots(kDefaultSchedule), [8, 12, 16, 20]); expect(minuteOfDay(_at(23, 59)), 1439);
}); });
test('daySlots returns the four default slots', () {
expect(daySlots(kDefaultSchedule), [480, 720, 960, 1200]);
}); });
group('withinEnforcementWindow', () { group('withinEnforcementWindow', () {
test('false before the day start hour', () { test('false at 07:59, true at 08:00', () {
expect( expect(withinEnforcementWindow(_at(7, 59), kDefaultSchedule), isFalse);
withinEnforcementWindow(DateTime(2026, 6, 22, 7, 59), kDefaultSchedule), expect(withinEnforcementWindow(_at(8), kDefaultSchedule), isTrue);
isFalse,
);
}); });
test('true at the day start hour', () { test('true at 21:59, false at the 22:00 cutoff', () {
expect( expect(withinEnforcementWindow(_at(21, 59), kDefaultSchedule), isTrue);
withinEnforcementWindow(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), expect(withinEnforcementWindow(_at(22), kDefaultSchedule), isFalse);
isTrue,
);
}); });
test('true just before the eating end hour', () { test('a 07:15 breakfast opens the window at 07:15', () {
expect( final schedule = _s(435, 1140, 5);
withinEnforcementWindow( expect(withinEnforcementWindow(_at(7, 14), schedule), isFalse);
DateTime(2026, 6, 22, 21, 59), expect(withinEnforcementWindow(_at(7, 15), schedule), isTrue);
kDefaultSchedule,
),
isTrue,
);
});
test('false at the eating end hour (exclusive)', () {
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 22, 0), kDefaultSchedule),
isFalse,
);
}); });
}); });
group('elapsedSlots', () { group('elapsedSlots', () {
test('empty outside the enforcement window', () { test('empty outside the enforcement window', () {
expect( expect(elapsedSlots(_at(23), kDefaultSchedule), isEmpty);
elapsedSlots(DateTime(2026, 6, 22, 23, 0), kDefaultSchedule), expect(elapsedSlots(_at(7, 59), kDefaultSchedule), isEmpty);
isEmpty,
);
}); });
test('only the 8 slot right at day start', () { test('grows through the day', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), [8]); 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', () { test('a slot opens on its minute', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 15, 59), kDefaultSchedule), [ final schedule = _s(435, 1140, 5);
8, expect(elapsedSlots(_at(10, 14), schedule), [435]);
12, expect(elapsedSlots(_at(10, 15), schedule), [435, 615]);
]);
});
test('all four slots once 20:00 has passed', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 21, 0), kDefaultSchedule), [
8,
12,
16,
20,
]);
}); });
}); });
group('missingSlots', () { test('missingSlots excludes logged slots', () {
test('excludes already-logged elapsed slots', () { expect(missingSlots(_at(17), {480}, kDefaultSchedule), [720, 960]);
expect( expect(missingSlots(_at(17), {480, 720, 960}, kDefaultSchedule), isEmpty);
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,
);
});
}); });
group('currentSlot', () { test('currentSlot is the latest elapsed slot, or null', () {
test('null outside the enforcement window', () { expect(currentSlot(_at(6), kDefaultSchedule), isNull);
expect( expect(currentSlot(_at(17, 41), kDefaultSchedule), 960);
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);
});
}); });
group('slotForLog', () { group('slotForLog', () {
// Keep in lockstep with `diet_guard/tests/test_slots.py`'s // Keep in lockstep with `diet_guard/tests/test_slots.py`'s
// TestSlotForLog: a divergence means the PC and the phone disagree about // TestSlotForLog: a divergence means the PC and the phone disagree about
// which checkpoint a meal satisfied. // which checkpoint a meal satisfied.
DateTime at(int hour) => DateTime(2026, 6, 22, hour, 0); test('clamps before the window and after it', () {
expect(slotForLog(_at(0), kDefaultSchedule), 480);
test('clamps to the first slot before the window opens', () { expect(slotForLog(_at(7, 59), kDefaultSchedule), 480);
expect(slotForLog(at(7), kDefaultSchedule), 8); 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', () { test('clamps the same way at every minute across schedules', () {
expect(slotForLog(at(0), kDefaultSchedule), 8); // `test_slots.py` runs the identical sweep.
}); final schedules = [
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 = [
kDefaultSchedule, kDefaultSchedule,
MealSchedule(first: 8, last: 20, count: 5), _s(480, 1200, 5),
MealSchedule(first: 6, last: 22, count: 6), _s(435, 1140, 5),
MealSchedule(first: 0, last: 23, count: 2), _s(443, 1181, 4),
MealSchedule(first: 10, last: 14, count: 3), _s(0, 1439, 2),
_s(600, 840, 3),
]; ];
for (final schedule in schedules) { for (final schedule in schedules) {
final slots = daySlots(schedule); final slots = daySlots(schedule);
for (var hour = 0; hour < 24; hour++) { for (var minute = 0; minute < 1440; minute++) {
final attributed = slotForLog(at(hour), schedule); final attributed = slotForLog(
expect(slots, contains(attributed)); _at(minute ~/ 60, minute % 60),
if (hour < slots.first) { schedule,
expect(attributed, slots.first); );
} else if (hour >= schedule.enforcementEndHour) { final int expected;
expect(attributed, slots.last); if (minute < slots.first) {
expected = slots.first;
} else if (minute >= schedule.enforcementEndMinute) {
expected = slots.last;
} else { } else {
// Inside the window: the most recent slot that has opened. expected = slots.where((s) => s <= minute).last;
expect(attributed, slots.where((s) => s <= hour).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', () { test('midnight is a real slot, not a falsy absence', () {
const schedule = MealSchedule(first: 0, last: 12, count: 3); final schedule = _s(0, 720, 3);
expect(daySlots(schedule), [0, 6, 12]); expect(daySlots(schedule), [0, 360, 720]);
expect(currentSlot(DateTime(2026, 6, 22), schedule), 0); expect(currentSlot(_at(0), schedule), 0);
expect(slotForLog(DateTime(2026, 6, 22), schedule), 0); expect(slotForLog(_at(0), schedule), 0);
}); });
}); });
group('slotLabel', () { group('nearestSlot / satisfiedSlots', () {
test('pads single-digit hours', () { test('an exact halfway goes to the earlier slot', () {
expect(slotLabel(8), '08:00'); expect(nearestSlot(600, kDefaultSchedule), 480);
expect(nearestSlot(601, kDefaultSchedule), 720);
}); });
test('formats double-digit hours', () { test('outside the window clamps to the ends', () {
expect(slotLabel(20), '20:00'); 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');
}); });
} }

View File

@ -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 A schedule is three numbers -- the first meal's minute of day, the last meal's
many meals fall between them inclusive -- from which the intermediate minute of day, and how many meals fall between them inclusive -- from which the
checkpoints are derived by even division. ``MealSchedule(8, 20, 5)`` yields intermediate checkpoints are derived by even division and rounded to the
``(8, 11, 14, 17, 20)``. 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 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 lives in :mod:`diet_guard._meal_schedule_store`, the slot arithmetic that
consumes a schedule lives in :mod:`diet_guard._slots`. 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 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 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 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 * **Integer arithmetic only.** No floats and no ``round()`` anywhere in the
derivation. Python's ``round`` is banker's rounding (``round(2.5) == 2``) 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 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. 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 * **Clamp, don't reject.** Every out-of-range input is normalised to the
nearest legal schedule rather than raising, so the two languages cannot nearest legal schedule rather than raising, so the two languages cannot
disagree about which inputs are errors. 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 __future__ import annotations
from dataclasses import dataclass 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 # 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 # stay legible on a phone at the widest (all-logged) size -- see
@ -33,8 +49,13 @@ from dataclasses import dataclass
MIN_MEAL_COUNT = 2 MIN_MEAL_COUNT = 2
MAX_MEAL_COUNT = 6 MAX_MEAL_COUNT = 6
FIRST_HOUR = 0 MINUTES_PER_DAY = 1440
LAST_HOUR = 23
# 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 # 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 # 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 # 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 # midnight at four meals, contradicting the "don't trap me overnight" intent
# this cutoff exists to serve. # 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: 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. """An eating window and the number of meals inside it.
Attributes: Attributes:
first: Hour of the first meal, 0-23. first_minute: Minute of day of the first meal, 0-1424.
last: Hour of the last meal, strictly after ``first``. 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. count: Total meals including both endpoints.
""" """
first: int first_minute: int
last: int last_minute: int
count: int count: int
def normalized(self) -> MealSchedule: def normalized(self) -> MealSchedule:
"""Return an equivalent schedule guaranteed to satisfy the invariants. """Return an equivalent schedule guaranteed to satisfy the invariants.
Ordering matters: ``first`` is clamped into the day, then ``last`` is Ordering matters: ``first_minute`` is clamped into the day leaving room
clamped to leave at least one hour of window, then ``count`` is clamped for one grid step, then ``last_minute`` is clamped to at least one grid
to the window's width. That last clamp is the load-bearing one -- see step after it, then ``count`` is clamped to how many grid-spaced
:meth:`slots`. checkpoints the window holds. That last clamp is the load-bearing one
-- see :meth:`slots`.
Returns: Returns:
A schedule whose ``slots()`` is strictly ascending. A schedule whose ``slots()`` is strictly ascending.
""" """
first = _clamp(self.first, FIRST_HOUR, LAST_HOUR - 1) first = _clamp(self.first_minute, 0, _LAST_MINUTE - SLOT_GRID_MINUTES)
last = _clamp(self.last, first + 1, LAST_HOUR) last = _clamp(self.last_minute, first + SLOT_GRID_MINUTES, _LAST_MINUTE)
# A window of N hours holds at most N+1 whole-hour checkpoints; asking # A window of W minutes holds at most W//15 + 1 checkpoints spaced a
# for more would repeat an hour (see slots()). # grid step apart; asking for more would round two onto one mark.
count = _clamp( capacity = (last - first) // SLOT_GRID_MINUTES + 1
self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, last - first + 1) count = _clamp(self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, capacity))
)
return MealSchedule(first, last, count) return MealSchedule(first, last, count)
def slots(self) -> tuple[int, ...]: 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 With ``span = last - first`` and ``d = count - 1``, interior slot *i*
integer arithmetic: slot *i* is ``first + (i*span + d//2) // d`` where is ``raw = first + (i*span + d//2) // d`` (even division, the ``d//2``
``span = last - first`` and ``d = count - 1``. The ``d//2`` term is a term a round-half-up bias that keeps it float-free), then snapped to
round-half-up bias applied before the division, which is what keeps the nearest absolute quarter hour by ``(raw + 7) // 15 * 15``. The
this float-free. 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, Strictly ascending, provably: :meth:`normalized` caps ``count`` so that
so the eating window is always honoured even when the interior spacing ``span / d >= 15``, hence consecutive ``raw`` values differ by at least
has to round. 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
The result is strictly ascending because :meth:`normalized` caps is at least 8 past ``first`` and the last at least 8 before ``last``.
``count`` at ``last - first + 1``. Without that cap a narrow window That matters because slot minutes are set members, dict keys *and*
repeats an hour (``08-12`` with 6 meals would give notification ids: a repeat silently drops a checkpoint.
``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.
Returns: Returns:
The slot hours, ascending, of length ``normalized().count``. The slot minutes, ascending, of length ``normalized().count``.
""" """
schedule = self.normalized() schedule = self.normalized()
span = schedule.last - schedule.first first, last = schedule.first_minute, schedule.last_minute
span = last - first
divisions = schedule.count - 1 divisions = schedule.count - 1
return tuple( interior = (
schedule.first + (index * span + divisions // 2) // divisions (first + (index * span + divisions // 2) // divisions + 7)
for index in range(schedule.count) // SLOT_GRID_MINUTES
* SLOT_GRID_MINUTES
for index in range(1, divisions)
) )
return (first, *interior, last)
@property @property
def enforcement_end_hour(self) -> int: def enforcement_end_minute(self) -> int:
"""Return the hour at which slot enforcement stops for the day. """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 Derived from the *normalised* last meal, so it always agrees with the
the cutoff at 25, making ``hour < cutoff`` vacuously true so the last entry of :meth:`slots`. Clamped to the end of the day: a 23:00
enforcement window never closes and the gate can never stop firing. 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 # 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 # enforcement window closing at 22:00. Still what a device uses before the
# user has ever chosen anything, so upgrading changes no behaviour. # 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:<date>`` 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:<date>`` 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()

109
diet_guard/_slot_wire.py Normal file
View File

@ -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

View File

@ -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 The stateful "which slots have I actually logged?" question lives in
:mod:`diet_guard._state`; the two are composed in :mod:`diet_guard._gate`. :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, A "slot" is the *minute of day* (``0..1439``) at which a meal checkpoint opens
16, 20 by default). A slot is *elapsed* once its hour has arrived and we are (480, 720, 960, 1200 -- 08:00, 12:00, 16:00, 20:00 -- by default). A slot is
still inside the daily enforcement window; an elapsed slot with no logged meal *elapsed* once its minute has arrived and we are still inside the daily
is what makes the gate fire. 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 ``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 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. satisfied. Making it required lets mypy enumerate the call sites instead.
Callers resolve the value at the impure edge, mirroring how Callers resolve the value at the impure edge, mirroring how
:mod:`diet_guard._daystatus` takes an explicit budget schedule. :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 from __future__ import annotations
@ -27,20 +37,27 @@ from __future__ import annotations
from datetime import date, datetime, time from datetime import date, datetime, time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard._meal_schedule import MINUTES_PER_DAY
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from diet_guard._meal_schedule import MealSchedule 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, ...]: 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: Args:
schedule: The eating window and meal count to derive slots from. schedule: The eating window and meal count to derive slots from.
Returns: Returns:
The slot hours in ascending order. The slot minutes in ascending order.
""" """
return schedule.slots() return schedule.slots()
@ -58,11 +75,12 @@ def within_enforcement_window(now: datetime, schedule: MealSchedule) -> bool:
Returns: Returns:
True if slot enforcement is active at ``now``. 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, ...]: 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 Empty outside the enforcement window (before the first slot, or after the
overnight cutoff), so the caller never has to special-case the night. 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. schedule: The schedule in force for that day.
Returns: Returns:
The elapsed slot hours, ascending (possibly empty). The elapsed slot minutes, ascending (possibly empty).
""" """
if not within_enforcement_window(now, schedule): if not within_enforcement_window(now, schedule):
return () 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( def missing_slots(
@ -86,11 +105,12 @@ def missing_slots(
Args: Args:
now: Reference local time. 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. schedule: The schedule in force for that day.
Returns: 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) 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. schedule: The schedule in force for that day.
Returns: 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) elapsed = elapsed_slots(now, schedule)
return elapsed[-1] if elapsed else None 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, 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 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 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 the value -- the shared fixture pins every minute edge for exactly that
schedules for exactly that reason. reason.
Unlike :func:`current_slot` this never returns None, which is the point: an 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 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. schedule: The schedule in force for that day.
Returns: Returns:
The slot hour to tag the log with. The slot minute to tag the log with.
""" """
slots = day_slots(schedule) slots = day_slots(schedule)
if now.hour < slots[0]: if minute_of_day(now) < slots[0]:
return slots[0] return slots[0]
current = current_slot(now, schedule) current = current_slot(now, schedule)
return current if current is not None else slots[-1] 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( def resolve_future_when(
date_str: str, hour: int, schedule: MealSchedule, now: datetime date_str: str, slot_minute: int, schedule: MealSchedule, now: datetime
) -> datetime: ) -> datetime:
"""Build a tz-aware datetime for a meal pre-logged against a future slot. """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- Lets a caller (the CLI's ``--date`` flag, or the app's future-date picker)
date picker) turn a user-picked date+hour into the ``when`` that turn a user-picked date+slot into the ``when`` that
:func:`diet_guard._state.log_meal` expects, with the same validation on :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 both platforms. Still clock-free per the module's own rule: ``now`` is
supplied by the caller rather than read here. supplied by the caller rather than read here.
Args: Args:
date_str: The chosen date as ``YYYY-MM-DD``. date_str: The chosen date as ``YYYY-MM-DD``.
hour: The chosen slot hour; must be one of ``schedule``'s slot hours. slot_minute: The chosen slot; must be one of ``schedule``'s slots.
schedule: The schedule in force, used to validate ``hour``. schedule: The schedule in force, used to validate ``slot_minute``.
now: Reference local time, used to reject a non-future date. now: Reference local time, used to reject a non-future date.
Returns: Returns:
A tz-aware datetime combining ``date_str`` and ``hour``, in ``now``'s A tz-aware datetime combining ``date_str`` and ``slot_minute``, in
timezone. ``now``'s timezone.
Raises: Raises:
ValueError: ``date_str`` doesn't parse, ``hour`` isn't a slot hour, or ValueError: ``date_str`` doesn't parse, ``slot_minute`` isn't a slot,
the resulting date isn't strictly after ``now``'s date. or the resulting date isn't strictly after ``now``'s date.
""" """
try: try:
parsed_date = date.fromisoformat(date_str) 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" msg = f"invalid date {date_str!r}, expected YYYY-MM-DD"
raise ValueError(msg) from exc raise ValueError(msg) from exc
slots = day_slots(schedule) slots = day_slots(schedule)
if hour not in slots: if slot_minute not in slots:
msg = f"hour {hour} is not a meal slot; choose one of {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) raise ValueError(msg)
if parsed_date <= now.date(): if parsed_date <= now.date():
msg = f"{date_str} is not a future date" msg = f"{date_str} is not a future date"
raise ValueError(msg) 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: def slot_label(minute: int) -> str:
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``.""" """Return a human ``HH:MM`` label for a slot minute, e.g. ``"07:15"``."""
return f"{slot % _HOURS_PER_DAY:02d}:00" wrapped = minute % MINUTES_PER_DAY
return f"{wrapped // 60:02d}:{wrapped % 60:02d}"

View File

@ -57,7 +57,7 @@ def _dishes(fixture: dict[str, Any]) -> list[Dish]:
def _slots_for(key: str) -> tuple[int, ...]: 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(",")) 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.""" """``i * S // N`` lands each dish on the slot the Dart side also picks."""
for key, expected in fixture["expected"]["slots"].items(): for key, expected in fixture["expected"]["slots"].items():
subject = dishes[:3] if key.startswith("first_three_") else dishes subject = dishes[:3] if key.startswith("first_three_") else dishes
hours = _slots_for(key.removeprefix("first_three_")) minutes = _slots_for(key.removeprefix("first_three_"))
actual = [item.slot for item in assign_slots(subject, hours)] actual = [item.slot for item in assign_slots(subject, minutes)]
assert actual == expected, f"slot assignment diverged for {key}" 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 dishes sharing both priority and name are the case that forces both
comparators to be total. 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"] 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"] fill = fixture["expected"]["fill"]
case = fill["log_dishes"]["twins_share_16"] case = fill["log_dishes"]["twins_share_16"]
spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)] spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)]
assert spread.count(("Twin dish", 16)) == 2 assert spread.count(("Twin dish", 960)) == 2
assert case["expected"].count(["Twin dish", 16]) == 1 assert case["expected"].count(["Twin dish", 960]) == 1

View File

@ -1,65 +1,73 @@
"""Tests for the pure meal-schedule derivation. """Tests for the pure meal-schedule derivation.
The vector table below is duplicated verbatim in The cross-language exact values live in the shared fixture
``app/test/models/meal_schedule_test.dart``. KEEP THE TWO IN SYNC: it is the ``tests/fixtures/meal_schedule_vectors.json`` and are asserted by
only thing that catches a Python/Dart divergence before it reaches a device, ``test_meal_schedule_vectors.py`` (and by its Dart mirror). This file holds
and a divergence there means one device nags for a checkpoint the other never the properties: the grid sweep, the hour-formula regression and the clamps a
offers -- a slot that can never be satisfied, i.e. a permanent lock. 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 __future__ import annotations
from itertools import pairwise
import pytest import pytest
from diet_guard._meal_schedule import ( from diet_guard._meal_schedule import (
DEFAULT_SCHEDULE, DEFAULT_SCHEDULE,
MAX_MEAL_COUNT, MAX_MEAL_COUNT,
MIN_MEAL_COUNT, MIN_MEAL_COUNT,
MINUTES_PER_DAY,
SLOT_GRID_MINUTES,
MealSchedule, MealSchedule,
schedule_from_wire,
schedule_to_wire,
) )
# (first, last, count) -> expected slot hours. Shared with the Dart mirror. _H = 60
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)), def _old_hour_slots(first: int, last: int, count: int) -> tuple[int, ...]:
(8, 20, 3, (8, 14, 20)), """Return the pre-minute whole-hour derivation, converted to minutes."""
(8, 20, 4, (8, 12, 16, 20)), # today's hardcoded schedule span, divisions = last - first, count - 1
(8, 20, 5, (8, 11, 14, 17, 20)), # the user's stated example return tuple(
(8, 20, 6, (8, 10, 13, 15, 18, 20)), (first + (index * span + divisions // 2) // divisions) * _H
# A window that does not divide evenly: 14 hours across 3 gaps. for index in range(count)
(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)),
]
class TestSlots: class TestSlots:
"""Slot derivation from a schedule.""" """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: def test_default_is_the_historical_schedule(self) -> None:
"""The default reproduces the hours that used to be hardcoded.""" """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: @pytest.mark.parametrize(
"""Rounding the interior never drags an endpoint off the window.""" ("first", "last", "count"), [(8, 20, 4), (8, 20, 5), (7, 19, 5)]
slots = MealSchedule(8, 20, 6).slots() )
assert slots[0] == 8 def test_whole_hour_schedules_match_the_old_hour_formula(
assert slots[-1] == 20 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: class TestNormalization:
@ -68,15 +76,17 @@ class TestNormalization:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("schedule", "expected"), ("schedule", "expected"),
[ [
(MealSchedule(8, 20, 99), MealSchedule(8, 20, MAX_MEAL_COUNT)), (MealSchedule(480, 1200, 99), MealSchedule(480, 1200, MAX_MEAL_COUNT)),
(MealSchedule(8, 20, 0), MealSchedule(8, 20, MIN_MEAL_COUNT)), (MealSchedule(480, 1200, 0), MealSchedule(480, 1200, MIN_MEAL_COUNT)),
(MealSchedule(-5, 20, 4), MealSchedule(0, 20, 4)), (MealSchedule(-5, 1200, 4), MealSchedule(0, 1200, 4)),
(MealSchedule(8, 99, 4), MealSchedule(8, 23, 4)), (MealSchedule(480, 9999, 4), MealSchedule(480, 1439, 4)),
# last <= first is pulled forward to leave a one-hour window. # last <= first is pulled forward to leave one grid step of window.
(MealSchedule(12, 12, 4), MealSchedule(12, 13, 2)), (MealSchedule(720, 720, 4), MealSchedule(720, 735, 2)),
(MealSchedule(12, 3, 4), MealSchedule(12, 13, 2)), (MealSchedule(720, 3, 4), MealSchedule(720, 735, 2)),
# first cannot occupy the final hour, or no window would remain. # first cannot sit in the final grid step, or no window remains.
(MealSchedule(23, 23, 2), MealSchedule(22, 23, 2)), (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( def test_clamps_to_the_nearest_legal_schedule(
@ -87,48 +97,79 @@ class TestNormalization:
def test_garbage_still_yields_usable_slots(self) -> None: def test_garbage_still_yields_usable_slots(self) -> None:
"""Wildly invalid input degrades to a schedule, not an exception.""" """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.""" """The daily cutoff derived from the last meal."""
def test_default_keeps_the_historical_cutoff(self) -> None: def test_default_keeps_the_historical_cutoff(self) -> None:
"""The default schedule still stops enforcing at 22:00.""" """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: def test_tail_follows_the_last_meal(self) -> None:
"""Moving the last meal moves the cutoff with it.""" """Moving the last meal moves the cutoff with it, to the minute."""
assert MealSchedule(8, 18, 4).enforcement_end_hour == 20 assert MealSchedule(480, 1095, 4).enforcement_end_minute == 1215
def test_clamped_to_the_end_of_the_day(self) -> None: def test_clamped_to_the_end_of_the_day(self) -> None:
"""A late last meal cannot push the cutoff past midnight. """A late last meal cannot push the cutoff past midnight.
An unclamped 23 + 2 = 25 would make ``hour < cutoff`` vacuously true, An unclamped 1380 + 120 = 1500 would make ``minute < cutoff``
so the enforcement window would never close. 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:<date>`` 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: class TestExhaustiveInvariants:
"""Properties that must hold for every input the UI or sync can produce.""" """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: def test_grid_sweep_yields_ascending_slots_with_exact_endpoints(self) -> None:
"""Sweep the whole input space, including out-of-range values. """Sweep every quarter-hour first/last pair at every count.
This is the cheap half of the cross-language parity guarantee; the The Dart mirror runs the identical loop. A wire round trip rides
Dart mirror runs the identical sweep. along, because it is the same sweep's worth of schedules.
""" """
for first in range(-2, 26): for first in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES):
for last in range(-2, 26): for last in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES):
for count in range(-2, 10): for count in range(MIN_MEAL_COUNT, MAX_MEAL_COUNT + 1):
schedule = MealSchedule(first, last, count) schedule = MealSchedule(first, last, count)
normalized = schedule.normalized() normalized = schedule.normalized()
slots = schedule.slots() slots = schedule.slots()
assert slots[0] == normalized.first_minute
assert slots[0] == normalized.first assert slots[-1] == normalized.last_minute
assert slots[-1] == normalized.last
assert len(slots) == normalized.count assert len(slots) == normalized.count
assert len(set(slots)) == len(slots) assert all(a < b for a, b in pairwise(slots))
assert list(slots) == sorted(slots) assert slots[0] >= 0
assert MIN_MEAL_COUNT <= len(slots) <= MAX_MEAL_COUNT 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))

View File

@ -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

View File

@ -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)

View File

@ -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 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 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 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. 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 from __future__ import annotations
@ -15,6 +18,7 @@ from diet_guard._slots import (
current_slot, current_slot,
day_slots, day_slots,
elapsed_slots, elapsed_slots,
minute_of_day,
missing_slots, missing_slots,
slot_for_log, slot_for_log,
slot_label, slot_label,
@ -22,45 +26,64 @@ from diet_guard._slots import (
) )
def _at(hour: int) -> datetime: def _at(hour: int, minute: int = 0) -> datetime:
"""Return a fixed local datetime at ``hour`` (date is irrelevant here).""" """Return a fixed local datetime at ``hour:minute`` (date is irrelevant)."""
return datetime(2026, 1, 1, hour, 0, tzinfo=UTC) 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: class TestDaySlots:
"""The fixed slot schedule derived from the constants.""" """The slot schedule derived from a schedule."""
def test_default_schedule(self) -> None: def test_default_schedule(self) -> None:
"""Slots open every 4h from 08:00 up to (not past) the 22:00 cutoff.""" """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: 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: def test_before_window(self) -> None:
"""An hour before the first slot is outside the window.""" """07:59 is outside the window."""
assert not within_enforcement_window(_at(7), DEFAULT_SCHEDULE) assert not within_enforcement_window(_at(7, 59), DEFAULT_SCHEDULE)
def test_first_slot_is_inside(self) -> None: 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) assert within_enforcement_window(_at(8), DEFAULT_SCHEDULE)
def test_last_active_hour_inside(self) -> None: def test_last_active_minute_inside(self) -> None:
"""21:00 is still inside; the cutoff is exclusive at 22:00.""" """21:59 is still inside; the cutoff is exclusive at 22:00."""
assert within_enforcement_window(_at(21), DEFAULT_SCHEDULE) assert within_enforcement_window(_at(21, 59), DEFAULT_SCHEDULE)
def test_cutoff_is_outside(self) -> None: 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) 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: class TestElapsedSlots:
"""Which slots have arrived as of now.""" """Which slots have arrived as of now."""
def test_empty_before_window(self) -> None: def test_empty_before_window(self) -> None:
"""Before the first slot, nothing has elapsed.""" """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: def test_empty_after_cutoff(self) -> None:
"""After the overnight cutoff, slots lapse to empty.""" """After the overnight cutoff, slots lapse to empty."""
@ -68,15 +91,21 @@ class TestElapsedSlots:
def test_first_slot_only(self) -> None: def test_first_slot_only(self) -> None:
"""At 08:00 exactly, only the 08:00 slot has elapsed.""" """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: def test_midday(self) -> None:
"""At 13:00, the 08:00 and 12:00 slots have elapsed.""" """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: def test_all_elapsed_late(self) -> None:
"""At 21:00, every slot for the day has elapsed.""" """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: class TestMissingSlots:
@ -84,11 +113,11 @@ class TestMissingSlots:
def test_none_missing_when_all_logged(self) -> None: def test_none_missing_when_all_logged(self) -> None:
"""All elapsed slots logged -> nothing due.""" """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: def test_reports_unlogged(self) -> None:
"""Only the unlogged elapsed slots are returned, ascending.""" """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: class TestCurrentSlot:
@ -100,7 +129,7 @@ class TestCurrentSlot:
def test_latest_elapsed(self) -> None: def test_latest_elapsed(self) -> None:
"""At 13:00 the current slot is 12:00 (the latest elapsed).""" """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: class TestSlotForLog:
@ -113,108 +142,78 @@ class TestSlotForLog:
def test_before_the_first_window_clamps_to_the_first_slot(self) -> None: def test_before_the_first_window_clamps_to_the_first_slot(self) -> None:
"""An early breakfast counts toward 08:00 rather than nothing.""" """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: def test_midnight_clamps_to_the_first_slot(self) -> None:
"""The small hours are still "before the first window".""" """The small hours are still "before the first window"."""
assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 8 assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 480
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
def test_inside_the_window_matches_current_slot(self) -> None: def test_inside_the_window_matches_current_slot(self) -> None:
"""Within the window attribution is just the latest elapsed slot.""" """Within the window attribution is just the latest elapsed slot."""
assert ( assert slot_for_log(_at(13), DEFAULT_SCHEDULE) == 720
slot_for_log(_at(13), DEFAULT_SCHEDULE)
== current_slot(_at(13), DEFAULT_SCHEDULE)
== 12
)
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.""" """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: def test_after_the_last_window_clamps_to_the_last_slot(self) -> None:
"""A late dinner counts toward 20:00 rather than nothing.""" """A late dinner counts toward 20:00 rather than nothing."""
assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 20 assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 1200
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)
)
def test_clamps_across_configured_schedules(self) -> None: 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 ``slot_test.dart`` runs the identical sweep: a device that attributes
identical sweep: the two must agree on every cell, because a device a meal to a different slot than its peer leaves the other device's
that attributes a meal to a different slot than its peer leaves the checkpoint permanently unsatisfied.
other device's checkpoint permanently unsatisfied.
""" """
for schedule in ( for schedule in (
DEFAULT_SCHEDULE, DEFAULT_SCHEDULE,
MealSchedule(8, 20, 5), MealSchedule(480, 1200, 5),
MealSchedule(6, 22, 6), MealSchedule(435, 1140, 5),
MealSchedule(0, 23, 2), MealSchedule(443, 1181, 4),
MealSchedule(10, 14, 3), MealSchedule(0, 1439, 2),
MealSchedule(600, 840, 3),
): ):
slots = day_slots(schedule) slots = day_slots(schedule)
for hour in range(24): for minute in range(1440):
attributed = slot_for_log(_at(hour), schedule) attributed = slot_for_log(_at(minute // 60, minute % 60), schedule)
assert attributed in slots if minute < slots[0]:
if hour < slots[0]:
assert attributed == slots[0] assert attributed == slots[0]
elif hour >= schedule.enforcement_end_hour: elif minute >= schedule.enforcement_end_minute:
assert attributed == slots[-1] assert attributed == slots[-1]
else: else:
# Inside the window: the most recent slot that has opened. assert attributed == max(s for s in slots if s <= minute)
assert attributed == max(s for s in slots if s <= hour)
class TestSlotLabel: class TestSlotLabel:
"""Human HH:00 labels.""" """Human HH:MM labels."""
def test_morning_zero_padded(self) -> None: def test_zero_padded(self) -> None:
"""A single-digit hour is zero-padded.""" """Hours and minutes are both zero-padded."""
assert slot_label(8) == "08:00" assert slot_label(435) == "07:15"
assert slot_label(5) == "00:05"
def test_evening(self) -> None: def test_evening(self) -> None:
"""A two-digit hour formats plainly.""" """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: class TestConfiguredSchedules:
"""Slot arithmetic against schedules other than the default.""" """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: 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". """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``: Guards the ``or`` bug this used to have in ``_gatelock._pending_slots``:
``current_slot(...) or day_slots()[0]`` silently swallowed slot 0. ``current_slot(...) or day_slots()[0]`` silently swallowed slot 0.
""" """
schedule = MealSchedule(0, 12, 3) schedule = MealSchedule(0, 720, 3)
assert day_slots(schedule) == (0, 6, 12) assert day_slots(schedule) == (0, 360, 720)
assert current_slot(_at(0), schedule) == 0 assert current_slot(_at(0), schedule) == 0
assert current_slot(_at(0), schedule) is not None
assert slot_for_log(_at(0), schedule) == 0 assert slot_for_log(_at(0), schedule) == 0

View File

@ -21,44 +21,49 @@ def _at(hour: int) -> datetime:
class TestResolveFutureWhen: class TestResolveFutureWhen:
"""Validation and construction for a pre-logged future meal's datetime.""" """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.""" """A future date on a real slot hour resolves to that datetime."""
now = _at(10) 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) assert when == datetime(2026, 1, 2, 12, 0, tzinfo=UTC)
def test_preserves_nows_timezone(self) -> None: def test_preserves_nows_timezone(self) -> None:
"""The returned datetime carries ``now``'s tzinfo, not a bare one.""" """The returned datetime carries ``now``'s tzinfo, not a bare one."""
now = _at(10) 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 assert when.tzinfo == now.tzinfo
def test_rejects_malformed_date(self) -> None: def test_rejects_malformed_date(self) -> None:
"""A date string that doesn't parse raises ValueError.""" """A date string that doesn't parse raises ValueError."""
with pytest.raises(ValueError, match="invalid date"): 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: def test_rejects_minute_not_a_slot(self) -> None:
"""An hour that isn't one of the schedule's slots raises ValueError.""" """A minute that is not one of the schedule's slots raises ValueError."""
with pytest.raises(ValueError, match="not a meal slot"): 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: def test_rejects_todays_date(self) -> None:
"""Today's own date is not "future", even on a real slot hour.""" """Today's own date is not "future", even on a real slot hour."""
now = datetime(2026, 1, 1, 10, 0, tzinfo=UTC) now = datetime(2026, 1, 1, 10, 0, tzinfo=UTC)
with pytest.raises(ValueError, match="not a future date"): 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: def test_rejects_past_date(self) -> None:
"""A date before today raises ValueError.""" """A date before today raises ValueError."""
now = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) now = datetime(2026, 1, 5, 10, 0, tzinfo=UTC)
with pytest.raises(ValueError, match="not a future date"): 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: def test_accepts_every_configured_slot(self) -> None:
"""Every hour the schedule actually opens is accepted, not just one.""" """Every slot the schedule actually opens is accepted, to the minute."""
schedule = MealSchedule(8, 20, 5) schedule = MealSchedule(435, 1140, 5)
now = _at(10) now = _at(10)
for hour in day_slots(schedule): for slot in day_slots(schedule):
when = resolve_future_when("2026-01-02", hour, schedule, now) when = resolve_future_when("2026-01-02", slot, schedule, now)
assert when.hour == hour 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))

View File

@ -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}

View File

@ -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 #: 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: #: 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. #: 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)) _ALL = list(range(8))
_SLOTS = [8, 12, 16, 20] _SLOTS = [480, 720, 960, 1200]
_PLAN_CASES = { _PLAN_CASES = {
"empty_day": {"take": _ALL, "occupied": []}, "empty_day": {"take": _ALL, "occupied": []},
"occupied_12": {"take": _ALL, "occupied": [12]}, "occupied_12": {"take": _ALL, "occupied": [720]},
# First five in slot order: pancakes and hummus double into 8, both kept. # First five in slot order: pancakes and hummus double into 08:00, both kept.
"five_on_four": {"take": _ALL[:5], "occupied": [16]}, "five_on_four": {"take": _ALL[:5], "occupied": [960]},
"full_day": {"take": _ALL, "occupied": _SLOTS}, "full_day": {"take": _ALL, "occupied": _SLOTS},
} }
# Case/space-insensitive name match, and the same name in another slot is no # 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. # 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 = { _LOG_CASES = {
"empty_day": {"take": _ALL, "today": []}, "empty_day": {"take": _ALL, "today": []},
"twins_share_16": {"take": _ALL[1:], "today": []}, "twins_share_16": {"take": _ALL[1:], "today": []},
@ -201,7 +204,10 @@ def build() -> dict[str, object]:
"showNutrition": True, "showNutrition": True,
} }
dishes = parse_menu(payload) 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 { return {
"_comment": ( "_comment": (
"Shared parity fixture for the Kuchnia Wikinga catering import. " "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], "dishes": [_dish_json(dish) for dish in dishes],
"dropped_count": len(payload["deliveryMenuMeal"]) - len(dishes), "dropped_count": len(payload["deliveryMenuMeal"]) - len(dishes),
"slots": { "slots": {
"8,12,16,20": [s.slot for s in assign_slots(dishes, default_slots)], **spreads,
"8,12,16,20,22": [
s.slot for s in assign_slots(dishes, (8, 12, 16, 20, 22))
],
# The other direction: fewer dishes than slots. # The other direction: fewer dishes than slots.
"first_three_8,12,16,20": [ "first_three_480,720,960,1200": [
s.slot for s in assign_slots(dishes[:3], default_slots) 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_keys": [dish.name.strip().casefold() for dish in dishes],
"bank_records": [dish_to_record(dish) for dish in dishes], "bank_records": [dish_to_record(dish) for dish in dishes],
"fill": _fill_cases(dishes), "fill": _fill_cases(dishes),

View File

@ -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()

125
scripts/meal_schedule_cases.py Executable file
View File

@ -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},
{},
]

View File

@ -174,35 +174,35 @@
"expected": [ "expected": [
[ [
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
8 480
], ],
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
12 720
], ],
[ [
"Fit ciasto, jak twix white", "Fit ciasto, jak twix white",
12 720
], ],
[ [
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
16 960
], ],
[ [
"Twin dish", "Twin dish",
16 960
], ],
[ [
"Twin dish", "Twin dish",
20 1200
], ],
[ [
"No priority, kept", "No priority, kept",
20 1200
] ]
], ],
"occupied": [], "occupied": [],
@ -221,23 +221,23 @@
"expected": [ "expected": [
[ [
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
8 480
], ],
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", "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", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
20 1200
] ]
], ],
"occupied": [ "occupied": [
16 960
], ],
"take": [ "take": [
0, 0,
@ -250,10 +250,10 @@
"full_day": { "full_day": {
"expected": [], "expected": [],
"occupied": [ "occupied": [
8, 480,
12, 720,
16, 960,
20 1200
], ],
"take": [ "take": [
0, 0,
@ -270,31 +270,31 @@
"expected": [ "expected": [
[ [
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
8 480
], ],
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
16 960
], ],
[ [
"Twin dish", "Twin dish",
16 960
], ],
[ [
"Twin dish", "Twin dish",
20 1200
], ],
[ [
"No priority, kept", "No priority, kept",
20 1200
] ]
], ],
"occupied": [ "occupied": [
12 720
], ],
"take": [ "take": [
0, 0,
@ -313,35 +313,35 @@
"expected": [ "expected": [
[ [
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy", "Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
8 480
], ],
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
12 720
], ],
[ [
"Fit ciasto, jak twix white", "Fit ciasto, jak twix white",
12 720
], ],
[ [
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
16 960
], ],
[ [
"Twin dish", "Twin dish",
16 960
], ],
[ [
"Twin dish", "Twin dish",
20 1200
], ],
[ [
"No priority, kept", "No priority, kept",
20 1200
] ]
], ],
"take": [ "take": [
@ -360,23 +360,23 @@
"expected": [ "expected": [
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
8 480
], ],
[ [
"Fit ciasto, jak twix white", "Fit ciasto, jak twix white",
12 720
], ],
[ [
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
12 720
], ],
[ [
"No priority, kept", "No priority, kept",
20 1200
] ]
], ],
"take": [ "take": [
@ -391,11 +391,11 @@
"today": [ "today": [
{ {
"desc": " twin DISH ", "desc": " twin DISH ",
"slot": 16 "slot": 960
}, },
{ {
"desc": "No priority, kept", "desc": "No priority, kept",
"slot": 8 "slot": 480
} }
] ]
}, },
@ -403,27 +403,27 @@
"expected": [ "expected": [
[ [
"Hummus paprykowy, mini wafle kukurydziane, warzywa", "Hummus paprykowy, mini wafle kukurydziane, warzywa",
8 480
], ],
[ [
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski", "Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
8 480
], ],
[ [
"Fit ciasto, jak twix white", "Fit ciasto, jak twix white",
12 720
], ],
[ [
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim", "Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
12 720
], ],
[ [
"Twin dish", "Twin dish",
16 960
], ],
[ [
"No priority, kept", "No priority, kept",
20 1200
] ]
], ],
"take": [ "take": [
@ -439,10 +439,10 @@
} }
}, },
"slots": [ "slots": [
8, 480,
12, 720,
16, 960,
20 1200
] ]
}, },
"slot_order": [ "slot_order": [
@ -456,30 +456,30 @@
"No priority, kept" "No priority, kept"
], ],
"slots": { "slots": {
"8,12,16,20": [ "480,720,960,1200": [
8, 480,
8, 480,
12, 720,
12, 720,
16, 960,
16, 960,
20, 1200,
20 1200
], ],
"8,12,16,20,22": [ "480,720,960,1200,1320": [
8, 480,
8, 480,
12, 720,
12, 720,
16, 960,
20, 1200,
20, 1200,
22 1320
], ],
"first_three_8,12,16,20": [ "first_three_480,720,960,1200": [
8, 480,
12, 720,
16 960
] ]
} }
}, },

4640
tests/fixtures/meal_schedule_vectors.json vendored Normal file

File diff suppressed because it is too large Load Diff