223 lines
7.0 KiB
Dart
223 lines
7.0 KiB
Dart
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<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');
|
|
}
|