fix: preserve queued settings and recover interrupted or corrupt state

This commit is contained in:
Codex
2026-10-13 09:32:50 +08:00
parent 5ceab20b67
commit da5b8076a0
16 changed files with 373 additions and 50 deletions
+58 -4
View File
@@ -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);
+31
View File
@@ -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 {
+72
View File
@@ -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();
}
}
+52
View File
@@ -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 {