diet-guard/app/test/services/meal_schedule_service_test.dart
Krzysztof kuhy Rudnicki f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via
  slotFields (whole-hour stays byte-identical, else slot_min), kept by
  copyWithDeleted and the edit screen, so the phone never republishes a
  stripped copy of a PC entry. Out-of-range peer values echo, never throw.
- Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere:
  loggedSlotsToday, due check, kuchnia occupancy, future-day logged set
  (that day's schedule), slot selector. Schedule edits no longer un-log.
- Future pre-log builds DateTime from slot ~/ 60, slot % 60.
- Schedule history + sync go through scheduleToWire/scheduleFromWire.
- Notifications: ids are slot minutes; syncToSlots cancels whatever the
  platform reports active (getActiveNotifications, reminder channel) that
  is not due, covering legacy hour ids; full 0..1439 sweep only if the
  lookup throws.
- Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid
  values always selectable, counts capped by grid capacity; editor moved
  to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:55:26 +02:00

148 lines
5.1 KiB
Dart

/// Tests for meal-schedule persistence.
///
/// Split from `meal_schedule_history_test.dart` (250-line cap); that file
/// keeps the pure parsing/derivation half. Mirrors the storage half of
/// `diet_guard/tests/test_meal_schedule_store.py`.
library;
import 'dart:convert';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/meal_schedule_history.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:flutter_test/flutter_test.dart';
/// In-memory store, so these tests need no plugin channel.
class _MemoryStore implements DocumentStore {
final Map<String, String> documents = {};
@override
Future<String?> read(String name) async => documents[name];
@override
Future<void> write(String name, String contents) async {
documents[name] = contents;
}
}
DateTime _at(String day) => DateTime.parse('${day}T12:00:00');
void main() {
late _MemoryStore store;
setUp(() {
store = _MemoryStore();
MealScheduleService.resetForTesting(store: store);
});
tearDown(MealScheduleService.resetForTesting);
group('MealScheduleService', () {
test('current is the default before any edit', () {
expect(MealScheduleService.current, kDefaultSchedule);
expect(MealScheduleService.updatedAt, isNull);
});
test('current degrades to the default when uninitialised', () {
MealScheduleService.resetForTesting();
expect(MealScheduleService.current, kDefaultSchedule);
expect(MealScheduleService.history.entries, isEmpty);
expect(MealScheduleService.isInitialized, isFalse);
});
test(
'recordChange persists, reloads, and grandfathers past days',
() async {
await MealScheduleService.initForTesting(store);
await MealScheduleService.instance.recordChange(
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
await MealScheduleService.initForTesting(store);
expect(
MealScheduleService.current,
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
// The whole point of the history: a day before the edit keeps the
// four-meal schedule it was actually judged against.
expect(
MealScheduleService.history.forDay('2020-01-01'),
kDefaultSchedule,
);
expect(MealScheduleService.updatedAt, isNotNull);
},
);
test('a corrupt document degrades to the default', () async {
store.documents[MealScheduleService.documentName] = '{not json';
await MealScheduleService.initForTesting(store);
expect(MealScheduleService.current, kDefaultSchedule);
});
test('an absent document loads as no history', () async {
await MealScheduleService.initForTesting(store);
expect(MealScheduleService.history.entries, isEmpty);
});
test(
'updatedAt stays null when the stored stamp is not a string',
() async {
store.documents[MealScheduleService.documentName] = jsonEncode({
'v': 1,
'e': <String, Object?>{},
't': 7,
});
await MealScheduleService.initForTesting(store);
expect(MealScheduleService.updatedAt, isNull);
},
);
test(
'applyMerged replaces the history but ignores an empty merge',
() async {
await MealScheduleService.initForTesting(store);
await MealScheduleService.instance.recordChange(
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
// A pre-feature peer contributes no `sched:` fields; writing that back
// would discard this device's own history.
await MealScheduleService.instance.applyMerged(const []);
expect(
MealScheduleService.current,
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
final stamp = DateTime.parse('2026-08-16T09:00:00.000');
await MealScheduleService.instance.applyMerged([
ScheduleEntry(
effectiveFrom: kScheduleEpochDay,
schedule: const MealSchedule(firstMinute: 7 * 60, lastMinute: 19 * 60, count: 3),
editedAt: stamp.toIso8601String(),
),
], updatedAt: stamp);
expect(
MealScheduleService.current,
const MealSchedule(firstMinute: 7 * 60, lastMinute: 19 * 60, count: 3),
);
// The winner's stamp is kept verbatim, so re-syncing is idempotent.
expect(MealScheduleService.updatedAt, stamp);
},
);
test('the stored document is plain readable JSON', () async {
await MealScheduleService.initForTesting(store);
await MealScheduleService.instance.recordChange(
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
when: _at('2026-08-16'),
);
final decoded =
jsonDecode(store.documents[MealScheduleService.documentName]!)
as Map<String, Object?>;
expect(decoded['v'], 1);
expect((decoded['e']! as Map).keys, contains('2026-08-16'));
});
});
}