mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
5cd89a4e99
commit
8bc1812147
@ -1,9 +1,15 @@
|
||||
/// The user's eating window and meal count, and the slot hours they imply.
|
||||
/// The user's eating window and meal count, and the slot minutes they imply.
|
||||
///
|
||||
/// A schedule is three numbers -- the first meal hour, the last meal hour, and
|
||||
/// how many meals fall between them inclusive -- from which the intermediate
|
||||
/// checkpoints are derived by even division. `MealSchedule(8, 20, 5)` yields
|
||||
/// `[8, 11, 14, 17, 20]`.
|
||||
/// A schedule is three numbers -- the first meal's minute of day, the last
|
||||
/// meal's minute of day, and how many meals fall between them inclusive --
|
||||
/// from which the intermediate checkpoints are derived by even division and
|
||||
/// rounded to the 15-minute grid. `MealSchedule(firstMinute: 480,
|
||||
/// lastMinute: 1200, count: 5)` yields `[480, 660, 840, 1020, 1200]`.
|
||||
///
|
||||
/// Every time here is a *minute of day*, an int in `0..1439`. The fields are
|
||||
/// deliberately named `firstMinute`/`lastMinute` rather than reusing the old
|
||||
/// hour-valued `first`/`last`, so `flutter analyze` enumerates every call site
|
||||
/// that still thinks in hours.
|
||||
///
|
||||
/// This file is pure: no clock, no storage, no settings lookup. Persistence
|
||||
/// lives in `services/meal_schedule_service.dart`, and the slot arithmetic
|
||||
@ -12,13 +18,17 @@
|
||||
/// KEEP IN SYNC WITH `diet_guard/_meal_schedule.py`. The two must agree on
|
||||
/// every input, because a device that derives different slots than its peer
|
||||
/// nags for checkpoints the other never offers -- and a slot that can never be
|
||||
/// satisfied is a permanent lock. Two rules make that agreement checkable:
|
||||
/// satisfied is a permanent lock. `tests/fixtures/meal_schedule_vectors.json`
|
||||
/// is read by both test suites; three rules make that agreement checkable:
|
||||
///
|
||||
/// * **Integer arithmetic only.** No doubles and no `round()` anywhere in the
|
||||
/// derivation. Dart's `round()` is half-away-from-zero (`2.5.round() == 3`)
|
||||
/// while Python's is banker's rounding (`round(2.5) == 2`), so any
|
||||
/// floating-point path is a latent cross-language split brain. `~/` here,
|
||||
/// `//` there.
|
||||
/// * **Non-negative operands to every division.** `~/` truncates while
|
||||
/// Python's `//` floors (`-7 ~/ 15 == 0`, `-7 // 15 == -1`). Every division
|
||||
/// below runs on a value [MealSchedule.normalized] has pulled into the day.
|
||||
/// * **Clamp, don't reject.** Every out-of-range input is normalised to the
|
||||
/// nearest legal schedule rather than throwing, so the two languages cannot
|
||||
/// disagree about which inputs are errors.
|
||||
@ -36,11 +46,16 @@ const int kMinMealCount = 2;
|
||||
/// `test/widgets/slot_selector_row_test.dart`.
|
||||
const int kMaxMealCount = 6;
|
||||
|
||||
/// Earliest hour a meal may be scheduled at.
|
||||
const int kFirstHour = 0;
|
||||
/// Minutes in a day; a minute of day is `0..kMinutesPerDay - 1`.
|
||||
const int kMinutesPerDay = 1440;
|
||||
|
||||
/// Latest hour a meal may be scheduled at.
|
||||
const int kLastHour = 23;
|
||||
/// Minimum spacing between two checkpoints, and the grid interior checkpoints
|
||||
/// are rounded onto.
|
||||
///
|
||||
/// One constant for both is what makes the strict-ascent proof in
|
||||
/// [MealSchedule.slots] work: points at least one grid step apart cannot round
|
||||
/// onto the same grid mark.
|
||||
const int kSlotGridMinutes = 15;
|
||||
|
||||
/// Grace period after the final checkpoint, before the gate stops firing.
|
||||
///
|
||||
@ -48,9 +63,9 @@ const int kLastHour = 23;
|
||||
/// have to log a late dinner, which has nothing to do with how many meals you
|
||||
/// eat. Tying it to the spacing would stretch the lockout window to midnight
|
||||
/// at four meals, contradicting the "don't trap me overnight" intent.
|
||||
const int kEnforcementTailHours = 2;
|
||||
const int kEnforcementTailMinutes = 120;
|
||||
|
||||
const int _hoursPerDay = 24;
|
||||
const int _lastMinute = kMinutesPerDay - 1;
|
||||
|
||||
int _clamp(int value, int low, int high) =>
|
||||
value < low ? low : (value > high ? high : value);
|
||||
@ -60,95 +75,142 @@ int _clamp(int value, int low, int high) =>
|
||||
class MealSchedule {
|
||||
/// Creates a [MealSchedule].
|
||||
const MealSchedule({
|
||||
required this.first,
|
||||
required this.last,
|
||||
required this.firstMinute,
|
||||
required this.lastMinute,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
/// Hour of the first meal, 0-23.
|
||||
final int first;
|
||||
/// Minute of day of the first meal, 0-1424.
|
||||
final int firstMinute;
|
||||
|
||||
/// Hour of the last meal, strictly after [first].
|
||||
final int last;
|
||||
/// Minute of day of the last meal, at least one grid step (15 minutes) after
|
||||
/// [firstMinute] and at most 1439.
|
||||
final int lastMinute;
|
||||
|
||||
/// Total meals including both endpoints.
|
||||
final int count;
|
||||
|
||||
/// Returns an equivalent schedule guaranteed to satisfy the invariants.
|
||||
///
|
||||
/// Ordering matters: [first] is clamped into the day, then [last] is clamped
|
||||
/// to leave at least one hour of window, then [count] is clamped to the
|
||||
/// window's width. That last clamp is the load-bearing one -- see [slots].
|
||||
/// Ordering matters: [firstMinute] is clamped into the day leaving room for
|
||||
/// one grid step, then [lastMinute] is clamped to at least one grid step
|
||||
/// after it, then [count] is clamped to how many grid-spaced checkpoints the
|
||||
/// window holds. That last clamp is the load-bearing one -- see [slots].
|
||||
MealSchedule normalized() {
|
||||
final normFirst = _clamp(first, kFirstHour, kLastHour - 1);
|
||||
final normLast = _clamp(last, normFirst + 1, kLastHour);
|
||||
// A window of N hours holds at most N+1 whole-hour checkpoints; asking for
|
||||
// more would repeat an hour (see slots()).
|
||||
final first = _clamp(firstMinute, 0, _lastMinute - kSlotGridMinutes);
|
||||
final last = _clamp(lastMinute, first + kSlotGridMinutes, _lastMinute);
|
||||
// A window of W minutes holds at most W ~/ 15 + 1 checkpoints spaced a
|
||||
// grid step apart; asking for more would round two onto one mark.
|
||||
final capacity = (last - first) ~/ kSlotGridMinutes + 1;
|
||||
final normCount = _clamp(
|
||||
count,
|
||||
kMinMealCount,
|
||||
kMaxMealCount < normLast - normFirst + 1
|
||||
? kMaxMealCount
|
||||
: normLast - normFirst + 1,
|
||||
kMaxMealCount < capacity ? kMaxMealCount : capacity,
|
||||
);
|
||||
return MealSchedule(first: normFirst, last: normLast, count: normCount);
|
||||
return MealSchedule(firstMinute: first, lastMinute: last, count: normCount);
|
||||
}
|
||||
|
||||
/// Returns the meal-slot hours, ascending, with both endpoints exact.
|
||||
/// Returns the meal-slot minutes, ascending, with both endpoints exact.
|
||||
///
|
||||
/// Meals are spread evenly across the window and rounded to whole hours by
|
||||
/// integer arithmetic: slot *i* is `first + (i*span + d ~/ 2) ~/ d` where
|
||||
/// `span = last - first` and `d = count - 1`. The `d ~/ 2` term is a
|
||||
/// round-half-up bias applied before the division, which is what keeps this
|
||||
/// free of floating point.
|
||||
/// With `span = last - first` and `d = count - 1`, interior slot *i* is
|
||||
/// `raw = first + (i*span + d ~/ 2) ~/ d` (even division, the `d ~/ 2` term
|
||||
/// a round-half-up bias that keeps it free of floating point), then snapped
|
||||
/// to the nearest absolute quarter hour by `(raw + 7) ~/ 15 * 15`. The
|
||||
/// endpoints are [firstMinute] and [lastMinute] exactly -- never snapped.
|
||||
///
|
||||
/// Both endpoints land exactly on [first] and [last] by construction, so the
|
||||
/// eating window is always honoured even when interior spacing must round.
|
||||
///
|
||||
/// The result is strictly ascending because [normalized] caps [count] at
|
||||
/// `last - first + 1`. Without that cap a narrow window repeats an hour
|
||||
/// (`08-12` with 6 meals would give `8, 9, 10, 10, 11, 12`), and since slot
|
||||
/// hours are used as set members, map keys *and* notification ids, a repeat
|
||||
/// silently drops a checkpoint.
|
||||
/// Strictly ascending, provably: [normalized] caps [count] so that
|
||||
/// `span / d >= 15`, hence consecutive `raw` values differ by at least 15,
|
||||
/// and two integers at least 15 apart never snap onto the same grid mark.
|
||||
/// Snapping moves a value by at most 7, so no interior slot can reach an
|
||||
/// endpoint. That matters because slot minutes are set members, map keys
|
||||
/// *and* notification ids: a repeat silently drops a checkpoint.
|
||||
List<int> slots() {
|
||||
final schedule = normalized();
|
||||
final span = schedule.last - schedule.first;
|
||||
final first = schedule.firstMinute;
|
||||
final last = schedule.lastMinute;
|
||||
final span = last - first;
|
||||
final divisions = schedule.count - 1;
|
||||
return [
|
||||
for (var index = 0; index < schedule.count; index++)
|
||||
schedule.first + (index * span + divisions ~/ 2) ~/ divisions,
|
||||
first,
|
||||
for (var index = 1; index < divisions; index++)
|
||||
(first + (index * span + divisions ~/ 2) ~/ divisions + 7) ~/
|
||||
kSlotGridMinutes *
|
||||
kSlotGridMinutes,
|
||||
last,
|
||||
];
|
||||
}
|
||||
|
||||
/// The hour at which slot enforcement stops for the day.
|
||||
/// The minute at which slot enforcement stops for the day.
|
||||
///
|
||||
/// Clamped to the end of the day: a 23:00 last meal would otherwise put the
|
||||
/// cutoff at 25, making `hour < cutoff` vacuously true so the enforcement
|
||||
/// window never closes and the reminder can never stop firing.
|
||||
int get enforcementEndHour {
|
||||
final end = last + kEnforcementTailHours;
|
||||
return end < _hoursPerDay ? end : _hoursPerDay;
|
||||
/// Derived from the *normalised* last meal, so it always agrees with the
|
||||
/// last entry of [slots]. Clamped to the end of the day: a 23:00 last meal
|
||||
/// would otherwise put the cutoff at 1500, making `minute < cutoff`
|
||||
/// vacuously true so the enforcement window never closes.
|
||||
int get enforcementEndMinute {
|
||||
final end = normalized().lastMinute + kEnforcementTailMinutes;
|
||||
return end < kMinutesPerDay ? end : kMinutesPerDay;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MealSchedule &&
|
||||
other.first == first &&
|
||||
other.last == last &&
|
||||
other.firstMinute == firstMinute &&
|
||||
other.lastMinute == lastMinute &&
|
||||
other.count == count;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(first, last, count);
|
||||
int get hashCode => Object.hash(firstMinute, lastMinute, count);
|
||||
|
||||
@override
|
||||
String toString() => 'MealSchedule($first-$last x$count)';
|
||||
String toString() => 'MealSchedule($firstMinute-$lastMinute x$count)';
|
||||
}
|
||||
|
||||
/// The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, enforcement
|
||||
/// closing at 22:00. Still what a device uses before the user has chosen
|
||||
/// anything, so upgrading changes no behaviour.
|
||||
const MealSchedule kDefaultSchedule = MealSchedule(
|
||||
first: 8,
|
||||
last: 20,
|
||||
firstMinute: 8 * 60,
|
||||
lastMinute: 20 * 60,
|
||||
count: 4,
|
||||
);
|
||||
|
||||
/// Returns the synced `sched:<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();
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py`.
|
||||
/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py` and
|
||||
/// `_slot_wire.py`.
|
||||
///
|
||||
/// Deliberately I/O-free and clock-free: every function is a total function of
|
||||
/// its `now` and `schedule` arguments, so the time-of-day edges are
|
||||
@ -7,6 +8,11 @@
|
||||
/// the Python original is shared between the gate dashboard and the lock
|
||||
/// decision.
|
||||
///
|
||||
/// A slot is a *minute of day* (`0..1439`): 480, 720, 960, 1200 by default.
|
||||
/// A logged meal satisfies the slot nearest its recorded slot minute
|
||||
/// ([satisfiedSlots]), so an entry written under an older schedule still
|
||||
/// counts after the schedule moves -- nothing stored is ever rewritten.
|
||||
///
|
||||
/// `schedule` is a required argument on every function here, deliberately: it
|
||||
/// used to be read from module constants, and a default would let a call site
|
||||
/// that was missed during a refactor keep deriving the old fixed hours on one
|
||||
@ -14,11 +20,24 @@
|
||||
/// slot one device offers and the other does not is a checkpoint that can
|
||||
/// never be satisfied. Callers resolve the value at the impure edge, through
|
||||
/// `MealScheduleService.current`.
|
||||
///
|
||||
/// **Entry wire rule** (mirrors `_slot_wire.py`): writers always write
|
||||
/// `slot = minute ~/ 60` and add `slot_min = minute` only when the minute is
|
||||
/// off the hour, so whole-hour entries stay byte-identical to the pre-minute
|
||||
/// format and an older peer still reads an hour. Readers prefer an int
|
||||
/// `slot_min`, then `slot * 60`, else no slot. See [slotFields] and
|
||||
/// [entrySlotMinute]; `tests/fixtures/meal_schedule_vectors.json` gates both
|
||||
/// languages.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
|
||||
/// Returns the meal-slot hours for a day, e.g. `[8, 12, 16, 20]`.
|
||||
/// Returns [now]'s minute of day, `0..1439` (seconds are ignored).
|
||||
///
|
||||
/// Mirrors `_slots.minute_of_day`.
|
||||
int minuteOfDay(DateTime now) => now.hour * 60 + now.minute;
|
||||
|
||||
/// Returns the meal-slot minutes for a day, e.g. `[480, 720, 960, 1200]`.
|
||||
///
|
||||
/// Mirrors `_slots.day_slots`.
|
||||
List<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.
|
||||
///
|
||||
/// Mirrors `_slots.within_enforcement_window`.
|
||||
bool withinEnforcementWindow(DateTime now, MealSchedule schedule) =>
|
||||
now.hour >= schedule.slots().first &&
|
||||
now.hour < schedule.enforcementEndHour;
|
||||
bool withinEnforcementWindow(DateTime now, MealSchedule schedule) {
|
||||
final minute = minuteOfDay(now);
|
||||
return schedule.slots().first <= minute &&
|
||||
minute < schedule.enforcementEndMinute;
|
||||
}
|
||||
|
||||
/// Returns today's slots whose hour has arrived as of [now].
|
||||
/// Returns today's slots whose minute has arrived as of [now].
|
||||
///
|
||||
/// Empty outside the enforcement window. Mirrors `_slots.elapsed_slots`.
|
||||
List<int> elapsedSlots(DateTime now, MealSchedule schedule) {
|
||||
if (!withinEnforcementWindow(now, schedule)) return const [];
|
||||
return daySlots(schedule).where((slot) => slot <= now.hour).toList();
|
||||
final minute = minuteOfDay(now);
|
||||
return daySlots(schedule).where((slot) => slot <= minute).toList();
|
||||
}
|
||||
|
||||
/// Returns elapsed slots not yet covered by [logged].
|
||||
/// Returns elapsed slots not yet covered by [logged] (normally
|
||||
/// [satisfiedSlots] of the entries' slot minutes).
|
||||
///
|
||||
/// Mirrors `_slots.missing_slots`.
|
||||
List<int> missingSlots(
|
||||
DateTime now,
|
||||
Set<int> logged,
|
||||
MealSchedule schedule,
|
||||
) => elapsedSlots(
|
||||
List<int> missingSlots(DateTime now, Set<int> logged, MealSchedule schedule) =>
|
||||
elapsedSlots(
|
||||
now,
|
||||
schedule,
|
||||
).where((slot) => !logged.contains(slot)).toList();
|
||||
).where((slot) => !logged.contains(slot)).toList();
|
||||
|
||||
/// Returns the most recent elapsed slot as of [now], or null.
|
||||
///
|
||||
@ -64,8 +84,7 @@ int? currentSlot(DateTime now, MealSchedule schedule) {
|
||||
/// first slot, clamp to the first slot; after the enforcement window ends,
|
||||
/// clamp to the last slot; behaviour inside a window is unchanged. The two
|
||||
/// languages must reach each answer by the *same* branch, not merely agree on
|
||||
/// the value -- `test/models/slot_test.dart` sweeps every hour of the day
|
||||
/// against several schedules for exactly that reason.
|
||||
/// the value -- the shared fixture pins every minute edge for that reason.
|
||||
///
|
||||
/// Unlike [currentSlot] this never returns null, which is the point: an
|
||||
/// off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
|
||||
@ -74,11 +93,65 @@ int? currentSlot(DateTime now, MealSchedule schedule) {
|
||||
/// *those* would instead make every slot fall due at the end of the day.
|
||||
int slotForLog(DateTime now, MealSchedule schedule) {
|
||||
final slots = daySlots(schedule);
|
||||
if (now.hour < slots.first) return slots.first;
|
||||
if (minuteOfDay(now) < slots.first) return slots.first;
|
||||
return currentSlot(now, schedule) ?? slots.last;
|
||||
}
|
||||
|
||||
/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`.
|
||||
/// Returns the slot closest to [minute]; an exact tie goes to the earlier.
|
||||
///
|
||||
/// Mirrors `_slots.nearest_slot`, whose `min` keeps the first minimal element
|
||||
/// of the ascending slots -- hence replacing the best only on a strictly
|
||||
/// smaller distance here.
|
||||
int nearestSlot(int minute, MealSchedule schedule) {
|
||||
final slots = daySlots(schedule);
|
||||
var best = slots.first;
|
||||
for (final slot in slots) {
|
||||
if ((slot - minute).abs() < (best - minute).abs()) best = slot;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Returns the slots covered by meals recorded at [entryMinutes].
|
||||
///
|
||||
/// Mirrors `_slots.satisfied_slots`. Many-to-one is intended: two meals
|
||||
/// snapping onto one slot satisfy only that slot.
|
||||
Set<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`.
|
||||
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';
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.104+1
|
||||
version: 1.0.105+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -116,7 +116,7 @@ void main() {
|
||||
// silently stops testing the intra-batch dedup.
|
||||
final testCase = cases('log_dishes')['twins_share_16'] as Map;
|
||||
final input = _pairs(spread(testCase.cast<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(
|
||||
(testCase['expected'] as List).cast<List<Object?>>().where(isTwin16),
|
||||
|
||||
@ -106,9 +106,9 @@ void main() {
|
||||
subject = dishes.take(3).toList();
|
||||
key = key.substring('first_three_'.length);
|
||||
}
|
||||
final hours = [for (final part in key.split(',')) int.parse(part)];
|
||||
final minutes = [for (final part in key.split(',')) int.parse(part)];
|
||||
final actual = [
|
||||
for (final item in assignSlots(subject, hours)) item.slot,
|
||||
for (final item in assignSlots(subject, minutes)) item.slot,
|
||||
];
|
||||
expect(
|
||||
actual,
|
||||
@ -124,10 +124,10 @@ void main() {
|
||||
// total. Run repeatedly: an unstable sort need not misbehave every time.
|
||||
for (var attempt = 0; attempt < 5; attempt++) {
|
||||
final ordered = assignSlots(parseMenu(fixture['payload']), const [
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
20,
|
||||
480,
|
||||
720,
|
||||
960,
|
||||
1200,
|
||||
]);
|
||||
expect(
|
||||
[for (final item in ordered) item.dish.name],
|
||||
|
||||
@ -1,94 +1,87 @@
|
||||
/// Tests for the pure meal-schedule derivation.
|
||||
///
|
||||
/// The vector table below is duplicated verbatim from
|
||||
/// `diet_guard/tests/test_meal_schedule.py`. KEEP THE TWO IN SYNC: it is the
|
||||
/// only thing that catches a Python/Dart divergence before it reaches a
|
||||
/// device, and a divergence there means one device nags for a checkpoint the
|
||||
/// other never offers -- a slot that can never be satisfied, i.e. a permanent
|
||||
/// lock.
|
||||
/// The cross-language exact values live in the shared fixture
|
||||
/// `tests/fixtures/meal_schedule_vectors.json`, asserted by
|
||||
/// `meal_schedule_vectors_test.dart` (and by its Python mirror). This file
|
||||
/// holds the properties: the grid sweep, the hour-formula regression and the
|
||||
/// clamps a human should read without opening the fixture. The sweeps are
|
||||
/// duplicated loop-for-loop in `diet_guard/tests/test_meal_schedule.py`.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// (first, last, count, expected slots). Shared with the Python mirror.
|
||||
const List<(int, int, int, List<int>)> scheduleVectors = [
|
||||
// The eating window the user described, at every supported meal count.
|
||||
(8, 20, 2, [8, 20]),
|
||||
(8, 20, 3, [8, 14, 20]),
|
||||
(8, 20, 4, [8, 12, 16, 20]), // today's hardcoded schedule
|
||||
(8, 20, 5, [8, 11, 14, 17, 20]), // the user's stated example
|
||||
(8, 20, 6, [8, 10, 13, 15, 18, 20]),
|
||||
// A window that does not divide evenly: 14 hours across 3 gaps.
|
||||
(7, 21, 4, [7, 12, 16, 21]),
|
||||
(7, 21, 5, [7, 11, 14, 18, 21]),
|
||||
(9, 19, 4, [9, 12, 16, 19]),
|
||||
// Narrow windows: count is capped at the number of whole hours available,
|
||||
// so the slots stay distinct instead of repeating an hour.
|
||||
(8, 12, 6, [8, 9, 10, 11, 12]),
|
||||
(8, 10, 5, [8, 9, 10]),
|
||||
(8, 9, 4, [8, 9]),
|
||||
// Whole-day extremes.
|
||||
(0, 23, 6, [0, 5, 9, 14, 18, 23]),
|
||||
(0, 1, 2, [0, 1]),
|
||||
];
|
||||
const int _h = 60;
|
||||
|
||||
/// The pre-minute whole-hour derivation, converted to minutes.
|
||||
List<int> _oldHourSlots(int first, int last, int count) {
|
||||
final span = last - first;
|
||||
final divisions = count - 1;
|
||||
return [
|
||||
for (var index = 0; index < count; index++)
|
||||
(first + (index * span + divisions ~/ 2) ~/ divisions) * _h,
|
||||
];
|
||||
}
|
||||
|
||||
bool _strictlyAscending(List<int> slots) {
|
||||
for (var i = 1; i < slots.length; i++) {
|
||||
if (slots[i - 1] >= slots[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
MealSchedule _s(int first, int last, int count) =>
|
||||
MealSchedule(firstMinute: first, lastMinute: last, count: count);
|
||||
|
||||
void main() {
|
||||
group('slots', () {
|
||||
for (final (first, last, count, expected) in scheduleVectors) {
|
||||
test('$first-$last x$count derives $expected', () {
|
||||
test('the default is the historical schedule', () {
|
||||
expect(kDefaultSchedule.slots(), [480, 720, 960, 1200]);
|
||||
});
|
||||
|
||||
// Upgrading must not move a checkpoint for the schedules people actually
|
||||
// have, or every existing hour-tagged entry would satisfy a neighbour.
|
||||
for (final (first, last, count) in const [
|
||||
(8, 20, 4),
|
||||
(8, 20, 5),
|
||||
(7, 19, 5),
|
||||
]) {
|
||||
test('$first-$last x$count matches the old hour formula', () {
|
||||
expect(
|
||||
MealSchedule(first: first, last: last, count: count).slots(),
|
||||
expected,
|
||||
_s(first * _h, last * _h, count).slots(),
|
||||
_oldHourSlots(first, last, count),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the default is the historical schedule', () {
|
||||
expect(kDefaultSchedule.slots(), [8, 12, 16, 20]);
|
||||
test('off-grid endpoints are exact; only the interior snaps', () {
|
||||
final slots = _s(443, 1181, 5).slots();
|
||||
expect(slots.first, 443);
|
||||
expect(slots.last, 1181);
|
||||
for (final slot in slots.sublist(1, slots.length - 1)) {
|
||||
expect(slot % kSlotGridMinutes, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('endpoints stay exact when the spacing rounds', () {
|
||||
final slots = const MealSchedule(first: 8, last: 20, count: 6).slots();
|
||||
expect(slots.first, 8);
|
||||
expect(slots.last, 20);
|
||||
test("the user's quarter-hour schedule", () {
|
||||
expect(_s(435, 1140, 5).slots(), [435, 615, 795, 960, 1140]);
|
||||
});
|
||||
});
|
||||
|
||||
group('normalized', () {
|
||||
const cases = <(MealSchedule, MealSchedule)>[
|
||||
(
|
||||
MealSchedule(first: 8, last: 20, count: 99),
|
||||
MealSchedule(first: 8, last: 20, count: kMaxMealCount),
|
||||
),
|
||||
(
|
||||
MealSchedule(first: 8, last: 20, count: 0),
|
||||
MealSchedule(first: 8, last: 20, count: kMinMealCount),
|
||||
),
|
||||
(
|
||||
MealSchedule(first: -5, last: 20, count: 4),
|
||||
MealSchedule(first: 0, last: 20, count: 4),
|
||||
),
|
||||
(
|
||||
MealSchedule(first: 8, last: 99, count: 4),
|
||||
MealSchedule(first: 8, last: 23, count: 4),
|
||||
),
|
||||
// last <= first is pulled forward to leave a one-hour window.
|
||||
(
|
||||
MealSchedule(first: 12, last: 12, count: 4),
|
||||
MealSchedule(first: 12, last: 13, count: 2),
|
||||
),
|
||||
(
|
||||
MealSchedule(first: 12, last: 3, count: 4),
|
||||
MealSchedule(first: 12, last: 13, count: 2),
|
||||
),
|
||||
// first cannot occupy the final hour, or no window would remain.
|
||||
(
|
||||
MealSchedule(first: 23, last: 23, count: 2),
|
||||
MealSchedule(first: 22, last: 23, count: 2),
|
||||
),
|
||||
final cases = <(MealSchedule, MealSchedule)>[
|
||||
(_s(480, 1200, 99), _s(480, 1200, kMaxMealCount)),
|
||||
(_s(480, 1200, 0), _s(480, 1200, kMinMealCount)),
|
||||
(_s(-5, 1200, 4), _s(0, 1200, 4)),
|
||||
(_s(480, 9999, 4), _s(480, 1439, 4)),
|
||||
// last <= first is pulled forward to leave one grid step of window.
|
||||
(_s(720, 720, 4), _s(720, 735, 2)),
|
||||
(_s(720, 3, 4), _s(720, 735, 2)),
|
||||
// first cannot sit in the final grid step, or no window remains.
|
||||
(_s(1439, 1439, 2), _s(1424, 1439, 2)),
|
||||
// Count is capped by the grid: 60 minutes hold 5 quarter-hour marks.
|
||||
(_s(480, 540, 6), _s(480, 540, 5)),
|
||||
];
|
||||
|
||||
for (final (input, expected) in cases) {
|
||||
test('$input clamps to $expected', () {
|
||||
expect(input.normalized(), expected);
|
||||
@ -96,56 +89,82 @@ void main() {
|
||||
}
|
||||
|
||||
test('garbage still yields usable slots', () {
|
||||
expect(
|
||||
const MealSchedule(first: 99, last: -5, count: 999).slots(),
|
||||
[22, 23],
|
||||
);
|
||||
expect(_s(9999, -5, 999).slots(), [1424, 1439]);
|
||||
});
|
||||
});
|
||||
|
||||
group('enforcementEndHour', () {
|
||||
group('enforcementEndMinute', () {
|
||||
test('the default keeps the historical 22:00 cutoff', () {
|
||||
expect(kDefaultSchedule.enforcementEndHour, 22);
|
||||
expect(kDefaultSchedule.enforcementEndMinute, 22 * _h);
|
||||
});
|
||||
|
||||
test('the tail follows the last meal', () {
|
||||
expect(
|
||||
const MealSchedule(first: 8, last: 18, count: 4).enforcementEndHour,
|
||||
20,
|
||||
);
|
||||
test('the tail follows the last meal, to the minute', () {
|
||||
expect(_s(480, 1095, 4).enforcementEndMinute, 1215);
|
||||
});
|
||||
|
||||
test('is clamped to the end of the day', () {
|
||||
// An unclamped 23 + 2 = 25 would make `hour < cutoff` vacuously true,
|
||||
// so the enforcement window would never close.
|
||||
expect(
|
||||
const MealSchedule(first: 8, last: 23, count: 4).enforcementEndHour,
|
||||
24,
|
||||
);
|
||||
// An unclamped 1380 + 120 = 1500 would make `minute < cutoff`
|
||||
// vacuously true, so the enforcement window would never close.
|
||||
expect(_s(480, 1380, 4).enforcementEndMinute, kMinutesPerDay);
|
||||
});
|
||||
|
||||
test('follows the normalised last meal', () {
|
||||
final schedule = _s(720, 3, 4);
|
||||
expect(schedule.enforcementEndMinute, schedule.slots().last + 120);
|
||||
});
|
||||
});
|
||||
|
||||
test('every input yields ascending slots with exact endpoints', () {
|
||||
// The Dart half of the cross-language parity guarantee; the Python mirror
|
||||
// runs the identical sweep.
|
||||
for (var first = -2; first < 26; first++) {
|
||||
for (var last = -2; last < 26; last++) {
|
||||
for (var count = -2; count < 10; count++) {
|
||||
final schedule = MealSchedule(
|
||||
first: first,
|
||||
last: last,
|
||||
count: count,
|
||||
);
|
||||
group('wire', () {
|
||||
test('a whole-hour schedule encodes exactly as before', () {
|
||||
expect(scheduleToWire(kDefaultSchedule), {'f': 8, 'l': 20, 'n': 4});
|
||||
});
|
||||
|
||||
test('off-hour endpoints add minute fields', () {
|
||||
expect(scheduleToWire(_s(435, 1181, 5)), {
|
||||
'f': 7,
|
||||
'l': 19,
|
||||
'n': 5,
|
||||
'fm': 435,
|
||||
'lm': 1181,
|
||||
});
|
||||
});
|
||||
|
||||
test('unusable values decode to null', () {
|
||||
expect(scheduleFromWire({'f': true, 'l': 20, 'n': 4}), isNull);
|
||||
expect(scheduleFromWire('not a map'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('grid sweep yields ascending slots with exact endpoints', () {
|
||||
// The Python mirror runs the identical loop. A wire round trip rides
|
||||
// along, because it is the same sweep's worth of schedules. Plain checks
|
||||
// with `fail` rather than `expect` per cell keep 46k cells fast.
|
||||
for (var first = 0; first < kMinutesPerDay; first += kSlotGridMinutes) {
|
||||
for (var last = 0; last < kMinutesPerDay; last += kSlotGridMinutes) {
|
||||
for (var count = kMinMealCount; count <= kMaxMealCount; count++) {
|
||||
final schedule = _s(first, last, count);
|
||||
final normalized = schedule.normalized();
|
||||
final slots = schedule.slots();
|
||||
final ok =
|
||||
slots.first == normalized.firstMinute &&
|
||||
slots.last == normalized.lastMinute &&
|
||||
slots.length == normalized.count &&
|
||||
_strictlyAscending(slots) &&
|
||||
slots.first >= 0 &&
|
||||
slots.last < kMinutesPerDay &&
|
||||
scheduleFromWire(scheduleToWire(schedule)) == normalized;
|
||||
if (!ok) fail('$schedule derived $slots');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(slots.first, normalized.first);
|
||||
expect(slots.last, normalized.last);
|
||||
expect(slots.length, normalized.count);
|
||||
expect(slots.toSet().length, slots.length);
|
||||
expect(slots, orderedEquals(List<int>.of(slots)..sort()));
|
||||
expect(slots.length, greaterThanOrEqualTo(kMinMealCount));
|
||||
expect(slots.length, lessThanOrEqualTo(kMaxMealCount));
|
||||
test('off-grid sweep stays strictly ascending', () {
|
||||
for (var first = 0; first < 120; first += 7) {
|
||||
for (var width = 15; width < 200; width += 11) {
|
||||
for (var count = kMinMealCount; count <= kMaxMealCount; count++) {
|
||||
final slots = _s(first, first + width, count).slots();
|
||||
if (!_strictlyAscending(slots)) fail('$first+$width x$count: $slots');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
177
app/test/models/meal_schedule_vectors_test.dart
Normal file
177
app/test/models/meal_schedule_vectors_test.dart
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,229 +1,172 @@
|
||||
/// Tests for `models/slot.dart`, in minutes of day.
|
||||
///
|
||||
/// Mirrors `diet_guard/tests/test_slots.py`, `test_slots_nearest.py` and
|
||||
/// `test_slot_wire.py`; the exhaustive cross-language edges live in the shared
|
||||
/// fixture (`meal_schedule_vectors_test.dart`).
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
import 'package:diet_guard_app/models/slot.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
DateTime _at(int hour, [int minute = 0]) => DateTime(2026, 6, 22, hour, minute);
|
||||
|
||||
MealSchedule _s(int first, int last, int count) =>
|
||||
MealSchedule(firstMinute: first, lastMinute: last, count: count);
|
||||
|
||||
void main() {
|
||||
group('daySlots', () {
|
||||
test('returns the four fixed hourly slots', () {
|
||||
expect(daySlots(kDefaultSchedule), [8, 12, 16, 20]);
|
||||
test('minuteOfDay counts minutes and ignores seconds', () {
|
||||
expect(minuteOfDay(DateTime(2026, 6, 22, 7, 15, 59)), 435);
|
||||
expect(minuteOfDay(_at(23, 59)), 1439);
|
||||
});
|
||||
|
||||
test('daySlots returns the four default slots', () {
|
||||
expect(daySlots(kDefaultSchedule), [480, 720, 960, 1200]);
|
||||
});
|
||||
|
||||
group('withinEnforcementWindow', () {
|
||||
test('false before the day start hour', () {
|
||||
expect(
|
||||
withinEnforcementWindow(DateTime(2026, 6, 22, 7, 59), kDefaultSchedule),
|
||||
isFalse,
|
||||
);
|
||||
test('false at 07:59, true at 08:00', () {
|
||||
expect(withinEnforcementWindow(_at(7, 59), kDefaultSchedule), isFalse);
|
||||
expect(withinEnforcementWindow(_at(8), kDefaultSchedule), isTrue);
|
||||
});
|
||||
|
||||
test('true at the day start hour', () {
|
||||
expect(
|
||||
withinEnforcementWindow(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule),
|
||||
isTrue,
|
||||
);
|
||||
test('true at 21:59, false at the 22:00 cutoff', () {
|
||||
expect(withinEnforcementWindow(_at(21, 59), kDefaultSchedule), isTrue);
|
||||
expect(withinEnforcementWindow(_at(22), kDefaultSchedule), isFalse);
|
||||
});
|
||||
|
||||
test('true just before the eating end hour', () {
|
||||
expect(
|
||||
withinEnforcementWindow(
|
||||
DateTime(2026, 6, 22, 21, 59),
|
||||
kDefaultSchedule,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('false at the eating end hour (exclusive)', () {
|
||||
expect(
|
||||
withinEnforcementWindow(DateTime(2026, 6, 22, 22, 0), kDefaultSchedule),
|
||||
isFalse,
|
||||
);
|
||||
test('a 07:15 breakfast opens the window at 07:15', () {
|
||||
final schedule = _s(435, 1140, 5);
|
||||
expect(withinEnforcementWindow(_at(7, 14), schedule), isFalse);
|
||||
expect(withinEnforcementWindow(_at(7, 15), schedule), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('elapsedSlots', () {
|
||||
test('empty outside the enforcement window', () {
|
||||
expect(
|
||||
elapsedSlots(DateTime(2026, 6, 22, 23, 0), kDefaultSchedule),
|
||||
isEmpty,
|
||||
);
|
||||
expect(elapsedSlots(_at(23), kDefaultSchedule), isEmpty);
|
||||
expect(elapsedSlots(_at(7, 59), kDefaultSchedule), isEmpty);
|
||||
});
|
||||
|
||||
test('only the 8 slot right at day start', () {
|
||||
expect(elapsedSlots(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), [8]);
|
||||
test('grows through the day', () {
|
||||
expect(elapsedSlots(_at(8), kDefaultSchedule), [480]);
|
||||
expect(elapsedSlots(_at(15, 59), kDefaultSchedule), [480, 720]);
|
||||
expect(elapsedSlots(_at(21), kDefaultSchedule), [480, 720, 960, 1200]);
|
||||
});
|
||||
|
||||
test('8 and 12 mid-afternoon before 16', () {
|
||||
expect(elapsedSlots(DateTime(2026, 6, 22, 15, 59), kDefaultSchedule), [
|
||||
8,
|
||||
12,
|
||||
]);
|
||||
});
|
||||
|
||||
test('all four slots once 20:00 has passed', () {
|
||||
expect(elapsedSlots(DateTime(2026, 6, 22, 21, 0), kDefaultSchedule), [
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
20,
|
||||
]);
|
||||
test('a slot opens on its minute', () {
|
||||
final schedule = _s(435, 1140, 5);
|
||||
expect(elapsedSlots(_at(10, 14), schedule), [435]);
|
||||
expect(elapsedSlots(_at(10, 15), schedule), [435, 615]);
|
||||
});
|
||||
});
|
||||
|
||||
group('missingSlots', () {
|
||||
test('excludes already-logged elapsed slots', () {
|
||||
expect(
|
||||
missingSlots(DateTime(2026, 6, 22, 17, 0), {8}, kDefaultSchedule),
|
||||
[12, 16],
|
||||
);
|
||||
test('missingSlots excludes logged slots', () {
|
||||
expect(missingSlots(_at(17), {480}, kDefaultSchedule), [720, 960]);
|
||||
expect(missingSlots(_at(17), {480, 720, 960}, kDefaultSchedule), isEmpty);
|
||||
});
|
||||
|
||||
test('empty once every elapsed slot is logged', () {
|
||||
expect(
|
||||
missingSlots(DateTime(2026, 6, 22, 17, 0), {
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
}, kDefaultSchedule),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('currentSlot', () {
|
||||
test('null outside the enforcement window', () {
|
||||
expect(
|
||||
currentSlot(DateTime(2026, 6, 22, 6, 0), kDefaultSchedule),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns the most recently elapsed slot', () {
|
||||
expect(currentSlot(DateTime(2026, 6, 22, 17, 41), kDefaultSchedule), 16);
|
||||
});
|
||||
|
||||
test('returns 8 right at day start', () {
|
||||
expect(currentSlot(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), 8);
|
||||
});
|
||||
test('currentSlot is the latest elapsed slot, or null', () {
|
||||
expect(currentSlot(_at(6), kDefaultSchedule), isNull);
|
||||
expect(currentSlot(_at(17, 41), kDefaultSchedule), 960);
|
||||
});
|
||||
|
||||
group('slotForLog', () {
|
||||
// Keep in lockstep with `diet_guard/tests/test_slots.py`'s
|
||||
// TestSlotForLog: a divergence means the PC and the phone disagree about
|
||||
// which checkpoint a meal satisfied.
|
||||
DateTime at(int hour) => DateTime(2026, 6, 22, hour, 0);
|
||||
|
||||
test('clamps to the first slot before the window opens', () {
|
||||
expect(slotForLog(at(7), kDefaultSchedule), 8);
|
||||
test('clamps before the window and after it', () {
|
||||
expect(slotForLog(_at(0), kDefaultSchedule), 480);
|
||||
expect(slotForLog(_at(7, 59), kDefaultSchedule), 480);
|
||||
expect(slotForLog(_at(13), kDefaultSchedule), 720);
|
||||
expect(slotForLog(_at(21, 59), kDefaultSchedule), 1200);
|
||||
expect(slotForLog(_at(22), kDefaultSchedule), 1200);
|
||||
});
|
||||
|
||||
test('clamps to the first slot in the small hours', () {
|
||||
expect(slotForLog(at(0), kDefaultSchedule), 8);
|
||||
});
|
||||
|
||||
test('is unchanged exactly at the first slot', () {
|
||||
expect(slotForLog(at(8), kDefaultSchedule), 8);
|
||||
});
|
||||
|
||||
test('matches currentSlot inside the window', () {
|
||||
expect(
|
||||
slotForLog(at(13), kDefaultSchedule),
|
||||
currentSlot(at(13), kDefaultSchedule),
|
||||
);
|
||||
expect(slotForLog(at(13), kDefaultSchedule), 12);
|
||||
});
|
||||
|
||||
test('is unchanged at the last in-window hour', () {
|
||||
expect(slotForLog(at(21), kDefaultSchedule), 20);
|
||||
});
|
||||
|
||||
test('clamps to the last slot once the window closes', () {
|
||||
expect(slotForLog(at(22), kDefaultSchedule), 20);
|
||||
});
|
||||
|
||||
test('clamps to the last slot late in the evening', () {
|
||||
expect(slotForLog(at(23), kDefaultSchedule), 20);
|
||||
});
|
||||
|
||||
test('never returns a slot outside the schedule', () {
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
expect(
|
||||
daySlots(kDefaultSchedule),
|
||||
contains(slotForLog(at(hour), kDefaultSchedule)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('clamps the same way across configured schedules', () {
|
||||
// Sweeps every hour against several windows. `test_slots.py` runs the
|
||||
// identical sweep: the two must agree on every cell, because a device
|
||||
// that attributes a meal to a different slot than its peer leaves the
|
||||
// other device's checkpoint permanently unsatisfied.
|
||||
const schedules = [
|
||||
test('clamps the same way at every minute across schedules', () {
|
||||
// `test_slots.py` runs the identical sweep.
|
||||
final schedules = [
|
||||
kDefaultSchedule,
|
||||
MealSchedule(first: 8, last: 20, count: 5),
|
||||
MealSchedule(first: 6, last: 22, count: 6),
|
||||
MealSchedule(first: 0, last: 23, count: 2),
|
||||
MealSchedule(first: 10, last: 14, count: 3),
|
||||
_s(480, 1200, 5),
|
||||
_s(435, 1140, 5),
|
||||
_s(443, 1181, 4),
|
||||
_s(0, 1439, 2),
|
||||
_s(600, 840, 3),
|
||||
];
|
||||
for (final schedule in schedules) {
|
||||
final slots = daySlots(schedule);
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
final attributed = slotForLog(at(hour), schedule);
|
||||
expect(slots, contains(attributed));
|
||||
if (hour < slots.first) {
|
||||
expect(attributed, slots.first);
|
||||
} else if (hour >= schedule.enforcementEndHour) {
|
||||
expect(attributed, slots.last);
|
||||
for (var minute = 0; minute < 1440; minute++) {
|
||||
final attributed = slotForLog(
|
||||
_at(minute ~/ 60, minute % 60),
|
||||
schedule,
|
||||
);
|
||||
final int expected;
|
||||
if (minute < slots.first) {
|
||||
expected = slots.first;
|
||||
} else if (minute >= schedule.enforcementEndMinute) {
|
||||
expected = slots.last;
|
||||
} else {
|
||||
// Inside the window: the most recent slot that has opened.
|
||||
expect(attributed, slots.where((s) => s <= hour).last);
|
||||
expected = slots.where((s) => s <= minute).last;
|
||||
}
|
||||
if (attributed != expected) fail('$schedule @ $minute: $attributed');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('configured schedules', () {
|
||||
test('five meals shifts the checkpoints', () {
|
||||
const schedule = MealSchedule(first: 8, last: 20, count: 5);
|
||||
expect(daySlots(schedule), [8, 11, 14, 17, 20]);
|
||||
expect(elapsedSlots(DateTime(2026, 6, 22, 14), schedule), [8, 11, 14]);
|
||||
expect(
|
||||
missingSlots(DateTime(2026, 6, 22, 14), {8, 11}, schedule),
|
||||
[14],
|
||||
);
|
||||
});
|
||||
|
||||
test('enforcement follows the last meal', () {
|
||||
const schedule = MealSchedule(first: 8, last: 18, count: 3);
|
||||
expect(
|
||||
withinEnforcementWindow(DateTime(2026, 6, 22, 19), schedule),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
withinEnforcementWindow(DateTime(2026, 6, 22, 20), schedule),
|
||||
isFalse,
|
||||
);
|
||||
expect(elapsedSlots(DateTime(2026, 6, 22, 20), schedule), isEmpty);
|
||||
});
|
||||
|
||||
test('midnight is a real slot, not a falsy absence', () {
|
||||
const schedule = MealSchedule(first: 0, last: 12, count: 3);
|
||||
expect(daySlots(schedule), [0, 6, 12]);
|
||||
expect(currentSlot(DateTime(2026, 6, 22), schedule), 0);
|
||||
expect(slotForLog(DateTime(2026, 6, 22), schedule), 0);
|
||||
final schedule = _s(0, 720, 3);
|
||||
expect(daySlots(schedule), [0, 360, 720]);
|
||||
expect(currentSlot(_at(0), schedule), 0);
|
||||
expect(slotForLog(_at(0), schedule), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('slotLabel', () {
|
||||
test('pads single-digit hours', () {
|
||||
expect(slotLabel(8), '08:00');
|
||||
group('nearestSlot / satisfiedSlots', () {
|
||||
test('an exact halfway goes to the earlier slot', () {
|
||||
expect(nearestSlot(600, kDefaultSchedule), 480);
|
||||
expect(nearestSlot(601, kDefaultSchedule), 720);
|
||||
});
|
||||
|
||||
test('formats double-digit hours', () {
|
||||
expect(slotLabel(20), '20:00');
|
||||
test('outside the window clamps to the ends', () {
|
||||
expect(nearestSlot(-60, kDefaultSchedule), 480);
|
||||
expect(nearestSlot(1800, kDefaultSchedule), 1200);
|
||||
});
|
||||
|
||||
test('meals logged at 07:00 and 10:00 survive a move to 07:15', () {
|
||||
expect(satisfiedSlots([420, 600], _s(435, 1140, 5)), {435, 615});
|
||||
});
|
||||
|
||||
test('two meals on one slot satisfy only that slot', () {
|
||||
expect(satisfiedSlots([480, 540], kDefaultSchedule), {480});
|
||||
expect(satisfiedSlots(const [], kDefaultSchedule), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('slotFields / entrySlotMinute', () {
|
||||
test('a whole hour is byte-identical to the old format', () {
|
||||
expect(slotFields(480), {'slot': 8});
|
||||
expect(slotFields(435), {'slot': 7, 'slot_min': 435});
|
||||
});
|
||||
|
||||
test('a minute outside the day is refused', () {
|
||||
expect(() => slotFields(-1), throwsArgumentError);
|
||||
expect(() => slotFields(1440), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('slot_min wins; legacy slot is an hour; bools are not ints', () {
|
||||
expect(entrySlotMinute({'slot': 8}), 480);
|
||||
expect(entrySlotMinute({'slot': 5, 'slot_min': 0}), 0);
|
||||
expect(entrySlotMinute({'slot': 8, 'slot_min': true}), 480);
|
||||
expect(entrySlotMinute({'slot': true}), isNull);
|
||||
expect(entrySlotMinute({}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('slotLabel zero-pads and wraps into the day', () {
|
||||
expect(slotLabel(435), '07:15');
|
||||
expect(slotLabel(5), '00:05');
|
||||
expect(slotLabel(1200), '20:00');
|
||||
expect(slotLabel(1440), '00:00');
|
||||
expect(slotLabel(-15), '23:45');
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,23 +1,38 @@
|
||||
"""The user's eating window and meal count, and the slot hours they imply.
|
||||
"""The user's eating window and meal count, and the slot minutes they imply.
|
||||
|
||||
A schedule is three numbers -- the first meal hour, the last meal hour, and how
|
||||
many meals fall between them inclusive -- from which the intermediate
|
||||
checkpoints are derived by even division. ``MealSchedule(8, 20, 5)`` yields
|
||||
``(8, 11, 14, 17, 20)``.
|
||||
A schedule is three numbers -- the first meal's minute of day, the last meal's
|
||||
minute of day, and how many meals fall between them inclusive -- from which the
|
||||
intermediate checkpoints are derived by even division and rounded to the
|
||||
15-minute grid. ``MealSchedule(480, 1200, 5)`` (08:00-20:00, five meals)
|
||||
yields ``(480, 660, 840, 1020, 1200)``: 08:00, 11:00, 14:00, 17:00, 20:00.
|
||||
|
||||
Every time here is a *minute of day*, an int in ``0..1439``. The fields are
|
||||
deliberately named ``first_minute``/``last_minute`` rather than reusing the old
|
||||
hour-valued ``first``/``last``: renaming them makes mypy enumerate every call
|
||||
site that still thinks in hours, instead of one quietly passing ``8`` and
|
||||
getting a 00:08 breakfast.
|
||||
|
||||
This module is pure: no clock, no filesystem, no configuration lookup. Storage
|
||||
lives in :mod:`diet_guard._meal_schedule_store`, and the slot arithmetic that
|
||||
consumes a schedule lives in :mod:`diet_guard._slots`.
|
||||
lives in :mod:`diet_guard._meal_schedule_store`, the slot arithmetic that
|
||||
consumes a schedule in :mod:`diet_guard._slots`, and a log entry's slot
|
||||
encoding in :mod:`diet_guard._slot_wire`. The schedule's own wire encoding
|
||||
(:func:`schedule_to_wire`/:func:`schedule_from_wire`) lives here.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/models/meal_schedule.dart``. The two must agree on
|
||||
every input, because a device that derives different slots than its peer nags
|
||||
for checkpoints the other never offers -- and a slot that can never be
|
||||
satisfied is a permanent lock. Two rules make that agreement checkable:
|
||||
satisfied is a permanent lock. ``tests/fixtures/meal_schedule_vectors.json``
|
||||
(regenerated by ``scripts/build_meal_schedule_fixture.py``) is read by both
|
||||
test suites; three rules make that agreement checkable:
|
||||
|
||||
* **Integer arithmetic only.** No floats and no ``round()`` anywhere in the
|
||||
derivation. Python's ``round`` is banker's rounding (``round(2.5) == 2``)
|
||||
while Dart's is half-away-from-zero (``2.5.round() == 3``), so any float path
|
||||
is a latent cross-language split brain. ``//`` here, ``~/`` there.
|
||||
* **Non-negative operands to every division.** Python's ``//`` floors while
|
||||
Dart's ``~/`` truncates, so ``-7 // 15 == -1`` but ``-7 ~/ 15 == 0``. Every
|
||||
division below runs on a value :meth:`MealSchedule.normalized` has already
|
||||
pulled into the day, which is what keeps the two operators equivalent.
|
||||
* **Clamp, don't reject.** Every out-of-range input is normalised to the
|
||||
nearest legal schedule rather than raising, so the two languages cannot
|
||||
disagree about which inputs are errors.
|
||||
@ -26,6 +41,7 @@ satisfied is a permanent lock. Two rules make that agreement checkable:
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeGuard
|
||||
|
||||
# A day's meals must fit inside a day, and the pills that render them have to
|
||||
# stay legible on a phone at the widest (all-logged) size -- see
|
||||
@ -33,8 +49,13 @@ from dataclasses import dataclass
|
||||
MIN_MEAL_COUNT = 2
|
||||
MAX_MEAL_COUNT = 6
|
||||
|
||||
FIRST_HOUR = 0
|
||||
LAST_HOUR = 23
|
||||
MINUTES_PER_DAY = 1440
|
||||
|
||||
# Both the minimum spacing between two checkpoints and the grid the interior
|
||||
# checkpoints are rounded onto. One constant for both is what makes the
|
||||
# strict-ascent proof in ``slots()`` work: points at least one grid step apart
|
||||
# cannot round onto the same grid mark.
|
||||
SLOT_GRID_MINUTES = 15
|
||||
|
||||
# Grace period after the final checkpoint, before the gate stops firing for
|
||||
# the day. Deliberately a constant rather than the slot spacing: it is how
|
||||
@ -42,9 +63,9 @@ LAST_HOUR = 23
|
||||
# meals you eat. Tying it to the spacing would stretch the lockout window to
|
||||
# midnight at four meals, contradicting the "don't trap me overnight" intent
|
||||
# this cutoff exists to serve.
|
||||
ENFORCEMENT_TAIL_HOURS = 2
|
||||
ENFORCEMENT_TAIL_MINUTES = 120
|
||||
|
||||
_HOURS_PER_DAY = 24
|
||||
_LAST_MINUTE = MINUTES_PER_DAY - 1
|
||||
|
||||
|
||||
def _clamp(value: int, low: int, high: int) -> int:
|
||||
@ -57,78 +78,141 @@ class MealSchedule:
|
||||
"""An eating window and the number of meals inside it.
|
||||
|
||||
Attributes:
|
||||
first: Hour of the first meal, 0-23.
|
||||
last: Hour of the last meal, strictly after ``first``.
|
||||
first_minute: Minute of day of the first meal, 0-1424.
|
||||
last_minute: Minute of day of the last meal, at least one grid step
|
||||
(15 minutes) after ``first_minute`` and at most 1439.
|
||||
count: Total meals including both endpoints.
|
||||
"""
|
||||
|
||||
first: int
|
||||
last: int
|
||||
first_minute: int
|
||||
last_minute: int
|
||||
count: int
|
||||
|
||||
def normalized(self) -> MealSchedule:
|
||||
"""Return an equivalent schedule guaranteed to satisfy the invariants.
|
||||
|
||||
Ordering matters: ``first`` is clamped into the day, then ``last`` is
|
||||
clamped to leave at least one hour of window, then ``count`` is clamped
|
||||
to the window's width. That last clamp is the load-bearing one -- see
|
||||
:meth:`slots`.
|
||||
Ordering matters: ``first_minute`` is clamped into the day leaving room
|
||||
for one grid step, then ``last_minute`` is clamped to at least one grid
|
||||
step after it, then ``count`` is clamped to how many grid-spaced
|
||||
checkpoints the window holds. That last clamp is the load-bearing one
|
||||
-- see :meth:`slots`.
|
||||
|
||||
Returns:
|
||||
A schedule whose ``slots()`` is strictly ascending.
|
||||
"""
|
||||
first = _clamp(self.first, FIRST_HOUR, LAST_HOUR - 1)
|
||||
last = _clamp(self.last, first + 1, LAST_HOUR)
|
||||
# A window of N hours holds at most N+1 whole-hour checkpoints; asking
|
||||
# for more would repeat an hour (see slots()).
|
||||
count = _clamp(
|
||||
self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, last - first + 1)
|
||||
)
|
||||
first = _clamp(self.first_minute, 0, _LAST_MINUTE - SLOT_GRID_MINUTES)
|
||||
last = _clamp(self.last_minute, first + SLOT_GRID_MINUTES, _LAST_MINUTE)
|
||||
# A window of W minutes holds at most W//15 + 1 checkpoints spaced a
|
||||
# grid step apart; asking for more would round two onto one mark.
|
||||
capacity = (last - first) // SLOT_GRID_MINUTES + 1
|
||||
count = _clamp(self.count, MIN_MEAL_COUNT, min(MAX_MEAL_COUNT, capacity))
|
||||
return MealSchedule(first, last, count)
|
||||
|
||||
def slots(self) -> tuple[int, ...]:
|
||||
"""Return the meal-slot hours, ascending, endpoints exact.
|
||||
"""Return the meal-slot minutes, ascending, endpoints exact.
|
||||
|
||||
Meals are spread evenly across the window and rounded to whole hours by
|
||||
integer arithmetic: slot *i* is ``first + (i*span + d//2) // d`` where
|
||||
``span = last - first`` and ``d = count - 1``. The ``d//2`` term is a
|
||||
round-half-up bias applied before the division, which is what keeps
|
||||
this float-free.
|
||||
With ``span = last - first`` and ``d = count - 1``, interior slot *i*
|
||||
is ``raw = first + (i*span + d//2) // d`` (even division, the ``d//2``
|
||||
term a round-half-up bias that keeps it float-free), then snapped to
|
||||
the nearest absolute quarter hour by ``(raw + 7) // 15 * 15``. The
|
||||
endpoints are ``first`` and ``last`` exactly -- never snapped -- so the
|
||||
eating window the user typed is always honoured, even at 07:23.
|
||||
|
||||
Both endpoints land exactly on ``first`` and ``last`` by construction,
|
||||
so the eating window is always honoured even when the interior spacing
|
||||
has to round.
|
||||
|
||||
The result is strictly ascending because :meth:`normalized` caps
|
||||
``count`` at ``last - first + 1``. Without that cap a narrow window
|
||||
repeats an hour (``08-12`` with 6 meals would give
|
||||
``8, 9, 10, 10, 11, 12``), and since slot hours are used as set
|
||||
members, dict keys *and* notification ids, a repeat silently drops a
|
||||
checkpoint.
|
||||
Strictly ascending, provably: :meth:`normalized` caps ``count`` so that
|
||||
``span / d >= 15``, hence consecutive ``raw`` values differ by at least
|
||||
15, and two integers at least 15 apart never snap onto the same grid
|
||||
mark. Snapping moves a value by at most 7, so the first interior slot
|
||||
is at least 8 past ``first`` and the last at least 8 before ``last``.
|
||||
That matters because slot minutes are set members, dict keys *and*
|
||||
notification ids: a repeat silently drops a checkpoint.
|
||||
|
||||
Returns:
|
||||
The slot hours, ascending, of length ``normalized().count``.
|
||||
The slot minutes, ascending, of length ``normalized().count``.
|
||||
"""
|
||||
schedule = self.normalized()
|
||||
span = schedule.last - schedule.first
|
||||
first, last = schedule.first_minute, schedule.last_minute
|
||||
span = last - first
|
||||
divisions = schedule.count - 1
|
||||
return tuple(
|
||||
schedule.first + (index * span + divisions // 2) // divisions
|
||||
for index in range(schedule.count)
|
||||
interior = (
|
||||
(first + (index * span + divisions // 2) // divisions + 7)
|
||||
// SLOT_GRID_MINUTES
|
||||
* SLOT_GRID_MINUTES
|
||||
for index in range(1, divisions)
|
||||
)
|
||||
return (first, *interior, last)
|
||||
|
||||
@property
|
||||
def enforcement_end_hour(self) -> int:
|
||||
"""Return the hour at which slot enforcement stops for the day.
|
||||
def enforcement_end_minute(self) -> int:
|
||||
"""Return the minute at which slot enforcement stops for the day.
|
||||
|
||||
Clamped to the end of the day: a 23:00 last meal would otherwise put
|
||||
the cutoff at 25, making ``hour < cutoff`` vacuously true so the
|
||||
enforcement window never closes and the gate can never stop firing.
|
||||
Derived from the *normalised* last meal, so it always agrees with the
|
||||
last entry of :meth:`slots`. Clamped to the end of the day: a 23:00
|
||||
last meal would otherwise put the cutoff at 1500, making
|
||||
``minute < cutoff`` vacuously true so the enforcement window never
|
||||
closes and the gate can never stop firing.
|
||||
"""
|
||||
return min(self.last + ENFORCEMENT_TAIL_HOURS, _HOURS_PER_DAY)
|
||||
last = self.normalized().last_minute
|
||||
return min(last + ENFORCEMENT_TAIL_MINUTES, MINUTES_PER_DAY)
|
||||
|
||||
|
||||
# The historical hardcoded schedule: 08:00, 12:00, 16:00, 20:00, with the
|
||||
# enforcement window closing at 22:00. Still what a device uses before the
|
||||
# user has ever chosen anything, so upgrading changes no behaviour.
|
||||
DEFAULT_SCHEDULE = MealSchedule(first=8, last=20, count=4)
|
||||
DEFAULT_SCHEDULE = MealSchedule(first_minute=8 * 60, last_minute=20 * 60, count=4)
|
||||
|
||||
|
||||
def is_wire_int(value: object) -> TypeGuard[int]:
|
||||
"""Return True for a real int; ``bool`` is excluded, as Dart's ``is int`` is.
|
||||
|
||||
Python's ``bool`` subclasses ``int``, so a bare ``isinstance`` would accept
|
||||
``true`` from a peer's JSON that the Dart side rejects -- two devices
|
||||
disagreeing about which values are usable.
|
||||
"""
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def schedule_to_wire(schedule: MealSchedule) -> dict[str, int]:
|
||||
"""Return the synced ``sched:<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
109
diet_guard/_slot_wire.py
Normal 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
|
||||
@ -7,10 +7,17 @@ exhaustively unit-testable without mocking the filesystem or the wall clock.
|
||||
The stateful "which slots have I actually logged?" question lives in
|
||||
:mod:`diet_guard._state`; the two are composed in :mod:`diet_guard._gate`.
|
||||
|
||||
A "slot" is simply the integer hour at which a meal checkpoint opens (08, 12,
|
||||
16, 20 by default). A slot is *elapsed* once its hour has arrived and we are
|
||||
still inside the daily enforcement window; an elapsed slot with no logged meal
|
||||
is what makes the gate fire.
|
||||
A "slot" is the *minute of day* (``0..1439``) at which a meal checkpoint opens
|
||||
(480, 720, 960, 1200 -- 08:00, 12:00, 16:00, 20:00 -- by default). A slot is
|
||||
*elapsed* once its minute has arrived and we are still inside the daily
|
||||
enforcement window; an elapsed slot with no logged meal is what makes the gate
|
||||
fire. How a log entry records its slot on the wire, and how the old
|
||||
hour-valued ``slot`` field is read back, lives in :mod:`diet_guard._slot_wire`.
|
||||
|
||||
A logged meal satisfies the slot *nearest* its recorded slot minute
|
||||
(:func:`satisfied_slots`), not only an exact match. That is how a meal logged
|
||||
under an old schedule (or an old hour-valued entry) still counts after the
|
||||
schedule moves by a few minutes -- nothing on disk is ever rewritten.
|
||||
|
||||
``schedule`` is a required argument on every function here, deliberately: it
|
||||
used to be read from module constants, and a default would let a call site that
|
||||
@ -20,6 +27,9 @@ device offers and the other does not is a checkpoint that can never be
|
||||
satisfied. Making it required lets mypy enumerate the call sites instead.
|
||||
Callers resolve the value at the impure edge, mirroring how
|
||||
:mod:`diet_guard._daystatus` takes an explicit budget schedule.
|
||||
|
||||
KEEP IN SYNC WITH ``app/lib/models/slot.dart``; the shared vectors in
|
||||
``tests/fixtures/meal_schedule_vectors.json`` gate the two.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -27,20 +37,27 @@ from __future__ import annotations
|
||||
from datetime import date, datetime, time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._meal_schedule import MINUTES_PER_DAY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from diet_guard._meal_schedule import MealSchedule
|
||||
|
||||
_HOURS_PER_DAY = 24
|
||||
|
||||
def minute_of_day(now: datetime) -> int:
|
||||
"""Return ``now``'s minute of day, ``0..1439`` (seconds are ignored)."""
|
||||
return now.hour * 60 + now.minute
|
||||
|
||||
|
||||
def day_slots(schedule: MealSchedule) -> tuple[int, ...]:
|
||||
"""Return the meal-slot hours for a day, e.g. ``(8, 12, 16, 20)``.
|
||||
"""Return the meal-slot minutes for a day, e.g. ``(480, 720, 960, 1200)``.
|
||||
|
||||
Args:
|
||||
schedule: The eating window and meal count to derive slots from.
|
||||
|
||||
Returns:
|
||||
The slot hours in ascending order.
|
||||
The slot minutes in ascending order.
|
||||
"""
|
||||
return schedule.slots()
|
||||
|
||||
@ -58,11 +75,12 @@ def within_enforcement_window(now: datetime, schedule: MealSchedule) -> bool:
|
||||
Returns:
|
||||
True if slot enforcement is active at ``now``.
|
||||
"""
|
||||
return schedule.slots()[0] <= now.hour < schedule.enforcement_end_hour
|
||||
minute = minute_of_day(now)
|
||||
return schedule.slots()[0] <= minute < schedule.enforcement_end_minute
|
||||
|
||||
|
||||
def elapsed_slots(now: datetime, schedule: MealSchedule) -> tuple[int, ...]:
|
||||
"""Return today's slots whose hour has arrived as of ``now``.
|
||||
"""Return today's slots whose minute has arrived as of ``now``.
|
||||
|
||||
Empty outside the enforcement window (before the first slot, or after the
|
||||
overnight cutoff), so the caller never has to special-case the night.
|
||||
@ -72,11 +90,12 @@ def elapsed_slots(now: datetime, schedule: MealSchedule) -> tuple[int, ...]:
|
||||
schedule: The schedule in force for that day.
|
||||
|
||||
Returns:
|
||||
The elapsed slot hours, ascending (possibly empty).
|
||||
The elapsed slot minutes, ascending (possibly empty).
|
||||
"""
|
||||
if not within_enforcement_window(now, schedule):
|
||||
return ()
|
||||
return tuple(slot for slot in day_slots(schedule) if slot <= now.hour)
|
||||
minute = minute_of_day(now)
|
||||
return tuple(slot for slot in day_slots(schedule) if slot <= minute)
|
||||
|
||||
|
||||
def missing_slots(
|
||||
@ -86,11 +105,12 @@ def missing_slots(
|
||||
|
||||
Args:
|
||||
now: Reference local time.
|
||||
logged: The set of slot hours already covered by today's log.
|
||||
logged: The slot minutes already covered by today's log -- normally
|
||||
:func:`satisfied_slots` of the entries' slot minutes.
|
||||
schedule: The schedule in force for that day.
|
||||
|
||||
Returns:
|
||||
The unsatisfied elapsed slot hours, ascending (empty == nothing due).
|
||||
The unsatisfied elapsed slot minutes, ascending (empty == nothing due).
|
||||
"""
|
||||
return tuple(slot for slot in elapsed_slots(now, schedule) if slot not in logged)
|
||||
|
||||
@ -107,7 +127,7 @@ def current_slot(now: datetime, schedule: MealSchedule) -> int | None:
|
||||
schedule: The schedule in force for that day.
|
||||
|
||||
Returns:
|
||||
The latest elapsed slot hour, or None when none have elapsed yet.
|
||||
The latest elapsed slot minute, or None when none have elapsed yet.
|
||||
"""
|
||||
elapsed = elapsed_slots(now, schedule)
|
||||
return elapsed[-1] if elapsed else None
|
||||
@ -120,8 +140,8 @@ def slot_for_log(now: datetime, schedule: MealSchedule) -> int:
|
||||
the first slot, clamp to the first slot; after the enforcement window ends,
|
||||
clamp to the last slot; behaviour inside a window is unchanged. Both
|
||||
languages must reach each answer by the *same* branch, not merely agree on
|
||||
the value -- ``test_slots.py`` sweeps every hour of the day against several
|
||||
schedules for exactly that reason.
|
||||
the value -- the shared fixture pins every minute edge for exactly that
|
||||
reason.
|
||||
|
||||
Unlike :func:`current_slot` this never returns None, which is the point: an
|
||||
off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
|
||||
@ -134,39 +154,61 @@ def slot_for_log(now: datetime, schedule: MealSchedule) -> int:
|
||||
schedule: The schedule in force for that day.
|
||||
|
||||
Returns:
|
||||
The slot hour to tag the log with.
|
||||
The slot minute to tag the log with.
|
||||
"""
|
||||
slots = day_slots(schedule)
|
||||
if now.hour < slots[0]:
|
||||
if minute_of_day(now) < slots[0]:
|
||||
return slots[0]
|
||||
current = current_slot(now, schedule)
|
||||
return current if current is not None else slots[-1]
|
||||
|
||||
|
||||
def nearest_slot(minute: int, schedule: MealSchedule) -> int:
|
||||
"""Return the slot closest to ``minute``; an exact tie goes to the earlier.
|
||||
|
||||
The tie rule is part of the cross-language contract: ``min`` keeps the
|
||||
first minimal element of the ascending slots, and the Dart loop replaces
|
||||
its best only on a strictly smaller distance.
|
||||
"""
|
||||
return min(day_slots(schedule), key=lambda slot: abs(slot - minute))
|
||||
|
||||
|
||||
def satisfied_slots(entry_minutes: Iterable[int], schedule: MealSchedule) -> set[int]:
|
||||
"""Return the slots covered by meals recorded at ``entry_minutes``.
|
||||
|
||||
Each recorded slot minute satisfies its :func:`nearest_slot`. Many-to-one
|
||||
is intended: two meals snapping onto one slot satisfy only that slot, so a
|
||||
double breakfast does not also clear lunch. This is what lets an entry
|
||||
written under an older schedule count toward today's nearest checkpoint
|
||||
without rewriting it on disk.
|
||||
"""
|
||||
return {nearest_slot(minute, schedule) for minute in entry_minutes}
|
||||
|
||||
|
||||
def resolve_future_when(
|
||||
date_str: str, hour: int, schedule: MealSchedule, now: datetime
|
||||
date_str: str, slot_minute: int, schedule: MealSchedule, now: datetime
|
||||
) -> datetime:
|
||||
"""Build a tz-aware datetime for a meal pre-logged against a future slot.
|
||||
|
||||
Lets a caller (the CLI's ``--date``/``--hour`` flags, or the app's future-
|
||||
date picker) turn a user-picked date+hour into the ``when`` that
|
||||
Lets a caller (the CLI's ``--date`` flag, or the app's future-date picker)
|
||||
turn a user-picked date+slot into the ``when`` that
|
||||
:func:`diet_guard._state.log_meal` expects, with the same validation on
|
||||
both platforms. Still clock-free per the module's own rule: ``now`` is
|
||||
supplied by the caller rather than read here.
|
||||
|
||||
Args:
|
||||
date_str: The chosen date as ``YYYY-MM-DD``.
|
||||
hour: The chosen slot hour; must be one of ``schedule``'s slot hours.
|
||||
schedule: The schedule in force, used to validate ``hour``.
|
||||
slot_minute: The chosen slot; must be one of ``schedule``'s slots.
|
||||
schedule: The schedule in force, used to validate ``slot_minute``.
|
||||
now: Reference local time, used to reject a non-future date.
|
||||
|
||||
Returns:
|
||||
A tz-aware datetime combining ``date_str`` and ``hour``, in ``now``'s
|
||||
timezone.
|
||||
A tz-aware datetime combining ``date_str`` and ``slot_minute``, in
|
||||
``now``'s timezone.
|
||||
|
||||
Raises:
|
||||
ValueError: ``date_str`` doesn't parse, ``hour`` isn't a slot hour, or
|
||||
the resulting date isn't strictly after ``now``'s date.
|
||||
ValueError: ``date_str`` doesn't parse, ``slot_minute`` isn't a slot,
|
||||
or the resulting date isn't strictly after ``now``'s date.
|
||||
"""
|
||||
try:
|
||||
parsed_date = date.fromisoformat(date_str)
|
||||
@ -174,15 +216,18 @@ def resolve_future_when(
|
||||
msg = f"invalid date {date_str!r}, expected YYYY-MM-DD"
|
||||
raise ValueError(msg) from exc
|
||||
slots = day_slots(schedule)
|
||||
if hour not in slots:
|
||||
msg = f"hour {hour} is not a meal slot; choose one of {slots}"
|
||||
if slot_minute not in slots:
|
||||
labels = ", ".join(slot_label(slot) for slot in slots)
|
||||
msg = f"{slot_label(slot_minute)} is not a meal slot; choose one of {labels}"
|
||||
raise ValueError(msg)
|
||||
if parsed_date <= now.date():
|
||||
msg = f"{date_str} is not a future date"
|
||||
raise ValueError(msg)
|
||||
return datetime.combine(parsed_date, time(hour=hour), tzinfo=now.tzinfo)
|
||||
clock = time(hour=slot_minute // 60, minute=slot_minute % 60)
|
||||
return datetime.combine(parsed_date, clock, tzinfo=now.tzinfo)
|
||||
|
||||
|
||||
def slot_label(slot: int) -> str:
|
||||
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
|
||||
return f"{slot % _HOURS_PER_DAY:02d}:00"
|
||||
def slot_label(minute: int) -> str:
|
||||
"""Return a human ``HH:MM`` label for a slot minute, e.g. ``"07:15"``."""
|
||||
wrapped = minute % MINUTES_PER_DAY
|
||||
return f"{wrapped // 60:02d}:{wrapped % 60:02d}"
|
||||
|
||||
@ -57,7 +57,7 @@ def _dishes(fixture: dict[str, Any]) -> list[Dish]:
|
||||
|
||||
|
||||
def _slots_for(key: str) -> tuple[int, ...]:
|
||||
"""Turn a fixture slot key such as ``"8,12,16,20"`` into slot hours."""
|
||||
"""Turn a fixture slot key such as ``"480,720,960,1200"`` into slot minutes."""
|
||||
return tuple(int(part) for part in key.split(","))
|
||||
|
||||
|
||||
@ -107,8 +107,8 @@ def test_slot_assignment_matches(fixture: dict[str, Any], dishes: list[Dish]) ->
|
||||
"""``i * S // N`` lands each dish on the slot the Dart side also picks."""
|
||||
for key, expected in fixture["expected"]["slots"].items():
|
||||
subject = dishes[:3] if key.startswith("first_three_") else dishes
|
||||
hours = _slots_for(key.removeprefix("first_three_"))
|
||||
actual = [item.slot for item in assign_slots(subject, hours)]
|
||||
minutes = _slots_for(key.removeprefix("first_three_"))
|
||||
actual = [item.slot for item in assign_slots(subject, minutes)]
|
||||
assert actual == expected, f"slot assignment diverged for {key}"
|
||||
|
||||
|
||||
@ -119,7 +119,7 @@ def test_slot_order_matches(fixture: dict[str, Any], dishes: list[Dish]) -> None
|
||||
dishes sharing both priority and name are the case that forces both
|
||||
comparators to be total.
|
||||
"""
|
||||
ordered = assign_slots(dishes, (8, 12, 16, 20))
|
||||
ordered = assign_slots(dishes, (480, 720, 960, 1200))
|
||||
assert [item.dish.name for item in ordered] == fixture["expected"]["slot_order"]
|
||||
|
||||
|
||||
@ -201,5 +201,5 @@ def test_twin_case_is_not_vacuous(fixture: dict[str, Any], dishes: list[Dish]) -
|
||||
fill = fixture["expected"]["fill"]
|
||||
case = fill["log_dishes"]["twins_share_16"]
|
||||
spread = [(item.dish.name, item.slot) for item in _spread(dishes, fill, case)]
|
||||
assert spread.count(("Twin dish", 16)) == 2
|
||||
assert case["expected"].count(["Twin dish", 16]) == 1
|
||||
assert spread.count(("Twin dish", 960)) == 2
|
||||
assert case["expected"].count(["Twin dish", 960]) == 1
|
||||
|
||||
@ -1,65 +1,73 @@
|
||||
"""Tests for the pure meal-schedule derivation.
|
||||
|
||||
The vector table below is duplicated verbatim in
|
||||
``app/test/models/meal_schedule_test.dart``. KEEP THE TWO IN SYNC: it is the
|
||||
only thing that catches a Python/Dart divergence before it reaches a device,
|
||||
and a divergence there means one device nags for a checkpoint the other never
|
||||
offers -- a slot that can never be satisfied, i.e. a permanent lock.
|
||||
The cross-language exact values live in the shared fixture
|
||||
``tests/fixtures/meal_schedule_vectors.json`` and are asserted by
|
||||
``test_meal_schedule_vectors.py`` (and by its Dart mirror). This file holds
|
||||
the properties: the grid sweep, the hour-formula regression and the clamps a
|
||||
human should be able to read without opening the fixture. The sweep is
|
||||
duplicated loop-for-loop in ``app/test/models/meal_schedule_test.dart``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard._meal_schedule import (
|
||||
DEFAULT_SCHEDULE,
|
||||
MAX_MEAL_COUNT,
|
||||
MIN_MEAL_COUNT,
|
||||
MINUTES_PER_DAY,
|
||||
SLOT_GRID_MINUTES,
|
||||
MealSchedule,
|
||||
schedule_from_wire,
|
||||
schedule_to_wire,
|
||||
)
|
||||
|
||||
# (first, last, count) -> expected slot hours. Shared with the Dart mirror.
|
||||
SCHEDULE_VECTORS: list[tuple[int, int, int, tuple[int, ...]]] = [
|
||||
# The eating window the user described, at every supported meal count.
|
||||
(8, 20, 2, (8, 20)),
|
||||
(8, 20, 3, (8, 14, 20)),
|
||||
(8, 20, 4, (8, 12, 16, 20)), # today's hardcoded schedule
|
||||
(8, 20, 5, (8, 11, 14, 17, 20)), # the user's stated example
|
||||
(8, 20, 6, (8, 10, 13, 15, 18, 20)),
|
||||
# A window that does not divide evenly: 14 hours across 3 gaps.
|
||||
(7, 21, 4, (7, 12, 16, 21)),
|
||||
(7, 21, 5, (7, 11, 14, 18, 21)),
|
||||
(9, 19, 4, (9, 12, 16, 19)),
|
||||
# Narrow windows: count is capped at the number of whole hours available,
|
||||
# so the slots stay distinct instead of repeating an hour.
|
||||
(8, 12, 6, (8, 9, 10, 11, 12)),
|
||||
(8, 10, 5, (8, 9, 10)),
|
||||
(8, 9, 4, (8, 9)),
|
||||
# Whole-day extremes.
|
||||
(0, 23, 6, (0, 5, 9, 14, 18, 23)),
|
||||
(0, 1, 2, (0, 1)),
|
||||
]
|
||||
_H = 60
|
||||
|
||||
|
||||
def _old_hour_slots(first: int, last: int, count: int) -> tuple[int, ...]:
|
||||
"""Return the pre-minute whole-hour derivation, converted to minutes."""
|
||||
span, divisions = last - first, count - 1
|
||||
return tuple(
|
||||
(first + (index * span + divisions // 2) // divisions) * _H
|
||||
for index in range(count)
|
||||
)
|
||||
|
||||
|
||||
class TestSlots:
|
||||
"""Slot derivation from a schedule."""
|
||||
|
||||
@pytest.mark.parametrize(("first", "last", "count", "expected"), SCHEDULE_VECTORS)
|
||||
def test_matches_shared_vectors(
|
||||
self, first: int, last: int, count: int, expected: tuple[int, ...]
|
||||
) -> None:
|
||||
"""Each shared vector derives its documented slot hours."""
|
||||
assert MealSchedule(first, last, count).slots() == expected
|
||||
|
||||
def test_default_is_the_historical_schedule(self) -> None:
|
||||
"""The default reproduces the hours that used to be hardcoded."""
|
||||
assert DEFAULT_SCHEDULE.slots() == (8, 12, 16, 20)
|
||||
assert DEFAULT_SCHEDULE.slots() == (480, 720, 960, 1200)
|
||||
|
||||
def test_endpoints_are_exact_when_spacing_rounds(self) -> None:
|
||||
"""Rounding the interior never drags an endpoint off the window."""
|
||||
slots = MealSchedule(8, 20, 6).slots()
|
||||
assert slots[0] == 8
|
||||
assert slots[-1] == 20
|
||||
@pytest.mark.parametrize(
|
||||
("first", "last", "count"), [(8, 20, 4), (8, 20, 5), (7, 19, 5)]
|
||||
)
|
||||
def test_whole_hour_schedules_match_the_old_hour_formula(
|
||||
self, first: int, last: int, count: int
|
||||
) -> None:
|
||||
"""Evenly dividing hour schedules derive exactly what they used to.
|
||||
|
||||
Upgrading must not move a checkpoint for the schedules people actually
|
||||
have, or every existing hour-tagged entry would satisfy a neighbour.
|
||||
"""
|
||||
schedule = MealSchedule(first * _H, last * _H, count)
|
||||
assert schedule.slots() == _old_hour_slots(first, last, count)
|
||||
|
||||
def test_off_grid_endpoints_are_exact(self) -> None:
|
||||
"""07:23-19:41 keeps both endpoints; only the interior snaps."""
|
||||
slots = MealSchedule(443, 1181, 5).slots()
|
||||
assert slots[0] == 443
|
||||
assert slots[-1] == 1181
|
||||
assert all(slot % SLOT_GRID_MINUTES == 0 for slot in slots[1:-1])
|
||||
|
||||
def test_the_users_quarter_hour_schedule(self) -> None:
|
||||
"""07:15-19:00 with five meals, as the user asked for it."""
|
||||
assert MealSchedule(435, 1140, 5).slots() == (435, 615, 795, 960, 1140)
|
||||
|
||||
|
||||
class TestNormalization:
|
||||
@ -68,15 +76,17 @@ class TestNormalization:
|
||||
@pytest.mark.parametrize(
|
||||
("schedule", "expected"),
|
||||
[
|
||||
(MealSchedule(8, 20, 99), MealSchedule(8, 20, MAX_MEAL_COUNT)),
|
||||
(MealSchedule(8, 20, 0), MealSchedule(8, 20, MIN_MEAL_COUNT)),
|
||||
(MealSchedule(-5, 20, 4), MealSchedule(0, 20, 4)),
|
||||
(MealSchedule(8, 99, 4), MealSchedule(8, 23, 4)),
|
||||
# last <= first is pulled forward to leave a one-hour window.
|
||||
(MealSchedule(12, 12, 4), MealSchedule(12, 13, 2)),
|
||||
(MealSchedule(12, 3, 4), MealSchedule(12, 13, 2)),
|
||||
# first cannot occupy the final hour, or no window would remain.
|
||||
(MealSchedule(23, 23, 2), MealSchedule(22, 23, 2)),
|
||||
(MealSchedule(480, 1200, 99), MealSchedule(480, 1200, MAX_MEAL_COUNT)),
|
||||
(MealSchedule(480, 1200, 0), MealSchedule(480, 1200, MIN_MEAL_COUNT)),
|
||||
(MealSchedule(-5, 1200, 4), MealSchedule(0, 1200, 4)),
|
||||
(MealSchedule(480, 9999, 4), MealSchedule(480, 1439, 4)),
|
||||
# last <= first is pulled forward to leave one grid step of window.
|
||||
(MealSchedule(720, 720, 4), MealSchedule(720, 735, 2)),
|
||||
(MealSchedule(720, 3, 4), MealSchedule(720, 735, 2)),
|
||||
# first cannot sit in the final grid step, or no window remains.
|
||||
(MealSchedule(1439, 1439, 2), MealSchedule(1424, 1439, 2)),
|
||||
# Count is capped by the grid: 60 minutes hold 5 quarter-hour marks.
|
||||
(MealSchedule(480, 540, 6), MealSchedule(480, 540, 5)),
|
||||
],
|
||||
)
|
||||
def test_clamps_to_the_nearest_legal_schedule(
|
||||
@ -87,48 +97,79 @@ class TestNormalization:
|
||||
|
||||
def test_garbage_still_yields_usable_slots(self) -> None:
|
||||
"""Wildly invalid input degrades to a schedule, not an exception."""
|
||||
assert MealSchedule(99, -5, 999).slots() == (22, 23)
|
||||
assert MealSchedule(9999, -5, 999).slots() == (1424, 1439)
|
||||
|
||||
|
||||
class TestEnforcementEndHour:
|
||||
class TestEnforcementEndMinute:
|
||||
"""The daily cutoff derived from the last meal."""
|
||||
|
||||
def test_default_keeps_the_historical_cutoff(self) -> None:
|
||||
"""The default schedule still stops enforcing at 22:00."""
|
||||
assert DEFAULT_SCHEDULE.enforcement_end_hour == 22
|
||||
assert DEFAULT_SCHEDULE.enforcement_end_minute == 22 * _H
|
||||
|
||||
def test_tail_follows_the_last_meal(self) -> None:
|
||||
"""Moving the last meal moves the cutoff with it."""
|
||||
assert MealSchedule(8, 18, 4).enforcement_end_hour == 20
|
||||
"""Moving the last meal moves the cutoff with it, to the minute."""
|
||||
assert MealSchedule(480, 1095, 4).enforcement_end_minute == 1215
|
||||
|
||||
def test_clamped_to_the_end_of_the_day(self) -> None:
|
||||
"""A late last meal cannot push the cutoff past midnight.
|
||||
|
||||
An unclamped 23 + 2 = 25 would make ``hour < cutoff`` vacuously true,
|
||||
so the enforcement window would never close.
|
||||
An unclamped 1380 + 120 = 1500 would make ``minute < cutoff``
|
||||
vacuously true, so the enforcement window would never close.
|
||||
"""
|
||||
assert MealSchedule(8, 23, 4).enforcement_end_hour == 24
|
||||
assert MealSchedule(480, 1380, 4).enforcement_end_minute == MINUTES_PER_DAY
|
||||
|
||||
def test_follows_the_normalised_last_meal(self) -> None:
|
||||
"""An inverted window's cutoff agrees with its normalised last slot."""
|
||||
schedule = MealSchedule(720, 3, 4)
|
||||
assert schedule.enforcement_end_minute == schedule.slots()[-1] + 120
|
||||
|
||||
|
||||
class TestWire:
|
||||
"""The ``sched:<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:
|
||||
"""Properties that must hold for every input the UI or sync can produce."""
|
||||
|
||||
def test_every_input_yields_ascending_slots_with_exact_endpoints(self) -> None:
|
||||
"""Sweep the whole input space, including out-of-range values.
|
||||
def test_grid_sweep_yields_ascending_slots_with_exact_endpoints(self) -> None:
|
||||
"""Sweep every quarter-hour first/last pair at every count.
|
||||
|
||||
This is the cheap half of the cross-language parity guarantee; the
|
||||
Dart mirror runs the identical sweep.
|
||||
The Dart mirror runs the identical loop. A wire round trip rides
|
||||
along, because it is the same sweep's worth of schedules.
|
||||
"""
|
||||
for first in range(-2, 26):
|
||||
for last in range(-2, 26):
|
||||
for count in range(-2, 10):
|
||||
for first in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES):
|
||||
for last in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES):
|
||||
for count in range(MIN_MEAL_COUNT, MAX_MEAL_COUNT + 1):
|
||||
schedule = MealSchedule(first, last, count)
|
||||
normalized = schedule.normalized()
|
||||
slots = schedule.slots()
|
||||
|
||||
assert slots[0] == normalized.first
|
||||
assert slots[-1] == normalized.last
|
||||
assert slots[0] == normalized.first_minute
|
||||
assert slots[-1] == normalized.last_minute
|
||||
assert len(slots) == normalized.count
|
||||
assert len(set(slots)) == len(slots)
|
||||
assert list(slots) == sorted(slots)
|
||||
assert MIN_MEAL_COUNT <= len(slots) <= MAX_MEAL_COUNT
|
||||
assert all(a < b for a, b in pairwise(slots))
|
||||
assert slots[0] >= 0
|
||||
assert slots[-1] < MINUTES_PER_DAY
|
||||
assert schedule_from_wire(schedule_to_wire(schedule)) == normalized
|
||||
|
||||
def test_off_grid_sweep_stays_strictly_ascending(self) -> None:
|
||||
"""Odd-minute windows near the capacity cap never repeat a slot."""
|
||||
for first in range(0, 120, 7):
|
||||
for width in range(15, 200, 11):
|
||||
for count in range(MIN_MEAL_COUNT, MAX_MEAL_COUNT + 1):
|
||||
slots = MealSchedule(first, first + width, count).slots()
|
||||
assert all(a < b for a, b in pairwise(slots))
|
||||
|
||||
143
diet_guard/tests/test_meal_schedule_vectors.py
Normal file
143
diet_guard/tests/test_meal_schedule_vectors.py
Normal 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
|
||||
89
diet_guard/tests/test_slot_wire.py
Normal file
89
diet_guard/tests/test_slot_wire.py
Normal 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)
|
||||
@ -1,9 +1,12 @@
|
||||
"""Tests for _slots.py — pure meal-slot arithmetic.
|
||||
"""Tests for _slots.py — pure meal-slot arithmetic, in minutes of day.
|
||||
|
||||
Every function is a total function of ``now`` and an explicit schedule, so the
|
||||
time-of-day edges are exercised directly with fixed ``datetime`` values. The
|
||||
assertions below use ``DEFAULT_SCHEDULE`` (08/12/16/20, cutoff 22:00) so they
|
||||
still pin the behaviour these functions had when those hours were hardcoded.
|
||||
The exhaustive cross-language edges live in the shared fixture
|
||||
(``test_meal_schedule_vectors.py``); nearest/satisfied in
|
||||
``test_slots_nearest.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -15,6 +18,7 @@ from diet_guard._slots import (
|
||||
current_slot,
|
||||
day_slots,
|
||||
elapsed_slots,
|
||||
minute_of_day,
|
||||
missing_slots,
|
||||
slot_for_log,
|
||||
slot_label,
|
||||
@ -22,45 +26,64 @@ from diet_guard._slots import (
|
||||
)
|
||||
|
||||
|
||||
def _at(hour: int) -> datetime:
|
||||
"""Return a fixed local datetime at ``hour`` (date is irrelevant here)."""
|
||||
return datetime(2026, 1, 1, hour, 0, tzinfo=UTC)
|
||||
def _at(hour: int, minute: int = 0) -> datetime:
|
||||
"""Return a fixed local datetime at ``hour:minute`` (date is irrelevant)."""
|
||||
return datetime(2026, 1, 1, hour, minute, tzinfo=UTC)
|
||||
|
||||
|
||||
class TestMinuteOfDay:
|
||||
"""The clock reading every other function compares against."""
|
||||
|
||||
def test_counts_minutes_and_ignores_seconds(self) -> None:
|
||||
"""07:15:59 is minute 435."""
|
||||
assert minute_of_day(datetime(2026, 1, 1, 7, 15, 59, tzinfo=UTC)) == 435
|
||||
|
||||
def test_day_ends(self) -> None:
|
||||
"""Midnight is 0 and 23:59 is 1439."""
|
||||
assert minute_of_day(_at(0)) == 0
|
||||
assert minute_of_day(_at(23, 59)) == 1439
|
||||
|
||||
|
||||
class TestDaySlots:
|
||||
"""The fixed slot schedule derived from the constants."""
|
||||
"""The slot schedule derived from a schedule."""
|
||||
|
||||
def test_default_schedule(self) -> None:
|
||||
"""Slots open every 4h from 08:00 up to (not past) the 22:00 cutoff."""
|
||||
assert day_slots(DEFAULT_SCHEDULE) == (8, 12, 16, 20)
|
||||
assert day_slots(DEFAULT_SCHEDULE) == (480, 720, 960, 1200)
|
||||
|
||||
|
||||
class TestEnforcementWindow:
|
||||
"""The [day_start, eating_end) active window."""
|
||||
"""The [first slot, enforcement end) active window, to the minute."""
|
||||
|
||||
def test_before_window(self) -> None:
|
||||
"""An hour before the first slot is outside the window."""
|
||||
assert not within_enforcement_window(_at(7), DEFAULT_SCHEDULE)
|
||||
"""07:59 is outside the window."""
|
||||
assert not within_enforcement_window(_at(7, 59), DEFAULT_SCHEDULE)
|
||||
|
||||
def test_first_slot_is_inside(self) -> None:
|
||||
"""The day-start hour is inside (inclusive lower bound)."""
|
||||
"""08:00 is inside (inclusive lower bound)."""
|
||||
assert within_enforcement_window(_at(8), DEFAULT_SCHEDULE)
|
||||
|
||||
def test_last_active_hour_inside(self) -> None:
|
||||
"""21:00 is still inside; the cutoff is exclusive at 22:00."""
|
||||
assert within_enforcement_window(_at(21), DEFAULT_SCHEDULE)
|
||||
def test_last_active_minute_inside(self) -> None:
|
||||
"""21:59 is still inside; the cutoff is exclusive at 22:00."""
|
||||
assert within_enforcement_window(_at(21, 59), DEFAULT_SCHEDULE)
|
||||
|
||||
def test_cutoff_is_outside(self) -> None:
|
||||
"""The cutoff hour itself is outside (exclusive upper bound)."""
|
||||
"""22:00 itself is outside (exclusive upper bound)."""
|
||||
assert not within_enforcement_window(_at(22), DEFAULT_SCHEDULE)
|
||||
|
||||
def test_quarter_hour_first_slot(self) -> None:
|
||||
"""A 07:15 breakfast opens the window at 07:15, not 07:00."""
|
||||
schedule = MealSchedule(435, 1140, 5)
|
||||
assert not within_enforcement_window(_at(7, 14), schedule)
|
||||
assert within_enforcement_window(_at(7, 15), schedule)
|
||||
|
||||
|
||||
class TestElapsedSlots:
|
||||
"""Which slots have arrived as of now."""
|
||||
|
||||
def test_empty_before_window(self) -> None:
|
||||
"""Before the first slot, nothing has elapsed."""
|
||||
assert elapsed_slots(_at(7), DEFAULT_SCHEDULE) == ()
|
||||
assert elapsed_slots(_at(7, 59), DEFAULT_SCHEDULE) == ()
|
||||
|
||||
def test_empty_after_cutoff(self) -> None:
|
||||
"""After the overnight cutoff, slots lapse to empty."""
|
||||
@ -68,15 +91,21 @@ class TestElapsedSlots:
|
||||
|
||||
def test_first_slot_only(self) -> None:
|
||||
"""At 08:00 exactly, only the 08:00 slot has elapsed."""
|
||||
assert elapsed_slots(_at(8), DEFAULT_SCHEDULE) == (8,)
|
||||
assert elapsed_slots(_at(8), DEFAULT_SCHEDULE) == (480,)
|
||||
|
||||
def test_midday(self) -> None:
|
||||
"""At 13:00, the 08:00 and 12:00 slots have elapsed."""
|
||||
assert elapsed_slots(_at(13), DEFAULT_SCHEDULE) == (8, 12)
|
||||
assert elapsed_slots(_at(13), DEFAULT_SCHEDULE) == (480, 720)
|
||||
|
||||
def test_all_elapsed_late(self) -> None:
|
||||
"""At 21:00, every slot for the day has elapsed."""
|
||||
assert elapsed_slots(_at(21), DEFAULT_SCHEDULE) == (8, 12, 16, 20)
|
||||
assert elapsed_slots(_at(21), DEFAULT_SCHEDULE) == (480, 720, 960, 1200)
|
||||
|
||||
def test_a_slot_opens_on_its_minute(self) -> None:
|
||||
"""10:14 has not reached a 10:15 slot; 10:15 has."""
|
||||
schedule = MealSchedule(435, 1140, 5)
|
||||
assert elapsed_slots(_at(10, 14), schedule) == (435,)
|
||||
assert elapsed_slots(_at(10, 15), schedule) == (435, 615)
|
||||
|
||||
|
||||
class TestMissingSlots:
|
||||
@ -84,11 +113,11 @@ class TestMissingSlots:
|
||||
|
||||
def test_none_missing_when_all_logged(self) -> None:
|
||||
"""All elapsed slots logged -> nothing due."""
|
||||
assert missing_slots(_at(13), {8, 12}, DEFAULT_SCHEDULE) == ()
|
||||
assert missing_slots(_at(13), {480, 720}, DEFAULT_SCHEDULE) == ()
|
||||
|
||||
def test_reports_unlogged(self) -> None:
|
||||
"""Only the unlogged elapsed slots are returned, ascending."""
|
||||
assert missing_slots(_at(17), {8}, DEFAULT_SCHEDULE) == (12, 16)
|
||||
assert missing_slots(_at(17), {480}, DEFAULT_SCHEDULE) == (720, 960)
|
||||
|
||||
|
||||
class TestCurrentSlot:
|
||||
@ -100,7 +129,7 @@ class TestCurrentSlot:
|
||||
|
||||
def test_latest_elapsed(self) -> None:
|
||||
"""At 13:00 the current slot is 12:00 (the latest elapsed)."""
|
||||
assert current_slot(_at(13), DEFAULT_SCHEDULE) == 12
|
||||
assert current_slot(_at(13), DEFAULT_SCHEDULE) == 720
|
||||
|
||||
|
||||
class TestSlotForLog:
|
||||
@ -113,108 +142,78 @@ class TestSlotForLog:
|
||||
|
||||
def test_before_the_first_window_clamps_to_the_first_slot(self) -> None:
|
||||
"""An early breakfast counts toward 08:00 rather than nothing."""
|
||||
assert slot_for_log(_at(7), DEFAULT_SCHEDULE) == 8
|
||||
assert slot_for_log(_at(7, 59), DEFAULT_SCHEDULE) == 480
|
||||
|
||||
def test_midnight_clamps_to_the_first_slot(self) -> None:
|
||||
"""The small hours are still "before the first window"."""
|
||||
assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 8
|
||||
|
||||
def test_first_slot_boundary_is_unchanged(self) -> None:
|
||||
"""08:00 exactly is inside the window, so no clamp applies."""
|
||||
assert slot_for_log(_at(8), DEFAULT_SCHEDULE) == 8
|
||||
assert slot_for_log(_at(0), DEFAULT_SCHEDULE) == 480
|
||||
|
||||
def test_inside_the_window_matches_current_slot(self) -> None:
|
||||
"""Within the window attribution is just the latest elapsed slot."""
|
||||
assert (
|
||||
slot_for_log(_at(13), DEFAULT_SCHEDULE)
|
||||
== current_slot(_at(13), DEFAULT_SCHEDULE)
|
||||
== 12
|
||||
)
|
||||
assert slot_for_log(_at(13), DEFAULT_SCHEDULE) == 720
|
||||
|
||||
def test_last_in_window_hour_is_unchanged(self) -> None:
|
||||
def test_last_in_window_minute_is_unchanged(self) -> None:
|
||||
"""21:59 is still inside the window and lands on 20:00."""
|
||||
assert slot_for_log(_at(21), DEFAULT_SCHEDULE) == 20
|
||||
assert slot_for_log(_at(21, 59), DEFAULT_SCHEDULE) == 1200
|
||||
|
||||
def test_after_the_last_window_clamps_to_the_last_slot(self) -> None:
|
||||
"""A late dinner counts toward 20:00 rather than nothing."""
|
||||
assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 20
|
||||
|
||||
def test_late_evening_clamps_to_the_last_slot(self) -> None:
|
||||
"""23:00 clamps the same way as the 22:00 boundary."""
|
||||
assert slot_for_log(_at(23), DEFAULT_SCHEDULE) == 20
|
||||
|
||||
def test_never_returns_none(self) -> None:
|
||||
"""Every hour of the day attributes to some slot."""
|
||||
assert all(
|
||||
slot_for_log(_at(hour), DEFAULT_SCHEDULE) in day_slots(DEFAULT_SCHEDULE)
|
||||
for hour in range(24)
|
||||
)
|
||||
assert slot_for_log(_at(22), DEFAULT_SCHEDULE) == 1200
|
||||
|
||||
def test_clamps_across_configured_schedules(self) -> None:
|
||||
"""The clamp rule holds for any schedule, not just the default.
|
||||
"""The clamp rule holds at every minute for several windows.
|
||||
|
||||
Sweeps every hour against several windows. ``slot.dart`` runs the
|
||||
identical sweep: the two must agree on every cell, because a device
|
||||
that attributes a meal to a different slot than its peer leaves the
|
||||
other device's checkpoint permanently unsatisfied.
|
||||
``slot_test.dart`` runs the identical sweep: a device that attributes
|
||||
a meal to a different slot than its peer leaves the other device's
|
||||
checkpoint permanently unsatisfied.
|
||||
"""
|
||||
for schedule in (
|
||||
DEFAULT_SCHEDULE,
|
||||
MealSchedule(8, 20, 5),
|
||||
MealSchedule(6, 22, 6),
|
||||
MealSchedule(0, 23, 2),
|
||||
MealSchedule(10, 14, 3),
|
||||
MealSchedule(480, 1200, 5),
|
||||
MealSchedule(435, 1140, 5),
|
||||
MealSchedule(443, 1181, 4),
|
||||
MealSchedule(0, 1439, 2),
|
||||
MealSchedule(600, 840, 3),
|
||||
):
|
||||
slots = day_slots(schedule)
|
||||
for hour in range(24):
|
||||
attributed = slot_for_log(_at(hour), schedule)
|
||||
assert attributed in slots
|
||||
if hour < slots[0]:
|
||||
for minute in range(1440):
|
||||
attributed = slot_for_log(_at(minute // 60, minute % 60), schedule)
|
||||
if minute < slots[0]:
|
||||
assert attributed == slots[0]
|
||||
elif hour >= schedule.enforcement_end_hour:
|
||||
elif minute >= schedule.enforcement_end_minute:
|
||||
assert attributed == slots[-1]
|
||||
else:
|
||||
# Inside the window: the most recent slot that has opened.
|
||||
assert attributed == max(s for s in slots if s <= hour)
|
||||
assert attributed == max(s for s in slots if s <= minute)
|
||||
|
||||
|
||||
class TestSlotLabel:
|
||||
"""Human HH:00 labels."""
|
||||
"""Human HH:MM labels."""
|
||||
|
||||
def test_morning_zero_padded(self) -> None:
|
||||
"""A single-digit hour is zero-padded."""
|
||||
assert slot_label(8) == "08:00"
|
||||
def test_zero_padded(self) -> None:
|
||||
"""Hours and minutes are both zero-padded."""
|
||||
assert slot_label(435) == "07:15"
|
||||
assert slot_label(5) == "00:05"
|
||||
|
||||
def test_evening(self) -> None:
|
||||
"""A two-digit hour formats plainly."""
|
||||
assert slot_label(20) == "20:00"
|
||||
assert slot_label(1200) == "20:00"
|
||||
|
||||
def test_wraps_into_the_day(self) -> None:
|
||||
"""Out-of-day values wrap, as the hour label used to with ``% 24``."""
|
||||
assert slot_label(1440) == "00:00"
|
||||
assert slot_label(-15) == "23:45"
|
||||
|
||||
|
||||
class TestConfiguredSchedules:
|
||||
"""Slot arithmetic against schedules other than the default."""
|
||||
|
||||
def test_five_meals_shifts_the_checkpoints(self) -> None:
|
||||
"""The user's example schedule drives elapsed/missing correctly."""
|
||||
schedule = MealSchedule(8, 20, 5)
|
||||
assert day_slots(schedule) == (8, 11, 14, 17, 20)
|
||||
assert elapsed_slots(_at(14), schedule) == (8, 11, 14)
|
||||
assert missing_slots(_at(14), {8, 11}, schedule) == (14,)
|
||||
|
||||
def test_enforcement_follows_the_last_meal(self) -> None:
|
||||
"""A window ending at 18:00 stops enforcing at 20:00, not 22:00."""
|
||||
schedule = MealSchedule(8, 18, 3)
|
||||
assert within_enforcement_window(_at(19), schedule)
|
||||
assert not within_enforcement_window(_at(20), schedule)
|
||||
assert elapsed_slots(_at(20), schedule) == ()
|
||||
|
||||
def test_midnight_first_slot_is_not_treated_as_absent(self) -> None:
|
||||
"""Slot 0 is a real slot, not a falsy stand-in for "nothing elapsed".
|
||||
|
||||
Guards the ``or`` bug this used to have in ``_gatelock._pending_slots``:
|
||||
``current_slot(...) or day_slots()[0]`` silently swallowed slot 0.
|
||||
"""
|
||||
schedule = MealSchedule(0, 12, 3)
|
||||
assert day_slots(schedule) == (0, 6, 12)
|
||||
schedule = MealSchedule(0, 720, 3)
|
||||
assert day_slots(schedule) == (0, 360, 720)
|
||||
assert current_slot(_at(0), schedule) == 0
|
||||
assert current_slot(_at(0), schedule) is not None
|
||||
assert slot_for_log(_at(0), schedule) == 0
|
||||
|
||||
@ -21,44 +21,49 @@ def _at(hour: int) -> datetime:
|
||||
class TestResolveFutureWhen:
|
||||
"""Validation and construction for a pre-logged future meal's datetime."""
|
||||
|
||||
def test_valid_future_date_and_slot_hour(self) -> None:
|
||||
def test_valid_future_date_and_slot(self) -> None:
|
||||
"""A future date on a real slot hour resolves to that datetime."""
|
||||
now = _at(10)
|
||||
when = resolve_future_when("2026-01-02", 12, DEFAULT_SCHEDULE, now)
|
||||
when = resolve_future_when("2026-01-02", 720, DEFAULT_SCHEDULE, now)
|
||||
assert when == datetime(2026, 1, 2, 12, 0, tzinfo=UTC)
|
||||
|
||||
def test_preserves_nows_timezone(self) -> None:
|
||||
"""The returned datetime carries ``now``'s tzinfo, not a bare one."""
|
||||
now = _at(10)
|
||||
when = resolve_future_when("2026-01-02", 8, DEFAULT_SCHEDULE, now)
|
||||
when = resolve_future_when("2026-01-02", 480, DEFAULT_SCHEDULE, now)
|
||||
assert when.tzinfo == now.tzinfo
|
||||
|
||||
def test_rejects_malformed_date(self) -> None:
|
||||
"""A date string that doesn't parse raises ValueError."""
|
||||
with pytest.raises(ValueError, match="invalid date"):
|
||||
resolve_future_when("not-a-date", 8, DEFAULT_SCHEDULE, _at(10))
|
||||
resolve_future_when("not-a-date", 480, DEFAULT_SCHEDULE, _at(10))
|
||||
|
||||
def test_rejects_hour_not_a_slot(self) -> None:
|
||||
"""An hour that isn't one of the schedule's slots raises ValueError."""
|
||||
def test_rejects_minute_not_a_slot(self) -> None:
|
||||
"""A minute that is not one of the schedule's slots raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not a meal slot"):
|
||||
resolve_future_when("2026-01-02", 13, DEFAULT_SCHEDULE, _at(10))
|
||||
resolve_future_when("2026-01-02", 780, DEFAULT_SCHEDULE, _at(10))
|
||||
|
||||
def test_rejects_todays_date(self) -> None:
|
||||
"""Today's own date is not "future", even on a real slot hour."""
|
||||
now = datetime(2026, 1, 1, 10, 0, tzinfo=UTC)
|
||||
with pytest.raises(ValueError, match="not a future date"):
|
||||
resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now)
|
||||
resolve_future_when("2026-01-01", 720, DEFAULT_SCHEDULE, now)
|
||||
|
||||
def test_rejects_past_date(self) -> None:
|
||||
"""A date before today raises ValueError."""
|
||||
now = datetime(2026, 1, 5, 10, 0, tzinfo=UTC)
|
||||
with pytest.raises(ValueError, match="not a future date"):
|
||||
resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now)
|
||||
resolve_future_when("2026-01-01", 720, DEFAULT_SCHEDULE, now)
|
||||
|
||||
def test_accepts_every_configured_slot_hour(self) -> None:
|
||||
"""Every hour the schedule actually opens is accepted, not just one."""
|
||||
schedule = MealSchedule(8, 20, 5)
|
||||
def test_accepts_every_configured_slot(self) -> None:
|
||||
"""Every slot the schedule actually opens is accepted, to the minute."""
|
||||
schedule = MealSchedule(435, 1140, 5)
|
||||
now = _at(10)
|
||||
for hour in day_slots(schedule):
|
||||
when = resolve_future_when("2026-01-02", hour, schedule, now)
|
||||
assert when.hour == hour
|
||||
for slot in day_slots(schedule):
|
||||
when = resolve_future_when("2026-01-02", slot, schedule, now)
|
||||
assert when.hour * 60 + when.minute == slot
|
||||
|
||||
def test_error_lists_the_slots_as_clock_times(self) -> None:
|
||||
"""The rejection names the offered slots readably, not as raw minutes."""
|
||||
with pytest.raises(ValueError, match=r"07:15, 10:15"):
|
||||
resolve_future_when("2026-01-02", 420, MealSchedule(435, 1140, 5), _at(9))
|
||||
|
||||
54
diet_guard/tests/test_slots_nearest.py
Normal file
54
diet_guard/tests/test_slots_nearest.py
Normal 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}
|
||||
@ -146,18 +146,21 @@ def _dish_json(dish: Dish) -> dict[str, object]:
|
||||
#: re-spread over ``slots`` (never a slice of the full spread). The full day
|
||||
#: spreads the twins to 16 and 20, so ``twins_share_16`` drops the first dish:
|
||||
#: 7 on 4 slots puts both twins on 16, the one place intra-batch dedup bites.
|
||||
#: Slots are MINUTES OF DAY, ``occupied``/``today[].slot`` too: resolved slot
|
||||
#: minutes, not the on-disk hour (seed real entries via ``slot_fields``).
|
||||
_ALL = list(range(8))
|
||||
_SLOTS = [8, 12, 16, 20]
|
||||
_SLOTS = [480, 720, 960, 1200]
|
||||
_PLAN_CASES = {
|
||||
"empty_day": {"take": _ALL, "occupied": []},
|
||||
"occupied_12": {"take": _ALL, "occupied": [12]},
|
||||
# First five in slot order: pancakes and hummus double into 8, both kept.
|
||||
"five_on_four": {"take": _ALL[:5], "occupied": [16]},
|
||||
"occupied_12": {"take": _ALL, "occupied": [720]},
|
||||
# First five in slot order: pancakes and hummus double into 08:00, both kept.
|
||||
"five_on_four": {"take": _ALL[:5], "occupied": [960]},
|
||||
"full_day": {"take": _ALL, "occupied": _SLOTS},
|
||||
}
|
||||
# Case/space-insensitive name match, and the same name in another slot is no
|
||||
# match: log_dishes dedups by (name, slot), it does not test occupancy.
|
||||
_SEEN = [{"desc": " twin DISH ", "slot": 16}, {"desc": "No priority, kept", "slot": 8}]
|
||||
_TWIN_SEEN = {"desc": " twin DISH ", "slot": 960}
|
||||
_SEEN = [_TWIN_SEEN, {"desc": "No priority, kept", "slot": 480}]
|
||||
_LOG_CASES = {
|
||||
"empty_day": {"take": _ALL, "today": []},
|
||||
"twins_share_16": {"take": _ALL[1:], "today": []},
|
||||
@ -201,7 +204,10 @@ def build() -> dict[str, object]:
|
||||
"showNutrition": True,
|
||||
}
|
||||
dishes = parse_menu(payload)
|
||||
default_slots = (8, 12, 16, 20)
|
||||
spreads = { # slot-minute keys, comma-joined; both tests parse them back
|
||||
",".join(map(str, slots)): [s.slot for s in assign_slots(dishes, slots)]
|
||||
for slots in (_SLOTS, (*_SLOTS, 1320))
|
||||
}
|
||||
return {
|
||||
"_comment": (
|
||||
"Shared parity fixture for the Kuchnia Wikinga catering import. "
|
||||
@ -214,16 +220,13 @@ def build() -> dict[str, object]:
|
||||
"dishes": [_dish_json(dish) for dish in dishes],
|
||||
"dropped_count": len(payload["deliveryMenuMeal"]) - len(dishes),
|
||||
"slots": {
|
||||
"8,12,16,20": [s.slot for s in assign_slots(dishes, default_slots)],
|
||||
"8,12,16,20,22": [
|
||||
s.slot for s in assign_slots(dishes, (8, 12, 16, 20, 22))
|
||||
],
|
||||
**spreads,
|
||||
# The other direction: fewer dishes than slots.
|
||||
"first_three_8,12,16,20": [
|
||||
s.slot for s in assign_slots(dishes[:3], default_slots)
|
||||
"first_three_480,720,960,1200": [
|
||||
s.slot for s in assign_slots(dishes[:3], _SLOTS)
|
||||
],
|
||||
},
|
||||
"slot_order": [s.dish.name for s in assign_slots(dishes, default_slots)],
|
||||
"slot_order": [s.dish.name for s in assign_slots(dishes, _SLOTS)],
|
||||
"bank_keys": [dish.name.strip().casefold() for dish in dishes],
|
||||
"bank_records": [dish_to_record(dish) for dish in dishes],
|
||||
"fill": _fill_cases(dishes),
|
||||
|
||||
184
scripts/build_meal_schedule_fixture.py
Executable file
184
scripts/build_meal_schedule_fixture.py
Executable 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
125
scripts/meal_schedule_cases.py
Executable 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},
|
||||
{},
|
||||
]
|
||||
142
tests/fixtures/kuchnia_day.json
vendored
142
tests/fixtures/kuchnia_day.json
vendored
@ -174,35 +174,35 @@
|
||||
"expected": [
|
||||
[
|
||||
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Fit ciasto, jak twix white",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
20
|
||||
1200
|
||||
],
|
||||
[
|
||||
"No priority, kept",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"occupied": [],
|
||||
@ -221,23 +221,23 @@
|
||||
"expected": [
|
||||
[
|
||||
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"occupied": [
|
||||
16
|
||||
960
|
||||
],
|
||||
"take": [
|
||||
0,
|
||||
@ -250,10 +250,10 @@
|
||||
"full_day": {
|
||||
"expected": [],
|
||||
"occupied": [
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
20
|
||||
480,
|
||||
720,
|
||||
960,
|
||||
1200
|
||||
],
|
||||
"take": [
|
||||
0,
|
||||
@ -270,31 +270,31 @@
|
||||
"expected": [
|
||||
[
|
||||
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
20
|
||||
1200
|
||||
],
|
||||
[
|
||||
"No priority, kept",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"occupied": [
|
||||
12
|
||||
720
|
||||
],
|
||||
"take": [
|
||||
0,
|
||||
@ -313,35 +313,35 @@
|
||||
"expected": [
|
||||
[
|
||||
"Marchewkowe pancakes, twarożek cynamonowy, sos truskawkowy",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Fit ciasto, jak twix white",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
20
|
||||
1200
|
||||
],
|
||||
[
|
||||
"No priority, kept",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"take": [
|
||||
@ -360,23 +360,23 @@
|
||||
"expected": [
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Fit ciasto, jak twix white",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"No priority, kept",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"take": [
|
||||
@ -391,11 +391,11 @@
|
||||
"today": [
|
||||
{
|
||||
"desc": " twin DISH ",
|
||||
"slot": 16
|
||||
"slot": 960
|
||||
},
|
||||
{
|
||||
"desc": "No priority, kept",
|
||||
"slot": 8
|
||||
"slot": 480
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -403,27 +403,27 @@
|
||||
"expected": [
|
||||
[
|
||||
"Hummus paprykowy, mini wafle kukurydziane, warzywa",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Makaron farfalle z indykiem w sosie z batatów z suszonymi pomidorami, ser włoski",
|
||||
8
|
||||
480
|
||||
],
|
||||
[
|
||||
"Fit ciasto, jak twix white",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Kaszotto grzybowe z pieczonym kurczakiem i serem włoskim",
|
||||
12
|
||||
720
|
||||
],
|
||||
[
|
||||
"Twin dish",
|
||||
16
|
||||
960
|
||||
],
|
||||
[
|
||||
"No priority, kept",
|
||||
20
|
||||
1200
|
||||
]
|
||||
],
|
||||
"take": [
|
||||
@ -439,10 +439,10 @@
|
||||
}
|
||||
},
|
||||
"slots": [
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
20
|
||||
480,
|
||||
720,
|
||||
960,
|
||||
1200
|
||||
]
|
||||
},
|
||||
"slot_order": [
|
||||
@ -456,30 +456,30 @@
|
||||
"No priority, kept"
|
||||
],
|
||||
"slots": {
|
||||
"8,12,16,20": [
|
||||
8,
|
||||
8,
|
||||
12,
|
||||
12,
|
||||
16,
|
||||
16,
|
||||
20,
|
||||
20
|
||||
"480,720,960,1200": [
|
||||
480,
|
||||
480,
|
||||
720,
|
||||
720,
|
||||
960,
|
||||
960,
|
||||
1200,
|
||||
1200
|
||||
],
|
||||
"8,12,16,20,22": [
|
||||
8,
|
||||
8,
|
||||
12,
|
||||
12,
|
||||
16,
|
||||
20,
|
||||
20,
|
||||
22
|
||||
"480,720,960,1200,1320": [
|
||||
480,
|
||||
480,
|
||||
720,
|
||||
720,
|
||||
960,
|
||||
1200,
|
||||
1200,
|
||||
1320
|
||||
],
|
||||
"first_three_8,12,16,20": [
|
||||
8,
|
||||
12,
|
||||
16
|
||||
"first_three_480,720,960,1200": [
|
||||
480,
|
||||
720,
|
||||
960
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
4640
tests/fixtures/meal_schedule_vectors.json
vendored
Normal file
4640
tests/fixtures/meal_schedule_vectors.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user