mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 22:13:53 +01:00
The phone/desktop half of the previous commit, so both devices now derive their slots the same way. Still no edit surface, so `MealScheduleService` returns the default and the derived hours are unchanged at 08/12/16/20. `slot.dart` takes the schedule as a required argument, mirroring `_slots.py`, and `slot_test.dart` gains the same every-hour x several-schedules sweep the Python suite runs. That sweep is the contract: the two must agree on which checkpoint a meal satisfies for every cell, not merely on the endpoints. Also fixes a real bug this feature would otherwise have shipped on top of. `syncToSlots` used the slot hour as the notification id but only cancelled ids in the *current* schedule, so changing 08/12/16/20 -> 08/11/14/17/20 would leave ids 12 and 16 posted with nothing that could ever cancel them: permanent reminders for checkpoints that no longer exist. It now sweeps the whole 0..23 id space. The regression test was confirmed to fail against the old loop (4 cancels vs 24) rather than passing vacuously. MealScheduleService.current degrades to the default when the singleton is absent, so `due_slot_check` deliberately does not init() it -- that would reach path_provider from widget tests and from the WorkManager isolate before main() runs. main.dart owns the real init, and background_sync_service.dart does its own, since a fresh isolate has its own static state and would otherwise nag against the default four meals. 609 Dart tests, 761 Python, both suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
139 lines
5.1 KiB
Dart
139 lines
5.1 KiB
Dart
import 'package:diet_guard_app/services/notification_backend_io.dart';
|
|
import 'package:diet_guard_app/services/notification_service.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../fake_notifications.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
tearDown(NotificationService.resetForTesting);
|
|
|
|
group('on Android', () {
|
|
test('init constructs the real plugin singleton on first use', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(); // no _instance yet
|
|
|
|
await NotificationService.init();
|
|
|
|
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
|
|
});
|
|
|
|
test('init calls the platform initialize method, idempotently', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
|
|
await NotificationService.init();
|
|
await NotificationService.init(); // second call must be a no-op
|
|
|
|
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
|
|
});
|
|
|
|
test('requestPermission delegates to the Android implementation', () async {
|
|
installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
await NotificationService.init();
|
|
|
|
expect(await NotificationService.instance.requestPermission(), isTrue);
|
|
});
|
|
|
|
test('syncToSlots shows due slots and cancels the rest', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
await NotificationService.init();
|
|
log.clear();
|
|
|
|
await NotificationService.instance.syncToSlots([12, 20]);
|
|
|
|
final shown = log
|
|
.where((c) => c.method == 'show')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
final cancelled = log
|
|
.where((c) => c.method == 'cancel')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
expect(shown, {12, 20});
|
|
// Every other id in the 0..23 space is cancelled, not just the other
|
|
// slots of the current schedule -- see syncToSlots' comment.
|
|
expect(cancelled, containsAll(<int>{8, 16}));
|
|
expect(cancelled.intersection(shown), isEmpty);
|
|
});
|
|
|
|
test('syncToSlots with no due slots cancels every id in the day', () async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
await NotificationService.init();
|
|
log.clear();
|
|
|
|
await NotificationService.instance.syncToSlots(const []);
|
|
|
|
expect(log.where((c) => c.method == 'show'), isEmpty);
|
|
// 24, not 4: the slot hour doubles as the notification id, so a
|
|
// schedule change would otherwise orphan the ids it no longer contains.
|
|
expect(log.where((c) => c.method == 'cancel'), hasLength(24));
|
|
});
|
|
|
|
test('syncToSlots cancels ids orphaned by a schedule change', () async {
|
|
// The regression this guards: the slot hour *is* the notification id,
|
|
// and syncToSlots used to iterate only the current schedule's slots.
|
|
// Switching 08/12/16/20 -> 08/11/14/17/20 therefore left ids 12 and 16
|
|
// posted with nothing that would ever cancel them, so the phone nagged
|
|
// forever about checkpoints that no longer existed.
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
await NotificationService.init();
|
|
|
|
// Due under the old four-meal schedule.
|
|
await NotificationService.instance.syncToSlots([12, 16]);
|
|
log.clear();
|
|
// Now due under a five-meal schedule, which has neither 12 nor 16.
|
|
await NotificationService.instance.syncToSlots([11]);
|
|
|
|
final cancelled = log
|
|
.where((c) => c.method == 'cancel')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
expect(cancelled, containsAll(<int>{12, 16}));
|
|
});
|
|
|
|
test(
|
|
'syncToSlots cancels a slot whose meal was logged after it fired',
|
|
() async {
|
|
final log = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(
|
|
FlutterLocalNotificationsPlugin(),
|
|
),
|
|
);
|
|
await NotificationService.init();
|
|
|
|
await NotificationService.instance.syncToSlots([12]);
|
|
log.clear();
|
|
await NotificationService.instance.syncToSlots(const []); // logged
|
|
|
|
expect(
|
|
log
|
|
.where((c) => c.method == 'cancel')
|
|
.map((c) => (c.arguments as Map)['id']),
|
|
contains(12),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('instance throws before init has ever been called', () {
|
|
expect(() => NotificationService.instance, throwsA(anything));
|
|
});
|
|
}
|