slots: minute-of-day contract (phase 1 of 3: pure model + shared fixture)

Meal-slot checkpoints move from whole hours to minute-of-day ints
(0..1439) in the pure model and slot arithmetic of both languages:
MealSchedule(first_minute, last_minute, count) / (firstMinute:,
lastMinute:, count:), interior slots snapped to the 15-minute grid,
endpoints exact, integer-only. New: nearest_slot, satisfied_slots,
slot_fields / entry_slot_minute (additive "slot_min" wire field) and
schedule_to_wire / schedule_from_wire ("fm"/"lm" only when off the hour,
so whole-hour data stays byte-identical), parse_hhmm.

tests/fixtures/meal_schedule_vectors.json (scripts/
build_meal_schedule_fixture.py) is read by both suites; kuchnia_day.json
slot values regenerated as minutes.

Consumers (CLI, gate, storage, sync, app screens, kuchnia, notifications)
are intentionally broken until phase 2. patch-targets skipped: it fails
only because diet_guard._gatelock no longer imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 19:21:47 +02:00
parent 5cd89a4e99
commit 8bc1812147
23 changed files with 6509 additions and 714 deletions

View File

@ -1,9 +1,15 @@
/// The user's eating window and meal count, and the slot hours they imply.
/// The user's eating window and meal count, and the slot minutes they imply.
///
/// A schedule is three numbers -- the first meal hour, the last meal hour, and
/// 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();
}

View File

@ -1,4 +1,5 @@
/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py`.
/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py` and
/// `_slot_wire.py`.
///
/// Deliberately I/O-free and clock-free: every function is a total function of
/// 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(
now,
schedule,
).where((slot) => !logged.contains(slot)).toList();
List<int> missingSlots(DateTime now, Set<int> logged, MealSchedule schedule) =>
elapsedSlots(
now,
schedule,
).where((slot) => !logged.contains(slot)).toList();
/// Returns the most recent elapsed slot as of [now], or null.
///
@ -64,8 +84,7 @@ int? currentSlot(DateTime now, MealSchedule schedule) {
/// first slot, clamp to the first slot; after the enforcement window ends,
/// clamp to the last slot; behaviour inside a window is unchanged. The two
/// languages must reach each answer by the *same* branch, not merely agree on
/// the value -- `test/models/slot_test.dart` sweeps every hour of the day
/// against several schedules for exactly that reason.
/// the value -- the shared fixture pins every minute edge for that reason.
///
/// Unlike [currentSlot] this never returns null, which is the point: an
/// off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
@ -74,11 +93,65 @@ int? currentSlot(DateTime now, MealSchedule schedule) {
/// *those* would instead make every slot fall due at the end of the day.
int slotForLog(DateTime now, MealSchedule schedule) {
final slots = daySlots(schedule);
if (now.hour < slots.first) return slots.first;
if (minuteOfDay(now) < slots.first) return slots.first;
return currentSlot(now, schedule) ?? slots.last;
}
/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`.
/// Returns the slot closest to [minute]; an exact tie goes to the earlier.
///
/// Mirrors `_slots.nearest_slot`, whose `min` keeps the first minimal element
/// of the ascending slots -- hence replacing the best only on a strictly
/// smaller distance here.
int nearestSlot(int minute, MealSchedule schedule) {
final slots = daySlots(schedule);
var best = slots.first;
for (final slot in slots) {
if ((slot - minute).abs() < (best - minute).abs()) best = slot;
}
return best;
}
/// Returns the slots covered by meals recorded at [entryMinutes].
///
/// Mirrors `_slots.satisfied_slots`. Many-to-one is intended: two meals
/// snapping onto one slot satisfy only that slot.
Set<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';
}

View File

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

View File

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

View File

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

View File

@ -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');
}
}
}

View File

@ -0,0 +1,177 @@
/// The Dart half of the cross-language meal-slot parity gate.
///
/// `diet_guard/tests/test_meal_schedule_vectors.py` asserts the *same*
/// expectations against the *same* `tests/fixtures/meal_schedule_vectors.json`.
/// Two independently written suites from the same prose is not a gate -- one
/// shared input with one shared expected result is: a device that derives a
/// slot its peer never offers has a checkpoint that can never be satisfied,
/// i.e. a permanent lock. Regenerate with
/// `scripts/build_meal_schedule_fixture.py`.
library;
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:flutter_test/flutter_test.dart';
/// The repo root, found by walking up from wherever `flutter test` runs.
Directory get _repoRoot {
var dir = Directory.current.absolute;
while (!File('${dir.path}/pubspec.yaml').existsSync()) {
final parent = dir.parent;
if (parent.path == dir.path) {
fail('could not locate app/ from ${Directory.current.path}');
}
dir = parent;
}
// `dir` is app/; the fixture is shared, so it lives at the repo root.
return dir.parent;
}
File get _fixtureFile =>
File('${_repoRoot.path}/tests/fixtures/meal_schedule_vectors.json');
MealSchedule _schedule(Object? row) {
final values = (row! as List).cast<int>();
return MealSchedule(
firstMinute: values[0],
lastMinute: values[1],
count: values[2],
);
}
DateTime _at(int minute) => DateTime(2026, 1, 1, minute ~/ 60, minute % 60);
List<int> _ints(Object? value) => (value! as List).cast<int>();
void main() {
late Map<String, dynamic> vectors;
setUpAll(() {
vectors =
jsonDecode(_fixtureFile.readAsStringSync()) as Map<String, dynamic>;
});
List<Map<String, dynamic>> rows(String key) =>
(vectors[key] as List).cast<Map<String, dynamic>>();
test('the shared fixture is present', () {
expect(
_fixtureFile.existsSync(),
isTrue,
reason: 'shared parity fixture missing at ${_fixtureFile.path}',
);
});
test('schedules: normalised form, slots, cutoff and wire', () {
expect(rows('schedules'), isNotEmpty);
for (final row in rows('schedules')) {
final schedule = _schedule(row['input']);
final reason = '${row['input']}';
expect(
schedule.normalized(),
_schedule(row['normalized']),
reason: reason,
);
expect(schedule.slots(), _ints(row['slots']), reason: reason);
expect(
schedule.enforcementEndMinute,
row['enforcement_end_minute'],
reason: reason,
);
expect(scheduleToWire(schedule), row['wire'], reason: reason);
expect(
scheduleFromWire(row['wire']),
schedule.normalized(),
reason: reason,
);
}
});
test('time edges: every clock-reading function', () {
expect(rows('time_edges'), isNotEmpty);
for (final row in rows('time_edges')) {
final schedule = _schedule(row['schedule']);
final now = _at(row['minute'] as int);
final reason = '${row['schedule']} @ ${row['minute']}';
expect(
withinEnforcementWindow(now, schedule),
row['within_window'],
reason: reason,
);
expect(
elapsedSlots(now, schedule),
_ints(row['elapsed']),
reason: reason,
);
expect(currentSlot(now, schedule), row['current_slot'], reason: reason);
expect(slotForLog(now, schedule), row['slot_for_log'], reason: reason);
}
});
test('missing slots', () {
for (final row in rows('missing')) {
final actual = missingSlots(
_at(row['minute'] as int),
_ints(row['logged']).toSet(),
_schedule(row['schedule']),
);
expect(actual, _ints(row['missing']), reason: '$row');
}
});
test('nearest slot', () {
for (final row in rows('nearest')) {
final actual = nearestSlot(
row['minute'] as int,
_schedule(row['schedule']),
);
expect(actual, row['nearest'], reason: '$row');
}
});
test('satisfied slots', () {
for (final row in rows('satisfied')) {
final actual = satisfiedSlots(
_ints(row['entry_minutes']),
_schedule(row['schedule']),
).toList()..sort();
expect(actual, _ints(row['satisfied']), reason: '${row['name']}');
}
});
test('labels', () {
for (final row in rows('labels')) {
expect(slotLabel(row['minute'] as int), row['label'], reason: '$row');
}
});
test('schedule wire decode', () {
for (final row in rows('schedule_wire_decode')) {
final expected = row['schedule'] == null
? null
: _schedule(row['schedule']);
expect(scheduleFromWire(row['raw']), expected, reason: '$row');
}
});
test('slot fields', () {
for (final row in rows('slot_fields')) {
final minute = row['minute'] as int;
if (row['error'] == true) {
expect(() => slotFields(minute), throwsArgumentError, reason: '$row');
} else {
expect(slotFields(minute), row['fields'], reason: '$row');
}
}
});
test('entry slot minute', () {
for (final row in rows('entry_slot_minute')) {
final entry = (row['entry'] as Map).cast<String, Object?>();
expect(entrySlotMinute(entry), row['minute'], reason: '$row');
}
});
}

View File

@ -1,229 +1,172 @@
/// Tests for `models/slot.dart`, in minutes of day.
///
/// Mirrors `diet_guard/tests/test_slots.py`, `test_slots_nearest.py` and
/// `test_slot_wire.py`; the exhaustive cross-language edges live in the shared
/// fixture (`meal_schedule_vectors_test.dart`).
library;
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:flutter_test/flutter_test.dart';
DateTime _at(int hour, [int minute = 0]) => DateTime(2026, 6, 22, hour, minute);
MealSchedule _s(int first, int last, int count) =>
MealSchedule(firstMinute: first, lastMinute: last, count: count);
void main() {
group('daySlots', () {
test('returns the four fixed hourly slots', () {
expect(daySlots(kDefaultSchedule), [8, 12, 16, 20]);
});
test('minuteOfDay counts minutes and ignores seconds', () {
expect(minuteOfDay(DateTime(2026, 6, 22, 7, 15, 59)), 435);
expect(minuteOfDay(_at(23, 59)), 1439);
});
test('daySlots returns the four default slots', () {
expect(daySlots(kDefaultSchedule), [480, 720, 960, 1200]);
});
group('withinEnforcementWindow', () {
test('false before the day start hour', () {
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 7, 59), kDefaultSchedule),
isFalse,
);
test('false at 07:59, true at 08:00', () {
expect(withinEnforcementWindow(_at(7, 59), kDefaultSchedule), isFalse);
expect(withinEnforcementWindow(_at(8), kDefaultSchedule), isTrue);
});
test('true at the day start hour', () {
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule),
isTrue,
);
test('true at 21:59, false at the 22:00 cutoff', () {
expect(withinEnforcementWindow(_at(21, 59), kDefaultSchedule), isTrue);
expect(withinEnforcementWindow(_at(22), kDefaultSchedule), isFalse);
});
test('true just before the eating end hour', () {
expect(
withinEnforcementWindow(
DateTime(2026, 6, 22, 21, 59),
kDefaultSchedule,
),
isTrue,
);
});
test('false at the eating end hour (exclusive)', () {
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 22, 0), kDefaultSchedule),
isFalse,
);
test('a 07:15 breakfast opens the window at 07:15', () {
final schedule = _s(435, 1140, 5);
expect(withinEnforcementWindow(_at(7, 14), schedule), isFalse);
expect(withinEnforcementWindow(_at(7, 15), schedule), isTrue);
});
});
group('elapsedSlots', () {
test('empty outside the enforcement window', () {
expect(
elapsedSlots(DateTime(2026, 6, 22, 23, 0), kDefaultSchedule),
isEmpty,
);
expect(elapsedSlots(_at(23), kDefaultSchedule), isEmpty);
expect(elapsedSlots(_at(7, 59), kDefaultSchedule), isEmpty);
});
test('only the 8 slot right at day start', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), [8]);
test('grows through the day', () {
expect(elapsedSlots(_at(8), kDefaultSchedule), [480]);
expect(elapsedSlots(_at(15, 59), kDefaultSchedule), [480, 720]);
expect(elapsedSlots(_at(21), kDefaultSchedule), [480, 720, 960, 1200]);
});
test('8 and 12 mid-afternoon before 16', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 15, 59), kDefaultSchedule), [
8,
12,
]);
});
test('all four slots once 20:00 has passed', () {
expect(elapsedSlots(DateTime(2026, 6, 22, 21, 0), kDefaultSchedule), [
8,
12,
16,
20,
]);
test('a slot opens on its minute', () {
final schedule = _s(435, 1140, 5);
expect(elapsedSlots(_at(10, 14), schedule), [435]);
expect(elapsedSlots(_at(10, 15), schedule), [435, 615]);
});
});
group('missingSlots', () {
test('excludes already-logged elapsed slots', () {
expect(
missingSlots(DateTime(2026, 6, 22, 17, 0), {8}, kDefaultSchedule),
[12, 16],
);
});
test('empty once every elapsed slot is logged', () {
expect(
missingSlots(DateTime(2026, 6, 22, 17, 0), {
8,
12,
16,
}, kDefaultSchedule),
isEmpty,
);
});
test('missingSlots excludes logged slots', () {
expect(missingSlots(_at(17), {480}, kDefaultSchedule), [720, 960]);
expect(missingSlots(_at(17), {480, 720, 960}, kDefaultSchedule), isEmpty);
});
group('currentSlot', () {
test('null outside the enforcement window', () {
expect(
currentSlot(DateTime(2026, 6, 22, 6, 0), kDefaultSchedule),
isNull,
);
});
test('returns the most recently elapsed slot', () {
expect(currentSlot(DateTime(2026, 6, 22, 17, 41), kDefaultSchedule), 16);
});
test('returns 8 right at day start', () {
expect(currentSlot(DateTime(2026, 6, 22, 8, 0), kDefaultSchedule), 8);
});
test('currentSlot is the latest elapsed slot, or null', () {
expect(currentSlot(_at(6), kDefaultSchedule), isNull);
expect(currentSlot(_at(17, 41), kDefaultSchedule), 960);
});
group('slotForLog', () {
// Keep in lockstep with `diet_guard/tests/test_slots.py`'s
// TestSlotForLog: a divergence means the PC and the phone disagree about
// which checkpoint a meal satisfied.
DateTime at(int hour) => DateTime(2026, 6, 22, hour, 0);
test('clamps to the first slot before the window opens', () {
expect(slotForLog(at(7), kDefaultSchedule), 8);
test('clamps before the window and after it', () {
expect(slotForLog(_at(0), kDefaultSchedule), 480);
expect(slotForLog(_at(7, 59), kDefaultSchedule), 480);
expect(slotForLog(_at(13), kDefaultSchedule), 720);
expect(slotForLog(_at(21, 59), kDefaultSchedule), 1200);
expect(slotForLog(_at(22), kDefaultSchedule), 1200);
});
test('clamps to the first slot in the small hours', () {
expect(slotForLog(at(0), kDefaultSchedule), 8);
});
test('is unchanged exactly at the first slot', () {
expect(slotForLog(at(8), kDefaultSchedule), 8);
});
test('matches currentSlot inside the window', () {
expect(
slotForLog(at(13), kDefaultSchedule),
currentSlot(at(13), kDefaultSchedule),
);
expect(slotForLog(at(13), kDefaultSchedule), 12);
});
test('is unchanged at the last in-window hour', () {
expect(slotForLog(at(21), kDefaultSchedule), 20);
});
test('clamps to the last slot once the window closes', () {
expect(slotForLog(at(22), kDefaultSchedule), 20);
});
test('clamps to the last slot late in the evening', () {
expect(slotForLog(at(23), kDefaultSchedule), 20);
});
test('never returns a slot outside the schedule', () {
for (var hour = 0; hour < 24; hour++) {
expect(
daySlots(kDefaultSchedule),
contains(slotForLog(at(hour), kDefaultSchedule)),
);
}
});
test('clamps the same way across configured schedules', () {
// Sweeps every hour against several windows. `test_slots.py` runs the
// identical sweep: the two must agree on every cell, because a device
// that attributes a meal to a different slot than its peer leaves the
// other device's checkpoint permanently unsatisfied.
const schedules = [
test('clamps the same way at every minute across schedules', () {
// `test_slots.py` runs the identical sweep.
final schedules = [
kDefaultSchedule,
MealSchedule(first: 8, last: 20, count: 5),
MealSchedule(first: 6, last: 22, count: 6),
MealSchedule(first: 0, last: 23, count: 2),
MealSchedule(first: 10, last: 14, count: 3),
_s(480, 1200, 5),
_s(435, 1140, 5),
_s(443, 1181, 4),
_s(0, 1439, 2),
_s(600, 840, 3),
];
for (final schedule in schedules) {
final slots = daySlots(schedule);
for (var hour = 0; hour < 24; hour++) {
final attributed = slotForLog(at(hour), schedule);
expect(slots, contains(attributed));
if (hour < slots.first) {
expect(attributed, slots.first);
} else if (hour >= schedule.enforcementEndHour) {
expect(attributed, slots.last);
for (var minute = 0; minute < 1440; minute++) {
final attributed = slotForLog(
_at(minute ~/ 60, minute % 60),
schedule,
);
final int expected;
if (minute < slots.first) {
expected = slots.first;
} else if (minute >= schedule.enforcementEndMinute) {
expected = slots.last;
} else {
// Inside the window: the most recent slot that has opened.
expect(attributed, slots.where((s) => s <= hour).last);
expected = slots.where((s) => s <= minute).last;
}
if (attributed != expected) fail('$schedule @ $minute: $attributed');
}
}
});
});
group('configured schedules', () {
test('five meals shifts the checkpoints', () {
const schedule = MealSchedule(first: 8, last: 20, count: 5);
expect(daySlots(schedule), [8, 11, 14, 17, 20]);
expect(elapsedSlots(DateTime(2026, 6, 22, 14), schedule), [8, 11, 14]);
expect(
missingSlots(DateTime(2026, 6, 22, 14), {8, 11}, schedule),
[14],
);
});
test('enforcement follows the last meal', () {
const schedule = MealSchedule(first: 8, last: 18, count: 3);
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 19), schedule),
isTrue,
);
expect(
withinEnforcementWindow(DateTime(2026, 6, 22, 20), schedule),
isFalse,
);
expect(elapsedSlots(DateTime(2026, 6, 22, 20), schedule), isEmpty);
});
test('midnight is a real slot, not a falsy absence', () {
const schedule = MealSchedule(first: 0, last: 12, count: 3);
expect(daySlots(schedule), [0, 6, 12]);
expect(currentSlot(DateTime(2026, 6, 22), schedule), 0);
expect(slotForLog(DateTime(2026, 6, 22), schedule), 0);
final schedule = _s(0, 720, 3);
expect(daySlots(schedule), [0, 360, 720]);
expect(currentSlot(_at(0), schedule), 0);
expect(slotForLog(_at(0), schedule), 0);
});
});
group('slotLabel', () {
test('pads single-digit hours', () {
expect(slotLabel(8), '08:00');
group('nearestSlot / satisfiedSlots', () {
test('an exact halfway goes to the earlier slot', () {
expect(nearestSlot(600, kDefaultSchedule), 480);
expect(nearestSlot(601, kDefaultSchedule), 720);
});
test('formats double-digit hours', () {
expect(slotLabel(20), '20:00');
test('outside the window clamps to the ends', () {
expect(nearestSlot(-60, kDefaultSchedule), 480);
expect(nearestSlot(1800, kDefaultSchedule), 1200);
});
test('meals logged at 07:00 and 10:00 survive a move to 07:15', () {
expect(satisfiedSlots([420, 600], _s(435, 1140, 5)), {435, 615});
});
test('two meals on one slot satisfy only that slot', () {
expect(satisfiedSlots([480, 540], kDefaultSchedule), {480});
expect(satisfiedSlots(const [], kDefaultSchedule), isEmpty);
});
});
group('slotFields / entrySlotMinute', () {
test('a whole hour is byte-identical to the old format', () {
expect(slotFields(480), {'slot': 8});
expect(slotFields(435), {'slot': 7, 'slot_min': 435});
});
test('a minute outside the day is refused', () {
expect(() => slotFields(-1), throwsArgumentError);
expect(() => slotFields(1440), throwsArgumentError);
});
test('slot_min wins; legacy slot is an hour; bools are not ints', () {
expect(entrySlotMinute({'slot': 8}), 480);
expect(entrySlotMinute({'slot': 5, 'slot_min': 0}), 0);
expect(entrySlotMinute({'slot': 8, 'slot_min': true}), 480);
expect(entrySlotMinute({'slot': true}), isNull);
expect(entrySlotMinute({}), isNull);
});
});
test('slotLabel zero-pads and wraps into the day', () {
expect(slotLabel(435), '07:15');
expect(slotLabel(5), '00:05');
expect(slotLabel(1200), '20:00');
expect(slotLabel(1440), '00:00');
expect(slotLabel(-15), '23:45');
});
}

View File

@ -1,23 +1,38 @@
"""The user's eating window and meal count, and the slot hours they imply.
"""The user's eating window and meal count, and the slot minutes they imply.
A schedule is three numbers -- the first meal hour, the last meal hour, and how
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
View File

@ -0,0 +1,109 @@
"""How a log entry records its meal slot on disk and on the sync wire.
Slots are minutes of day (see :mod:`diet_guard._slots`), but every entry ever
written before that change carries an hour in ``"slot"``, and a peer running an
older build reads only that field. So the wire rule is additive:
* **Writers** always write ``"slot" = minute // 60`` and add
``"slot_min" = minute`` *only* when ``minute % 60 != 0``. A whole-hour slot
therefore encodes byte-identically to the pre-minute format -- no re-signing
storm, no diff on every existing entry -- and an older peer reading an
off-hour entry sees the hour it starts in. :func:`slot_fields` is that rule.
* **Readers** prefer an int ``"slot_min"``, fall back to ``"slot" * 60``, and
otherwise report no slot. :func:`entry_slot_minute` is that rule.
``bool`` is rejected wherever an int is expected: Python's ``bool`` subclasses
``int`` but Dart's does not, and a value one device accepts while the other
ignores is the cross-device split brain the shared fixture exists to catch.
KEEP IN SYNC WITH ``app/lib/models/slot.dart`` (``slotFields``,
``entrySlotMinute``); ``tests/fixtures/meal_schedule_vectors.json`` gates both.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from diet_guard._meal_schedule import MINUTES_PER_DAY, is_wire_int
if TYPE_CHECKING:
from collections.abc import Mapping
# ASCII digits only: ``\d`` would also accept Arabic-Indic and other Unicode
# digits, which ``int()`` happily parses.
_HHMM = re.compile(r"([0-9]{1,2})(?::([0-9]{2}))?")
_HOURS_PER_DAY = 24
_MINUTES_PER_HOUR = 60
def slot_fields(minute: int) -> dict[str, int]:
"""Return the entry fields that record ``minute`` as its slot.
Args:
minute: The slot's minute of day, ``0..1439``.
Returns:
``{"slot": hour}``, plus ``"slot_min"`` when ``minute`` is off the hour.
Raises:
ValueError: ``minute`` is outside the day -- a writer bug, refused
rather than encoded, since ``//`` on a negative value would floor
here and truncate in Dart.
"""
if not 0 <= minute < MINUTES_PER_DAY:
msg = f"slot minute {minute} is outside 0..{MINUTES_PER_DAY - 1}"
raise ValueError(msg)
fields = {"slot": minute // 60}
if minute % 60:
fields["slot_min"] = minute
return fields
def entry_slot_minute(entry: Mapping[str, object]) -> int | None:
"""Return the slot minute an entry records, or None when it has none.
Args:
entry: One decoded log entry.
Returns:
``entry["slot_min"]`` if it is an int, else ``entry["slot"] * 60`` if
that is an int, else None. Values are not range-checked: an entry is
reported as written, and :func:`diet_guard._slots.nearest_slot` maps
anything onto a real slot.
"""
slot_min = entry.get("slot_min")
if is_wire_int(slot_min):
return slot_min
slot = entry.get("slot")
if is_wire_int(slot):
return slot * 60
return None
def parse_hhmm(text: str) -> int:
"""Parse a user-typed clock time into a minute of day.
Accepts ``H``, ``HH``, ``H:MM`` and ``HH:MM`` with surrounding whitespace,
e.g. ``"7"`` -> 420, ``"07:15"`` -> 435.
Args:
text: The time as typed.
Returns:
The minute of day, ``0..1439``.
Raises:
ValueError: ``text`` is not in one of those forms, or the hour is not
0-23 or the minutes not 0-59.
"""
match = _HHMM.fullmatch(text.strip())
if match is None:
msg = f"invalid time {text!r}, expected HH:MM (e.g. 07:15) or HH"
raise ValueError(msg)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
if hour >= _HOURS_PER_DAY or minute >= _MINUTES_PER_HOUR:
msg = f"invalid time {text!r}: hour must be 0-23 and minutes 0-59"
raise ValueError(msg)
return hour * 60 + minute

View File

@ -7,10 +7,17 @@ exhaustively unit-testable without mocking the filesystem or the wall clock.
The stateful "which slots have I actually logged?" question lives in
: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}"

View File

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

View File

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

View File

@ -0,0 +1,143 @@
"""The Python half of the cross-language meal-slot parity gate.
``app/test/models/meal_schedule_vectors_test.dart`` asserts the *same*
expectations against the *same* ``tests/fixtures/meal_schedule_vectors.json``.
Two independently written suites from the same prose is not a gate -- one
shared input with one shared expected result is: a device that derives a slot
its peer never offers has a checkpoint that can never be satisfied, i.e. a
permanent lock. Regenerate with ``scripts/build_meal_schedule_fixture.py``.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
from typing import Any
import pytest
from diet_guard._meal_schedule import (
MealSchedule,
schedule_from_wire,
schedule_to_wire,
)
from diet_guard._slot_wire import entry_slot_minute, slot_fields
from diet_guard._slots import (
current_slot,
elapsed_slots,
missing_slots,
nearest_slot,
satisfied_slots,
slot_for_log,
slot_label,
within_enforcement_window,
)
FIXTURE = (
Path(__file__).resolve().parents[2] / "tests/fixtures/meal_schedule_vectors.json"
)
@pytest.fixture(name="vectors", scope="module")
def _vectors() -> dict[str, Any]:
"""The shared fixture. ``Any``: decoded JSON whose shape is the contract."""
with FIXTURE.open(encoding="utf-8") as handle:
data: dict[str, Any] = json.load(handle)
return data
def _schedule(row: list[int]) -> MealSchedule:
"""Return the schedule a ``[first, last, count]`` fixture row encodes."""
return MealSchedule(*row)
def _at(minute: int) -> datetime:
"""Return a fixed datetime at ``minute`` of day (the date is irrelevant)."""
return datetime(2026, 1, 1, minute // 60, minute % 60, tzinfo=UTC)
def test_fixture_is_present() -> None:
"""A relocation fails here with a clear reason, not as a KeyError later."""
assert FIXTURE.is_file(), f"shared parity fixture missing at {FIXTURE}"
def test_schedules(vectors: dict[str, Any]) -> None:
"""Normalisation, slots, cutoff and wire encoding, row by row."""
assert vectors["schedules"]
for row in vectors["schedules"]:
schedule = _schedule(row["input"])
normalized = schedule.normalized()
assert [
normalized.first_minute,
normalized.last_minute,
normalized.count,
] == row["normalized"], row["input"]
assert list(schedule.slots()) == row["slots"], row["input"]
assert schedule.enforcement_end_minute == row["enforcement_end_minute"]
assert schedule_to_wire(schedule) == row["wire"], row["input"]
assert schedule_from_wire(row["wire"]) == normalized, row["input"]
def test_time_edges(vectors: dict[str, Any]) -> None:
"""Every clock-reading function agrees at each minute edge."""
assert vectors["time_edges"]
for row in vectors["time_edges"]:
schedule, now = _schedule(row["schedule"]), _at(row["minute"])
where = (row["schedule"], row["minute"])
assert within_enforcement_window(now, schedule) == row["within_window"], where
assert list(elapsed_slots(now, schedule)) == row["elapsed"], where
assert current_slot(now, schedule) == row["current_slot"], where
assert slot_for_log(now, schedule) == row["slot_for_log"], where
def test_missing(vectors: dict[str, Any]) -> None:
"""Missing slots given a set of satisfied slot minutes."""
for row in vectors["missing"]:
schedule, now = _schedule(row["schedule"]), _at(row["minute"])
actual = missing_slots(now, set(row["logged"]), schedule)
assert list(actual) == row["missing"], row
def test_nearest(vectors: dict[str, Any]) -> None:
"""Nearest slot, including exact-halfway ties and out-of-day minutes."""
for row in vectors["nearest"]:
actual = nearest_slot(row["minute"], _schedule(row["schedule"]))
assert actual == row["nearest"], row
def test_satisfied(vectors: dict[str, Any]) -> None:
"""Satisfied slots, many-to-one included."""
for row in vectors["satisfied"]:
actual = satisfied_slots(row["entry_minutes"], _schedule(row["schedule"]))
assert sorted(actual) == row["satisfied"], row["name"]
def test_labels(vectors: dict[str, Any]) -> None:
"""``HH:MM`` labels, wrapped into the day."""
for row in vectors["labels"]:
assert slot_label(row["minute"]) == row["label"], row
def test_schedule_wire_decode(vectors: dict[str, Any]) -> None:
"""Malformed and legacy ``sched:`` values decode identically."""
for row in vectors["schedule_wire_decode"]:
triple = row["schedule"]
expected = None if triple is None else _schedule(triple)
assert schedule_from_wire(row["raw"]) == expected, row
def test_slot_fields(vectors: dict[str, Any]) -> None:
"""The entry writer's fields, or a refusal for a minute outside the day."""
for row in vectors["slot_fields"]:
if row.get("error"):
with pytest.raises(ValueError, match="outside"):
slot_fields(row["minute"])
else:
assert slot_fields(row["minute"]) == row["fields"], row
def test_entry_slot_minute(vectors: dict[str, Any]) -> None:
"""The entry reader: ``slot_min`` wins, legacy hours, bools rejected."""
for row in vectors["entry_slot_minute"]:
assert entry_slot_minute(row["entry"]) == row["minute"], row

View File

@ -0,0 +1,89 @@
"""Tests for _slot_wire.py — a log entry's slot fields and clock parsing.
The cross-language cases live in the shared fixture
(``test_meal_schedule_vectors.py``); these pin the rule's intent readably,
plus ``parse_hhmm``, which is Python-only.
"""
from __future__ import annotations
import pytest
from diet_guard._slot_wire import entry_slot_minute, parse_hhmm, slot_fields
class TestSlotFields:
"""The writer half of the entry wire rule."""
def test_whole_hour_is_byte_identical_to_the_old_format(self) -> None:
"""No ``slot_min`` key at all for 08:00."""
assert slot_fields(480) == {"slot": 8}
def test_off_hour_adds_the_minute(self) -> None:
"""07:15 keeps ``slot`` 7 for old readers and adds ``slot_min``."""
assert slot_fields(435) == {"slot": 7, "slot_min": 435}
@pytest.mark.parametrize("minute", [-1, 1440])
def test_refuses_minutes_outside_the_day(self, minute: int) -> None:
"""A writer bug is refused rather than encoded."""
with pytest.raises(ValueError, match=r"outside 0\.\.1439"):
slot_fields(minute)
class TestEntrySlotMinute:
"""The reader half of the entry wire rule."""
def test_legacy_hour_entry(self) -> None:
"""A pre-minute entry's ``slot`` is an hour."""
assert entry_slot_minute({"slot": 8}) == 480
def test_slot_min_wins(self) -> None:
"""``slot_min`` is authoritative, even when it is zero."""
assert entry_slot_minute({"slot": 7, "slot_min": 435}) == 435
assert entry_slot_minute({"slot": 5, "slot_min": 0}) == 0
def test_bools_are_not_ints(self) -> None:
"""``True`` is rejected in both fields, as Dart's ``is int`` rejects it."""
assert entry_slot_minute({"slot": 8, "slot_min": True}) == 480
assert entry_slot_minute({"slot": True}) is None
def test_no_slot(self) -> None:
"""An entry without an int slot reports none."""
assert entry_slot_minute({}) is None
assert entry_slot_minute({"slot": "8"}) is None
class TestParseHhmm:
"""User-typed clock times."""
@pytest.mark.parametrize(
("text", "minute"),
[
("7", 420),
("07", 420),
("7:15", 435),
("07:15", 435),
(" 19:41 ", 1181),
("0", 0),
("23:59", 1439),
],
)
def test_accepted_forms(self, text: str, minute: int) -> None:
"""H, HH, H:MM and HH:MM, whitespace stripped."""
assert parse_hhmm(text) == minute
@pytest.mark.parametrize(
"text",
# chr(0x667) is ARABIC-INDIC DIGIT SEVEN, which ``\d`` and int() accept.
["", "7:5", "7:", ":15", "123", "07:15:00", "7.15", "x", chr(0x667)],
)
def test_rejects_malformed_text(self, text: str) -> None:
"""Anything else is a clear error, including non-ASCII digits."""
with pytest.raises(ValueError, match="expected HH:MM"):
parse_hhmm(text)
@pytest.mark.parametrize("text", ["24", "24:00", "7:60", "99:99"])
def test_rejects_out_of_range_values(self, text: str) -> None:
"""Hours must be 0-23 and minutes 0-59."""
with pytest.raises(ValueError, match="hour must be 0-23"):
parse_hhmm(text)

View File

@ -1,9 +1,12 @@
"""Tests for _slots.py — pure meal-slot arithmetic.
"""Tests for _slots.py — pure meal-slot arithmetic, in minutes of day.
Every function is a total function of ``now`` and an explicit schedule, so the
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

View File

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

View File

@ -0,0 +1,54 @@
"""Tests for _slots.nearest_slot / satisfied_slots.
How a recorded slot minute -- possibly written under an older schedule, or as a
legacy whole hour -- counts toward today's checkpoints. Split out of
test_slots.py for the repo's 250-line cap.
"""
from __future__ import annotations
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._slots import nearest_slot, satisfied_slots
class TestNearestSlot:
"""The closest slot, ties to the earlier."""
def test_exact_slot_is_itself(self) -> None:
"""A minute that is a slot maps to that slot."""
assert nearest_slot(720, DEFAULT_SCHEDULE) == 720
def test_exact_halfway_goes_to_the_earlier_slot(self) -> None:
"""10:00 is equidistant from 08:00 and 12:00; the earlier wins."""
assert nearest_slot(600, DEFAULT_SCHEDULE) == 480
assert nearest_slot(601, DEFAULT_SCHEDULE) == 720
def test_outside_the_window_clamps_to_the_ends(self) -> None:
"""Before the first slot and after the last, the end slots win."""
assert nearest_slot(-60, DEFAULT_SCHEDULE) == 480
assert nearest_slot(1800, DEFAULT_SCHEDULE) == 1200
class TestSatisfiedSlots:
"""Which checkpoints a day's recorded slot minutes cover."""
def test_empty_input_satisfies_nothing(self) -> None:
"""No meals, no satisfied slots."""
assert satisfied_slots([], DEFAULT_SCHEDULE) == set()
def test_meals_survive_a_schedule_move(self) -> None:
"""The user's case: logged at 07:00 and 10:00, then moved to 07:15.
Under 07:15-19:00 x5 the slots are 07:15, 10:15, 13:15, 16:00, 19:00;
each old meal lands on its own nearest slot, so both still count.
"""
moved = MealSchedule(435, 1140, 5)
assert satisfied_slots([420, 600], moved) == {435, 615}
def test_two_meals_on_one_slot_satisfy_only_that_slot(self) -> None:
"""A double breakfast does not also clear lunch."""
assert satisfied_slots([480, 540], DEFAULT_SCHEDULE) == {480}
def test_legacy_hour_entries_map_onto_five_meals(self) -> None:
"""Entries tagged 08 and 12 under four meals, read under five."""
assert satisfied_slots([480, 720], MealSchedule(480, 1200, 5)) == {480, 660}

View File

@ -146,18 +146,21 @@ def _dish_json(dish: Dish) -> dict[str, object]:
#: re-spread over ``slots`` (never a slice of the full spread). The full day
#: 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),

View File

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Regenerate the shared cross-language meal-schedule parity fixture.
The fixture (``tests/fixtures/meal_schedule_vectors.json``) is read by *both*
``diet_guard/tests/test_meal_schedule_vectors.py`` and
``app/test/models/meal_schedule_vectors_test.dart``: one shared input, one
shared expected result, so the PC and the phone derive the same slot minutes,
attribute a meal to the same slot and read the same wire fields. A slot one
device offers while the other does not is a checkpoint that can never be
satisfied -- a permanent lock. The inputs live in
``scripts/meal_schedule_cases.py``.
``expected`` values come from the Python implementation, the reference the Dart
port must reproduce -- so regenerating blesses whatever Python does now. Only
run it after an intended change, and re-read the diff before committing.
Run it from the repo root with the repo on the path, so ``diet_guard``
resolves to *this* tree rather than wherever the editable install
points:
PYTHONPATH="$PWD" python3 scripts/build_meal_schedule_fixture.py
"""
from __future__ import annotations
from datetime import UTC, datetime
from itertools import pairwise
import json
from pathlib import Path
import meal_schedule_cases as cases
from diet_guard._meal_schedule import (
MINUTES_PER_DAY,
MealSchedule,
schedule_from_wire,
schedule_to_wire,
)
from diet_guard._slot_wire import entry_slot_minute, slot_fields
from diet_guard._slots import (
current_slot,
elapsed_slots,
missing_slots,
nearest_slot,
satisfied_slots,
slot_for_log,
slot_label,
within_enforcement_window,
)
FIXTURE = (
Path(__file__).resolve().parent.parent / "tests/fixtures/meal_schedule_vectors.json"
)
def _triple(schedule: MealSchedule | None) -> list[int] | None:
"""Return a schedule as the ``[first, last, count]`` row it is stored as."""
if schedule is None:
return None
return [schedule.first_minute, schedule.last_minute, schedule.count]
def _at(minute: int) -> datetime:
"""Return a fixed datetime at ``minute`` of day (the date is irrelevant)."""
return datetime(2026, 1, 1, minute // 60, minute % 60, tzinfo=UTC)
def _schedule_row(triple: cases.Triple) -> dict[str, object]:
"""Return one schedule's normalised form, slots, cutoff and wire encoding."""
schedule = MealSchedule(*triple)
return {
"input": list(triple),
"normalized": _triple(schedule.normalized()),
"slots": list(schedule.slots()),
"enforcement_end_minute": schedule.enforcement_end_minute,
"wire": schedule_to_wire(schedule),
}
def _edge_minutes(schedule: MealSchedule) -> list[int]:
"""Return every minute around a slot or the cutoff, plus the day's ends."""
around = [schedule.enforcement_end_minute, *schedule.slots()]
minutes = {0, 1439} | {m + d for m in around for d in (-1, 0, 1)}
return sorted(m for m in minutes if 0 <= m < MINUTES_PER_DAY)
def _edge_row(triple: cases.Triple, minute: int) -> dict[str, object]:
"""Return what every clock-reading function says at ``minute``."""
schedule, now = MealSchedule(*triple), _at(minute)
return {
"schedule": list(triple),
"minute": minute,
"within_window": within_enforcement_window(now, schedule),
"elapsed": list(elapsed_slots(now, schedule)),
"current_slot": current_slot(now, schedule),
"slot_for_log": slot_for_log(now, schedule),
}
def _nearest_minutes(schedule: MealSchedule) -> list[int]:
"""Return each slot, each midpoint +/- 1, and out-of-day minutes."""
slots = schedule.slots()
mids = [(a + b) // 2 for a, b in pairwise(slots)]
minutes = {-60, 0, 1439, 1800, *slots}
minutes |= {m + d for m in mids for d in (-1, 0, 1)}
return sorted(minutes)
def _slot_field_row(minute: int) -> dict[str, object]:
"""Return ``slot_fields(minute)``, or an error marker when it raises."""
try:
return {"minute": minute, "fields": slot_fields(minute)}
except ValueError:
return {"minute": minute, "error": True}
def _missing_row(triple: cases.Triple, minute: int, logged: list[int]) -> object:
"""Return the unsatisfied elapsed slots at ``minute`` given ``logged``."""
missing = missing_slots(_at(minute), set(logged), MealSchedule(*triple))
return {
"schedule": list(triple),
"minute": minute,
"logged": logged,
"missing": list(missing),
}
def _satisfied_row(name: str, triple: cases.Triple, minutes: list[int]) -> object:
"""Return the slots ``minutes`` satisfy under ``triple``, sorted."""
satisfied = satisfied_slots(minutes, MealSchedule(*triple))
return {
"name": name,
"schedule": list(triple),
"entry_minutes": minutes,
"satisfied": sorted(satisfied),
}
def build() -> dict[str, object]:
"""Return the whole fixture: every input with its expected result."""
edge = [(t, MealSchedule(*t)) for t in cases.EDGE_SCHEDULES]
return {
"_comment": (
"Shared parity fixture for meal-slot minutes. Read by BOTH "
"diet_guard/tests/test_meal_schedule_vectors.py and "
"app/test/models/meal_schedule_vectors_test.dart. Regenerate with "
"scripts/build_meal_schedule_fixture.py."
),
"schedules": [_schedule_row(t) for t in cases.SCHEDULES],
"time_edges": [_edge_row(t, m) for t, s in edge for m in _edge_minutes(s)],
"missing": [_missing_row(*case) for case in cases.MISSING],
"nearest": [
{"schedule": list(t), "minute": m, "nearest": nearest_slot(m, s)}
for t, s in edge
for m in _nearest_minutes(s)
],
"satisfied": [_satisfied_row(*case) for case in cases.SATISFIED],
"labels": [{"minute": m, "label": slot_label(m)} for m in cases.LABELS],
"schedule_wire_decode": [
{"raw": raw, "schedule": _triple(schedule_from_wire(raw))}
for raw in cases.WIRE_DECODE
],
"slot_fields": [_slot_field_row(m) for m in cases.SLOT_FIELD_MINUTES],
"entry_slot_minute": [
{"entry": entry, "minute": entry_slot_minute(entry)}
for entry in cases.ENTRIES
],
}
def main() -> None:
"""Write the fixture to disk.
Deliberately silent: ``T201`` bans ``print`` here, and the script's output
is the file itself -- ``git diff`` is how you check what changed.
"""
FIXTURE.parent.mkdir(parents=True, exist_ok=True)
with FIXTURE.open("w", encoding="utf-8") as handle:
json.dump(build(), handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
if __name__ == "__main__":
main()

125
scripts/meal_schedule_cases.py Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Input cases for ``scripts/build_meal_schedule_fixture.py``.
Split out of the builder for the repo's 250-line cap. Inputs only: every
expected value is computed by the builder from the Python implementation.
A script-style file like its siblings (hence the shebang); running it
directly does nothing -- run the builder.
"""
from __future__ import annotations
H = 60
Triple = tuple[int, int, int]
def window(first: int, last: int) -> list[Triple]:
"""Return ``(first, last, count)`` for counts 2..6 plus a clamp either side."""
return [(first, last, count) for count in (0, 2, 3, 4, 5, 6, 7)]
#: Curated (first_minute, last_minute, count) inputs. Both suites also sweep
#: the 15-minute grid in code; this table holds exact values a human can check.
SCHEDULES: list[Triple] = [
*window(8 * H, 20 * H), # the default window
*window(7 * H + 15, 19 * H), # the user's quarter-hour breakfast
*window(7 * H + 23, 19 * H + 41), # off-grid endpoints, never snapped
*window(0, 1439), # the whole day, last at 23:59
(7 * H, 19 * H, 5),
(7 * H, 21 * H, 4), # uneven: the old hour formula said 12:00 here
(7 * H, 21 * H, 5),
(9 * H, 19 * H, 4),
(0, 12 * H, 3), # a midnight first slot is a real slot
(0, H, 2),
(0, H, 6),
(22 * H, 1439, 3),
(23 * H, 1439, 6),
# Narrow windows: count is capped at window // 15 + 1.
(8 * H, 8 * H + 15, 6),
(8 * H, 8 * H + 30, 6),
(8 * H, 8 * H + 45, 6),
(8 * H, 9 * H, 6),
(8 * H, 9 * H + 14, 6),
(8 * H, 9 * H + 15, 6),
(481, 496, 3),
(8 * H + 7, 9 * H + 22, 6),
# Every normalized() clamp.
(-5, 20 * H, 4),
(1430, 1439, 2),
(1439, 1439, 2),
(2000, -5, 999),
(8 * H, 5000, 4),
(12 * H, 12 * H, 4),
(12 * H, 100, 4),
(12 * H, 12 * H + 14, 2),
(8 * H, 20 * H, -3),
(8 * H, 20 * H, 99),
(8, 20, 4), # an hour-valued caller that missed the rename: 00:08-00:23
]
#: Schedules whose every slot +/- 1 minute and cutoff edge is pinned.
EDGE_SCHEDULES: list[Triple] = [
(8 * H, 20 * H, 4),
(8 * H, 20 * H, 5),
(7 * H + 15, 19 * H, 5),
(7 * H + 23, 19 * H + 41, 4),
(0, 12 * H, 3),
(0, 1439, 6), # cutoff clamps to 1440: the window closes at midnight
(22 * H, 1439, 3),
]
DEFAULT: Triple = (8 * H, 20 * H, 4)
SATISFIED: list[tuple[str, Triple, list[int]]] = [
("empty input satisfies nothing", DEFAULT, []),
# Logged 07:00 and 10:00 under 07:00-19:00 x5, then the schedule moved.
("old 07:00/10:00 meals after moving to 07:15", (435, 1140, 5), [420, 600]),
("two meals snap onto one slot", DEFAULT, [480, 540]),
("legacy hour entries under five meals", (8 * H, 20 * H, 5), [480, 720]),
("exact halfway goes to the earlier slot", DEFAULT, [600, 840]),
("out-of-window minutes clamp to the ends", DEFAULT, [0, 1439]),
("every slot exactly", DEFAULT, [480, 720, 960, 1200]),
]
#: (schedule, minute, logged slot minutes) for missing_slots.
MISSING: list[tuple[Triple, int, list[int]]] = [
(DEFAULT, 13 * H, [480, 720]),
(DEFAULT, 17 * H, [480]),
(DEFAULT, 23 * H, []),
((435, 1140, 5), 615, [435]),
((435, 1140, 5), 614, []),
]
LABELS = [0, 5, 59, 60, 435, 480, 615, 1200, 1439, 1440, -15]
WIRE_DECODE: list[object] = [
{"f": 8, "l": 20, "n": 4},
{"f": 7, "l": 19, "n": 5, "fm": 435},
{"f": 7, "l": 19, "n": 5, "fm": 443, "lm": 1181},
{"f": 7, "l": 19, "n": 5, "fm": 600}, # fm wins even if it disagrees with f
{"f": 7, "l": 19, "n": 5, "fm": True}, # bool is not an int: f wins
{"f": 7, "l": 19, "n": 5, "fm": 435.0}, # nor is a float
{"f": 7, "l": 19, "n": 5, "lm": None},
{"f": True, "l": 20, "n": 4},
{"f": 8, "l": 20},
{"f": "8", "l": 20, "n": 4},
{"f": 99, "l": -5, "n": 999}, # garbage from a peer is normalised
[8, 20, 4],
None,
]
SLOT_FIELD_MINUTES = [0, 59, 60, 435, 480, 1200, 1439, -1, 1440]
ENTRIES: list[dict[str, object]] = [
{"slot": 8},
{"slot": 0},
{"slot": 7, "slot_min": 435},
{"slot_min": 435},
{"slot": 5, "slot_min": 0}, # a zero slot_min still wins
{"slot": 8, "slot_min": True},
{"slot": 8, "slot_min": False},
{"slot": True},
{"slot": "8"},
{"slot": 8.0},
{"slot": None},
{},
]

View File

@ -174,35 +174,35 @@
"expected": [
[
"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

File diff suppressed because it is too large Load Diff