import 'package:flutter_test/flutter_test.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:hepan_everyday/services/local_notification_service.dart'; import 'package:hepan_everyday/services/notification_service.dart'; import 'package:hepan_everyday/domain/schedule_engine.dart'; import 'package:hepan_everyday/domain/app_settings.dart'; import 'package:hepan_everyday/domain/app_snapshot.dart'; import '../domain/schedule_engine_test.dart' show course; class AlarmGateway extends ReminderGateway { ReminderPermissions permission = const ReminderPermissions(true, true); final alarms = {}; int permissionRequests = 0; @override Future permissions({required bool request}) async { if (request) permissionRequests++; return permission; } @override Future> pendingIds() async => alarms.keys.toSet(); @override Future schedule(ReminderRequest value) async { alarms[value.id] = value; } @override Future cancel(int id) async { alarms.remove(id); } } void main() { setUpAll(tzdata.initializeTimeZones); final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5); final settings = AppSettings(semester: term); late AlarmGateway gateway; late LocalNotificationService service; setUp(() { gateway = AlarmGateway(); service = LocalNotificationService(gateway); }); AppSettings multiple(List minutes) { final json = AppSnapshot(settings: settings).toJson(); (json['settings'] as Map)['reminderOffsets'] = minutes; return AppSnapshot.fromJson(json).settings; } test( 'selected advance times produce independent alarms and survive refresh', () async { final events = ScheduleEngine.occurrences([course(last: 2)], term); await service.reconcile( events, multiple([5, 15]), DateTime.utc(2026, 9, 9), ); expect(gateway.alarms, hasLength(2)); expect(gateway.alarms.values.map((a) => a.fireAt.toUtc()).toSet(), { DateTime.utc(2026, 9, 9, 0, 15), DateTime.utc(2026, 9, 9, 0, 25), }); final ids = gateway.alarms.keys.toSet(); await service.reconcile( events, multiple([15, 5]), DateTime.utc(2026, 9, 9), ); expect(gateway.alarms.keys.toSet(), ids); await service.reconcile(events, multiple([5]), DateTime.utc(2026, 9, 9)); expect(gateway.alarms, hasLength(1)); expect( gateway.alarms.values.single.fireAt.toUtc(), DateTime.utc(2026, 9, 9, 0, 25), ); }, ); test( 'elapsed earlier reminder does not remove the later selected reminder', () async { final events = ScheduleEngine.occurrences([course()], term); await service.reconcile( events, multiple([5, 15]), DateTime.utc(2026, 9, 9, 0, 20), ); expect(gateway.alarms, hasLength(1)); expect( gateway.alarms.values.single.fireAt.toUtc(), DateTime.utc(2026, 9, 9, 0, 25), ); }, ); test( 'limit selects earliest reminder instants across all selected times', () async { final events = ScheduleEngine.occurrences( List.generate(501, (i) => course(id: 'c-$i')), term, ); final result = await service.reconcile( events, multiple([5, 15]), DateTime.utc(2026, 9, 9), ); expect(result.scheduledCount, 400); expect( gateway.alarms.values.every( (a) => a.fireAt.isAtSameMomentAs(DateTime.utc(2026, 9, 9, 0, 15)), ), isTrue, ); }, ); test( 'continuous course schedules one alarm fifteen minutes before school start', () async { final events = ScheduleEngine.occurrences([course(last: 2)], term); final result = await service.reconcile( events, settings, DateTime.utc(2026, 9, 9), ); expect(result.scheduledCount, 1); expect(gateway.alarms, hasLength(1)); expect( gateway.alarms.values.single.fireAt.toUtc(), DateTime.utc(2026, 9, 9, 0, 15), ); expect(gateway.alarms.values.single.id, 1794407394); expect(gateway.permissionRequests, 0); }, ); test( 'repeat updates replace one alarm and use changed advance time', () async { final events = ScheduleEngine.occurrences([course()], term); await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); await service.reconcile( events, settings.copyWith(reminderMinutes: 5), DateTime.utc(2026, 9, 9), ); expect(gateway.alarms, hasLength(1)); expect( gateway.alarms.values.single.fireAt.toUtc(), DateTime.utc(2026, 9, 9, 0, 25), ); }, ); test( 'past reminder, demo and disabled settings remove pending own alarms', () async { final events = ScheduleEngine.occurrences([course()], term); await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); expect(gateway.alarms, hasLength(1)); await service.reconcile( events, settings, DateTime.utc(2026, 9, 9, 0, 20), ); expect(gateway.alarms, isEmpty); await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); expect(gateway.alarms, hasLength(1)); await service.reconcile( events, settings.copyWith(remindersEnabled: false), DateTime.utc(2026, 9, 9), ); expect(gateway.alarms, isEmpty); }, ); test('denied notifications and denied exact alarm have distinct effective status', () async { final events = ScheduleEngine.occurrences([course()], term); gateway.permission = const ReminderPermissions(false, false); expect( (await service.reconcile( events, settings, DateTime.utc(2026, 9, 9), )).status, ReminderStatus.permissionDenied, ); expect(gateway.alarms, isEmpty); gateway.permission = const ReminderPermissions(true, false); expect( (await service.reconcile( events, settings, DateTime.utc(2026, 9, 9), )).status, ReminderStatus.inexact, ); expect(gateway.alarms.values.single.exact, isFalse); }); test('permission revoked after scheduling cancels previous alarms', () async { final events = ScheduleEngine.occurrences([course()], term); await service.reconcile(events, settings, DateTime.utc(2026, 9, 9)); gateway.permission = const ReminderPermissions(false, false); final result = await service.reconcile( events, settings, DateTime.utc(2026, 9, 9), ); expect(result.status, ReminderStatus.permissionDenied); expect(gateway.alarms, isEmpty); }); test( 'large semester keeps at most four hundred earliest reminders', () async { final many = ScheduleEngine.occurrences( List.generate(501, (i) => course(id: 'class-$i')), term, ); final result = await service.reconcile( many, settings, DateTime.utc(2026, 9, 9), ); expect(result.scheduledCount, 400); expect(gateway.alarms, hasLength(400)); expect(result.message, contains('后续')); }, ); }