Files
TPSchedule/mobile/test/services/notification_service_test.dart

282 lines
9.0 KiB
Dart

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, ReminderRequest>{};
int permissionRequests = 0;
bool dropWrites = false;
final missingHandles = <int>{};
Set<int> lastAuditedIds = {};
int scheduleCalls = 0;
@override
Future<Set<int>> missingSystemIds(Set<int> ids) async {
lastAuditedIds = {...ids};
return missingHandles.intersection(ids);
}
@override
Future<ReminderPermissions> permissions({required bool request}) async {
if (request) permissionRequests++;
return permission;
}
@override
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
@override
Future<void> schedule(ReminderRequest value) async {
scheduleCalls++;
if (!dropWrites) alarms[value.id] = value;
missingHandles.remove(value.id);
}
@override
Future<void> 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<int> minutes) {
final json = AppSnapshot(settings: settings).toJson();
(json['settings'] as Map<String, dynamic>)['reminderOffsets'] = minutes;
return AppSnapshot.fromJson(json).settings;
}
test(
'overdue cached reminders are audited before removal on warm resume',
() async {
final events = ScheduleEngine.occurrences([course()], term);
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
final cached = gateway.alarms.keys.toSet();
expect(cached, isNotEmpty);
await service.reconcile(events, settings, DateTime.utc(2027, 1, 1));
expect(gateway.lastAuditedIds, cached);
expect(gateway.alarms, isEmpty);
},
);
test('cached fingerprint does not hide a missing system trigger', () async {
final events = ScheduleEngine.occurrences([course()], term);
final now = DateTime.utc(2026, 9, 9);
await service.reconcile(events, settings, now);
final previous = gateway.scheduleCalls;
gateway.missingHandles.addAll(gateway.alarms.keys);
await service.reconcile(events, settings, now);
expect(gateway.scheduleCalls, previous + 1);
expect(gateway.missingHandles, isEmpty);
await service.reconcile(events, settings, now);
expect(gateway.scheduleCalls, previous + 1);
});
test(
'does not report success when native scheduling did not persist',
() async {
gateway.dropWrites = true;
final events = ScheduleEngine.occurrences([course()], term);
await expectLater(
service.reconcile(events, settings, DateTime.utc(2026, 9, 9)),
throwsA(
isA<ReminderSetupException>().having(
(e) => e.code,
'code',
'schedule_not_saved',
),
),
);
},
);
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('后续'));
},
);
}