feat: add local reminders and owned calendar synchronization

This commit is contained in:
Codex
2026-10-13 00:11:48 +08:00
parent ffd3920c0d
commit 2c4256b880
13 changed files with 978 additions and 8 deletions
+73
View File
@@ -35,6 +35,63 @@ void main() {
);
});
tearDown(() => controller.dispose());
test(
'failed calendar cleanup is retried after reopening while sync is off',
() async {
final provider = CleanupCalendar()..failDelete = true;
final first = AppController(
store,
notifications,
provider,
controller.clock,
);
await first.initialize();
await first.disableCalendar(deleteEvents: true);
expect(first.lastError, isNotNull);
first.dispose();
provider.failDelete = false;
final reopened = AppController(
store,
notifications,
provider,
controller.clock,
);
await reopened.initialize();
expect(provider.deletions, 2);
expect(reopened.snapshot.settings.calendarEnabled, isFalse);
reopened.dispose();
},
);
test(
'notification cleanup failure does not prevent calendar cleanup',
() async {
final broken = AppController(
store,
BrokenCancellation(),
calendar,
controller.clock,
);
await broken.initialize();
await broken.clearCourses();
expect(calendar.deletions, 1);
expect(broken.snapshot.courses, isEmpty);
broken.dispose();
},
);
test(
'denied calendar permission and unavailable calendars stay off',
() async {
await controller.initialize();
calendar.access = false;
await controller.enableCalendarFromUserAction('local');
expect(controller.snapshot.settings.calendarEnabled, isFalse);
expect(controller.calendarSyncError, isNotNull);
calendar.access = true;
await controller.enableCalendarFromUserAction('readonly-or-missing');
expect(controller.snapshot.settings.calendarEnabled, isFalse);
expect(calendar.synchronizations, 0);
},
);
test('a new successful demo action clears a stale save error', () async {
await controller.initialize();
store.failSave = true;
@@ -121,3 +178,19 @@ class DamagedStore extends MemoryStore {
@override
Future<AppSnapshot?> load() async => throw const FormatException('damaged');
}
class CleanupCalendar extends FakeCalendar {
bool failDelete = false;
@override
Future<void> deleteOwned(Map<String, String> ids) async {
deletions++;
if (failDelete) {
throw StateError('denied');
}
}
}
class BrokenCancellation extends FakeNotifications {
@override
Future<void> cancelOwned() async => throw StateError('denied');
}
@@ -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('后续'));
},
);
}