diet-guard/app/test/services/notification_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

178 lines
5.9 KiB
Dart

import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/services/notification_backend.dart';
import 'package:diet_guard_app/services/notification_backend_io.dart';
import 'package:diet_guard_app/services/notification_service.dart';
import 'package:flutter/services.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);
});
Future<List<MethodCall>> ready({Map<int, String>? active}) async {
final log = installFakeAndroidNotifications(active: active);
NotificationService.resetForTesting(
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
);
await NotificationService.init();
log.clear();
return log;
}
Set<Object?> ids(List<MethodCall> log, String method) => {
for (final call in log)
if (call.method == method) (call.arguments as Map)['id'],
};
test('syncToSlots shows due slots and cancels other reminders', () async {
final log = await ready(
active: {480: reminderChannelId, 720: reminderChannelId},
);
await NotificationService.instance.syncToSlots([720, 1200]);
expect(ids(log, 'show'), {720, 1200});
expect(ids(log, 'cancel'), {480});
});
test('a quiet tick costs one lookup and no cancels', () async {
// The cost bound: the old design swept every id each tick, which at
// minute resolution would be 1440 platform calls per background run.
final log = await ready();
await NotificationService.instance.syncToSlots(const []);
expect(log.map((c) => c.method), ['getActiveNotifications']);
});
test('the upgrade cancels reminders keyed by the old slot hours', () async {
// Older builds used the slot *hour* as the id. Nothing in the minute
// schedule names 8/12/16, so only the active lookup can find them.
final log = await ready(
active: {
8: reminderChannelId,
12: reminderChannelId,
16: reminderChannelId,
},
);
await NotificationService.instance.syncToSlots([720]);
expect(ids(log, 'cancel'), {8, 12, 16});
expect(ids(log, 'show'), {720});
});
test('syncToSlots cancels ids orphaned by a schedule change', () async {
// The regression this guards: the slot minute *is* the notification
// id, so iterating only the current schedule's slots would leave the
// old schedule's reminders posted forever.
final log = await ready();
await NotificationService.instance.syncToSlots([720, 960]);
log.clear();
// Now due under a schedule that has neither 720 nor 960.
await NotificationService.instance.syncToSlots([660]);
expect(ids(log, 'cancel'), {720, 960});
});
test('leaves notifications on other channels alone', () async {
final log = await ready(active: {5: 'some_other_channel'});
await NotificationService.instance.syncToSlots(const []);
expect(ids(log, 'cancel'), isEmpty);
});
test(
'syncToSlots cancels a slot whose meal was logged after it fired',
() async {
final log = await ready();
await NotificationService.instance.syncToSlots([720]);
log.clear();
await NotificationService.instance.syncToSlots(const []); // logged
expect(ids(log, 'cancel'), {720});
},
);
});
test('a failed active lookup falls back to sweeping every id', () async {
final backend = _FakeBackend()..failLookup = true;
NotificationService.resetForTesting(backend: backend);
await NotificationService.init();
await NotificationService.instance.syncToSlots([435]);
expect(backend.shown, [435]);
expect(backend.cancelled, hasLength(kMinutesPerDay - 1));
expect(backend.cancelled, isNot(contains(435)));
expect(backend.cancelled, containsAll(<int>[0, 8, 23, 720, 1439]));
});
test('instance throws before init has ever been called', () {
expect(() => NotificationService.instance, throwsA(anything));
});
}
/// A platform-free backend for the paths the channel fake cannot reach.
class _FakeBackend implements NotificationBackend {
bool failLookup = false;
final shown = <int>[];
final cancelled = <int>[];
@override
Future<void> initialize() async {}
@override
Future<bool?> requestPermission() async => null;
@override
Future<void> show(int slot, String title, String body) async =>
shown.add(slot);
@override
Future<void> cancel(int slot) async => cancelled.add(slot);
@override
Future<Set<int>> activeIds() async {
if (failLookup) throw StateError('no notification service');
return {};
}
}