import 'package:flutter_test/flutter_test.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:hepan_everyday/app/app_controller.dart'; import 'package:hepan_everyday/domain/app_snapshot.dart'; import 'package:hepan_everyday/domain/app_settings.dart'; import 'package:hepan_everyday/domain/semester.dart'; import 'package:hepan_everyday/domain/course.dart'; import 'package:hepan_everyday/data/json_schedule_import.dart'; import '../support/fakes.dart'; import '../domain/schedule_engine_test.dart' show course; import '../services/calendar_service_test.dart' show CalendarProvider; import 'package:hepan_everyday/services/android_calendar_service.dart'; void main() { setUpAll(tzdata.initializeTimeZones); late MemoryStore store; late FakeNotifications notifications; late FakeCalendar calendar; late AppController controller; setUp(() { store = MemoryStore( AppSnapshot( courses: [course()], settings: AppSettings( semester: Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5), ), ), ); notifications = FakeNotifications(); calendar = FakeCalendar(); controller = AppController( store, notifications, calendar, () => DateTime.utc(2026, 9, 8), ); }); tearDown(() => controller.dispose()); test('turning off after interrupted first sync cleans events even without saved IDs', () async { store.value = store.value!.copyWith( courses: [ course(id: 'a'), course(id: 'b'), ], ); final provider = CalendarProvider()..failAfterFirst = true; final interrupted = AppController( store, notifications, AndroidCalendarService(provider), controller.clock, ); await interrupted.initialize(); await interrupted.enableCalendarFromUserAction('local'); expect(interrupted.calendarSyncError, isNotNull); expect(provider.records, hasLength(1)); provider.failAfterFirst = false; await interrupted.disableCalendar(deleteEvents: true); expect(provider.records, isEmpty); interrupted.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; await controller.updateSettings( controller.snapshot.settings.copyWith(reminderMinutes: 30), ); expect(controller.lastError, isNotNull); await controller.showDemo(); expect(controller.lastError, isNull); }); test( 'refresh after corrupt storage never reconciles an empty timetable', () async { final damaged = AppController( DamagedStore(), notifications, calendar, () => DateTime.utc(2026, 9, 8), ); await damaged.initialize(); await damaged.refreshServices(); expect(notifications.reconciliations, 0); expect(damaged.storageReady, isFalse); damaged.dispose(); }, ); test('failed saving does not change currently visible settings', () async { await controller.initialize(); store.failSave = true; await controller.updateSettings( controller.snapshot.settings.copyWith(reminderMinutes: 30), ); expect(controller.snapshot.settings.reminderMinutes, 15); expect(controller.lastError, isNotNull); }); test('concurrent settings changes persist in user action order', () async { await controller.initialize(); await Future.wait([ controller.updateSettings( controller.snapshot.settings.copyWith(reminderMinutes: 30), ), controller.updateSettings( controller.snapshot.settings.copyWith(reminderMinutes: 60), ), ]); expect(store.value!.settings.reminderMinutes, 60); }); test('demo cannot replace real courses or create real reminders', () async { await controller.initialize(); final previous = notifications.reconciliations; await controller.showDemo(); expect(controller.isDemo, isTrue); expect(store.value!.courses.single.id, 'math'); expect(notifications.reconciliations, previous); expect(calendar.synchronizations, 0); }); test('unconfirmed empty import preserves saved timetable', () async { await controller.initialize(); await controller.applyImport( const ImportDraft( courses: [], explicitlyEmpty: false, source: CourseSource.file, parserVersion: 'json-v1', ), confirmedEmpty: false, ); expect(controller.snapshot.courses.single.id, 'math'); expect(controller.lastError, isNotNull); }); test( 'clearing courses cancels owned notifications and calendar events', () async { await controller.initialize(); await controller.clearCourses(); expect(store.value!.courses, isEmpty); expect(notifications.cancellations, 1); expect(calendar.deletions, 1); }, ); } class DamagedStore extends MemoryStore { @override Future load() async => throw const FormatException('damaged'); } class CleanupCalendar extends FakeCalendar { bool failDelete = false; @override Future deleteOwned(Map ids) async { deletions++; if (failDelete) { throw StateError('denied'); } } } class BrokenCancellation extends FakeNotifications { @override Future cancelOwned() async => throw StateError('denied'); }