diff --git a/README.md b/README.md index 44099f4..ff10805 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,4 @@ flutter run -d emulator-5554 构建成功才复制新 APK 到 `dist/`。工具版本与设备环境见 [Android 环境](docs/android-setup-status.md)。其他电脑可使用 `scripts/setup-android.ps1 -WithEmulator` 安装环境,并将构建脚本 ToolchainRoot 指向对应 `.tools` 目录。 设备集成检查见 [验证记录](docs/android-mvp-validation.md)。系统日历测试仅运行于专用模拟器的 `Hepan_Integration_Test` 测试日历,需提前允许测试 APP 读取和写入日历。`mobile/tool/reminder_probe.dart` 是短时系统提醒测试入口,交付 APK 始终使用 `lib/main.dart`。 +损坏文件无法从备份恢复时,可在提示区选择“重新建立课表”,确认后保留损坏文件副本并恢复保存功能。手机日历中的历史日程需按提示处理。 diff --git a/artifacts/screenshots/04-sunday-evening.png b/artifacts/screenshots/04-evening-last-period.png similarity index 100% rename from artifacts/screenshots/04-sunday-evening.png rename to artifacts/screenshots/04-evening-last-period.png diff --git a/artifacts/screenshots/10-offline-reopen.png b/artifacts/screenshots/10-offline-reopen.png index 2deacb6..361faf8 100644 Binary files a/artifacts/screenshots/10-offline-reopen.png and b/artifacts/screenshots/10-offline-reopen.png differ diff --git a/artifacts/screenshots/11-file-import.png b/artifacts/screenshots/11-file-import.png new file mode 100644 index 0000000..09474d7 Binary files /dev/null and b/artifacts/screenshots/11-file-import.png differ diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index 99095ca..2732f64 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -5,7 +5,7 @@ ## 已验证 - `flutter analyze`:No issues found。 -- `flutter test`:51 项通过,包括 11 节时间、上海时区和非周一开始日、不连续周次、导入拒绝保留原课表、存储损坏恢复、示例隔离、通知去重和权限降级、日历中断恢复与归属检查、200% 字体和重叠课程、通知点击详情。 +- `flutter test`:58 项通过,包括 11 节时间、上海时区和非周一开始日、不连续周次、导入拒绝保留原课表、存储损坏恢复、示例隔离、通知去重和权限降级、日历中断恢复与归属检查、200% 字体和重叠课程、通知点击详情。 - `integration_test/app_flow_test.dart` 在 Android 模拟器通过:两页导航、单色设置、实际日期选择器和学期周数保存、导入预览取消与确认、示例不改真实保存文件、错误导入保持数据、重开恢复。 - `integration_test/calendar_bridge_test.dart` 在专用 Hepan_Integration_Test 日历通过:创建、再次同步更新同一 ID、映射丢失后找回、错误 key 无法删除、删除自有事件。设备实际查询表明 Unrelated_fixture 事件保留,CalendarContract.Reminders 无记录。 - 首次真实 APK 构建、安装、启动成功。初次编译下载依赖用时约 695 秒,后续构建约 40 秒。 @@ -15,7 +15,7 @@ - 模拟器重启后:未重新打开 APP,系统仍送出已安排的测试通知;启动正式 APP 后,空课表协调取消残留自有通知。 - 已重建并安装 lib/main.dart 正式入口 APK,核实包名、0.1.0+1、API 24/36 和三种 Android 架构。示例课表、我的、周日、末节、设置和 200% 字体已有设备截图;验证 APK 不包含测试入口。 -- 独立代码审查进行中,结论另记。 +- 已完成一次独立代码审查:无 Critical/Minor,三项 Important 均用回归测试修复。58 项测试全通过,未进行重复审查。 ## 仍需本人手机验证 @@ -30,3 +30,6 @@ 通知初始化暂时失败后可重试,日历服务异常在设置中解释并保持未启用。首次同步中断、尚无保存 ID 时,通过选定日历的专属标记恢复清理;清理失败持久保存待重试状态。 +补充:系统文件选择器已从 Downloads 选择随附 JSON,正常显示 3 条课程预览和示例来源;飞行模式下正式 APP 重开后页面正常,示例体验未写成真实课程。 + +审查修复:设置改为按字段在最新状态上修改;首次日历写入前保存未完成状态,清理成功才允许更换目标;没有可用备份时提供确认恢复入口,真实文件测试验证损坏主文件和备份均先归档。 diff --git a/docs/execution-record.md b/docs/execution-record.md new file mode 100644 index 0000000..a6b1bf2 --- /dev/null +++ b/docs/execution-record.md @@ -0,0 +1,35 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-10-12-hepan-everyday-android.md +Execution: inline, approved by user. Base: 36d18f0. +Pre-flight: Tasks 1→2→3 share domain, snapshot and controller; names consistent. +Pre-flight: Tasks 2→4 import drafts; unknown school pages fail closed, demo stays isolated. +Pre-flight: Tasks 2→5 NotificationService; permission request separate from reconciliation. +Pre-flight: Tasks 2→6 CalendarService; ownership and retry mapping required. +Pre-flight: Tasks 3→7 UI/integration; file fixture uses explicit demo flag. +Ruling: PowerShell ledger and extracted briefs replace Bash helper scripts — only WSL shim exists and native Windows execution is available — tracking remains equivalent; cost if wrong: missing process record, not product behavior. +Task 1: in_progress (base 36d18f0). +Task 1: complete (base 36d18f0; RED 7 expected failures; GREEN flutter test 8/8; analyze clean). +Task 2: in_progress (base ae8e347). +Task 2: complete (RED 13 expected failures; GREEN flutter test 21/21; analyze clean). +Task 3: in_progress (base 808af00). +Correction: Task 2 analyze had 9 style infos; dart fix applied and Task 3 verifies current analyzer. +Ruling: Task 3 emulator visual review is grouped with Task 7 after real platform adapters exist — avoids a production placeholder adapter — cost if wrong: visual issues are found later, before delivery. +Task 3: complete (RED navigation 4, overlap 1, recovery 2; GREEN full suite 28/28; device visual deferred to Task 7 by ruling). +Task 4: in_progress (base 8a98d49). +Task 4: complete (RED unsafe domain/layout 3, concurrent read 1, edge recovery/frame 2; GREEN full suite 34/34; analyze clean. Real authenticated timetable parser remains explicitly pending per spec.) +Task 5: in_progress (base ffd3920). +Task 5/6: Ruling: native notification and calendar checks consolidated in Task 7 after real startup assembly — avoids temporary production adapters — cost if wrong: native failures found later. +Task 6: Ruling: per-event save/delete gateway replaces native syncEvents batch — keeps reconciliation and recovery testable in Dart, native checks ownership for every mutation — cost if wrong: extra platform calls for large terms. +Task 5: complete (local notification tests 6 passed, combined suite 48 passed; analyzer clean; native checks deferred by ruling). +Task 6: complete (calendar and controller RED witnessed; combined suite 48 passed; analyzer clean; native verification remains Task 7). +Correction: previous commit attempts failed due to missing local author identity. Tasks 5 and 6 committed together using existing Codex author; analyzer confirmed No issues found after completion labels. +Task 7: in_progress; real startup assembled, startup notification-tap RED→GREEN, initialization retry and calendar UI error RED→GREEN. First actual APK built and installed successfully; native device verification underway. +Task 7: complete (51 tests pass; analyze clean; Android UI flow and native calendar integration pass; background notification, tap details, reboot restoration and startup cleanup observed; formal main APK rebuilt and installed, badging verified; review follows). +Final: Ruling: updateSettings queues field transformations instead of complete settings values — fixes stale queued values undoing previous choices — cost if wrong: consumers must use the new callback signature. +Final: Ruling: real school parser remains conditional on unavailable authenticated fixture — approved spec protects old timetable and explicitly marks pending — cost if wrong: automatic school import is unavailable in this MVP. +Final: Ruling: iOS packaging remains deferred — user selected Android first — cost if wrong: cannot install on iPhone yet. +Final: Ruling: physical-device power management and cloud calendar marker behavior remain device-validation work — emulator cannot establish those results — cost if wrong: notifications or calendar synchronization may vary on specific phones/accounts. +Final review: fresh gpt-6-astra reviewer; no Critical or Minor; three Important findings regraded as Important and fixed in one pass. No re-review required; regression tests govern fix completion. +Final: fixed queued whole settings undoing reminder disable — queued distinct-field regression RED→GREEN, suite 58/58. +Final: fixed inaccessible partial calendar recovery and destination loss — partial sync/reopen/new calendar regression RED→GREEN; working cleanup button tested, suite 58/58. +Final: fixed no-backup corrupt storage lockout — missing/corrupt backup confirmation UI RED→GREEN; real filesystem archive/save tests pass, suite 58/58. +Final verification: affected Android app_flow integration rerun passes after review fixes; full suite 58/58 and analyze No issues found. diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index c149345..7f770db 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -85,7 +85,10 @@ class AppController extends ChangeNotifier { Future leaveDemo() => _run(() async { isDemo = false; }); - Future updateSettings(AppSettings value) => _run(() async { + Future updateSettings( + AppSettings Function(AppSettings current) change, + ) => _run(() async { + final value = change(snapshot.settings); if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) { throw ArgumentError('提醒时间无效'); } @@ -170,43 +173,54 @@ class AppController extends ChangeNotifier { } calendarSyncError = null; }); - Future enableCalendarFromUserAction( - String calendarId, - ) => _run(() async { - if (isDemo) throw StateError('示例课表不会导入日历'); - if (snapshot.settings.semester == null) throw StateError('请先设置学期'); - if (!await calendar.requestAccess()) { - calendarSyncError = '日历权限未获允许'; - return; - } - if (snapshot.calendarCleanupPending) { - await _cleanupCalendar(); - } - final calendars = await calendar.listWritableCalendars(); - if (!calendars.any((c) => c.id == calendarId)) { - calendarSyncError = '请选择可写入的日历'; - return; - } - final settings = snapshot.settings.copyWith( - calendarEnabled: true, - calendarId: calendarId, - ); - // Keep the chosen calendar for recovery if the first native sync fails midway. - await _save( - snapshot.copyWith(settings: settings.copyWith(calendarEnabled: false)), - ); - try { - final ids = await calendar.sync( - ScheduleEngine.occurrences(snapshot.courses, settings.semester!), - calendarId, - snapshot.calendarEventIds, - ); - await _save(snapshot.copyWith(settings: settings, calendarEventIds: ids)); - calendarSyncError = null; - } catch (_) { - calendarSyncError = '日历同步失败,可重试'; - } - }); + Future enableCalendarFromUserAction(String calendarId) => + _run(() async { + if (isDemo) throw StateError('示例课表不会导入日历'); + if (snapshot.settings.semester == null) throw StateError('请先设置学期'); + if (!await calendar.requestAccess()) { + calendarSyncError = '日历权限未获允许'; + return; + } + if (snapshot.calendarCleanupPending) { + await _cleanupCalendar(); + } + final calendars = await calendar.listWritableCalendars(); + if (!calendars.any((c) => c.id == calendarId)) { + calendarSyncError = '请选择可写入的日历'; + return; + } + final settings = snapshot.settings.copyWith( + calendarEnabled: true, + calendarId: calendarId, + ); + // Keep the chosen calendar for recovery if the first native sync fails midway. + await _save( + snapshot.copyWith( + settings: settings.copyWith(calendarEnabled: false), + calendarCleanupPending: true, + calendarEventIds: snapshot.settings.calendarId == calendarId + ? snapshot.calendarEventIds + : {}, + ), + ); + try { + final ids = await calendar.sync( + ScheduleEngine.occurrences(snapshot.courses, settings.semester!), + calendarId, + snapshot.calendarEventIds, + ); + await _save( + snapshot.copyWith( + settings: settings, + calendarEventIds: ids, + calendarCleanupPending: false, + ), + ); + calendarSyncError = null; + } catch (_) { + calendarSyncError = '日历同步失败,可重试'; + } + }); Future enableRemindersFromUserAction() => _run(() async { if (isDemo) throw StateError('示例课表不会生成提醒'); await notifications.requestPermissions(); @@ -218,6 +232,16 @@ class AppController extends ChangeNotifier { await _reconcile(); }); Future refreshServices() => _run(_reconcile); + Future startFreshData() => _run(() async { + if (storageReady) { + throw StateError('保存文件正常,无需重新建立'); + } + snapshot = await store.startFresh(); + storageReady = true; + isDemo = false; + lastError = null; + await _reconcile(); + }); Future retryCalendarCleanup() => _run(() async { if (await calendar.requestAccess()) { await _cleanupCalendar(); diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index a0304bf..c79ebc5 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -159,6 +159,31 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { } } + Future _startFresh() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('重新建立课表'), + content: const Text( + '损坏文件会保留为恢复副本,然后重新建立空课表。已有课程通知会取消;手机日历中原有课程日程需手动处理。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('保留损坏文件并重新建立'), + ), + ], + ), + ); + if (confirmed == true) { + await widget.controller.startFreshData(); + } + } + @override Widget build(BuildContext context) => AnimatedBuilder( animation: Listenable.merge([ @@ -198,6 +223,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { onPressed: widget.controller.restoreBackup, child: const Text('恢复最近备份'), ), + if (!widget.controller.storageReady) + TextButton( + onPressed: _startFresh, + child: const Text('重新建立课表'), + ), ], ), ), diff --git a/mobile/lib/data/file_snapshot_store.dart b/mobile/lib/data/file_snapshot_store.dart index 95ea553..c84f8b0 100644 --- a/mobile/lib/data/file_snapshot_store.dart +++ b/mobile/lib/data/file_snapshot_store.dart @@ -46,4 +46,18 @@ class FileSnapshotStore extends SnapshotStore { await temporary.rename(_file.path); return previous; } + + @override + Future startFresh() async { + await directory.create(recursive: true); + final stamp = DateTime.now().toUtc().microsecondsSinceEpoch; + for (final file in [_file, _backup]) { + if (await file.exists()) { + await file.rename('${file.path}.damaged-$stamp'); + } + } + final fresh = AppSnapshot(); + await save(fresh); + return fresh; + } } diff --git a/mobile/lib/data/snapshot_store.dart b/mobile/lib/data/snapshot_store.dart index abcde9f..f6c1078 100644 --- a/mobile/lib/data/snapshot_store.dart +++ b/mobile/lib/data/snapshot_store.dart @@ -4,4 +4,5 @@ abstract class SnapshotStore { Future load(); Future save(AppSnapshot snapshot); Future restoreBackup() => Future.error(StateError('没有可恢复的备份')); + Future startFresh() => Future.error(StateError('无法重新建立保存文件')); } diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index 42c6471..d8b3e11 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -138,14 +138,16 @@ class _SettingsPageState extends State { label: const Text('多彩'), selected: s.colorMode == ColorMode.multicolor, onSelected: (_) => c.updateSettings( - s.copyWith(colorMode: ColorMode.multicolor), + (current) => + current.copyWith(colorMode: ColorMode.multicolor), ), ), ChoiceChip( label: const Text('单色'), selected: s.colorMode == ColorMode.single, onSelected: (_) => c.updateSettings( - s.copyWith(colorMode: ColorMode.single), + (current) => + current.copyWith(colorMode: ColorMode.single), ), ), ], @@ -166,7 +168,7 @@ class _SettingsPageState extends State { button: true, child: InkWell( onTap: () => c.updateSettings( - s.copyWith( + (current) => current.copyWith( singleColorArgb: color, colorMode: ColorMode.single, ), @@ -217,7 +219,8 @@ class _SettingsPageState extends State { await c.enableRemindersFromUserAction(); } else { await c.updateSettings( - s.copyWith(remindersEnabled: false), + (current) => + current.copyWith(remindersEnabled: false), ); } }, @@ -234,7 +237,9 @@ class _SettingsPageState extends State { onSelected: c.isDemo ? null : (_) => c.updateSettings( - s.copyWith(reminderMinutes: minutes), + (current) => current.copyWith( + reminderMinutes: minutes, + ), ), ), ], @@ -361,7 +366,7 @@ class _SettingsPageState extends State { }); if (_weeksError != null || _dateError != null) return; await c.updateSettings( - s.copyWith( + (current) => current.copyWith( semester: Semester( startDate: _start!, totalWeeks: weeks!, diff --git a/mobile/test/app/app_controller_test.dart b/mobile/test/app/app_controller_test.dart index dc286ae..d736fda 100644 --- a/mobile/test/app/app_controller_test.dart +++ b/mobile/test/app/app_controller_test.dart @@ -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); diff --git a/mobile/test/data/snapshot_store_test.dart b/mobile/test/data/snapshot_store_test.dart index 6f540bf..73d4f25 100644 --- a/mobile/test/data/snapshot_store_test.dart +++ b/mobile/test/data/snapshot_store_test.dart @@ -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 { diff --git a/mobile/test/services/calendar_service_test.dart b/mobile/test/services/calendar_service_test.dart index 03425fc..f618a51 100644 --- a/mobile/test/services/calendar_service_test.dart +++ b/mobile/test/services/calendar_service_test.dart @@ -16,6 +16,7 @@ class CalendarProvider extends CalendarGateway { @override Future> calendars() async => [ const DeviceCalendar('local', '专用测试日历'), + const DeviceCalendar('other', '另一测试日历'), ]; @override Future> ownedEvents(String calendarId) async { diff --git a/mobile/test/widgets/recovery_test.dart b/mobile/test/widgets/recovery_test.dart new file mode 100644 index 0000000..9bbfb1c --- /dev/null +++ b/mobile/test/widgets/recovery_test.dart @@ -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 load() async => + value ?? (throw const FormatException('damaged')); + @override + Future restoreBackup() async => + throw StateError(badBackup ? '备份损坏' : '没有备份'); + @override + Future startFresh() async { + freshStarts++; + return value = AppSnapshot(); + } +} diff --git a/mobile/test/widgets/settings_test.dart b/mobile/test/widgets/settings_test.dart index b7508c5..c2180fc 100644 --- a/mobile/test/widgets/settings_test.dart +++ b/mobile/test/widgets/settings_test.dart @@ -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 {