mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-12 08:30:51 +01:00
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
154 lines
5.0 KiB
Dart
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));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|