fix: preserve queued settings and recover interrupted or corrupt state
This commit is contained in:
@@ -38,6 +38,60 @@ void main() {
|
||||
);
|
||||
});
|
||||
tearDown(() => controller.dispose());
|
||||
test(
|
||||
'queued changes to different settings preserve disabling reminders',
|
||||
() async {
|
||||
await controller.initialize();
|
||||
|
||||
await Future.wait([
|
||||
controller.updateSettings(
|
||||
(current) => current.copyWith(remindersEnabled: false),
|
||||
),
|
||||
controller.updateSettings(
|
||||
(current) => current.copyWith(colorMode: ColorMode.single),
|
||||
),
|
||||
]);
|
||||
expect(store.value!.settings.remindersEnabled, isFalse);
|
||||
expect(store.value!.settings.colorMode, ColorMode.single);
|
||||
},
|
||||
);
|
||||
test('partial first calendar sync survives restart and is cleaned before another destination', () async {
|
||||
store.value = store.value!.copyWith(
|
||||
courses: [
|
||||
course(id: 'a'),
|
||||
course(id: 'b'),
|
||||
],
|
||||
);
|
||||
final provider = CalendarProvider()..failAfterFirst = true;
|
||||
final first = AppController(
|
||||
store,
|
||||
notifications,
|
||||
AndroidCalendarService(provider),
|
||||
controller.clock,
|
||||
);
|
||||
await first.initialize();
|
||||
await first.enableCalendarFromUserAction('local');
|
||||
expect(provider.records, hasLength(1));
|
||||
first.dispose();
|
||||
provider.failAfterFirst = false;
|
||||
final reopened = AppController(
|
||||
store,
|
||||
notifications,
|
||||
AndroidCalendarService(provider),
|
||||
controller.clock,
|
||||
);
|
||||
await reopened.initialize();
|
||||
expect(provider.records, isEmpty);
|
||||
await reopened.enableCalendarFromUserAction('other');
|
||||
expect(
|
||||
provider.records.values.every((e) => e.calendarId == 'other'),
|
||||
isTrue,
|
||||
);
|
||||
expect(provider.records, hasLength(2));
|
||||
await reopened.disableCalendar(deleteEvents: true);
|
||||
expect(provider.records, isEmpty);
|
||||
reopened.dispose();
|
||||
});
|
||||
test('turning off after interrupted first sync cleans events even without saved IDs', () async {
|
||||
store.value = store.value!.copyWith(
|
||||
courses: [
|
||||
@@ -122,7 +176,7 @@ void main() {
|
||||
await controller.initialize();
|
||||
store.failSave = true;
|
||||
await controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
);
|
||||
expect(controller.lastError, isNotNull);
|
||||
await controller.showDemo();
|
||||
@@ -148,7 +202,7 @@ void main() {
|
||||
await controller.initialize();
|
||||
store.failSave = true;
|
||||
await controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
);
|
||||
expect(controller.snapshot.settings.reminderMinutes, 15);
|
||||
expect(controller.lastError, isNotNull);
|
||||
@@ -157,10 +211,10 @@ void main() {
|
||||
await controller.initialize();
|
||||
await Future.wait([
|
||||
controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
),
|
||||
controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 60),
|
||||
(current) => current.copyWith(reminderMinutes: 60),
|
||||
),
|
||||
]);
|
||||
expect(store.value!.settings.reminderMinutes, 60);
|
||||
|
||||
@@ -15,6 +15,37 @@ void main() {
|
||||
tearDown(() async {
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
for (final badBackup in [false, true]) {
|
||||
test(
|
||||
'fresh recovery archives corrupt primary and ${badBackup ? 'corrupt' : 'missing'} backup before saving',
|
||||
() async {
|
||||
await File('${directory.path}/hepan_snapshot.json')
|
||||
.writeAsString('damaged primary');
|
||||
if (badBackup) {
|
||||
await File('${directory.path}/hepan_snapshot.bak')
|
||||
.writeAsString('damaged backup');
|
||||
}
|
||||
final store = FileSnapshotStore(directory);
|
||||
await expectLater(store.load(), throwsFormatException);
|
||||
await store.startFresh();
|
||||
final archives = await directory
|
||||
.list()
|
||||
.where((e) => e.path.contains('.damaged-'))
|
||||
.toList();
|
||||
expect(archives, hasLength(badBackup ? 2 : 1));
|
||||
final contents = await Future.wait(
|
||||
archives.map((e) => File(e.path).readAsString()),
|
||||
);
|
||||
expect(contents, contains('damaged primary'));
|
||||
if (badBackup) {
|
||||
expect(contents, contains('damaged backup'));
|
||||
}
|
||||
expect((await store.load())!.courses, isEmpty);
|
||||
await store.save(AppSnapshot(courses: [course()]));
|
||||
expect((await store.load())!.courses.single.id, 'math');
|
||||
},
|
||||
);
|
||||
}
|
||||
test(
|
||||
'reopening restores courses, settings and calendar identifiers',
|
||||
() async {
|
||||
|
||||
@@ -16,6 +16,7 @@ class CalendarProvider extends CalendarGateway {
|
||||
@override
|
||||
Future<List<DeviceCalendar>> calendars() async => [
|
||||
const DeviceCalendar('local', '专用测试日历'),
|
||||
const DeviceCalendar('other', '另一测试日历'),
|
||||
];
|
||||
@override
|
||||
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.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/app/hepan_app.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
for (final badBackup in [false, true]) {
|
||||
testWidgets(
|
||||
'corrupt primary with ${badBackup ? "corrupt" : "missing"} backup has confirmed fresh recovery',
|
||||
(tester) async {
|
||||
final store = DamagedStore(badBackup);
|
||||
final controller = AppController(
|
||||
store,
|
||||
FakeNotifications(),
|
||||
FakeCalendar(),
|
||||
DateTime.now,
|
||||
);
|
||||
await controller.initialize();
|
||||
await controller.restoreBackup();
|
||||
await tester.pumpWidget(HepanApp(controller: controller));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isFalse);
|
||||
expect(find.text('重新建立课表'), findsOneWidget);
|
||||
await tester.tap(find.text('重新建立课表'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isFalse);
|
||||
expect(store.freshStarts, 0);
|
||||
await tester.tap(find.text('重新建立课表'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('保留损坏文件并重新建立'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isTrue);
|
||||
await tester.tap(find.bySemanticsLabel('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('课表设置'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('课表设置'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('单色'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.value!.settings.colorMode.name, 'single');
|
||||
expect(store.freshStarts, 1);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DamagedStore extends MemoryStore {
|
||||
DamagedStore(this.badBackup);
|
||||
final bool badBackup;
|
||||
int freshStarts = 0;
|
||||
@override
|
||||
Future<AppSnapshot?> load() async =>
|
||||
value ?? (throw const FormatException('damaged'));
|
||||
@override
|
||||
Future<AppSnapshot> restoreBackup() async =>
|
||||
throw StateError(badBackup ? '备份损坏' : '没有备份');
|
||||
@override
|
||||
Future<AppSnapshot> startFresh() async {
|
||||
freshStarts++;
|
||||
return value = AppSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,61 @@ import 'package:hepan_everyday/services/calendar_service.dart';
|
||||
import '../support/fakes.dart';
|
||||
|
||||
import 'navigation_test.dart' show uiController;
|
||||
import '../services/calendar_service_test.dart' show CalendarProvider;
|
||||
import '../domain/schedule_engine_test.dart' show course;
|
||||
|
||||
import 'package:hepan_everyday/services/android_calendar_service.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/domain/semester.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
testWidgets(
|
||||
'partial initial calendar sync exposes working cleanup action while switch is off',
|
||||
(tester) async {
|
||||
final provider = CalendarProvider()..failAfterFirst = true;
|
||||
final controller = AppController(
|
||||
MemoryStore(
|
||||
AppSnapshot(
|
||||
courses: [
|
||||
course(id: 'a'),
|
||||
course(id: 'b'),
|
||||
],
|
||||
settings: AppSettings(
|
||||
semester: Semester(
|
||||
startDate: DateTime(2026, 9, 9),
|
||||
totalWeeks: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FakeNotifications(),
|
||||
AndroidCalendarService(provider),
|
||||
DateTime.now,
|
||||
);
|
||||
await controller.initialize();
|
||||
await controller.enableCalendarFromUserAction('local');
|
||||
expect(controller.snapshot.settings.calendarEnabled, isFalse);
|
||||
expect(provider.records, hasLength(1));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: SettingsPage(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('重试清理课程日程'),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.ensureVisible(find.text('重试清理课程日程'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('重试清理课程日程'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(provider.records, isEmpty);
|
||||
expect(controller.snapshot.calendarCleanupPending, isFalse);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
testWidgets('calendar provider failure is explained and keeps switch off', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user