From 36d18f029fe5b6202522d3a33e46796e301556b6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 12 Oct 2026 22:56:43 +0800 Subject: [PATCH] docs: pin reminder identity and calendar permission contracts --- .../plans/2026-10-12-hepan-everyday-android.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-12-hepan-everyday-android.md b/docs/superpowers/plans/2026-10-12-hepan-everyday-android.md index 0644c36..793e42c 100644 --- a/docs/superpowers/plans/2026-10-12-hepan-everyday-android.md +++ b/docs/superpowers/plans/2026-10-12-hepan-everyday-android.md @@ -89,7 +89,7 @@ test('morning break before period 3 is fifteen minutes', () { - `ImportDraft({required List courses, required bool explicitlyEmpty, required CourseSource source, required String parserVersion})`;`JsonScheduleImport.parse(String text) -> ImportDraft`,只接受 schemaVersion 1、结构化字段和最大 2 MiB UTF-8 数据。根字段为 `schemaVersion`、`isDemo`、`explicitlyEmpty`、`courses`;课程字段为 `id/name/teacher/room/weekday/startPeriod/endPeriod/weeks`,weeks 是整数数组。文件来源仅可为 file 或 demo,不能冒充 school;isDemo=true 进入示例体验。 - `NotificationService.reconcile(List occurrences, AppSettings settings, DateTime now) -> Future`,`requestPermissions() -> Future`、`cancelOwned() -> Future`;reconcile 仅检查权限,不自行弹窗。`ReminderResult` 的 status 为 off/permissionDenied/exact/inexact/error,包含 scheduledCount 与说明。 - `CalendarService.requestAccess() -> Future`,`listWritableCalendars() -> Future>`,`sync(List occurrences, String calendarId, Map knownEventIds) -> Future>`,`deleteOwned(Map eventIds) -> Future`;`DeviceCalendar(id,name)`。 -- `AppController(SnapshotStore store, NotificationService notifications, CalendarService calendar, DateTime Function() clock)` extends ChangeNotifier;暴露持久化 `snapshot`、显示用 `visibleCourses/visibleSettings`、`isDemo`、`lastError`、`reminderResult`、`calendarSyncError`。`initialize()`、`showDemo({List? courses})`、`leaveDemo()`、`updateSettings(AppSettings value)`、`applyImport(ImportDraft draft,{required bool confirmedEmpty})`、`clearCourses()`、`disableCalendar({required bool deleteEvents})`、`enableRemindersFromUserAction()` 均返回 Future。示例有单独临时数据和“示例”学期,以 clock 所在周为第 1 周,不改变真实快照。clearCourses 始终清理对应通知和自有日历事件;仅关闭日历的保留选择由 disableCalendar 处理。 +- `AppController(SnapshotStore store, NotificationService notifications, CalendarService calendar, DateTime Function() clock)` extends ChangeNotifier;暴露持久化 `snapshot`、显示用 `visibleCourses/visibleSettings`、`isDemo`、`lastError`、`reminderResult`、`calendarSyncError`。`initialize()`、`showDemo({List? courses})`、`leaveDemo()`、`updateSettings(AppSettings value)`、`applyImport(ImportDraft draft,{required bool confirmedEmpty})`、`clearCourses()`、`disableCalendar({required bool deleteEvents})`、`enableCalendarFromUserAction(String calendarId)`、`enableRemindersFromUserAction()` 均返回 Future。示例有单独临时数据和“示例”学期,以 clock 所在周为第 1 周,不改变真实快照。clearCourses 始终清理对应通知和自有日历事件;仅关闭日历的保留选择由 disableCalendar 处理。 - [ ] 写失败测试:有效 JSON 转为确定周次集合;部分记录非法使整个草稿失败;空数组且 explicitlyEmpty=false 不得覆盖;重复 ID 拒绝;schemaVersion 未知和超过 2 MiB 拒绝;isDemo=true 导入仅改变显示数据,不改真实快照、不调用平台服务。 - [ ] 写失败测试:临时目录保存、重新打开恢复所有课程/设置;损坏文件报错且不返回“空课表”;模拟保存异常后控制器仍保留旧 snapshot;showDemo 不调用通知或日历;快速并发两次更新按顺序保留最后一份设置;移除课程后只清理本 APP 的副作用。 @@ -153,15 +153,14 @@ test('lookalike auth host cannot receive credentials', () { **Files:** 创建 local_notification_service、`mobile/test/services/notification_service_test.dart`;修改 `mobile/android/app/build.gradle.kts`、`mobile/android/app/src/main/AndroidManifest.xml`、`mobile/android/app/src/main/res/drawable/ic_stat_calendar.xml`、settings_page/main。 -**Interfaces:** `LocalNotificationService` implements Task 2 NotificationService;可注入 `ReminderGateway`,其 `permissions({required bool request}) -> Future`、`pendingIds() -> Future>`、`schedule(ReminderRequest value) -> Future`、`cancel(int id) -> Future`。`ReminderRequest(id, occurrenceKey, fireAt, title, body, exact)`;`ReminderPermissions(notifications,exactAlarm)`。`notificationId(String occurrenceKey) -> int` 使用固定算法并显式解决本次集合碰撞,不能依赖 Dart hashCode。 +**Interfaces:** `LocalNotificationService` implements Task 2 NotificationService;可注入 `ReminderGateway`,其 `permissions({required bool request}) -> Future`、`pendingIds() -> Future>`、`schedule(ReminderRequest value) -> Future`、`cancel(int id) -> Future`。`ReminderRequest(id, occurrenceKey, fireAt, title, body, exact)`;`ReminderPermissions(notifications,exactAlarm)`。`notificationId(String occurrenceKey) -> int` 使用 FNV-1a 32-bit 后取正 31-bit;本次集合碰撞按排序后线性探测解决,不能依赖 Dart hashCode。ID 变化时先取消旧计划再重排,不能覆盖碰撞课程。 - [ ] 写失败测试:08:30 课程默认 08:15 触发;已过提醒时间不排任务;只排实际上课周、每连续课程一次;demo、无学期、提醒关闭不排;重复 reconcile 无重复;删除课程取消旧 ID;通知权限拒绝返回 permissionDenied;精确权限拒绝可用 inexact 并显示说明;撤销权限后不显示已生效。 - [ ] 写失败测试:500 条以上未来实例时仅保留最早 400 条并显示“后续提醒在再次打开时补充”;初始化/设置更改补充未来任务,Android 重启恢复已安排的任务。用 FakeReminderGateway 检验相同实例保留稳定 ID。 ```dart test('notification identity is stable', () { - expect(notificationId('math/2026-09-09'), notificationId('math/2026-09-09')); - expect(notificationId('math/2026-09-09'), isNonNegative); + expect(notificationId('math/2026-09-09'), 1794407394); }); ``` @@ -181,12 +180,13 @@ test('notification identity is stable', () { ```dart test('denied calendar permission keeps calendar off', () async { // setup 中 Fake CalendarService.requestAccess 返回 false。 - expect(await calendar.requestAccess(), isFalse); + await controller.enableCalendarFromUserAction('selected-calendar'); expect(controller.snapshot.settings.calendarEnabled, isFalse); + expect(controller.calendarSyncError, isNotNull); }); ``` -- [ ] 运行 `flutter test test/services/calendar_service_test.dart` 确认失败;实现 READ_CALENDAR/WRITE_CALENDAR 运行时权限,选日历后才开启设置;写入 TZ 实例对应的 UTC 毫秒与 EVENT_TIMEZONE=Asia/Shanghai。 +- [ ] 运行 `flutter test test/services/calendar_service_test.dart` 确认失败;实现 READ_CALENDAR/WRITE_CALENDAR 运行时权限。enableCalendarFromUserAction 检查授权和可写日历,成功同步后才开启设置;写入 TZ 实例对应的 UTC 毫秒与 EVENT_TIMEZONE=Asia/Shanghai。 - [ ] 实现 Kotlin 桥接和 Dart 重试逻辑。系统写入与快照无法组成跨系统事务,先凭 app URI 查询去重再写;每次已完成映射及时持久化,重启后可恢复。仅操作用户选定日历,无法校验拥有标记时跳过;disableCalendar 保留时关闭后续自动同步,删除时先清理自有事件。清理失败保留待重试映射,不丢失追踪。 - [ ] 同一测试通过;`flutter test integration_test/calendar_bridge_test.dart -d emulator-5554` 验证创建、更新、删除和权限结果。没有可写日历时设备测试明确跳过并报告;仅使用测试专用日历,不向用户其他日历写测试数据。提交。