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 = {}; final drafts = {}; int inserts = 0; @override Future requestAccess() async => access; @override Future> calendars() async => [ const DeviceCalendar('local', '专用测试日历'), ]; @override Future> ownedEvents(String calendarId) async { if (!access) throw StateError('permission denied'); return records.values.where((r) => r.calendarId == calendarId).toList(); } @override Future 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 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']); }); }