diet-guard/app/test/models/meal_schedule_test.dart
Krzysztof kuhy Rudnicki 83c3030b55 Derive meal slots from an eating window and a meal count
First step of the configurable meal schedule: the pure arithmetic, in both
languages, with nothing wired to it yet. MealSchedule(8, 20, 5) gives
08, 11, 14, 17, 20; the default MealSchedule(8, 20, 4) reproduces the hours
that are currently hardcoded, so nothing changes behaviour until a user picks
something else.

Two properties are load-bearing and both are tested rather than asserted in
prose:

Integer arithmetic only, no floats and no round(). Python's round() is
banker's (round(2.5) == 2) and Dart's is half-away-from-zero
(2.5.round() == 3), so a floating-point derivation would silently disagree
across devices -- and a slot one device offers but the other does not is a
checkpoint that can never be satisfied, i.e. a permanent lock. The shared
vector table plus an identical 6912-case sweep in both suites is what catches
that class of bug before it reaches a device.

Counts are clamped to the window width rather than rejected. A narrow window
(08-12 across 6 meals) would otherwise repeat an hour -- 8, 9, 10, 10, 11, 12
-- and slot hours are used as set members, map keys and notification ids, so a
repeat silently drops a checkpoint. Clamping also means the two languages
cannot disagree about which inputs are errors.

The enforcement cutoff is now derived as last + 2h (clamped to midnight)
rather than sharing the constant that also bounded the slot range. The clamp
matters: 23:00 + 2 = 25 would make `hour < cutoff` vacuously true and the
window would never close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 14:21:12 +02:00

154 lines
5.0 KiB
Dart

/// 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.
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]),
];
void main() {
group('slots', () {
for (final (first, last, count, expected) in scheduleVectors) {
test('$first-$last x$count derives $expected', () {
expect(
MealSchedule(first: first, last: last, count: count).slots(),
expected,
);
});
}
test('the default is the historical schedule', () {
expect(kDefaultSchedule.slots(), [8, 12, 16, 20]);
});
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);
});
});
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),
),
];
for (final (input, expected) in cases) {
test('$input clamps to $expected', () {
expect(input.normalized(), expected);
});
}
test('garbage still yields usable slots', () {
expect(
const MealSchedule(first: 99, last: -5, count: 999).slots(),
[22, 23],
);
});
});
group('enforcementEndHour', () {
test('the default keeps the historical 22:00 cutoff', () {
expect(kDefaultSchedule.enforcementEndHour, 22);
});
test('the tail follows the last meal', () {
expect(
const MealSchedule(first: 8, last: 18, count: 4).enforcementEndHour,
20,
);
});
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,
);
});
});
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,
);
final normalized = schedule.normalized();
final slots = schedule.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));
}
}
}
});
}