feat: add local reminders and owned calendar synchronization
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:hepan_everyday/services/android_calendar_service.dart';
|
||||
import 'package:hepan_everyday/services/calendar_service.dart';
|
||||
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||
|
||||
import '../domain/schedule_engine_test.dart' show course;
|
||||
|
||||
class CalendarProvider extends CalendarGateway {
|
||||
bool access = true, failAfterFirst = false;
|
||||
final records = <String, CalendarRecord>{};
|
||||
final drafts = <String, CalendarEventDraft>{};
|
||||
int inserts = 0;
|
||||
@override
|
||||
Future<bool> requestAccess() async => access;
|
||||
@override
|
||||
Future<List<DeviceCalendar>> calendars() async => [
|
||||
const DeviceCalendar('local', '专用测试日历'),
|
||||
];
|
||||
@override
|
||||
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||
if (!access) throw StateError('permission denied');
|
||||
return records.values.where((r) => r.calendarId == calendarId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> save(CalendarEventDraft event, String? eventId) async {
|
||||
if (failAfterFirst && inserts >= 1) {
|
||||
throw StateError('provider unavailable');
|
||||
}
|
||||
final id = eventId ?? '${++inserts}';
|
||||
records[id] = CalendarRecord(
|
||||
id: id,
|
||||
key: event.key,
|
||||
calendarId: event.calendarId,
|
||||
owned: true,
|
||||
);
|
||||
drafts[id] = event;
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String eventId, String expectedKey) async {
|
||||
final r = records[eventId];
|
||||
if (r?.owned == true && r?.key == expectedKey) {
|
||||
records.remove(eventId);
|
||||
drafts.remove(eventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5);
|
||||
late CalendarProvider provider;
|
||||
late AndroidCalendarService service;
|
||||
setUp(() {
|
||||
provider = CalendarProvider();
|
||||
service = AndroidCalendarService(provider);
|
||||
});
|
||||
test(
|
||||
'repeat import creates one event with UTC instants and school metadata',
|
||||
() async {
|
||||
final events = ScheduleEngine.occurrences([course(last: 2)], term);
|
||||
final ids = await service.sync(events, 'local', {});
|
||||
expect(ids, hasLength(1));
|
||||
await service.sync(events, 'local', ids);
|
||||
expect(provider.records, hasLength(1));
|
||||
expect(provider.inserts, 1);
|
||||
final event = provider.drafts.values.single;
|
||||
expect(
|
||||
event.startMs,
|
||||
DateTime.utc(2026, 9, 9, 0, 30).millisecondsSinceEpoch,
|
||||
);
|
||||
expect(
|
||||
event.endMs,
|
||||
DateTime.utc(2026, 9, 9, 2, 5).millisecondsSinceEpoch,
|
||||
);
|
||||
expect(event.description, contains('示例教师'));
|
||||
},
|
||||
);
|
||||
test(
|
||||
'lost mapping after an interrupted sync recovers via owned event identity',
|
||||
() async {
|
||||
final events = ScheduleEngine.occurrences([
|
||||
course(id: 'a'),
|
||||
course(id: 'b'),
|
||||
], term);
|
||||
provider.failAfterFirst = true;
|
||||
await expectLater(service.sync(events, 'local', {}), throwsStateError);
|
||||
expect(provider.records, hasLength(1));
|
||||
provider.failAfterFirst = false;
|
||||
await service.sync(events, 'local', {});
|
||||
expect(provider.records, hasLength(2));
|
||||
expect(provider.inserts, 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'manual deletion is repaired and unrelated events are preserved',
|
||||
() async {
|
||||
final events = ScheduleEngine.occurrences([course()], term);
|
||||
final ids = await service.sync(events, 'local', {});
|
||||
expect(ids, hasLength(1));
|
||||
provider.records.clear();
|
||||
provider.records['private'] = const CalendarRecord(
|
||||
id: 'private',
|
||||
key: 'private',
|
||||
calendarId: 'local',
|
||||
owned: false,
|
||||
);
|
||||
final updated = await service.sync(events, 'local', ids);
|
||||
expect(updated, hasLength(1));
|
||||
expect(provider.records, hasLength(2));
|
||||
await service.deleteOwned(updated);
|
||||
expect(provider.records.keys, ['private']);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'changed ownership marker cannot be deleted using an old stored mapping',
|
||||
() async {
|
||||
provider.records['17'] = const CalendarRecord(
|
||||
id: '17',
|
||||
key: 'math/2026-09-09',
|
||||
calendarId: 'local',
|
||||
owned: false,
|
||||
);
|
||||
await service.deleteOwned({'math/2026-09-09': '17'});
|
||||
expect(provider.records.keys, ['17']);
|
||||
},
|
||||
);
|
||||
test('replacing timetable removes stale owned events within selected calendar only', () async {
|
||||
provider.records['other'] = const CalendarRecord(
|
||||
id: 'other',
|
||||
key: 'old',
|
||||
calendarId: 'different',
|
||||
owned: true,
|
||||
);
|
||||
final events = ScheduleEngine.occurrences([course()], term);
|
||||
final ids = await service.sync(events, 'local', {});
|
||||
expect(ids, hasLength(1));
|
||||
await service.sync([], 'local', ids);
|
||||
expect(provider.records.keys, ['other']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 '../domain/schedule_engine_test.dart' show course;
|
||||
|
||||
class AlarmGateway extends ReminderGateway {
|
||||
ReminderPermissions permission = const ReminderPermissions(true, true);
|
||||
final alarms = <int, ReminderRequest>{};
|
||||
int permissionRequests = 0;
|
||||
@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 {
|
||||
alarms[value.id] = value;
|
||||
}
|
||||
|
||||
@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);
|
||||
});
|
||||
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('后续'));
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user