From 808af007a212da115e5319f5c6850ecdfdfa0537 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 12 Oct 2026 23:18:51 +0800 Subject: [PATCH] feat: persist timetable and validate confirmed imports --- mobile/lib/app/app_controller.dart | 228 ++++++++++++++++++ mobile/lib/data/demo_schedule.dart | 31 +++ mobile/lib/data/file_snapshot_store.dart | 49 ++++ mobile/lib/data/json_schedule_import.dart | 57 +++++ mobile/lib/data/snapshot_store.dart | 7 + mobile/lib/domain/app_snapshot.dart | 122 ++++++++++ mobile/lib/domain/course.dart | 39 +++ mobile/lib/services/calendar_service.dart | 17 ++ mobile/lib/services/notification_service.dart | 25 ++ mobile/test/app/app_controller_test.dart | 92 +++++++ .../test/data/json_schedule_import_test.dart | 55 +++++ mobile/test/data/snapshot_store_test.dart | 57 +++++ mobile/test/support/fakes.dart | 73 ++++++ 13 files changed, 852 insertions(+) create mode 100644 mobile/lib/app/app_controller.dart create mode 100644 mobile/lib/data/demo_schedule.dart create mode 100644 mobile/lib/data/file_snapshot_store.dart create mode 100644 mobile/lib/data/json_schedule_import.dart create mode 100644 mobile/lib/data/snapshot_store.dart create mode 100644 mobile/lib/domain/app_snapshot.dart create mode 100644 mobile/lib/services/calendar_service.dart create mode 100644 mobile/lib/services/notification_service.dart create mode 100644 mobile/test/app/app_controller_test.dart create mode 100644 mobile/test/data/json_schedule_import_test.dart create mode 100644 mobile/test/data/snapshot_store_test.dart create mode 100644 mobile/test/support/fakes.dart diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart new file mode 100644 index 0000000..9ab91df --- /dev/null +++ b/mobile/lib/app/app_controller.dart @@ -0,0 +1,228 @@ +import 'package:flutter/foundation.dart'; + +import '../domain/app_snapshot.dart'; +import '../domain/app_settings.dart'; +import '../domain/course.dart'; +import '../data/snapshot_store.dart'; +import '../data/json_schedule_import.dart'; +import '../services/notification_service.dart'; +import '../services/calendar_service.dart'; +import '../domain/schedule_engine.dart'; +import '../data/demo_schedule.dart'; + +class AppController extends ChangeNotifier { + AppController(this.store, this.notifications, this.calendar, this.clock); + final SnapshotStore store; + final NotificationService notifications; + final CalendarService calendar; + final DateTime Function() clock; + AppSnapshot snapshot = AppSnapshot(); + bool isDemo = false; + bool storageReady = true; + List _demo = []; + AppSettings? _demoSettings; + Future _queue = Future.value(); + String? lastError, calendarSyncError; + ReminderResult reminderResult = const ReminderResult(ReminderStatus.off); + List get visibleCourses => isDemo ? _demo : snapshot.courses; + AppSettings get visibleSettings => + isDemo ? _demoSettings! : snapshot.settings; + Future _run(Future Function() operation) { + final next = _queue.then((_) async { + try { + await operation(); + } catch (error) { + lastError = error.toString(); + } + notifyListeners(); + }); + _queue = next; + return next; + } + + void _checkStorage() { + if (!storageReady) throw StateError('请先恢复备份,避免覆盖损坏的保存文件'); + } + + Future _save(AppSnapshot next) async { + _checkStorage(); + await store.save(next); + snapshot = next; + lastError = null; + } + + Future initialize() => _run(() async { + try { + snapshot = await store.load() ?? AppSnapshot(); + storageReady = true; + } catch (_) { + storageReady = false; + throw const FormatException('保存文件读取失败,可恢复最近备份'); + } + await _reconcile(); + }); + Future restoreBackup() => _run(() async { + snapshot = await store.restoreBackup(); + storageReady = true; + lastError = null; + await _reconcile(); + }); + Future showDemo({List? courses}) => _run(() async { + _demo = courses ?? demoCourses(); + isDemo = true; + final today = ScheduleEngine.schoolDate(clock()); + _demoSettings = snapshot.settings.copyWith( + semester: Semester( + startDate: today.subtract(Duration(days: today.weekday - 1)), + totalWeeks: 20, + ), + remindersEnabled: false, + calendarEnabled: false, + ); + }); + Future leaveDemo() => _run(() async { + isDemo = false; + }); + Future updateSettings(AppSettings value) => _run(() async { + if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) + throw ArgumentError('提醒时间无效'); + await _save(snapshot.copyWith(settings: value)); + if (isDemo) + _demoSettings = _demoSettings!.copyWith( + colorMode: value.colorMode, + singleColorArgb: value.singleColorArgb, + ); + await _reconcile(); + }); + Future applyImport( + ImportDraft draft, { + required bool confirmedEmpty, + }) => _run(() async { + if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) { + throw const FormatException('未检测到课程,已有课表保持不变'); + } + if (draft.courses.map((c) => c.id).toSet().length != draft.courses.length) + throw const FormatException('课程标识重复'); + if (draft.source == CourseSource.demo) { + _demo = draft.courses; + isDemo = true; + final today = ScheduleEngine.schoolDate(clock()); + _demoSettings = snapshot.settings.copyWith( + semester: Semester( + startDate: today.subtract(Duration(days: today.weekday - 1)), + totalWeeks: 20, + ), + remindersEnabled: false, + calendarEnabled: false, + ); + return; + } + if (draft.courses.any((c) => c.source != draft.source)) + throw const FormatException('课程来源不一致'); + await _save( + snapshot.copyWith( + courses: draft.courses, + importMetadata: ImportMetadata( + source: draft.source, + importedAt: clock(), + parserVersion: draft.parserVersion, + ), + ), + ); + isDemo = false; + await _reconcile(); + }); + Future clearCourses() => _run(() async { + // Preserve ownership mapping until cleanup has actually succeeded. + await _save(snapshot.copyWith(courses: [], clearMetadata: true)); + isDemo = false; + await notifications.cancelOwned(); + await calendar.deleteOwned(snapshot.calendarEventIds); + await _save(snapshot.copyWith(calendarEventIds: {})); + }); + Future disableCalendar({required bool deleteEvents}) => _run(() async { + await _save( + snapshot.copyWith( + settings: snapshot.settings.copyWith(calendarEnabled: false), + ), + ); + if (deleteEvents) { + await calendar.deleteOwned(snapshot.calendarEventIds); + await _save(snapshot.copyWith(calendarEventIds: {})); + } + 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; + } + final calendars = await calendar.listWritableCalendars(); + if (!calendars.any((c) => c.id == calendarId)) { + calendarSyncError = '请选择可写入的日历'; + return; + } + final settings = snapshot.settings.copyWith( + calendarEnabled: true, + calendarId: calendarId, + ); + 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 enableRemindersFromUserAction() => _run(() async { + if (isDemo) throw StateError('示例课表不会生成提醒'); + await notifications.requestPermissions(); + await _save( + snapshot.copyWith( + settings: snapshot.settings.copyWith(remindersEnabled: true), + ), + ); + await _reconcile(); + }); + Future refreshServices() => _run(_reconcile); + Future _reconcile() async { + final settings = snapshot.settings; + final occurrences = settings.semester == null + ? [] + : ScheduleEngine.occurrences(snapshot.courses, settings.semester!); + try { + reminderResult = await notifications.reconcile( + occurrences, + settings, + clock(), + ); + } catch (_) { + reminderResult = const ReminderResult( + ReminderStatus.error, + message: '提醒设置未完成,可重试', + ); + } + if (settings.calendarEnabled && settings.calendarId != null) { + try { + final ids = await calendar.sync( + occurrences, + settings.calendarId!, + snapshot.calendarEventIds, + ); + await _save(snapshot.copyWith(calendarEventIds: ids)); + calendarSyncError = null; + } catch (_) { + calendarSyncError = '日历同步失败,可重试'; + } + } + } +} diff --git a/mobile/lib/data/demo_schedule.dart b/mobile/lib/data/demo_schedule.dart new file mode 100644 index 0000000..a99c17f --- /dev/null +++ b/mobile/lib/data/demo_schedule.dart @@ -0,0 +1,31 @@ +import '../domain/course.dart'; + +List demoCourses() => [ + _demo('english', '学术英语', '示例 · 张老师', '示例 · 综合楼 A301', 1, 1, 2), + _demo('matrix', '矩阵论', '示例 · 李老师', '示例 · 品学楼 B205', 1, 5, 6), + _demo('research', '研究方法', '示例 · 陈老师', '示例 · 立人楼 A208', 2, 3, 4), + _demo('signal', '现代信号处理', '示例 · 王老师', '示例 · 品学楼 C302', 3, 1, 2), + _demo('seminar', '学术研讨', '示例 · 刘老师', '示例 · 实验室 501', 3, 9, 11), + _demo('science', '科技论文写作', '示例 · 周老师', '示例 · 综合楼 B102', 4, 5, 6), + _demo('experiment', '实验与实践', '示例 · 赵老师', '示例 · 实验楼 302', 5, 3, 4), + _demo('weekend', '专题报告', '示例 · 导师组', '示例 · 学术报告厅', 7, 9, 11), +]; +Course _demo( + String id, + String name, + String teacher, + String room, + int day, + int first, + int last, +) => Course( + id: id, + name: name, + teacher: teacher, + room: room, + weekday: day, + startPeriod: first, + endPeriod: last, + weeks: Set.from(List.generate(20, (i) => i + 1)), + source: CourseSource.demo, +); diff --git a/mobile/lib/data/file_snapshot_store.dart b/mobile/lib/data/file_snapshot_store.dart new file mode 100644 index 0000000..95ea553 --- /dev/null +++ b/mobile/lib/data/file_snapshot_store.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'snapshot_store.dart'; +import '../domain/app_snapshot.dart'; + +class FileSnapshotStore extends SnapshotStore { + FileSnapshotStore(this.directory); + final Directory directory; + File get _file => File('${directory.path}/hepan_snapshot.json'); + File get _backup => File('${directory.path}/hepan_snapshot.bak'); + Future _read(File file) async { + final value = jsonDecode(await file.readAsString()); + if (value is! Map) throw const FormatException('保存文件损坏'); + return AppSnapshot.fromJson(value); + } + + @override + Future load() async => + await _file.exists() ? await _read(_file) : null; + @override + Future save(AppSnapshot snapshot) async { + await directory.create(recursive: true); + final text = jsonEncode(snapshot.toJson()); + // Validate before touching the last successfully saved version. + AppSnapshot.fromJson(jsonDecode(text) as Map); + if (await _file.exists()) { + try { + await _read(_file); + await _file.copy(_backup.path); + } on FormatException { + /* Keep an existing valid backup. */ + } + } + final temporary = File('${_file.path}.tmp'); + await temporary.writeAsString(text, flush: true); + await temporary.rename(_file.path); + } + + @override + Future restoreBackup() async { + if (!await _backup.exists()) throw StateError('没有可恢复的备份'); + final previous = await _read(_backup); + final temporary = File('${_file.path}.tmp'); + await temporary.writeAsString(jsonEncode(previous.toJson()), flush: true); + await temporary.rename(_file.path); + return previous; + } +} diff --git a/mobile/lib/data/json_schedule_import.dart b/mobile/lib/data/json_schedule_import.dart new file mode 100644 index 0000000..ae0fb4c --- /dev/null +++ b/mobile/lib/data/json_schedule_import.dart @@ -0,0 +1,57 @@ +import '../domain/course.dart'; + +import 'dart:convert'; + +class ImportDraft { + const ImportDraft({ + required this.courses, + required this.explicitlyEmpty, + required this.source, + required this.parserVersion, + }); + final List courses; + final bool explicitlyEmpty; + final CourseSource source; + final String parserVersion; +} + +class JsonScheduleImport { + static ImportDraft parse(String text) { + try { + if (utf8.encode(text).length > 2 * 1024 * 1024) + throw const FormatException('文件不能超过 2 MB'); + final json = jsonDecode(text); + if (json is! Map || + json['schemaVersion'] != 1 || + json['courses'] is! List || + (json['isDemo'] != null && json['isDemo'] is! bool) || + (json['explicitlyEmpty'] != null && + json['explicitlyEmpty'] is! bool)) { + throw const FormatException('请选择版本 1 的课表 JSON 文件'); + } + final source = json['isDemo'] == true + ? CourseSource.demo + : CourseSource.file; + final courses = (json['courses'] as List) + .map( + (c) => Course.fromJson( + Map.from(c as Map), + source: source, + ), + ) + .toList(); + if (courses.map((c) => c.id).toSet().length != courses.length) + throw const FormatException('课程标识重复'); + return ImportDraft( + courses: List.unmodifiable(courses), + explicitlyEmpty: json['explicitlyEmpty'] == true, + source: source, + parserVersion: 'json-v1', + ); + } on FormatException { + rethrow; + } catch (_) { + throw const FormatException('课表文件格式无效'); + } + } +} diff --git a/mobile/lib/data/snapshot_store.dart b/mobile/lib/data/snapshot_store.dart new file mode 100644 index 0000000..abcde9f --- /dev/null +++ b/mobile/lib/data/snapshot_store.dart @@ -0,0 +1,7 @@ +import '../domain/app_snapshot.dart'; + +abstract class SnapshotStore { + Future load(); + Future save(AppSnapshot snapshot); + Future restoreBackup() => Future.error(StateError('没有可恢复的备份')); +} diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart new file mode 100644 index 0000000..b278218 --- /dev/null +++ b/mobile/lib/domain/app_snapshot.dart @@ -0,0 +1,122 @@ +import 'app_settings.dart'; +import 'course.dart'; +import 'semester.dart'; + +class ImportMetadata { + const ImportMetadata({ + required this.source, + required this.importedAt, + required this.parserVersion, + }); + final CourseSource source; + final DateTime importedAt; + final String parserVersion; +} + +class AppSnapshot { + AppSnapshot({ + this.settings = const AppSettings(), + List courses = const [], + this.importMetadata, + Map calendarEventIds = const {}, + }) : courses = List.unmodifiable(courses), + calendarEventIds = Map.unmodifiable(calendarEventIds); + final AppSettings settings; + final List courses; + final ImportMetadata? importMetadata; + final Map calendarEventIds; + AppSnapshot copyWith({ + AppSettings? settings, + List? courses, + ImportMetadata? importMetadata, + Map? calendarEventIds, + bool clearMetadata = false, + }) => AppSnapshot( + settings: settings ?? this.settings, + courses: courses ?? this.courses, + importMetadata: clearMetadata + ? null + : importMetadata ?? this.importMetadata, + calendarEventIds: calendarEventIds ?? this.calendarEventIds, + ); + Map toJson() => { + 'schemaVersion': 1, + 'courses': courses.map((c) => c.toJson()).toList(), + 'settings': { + 'semester': settings.semester == null + ? null + : { + 'startDate': settings.semester!.startDate + .toIso8601String() + .substring(0, 10), + 'totalWeeks': settings.semester!.totalWeeks, + }, + 'remindersEnabled': settings.remindersEnabled, + 'reminderMinutes': settings.reminderMinutes, + 'colorMode': settings.colorMode.name, + 'singleColorArgb': settings.singleColorArgb, + 'calendarEnabled': settings.calendarEnabled, + 'calendarId': settings.calendarId, + }, + 'importMetadata': importMetadata == null + ? null + : { + 'source': importMetadata!.source.name, + 'importedAt': importMetadata!.importedAt.toUtc().toIso8601String(), + 'parserVersion': importMetadata!.parserVersion, + }, + 'calendarEventIds': calendarEventIds, + }; + static AppSnapshot fromJson(Map json) { + try { + if (json['schemaVersion'] != 1) throw const FormatException('不支持的保存版本'); + final values = (json['courses'] as List) + .map((c) => Course.fromJson(Map.from(c as Map))) + .toList(); + if (values.map((c) => c.id).toSet().length != values.length || + values.any((c) => c.source == CourseSource.demo)) { + throw const FormatException('保存课表无效'); + } + final s = Map.from(json['settings'] as Map); + final minutes = s['reminderMinutes'] as int; + if (![5, 10, 15, 30, 60].contains(minutes)) + throw const FormatException('提醒时间无效'); + final color = s['singleColorArgb'] as int; + if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); + final sem = s['semester']; + final term = sem == null + ? null + : Semester( + startDate: DateTime.parse(sem['startDate'] as String), + totalWeeks: sem['totalWeeks'] as int, + ); + final metadata = json['importMetadata']; + return AppSnapshot( + courses: values, + settings: AppSettings( + semester: term, + remindersEnabled: s['remindersEnabled'] as bool, + reminderMinutes: minutes, + colorMode: ColorMode.values.byName(s['colorMode'] as String), + singleColorArgb: color, + calendarEnabled: s['calendarEnabled'] as bool, + calendarId: s['calendarId'] as String?, + ), + importMetadata: metadata == null + ? null + : ImportMetadata( + source: CourseSource.values.byName( + metadata['source'] as String, + ), + importedAt: DateTime.parse(metadata['importedAt'] as String), + parserVersion: metadata['parserVersion'] as String, + ), + calendarEventIds: (json['calendarEventIds'] as Map).map( + (k, v) => MapEntry(k as String, v as String), + ), + ); + } catch (_) { + throw const FormatException('保存文件内容无效,未覆盖已有数据'); + } + } +} diff --git a/mobile/lib/domain/course.dart b/mobile/lib/domain/course.dart index 3aa6978..79f1faf 100644 --- a/mobile/lib/domain/course.dart +++ b/mobile/lib/domain/course.dart @@ -27,4 +27,43 @@ class Course { final int weekday, startPeriod, endPeriod; final Set weeks; final CourseSource source; + + Map toJson() => { + 'id': id, + 'name': name, + 'teacher': teacher, + 'room': room, + 'weekday': weekday, + 'startPeriod': startPeriod, + 'endPeriod': endPeriod, + 'weeks': weeks.toList()..sort(), + 'source': source.name, + }; + + static Course fromJson(Map json, {CourseSource? source}) { + try { + String text(String key) { + final v = json[key]; + if (v is! String) throw const FormatException('课程字段缺失'); + return v.trim(); + } + + final rawWeeks = json['weeks']; + if (rawWeeks is! List || rawWeeks.any((w) => w is! int)) + throw const FormatException('周次必须是整数数组'); + return Course( + id: text('id'), + name: text('name'), + teacher: text('teacher'), + room: text('room'), + weekday: json['weekday'] as int, + startPeriod: json['startPeriod'] as int, + endPeriod: json['endPeriod'] as int, + weeks: rawWeeks.cast().toSet(), + source: source ?? CourseSource.values.byName(json['source'] as String), + ); + } catch (_) { + throw const FormatException('课程字段、节次或周次无效'); + } + } } diff --git a/mobile/lib/services/calendar_service.dart b/mobile/lib/services/calendar_service.dart new file mode 100644 index 0000000..9165ba4 --- /dev/null +++ b/mobile/lib/services/calendar_service.dart @@ -0,0 +1,17 @@ +import '../domain/course_occurrence.dart'; + +class DeviceCalendar { + const DeviceCalendar(this.id, this.name); + final String id, name; +} + +abstract class CalendarService { + Future requestAccess(); + Future> listWritableCalendars(); + Future> sync( + List occurrences, + String calendarId, + Map knownEventIds, + ); + Future deleteOwned(Map eventIds); +} diff --git a/mobile/lib/services/notification_service.dart b/mobile/lib/services/notification_service.dart new file mode 100644 index 0000000..791672e --- /dev/null +++ b/mobile/lib/services/notification_service.dart @@ -0,0 +1,25 @@ +import '../domain/app_settings.dart'; +import '../domain/course_occurrence.dart'; + +enum ReminderStatus { off, permissionDenied, exact, inexact, error } + +class ReminderResult { + const ReminderResult( + this.status, { + this.scheduledCount = 0, + this.message = '', + }); + final ReminderStatus status; + final int scheduledCount; + final String message; +} + +abstract class NotificationService { + Future reconcile( + List occurrences, + AppSettings settings, + DateTime now, + ); + Future requestPermissions(); + Future cancelOwned(); +} diff --git a/mobile/test/app/app_controller_test.dart b/mobile/test/app/app_controller_test.dart new file mode 100644 index 0000000..074eb85 --- /dev/null +++ b/mobile/test/app/app_controller_test.dart @@ -0,0 +1,92 @@ +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; + +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('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); + }, + ); +} diff --git a/mobile/test/data/json_schedule_import_test.dart b/mobile/test/data/json_schedule_import_test.dart new file mode 100644 index 0000000..d701bb6 --- /dev/null +++ b/mobile/test/data/json_schedule_import_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/data/json_schedule_import.dart'; +import 'package:hepan_everyday/domain/course.dart'; + +Map row({String id = 'a', int day = 1}) => { + 'id': id, + 'name': '矩阵论', + 'teacher': '老师', + 'room': '教室', + 'weekday': day, + 'startPeriod': 1, + 'endPeriod': 2, + 'weeks': [1, 3, 5], +}; +String input(List courses, {int version = 1, bool demo = false}) => + jsonEncode({'schemaVersion': version, 'isDemo': demo, 'courses': courses}); +void main() { + test('file import normalizes weeks and cannot claim school provenance', () { + final parsed = JsonScheduleImport.parse(input([row()])); + expect(parsed.courses, hasLength(1)); + expect(parsed.courses.single.weeks, {1, 3, 5}); + expect(parsed.source, CourseSource.file); + }); + test('one invalid course rejects the whole draft', () { + expect( + () => JsonScheduleImport.parse(input([row(), row(id: 'b', day: 8)])), + throwsFormatException, + ); + }); + test('duplicate identifiers and unsupported schema are rejected', () { + expect( + () => JsonScheduleImport.parse(input([row(), row()])), + throwsFormatException, + ); + expect( + () => JsonScheduleImport.parse(input([], version: 2)), + throwsFormatException, + ); + }); + test('demo import is explicitly separated from real records', () { + expect( + JsonScheduleImport.parse(input([row()], demo: true)).source, + CourseSource.demo, + ); + }); + test('valid but oversized JSON is rejected before accepting a course', () { + final large = row()..['name'] = List.filled(2100000, 'x').join(); + expect( + () => JsonScheduleImport.parse(input([large])), + throwsFormatException, + ); + }); +} diff --git a/mobile/test/data/snapshot_store_test.dart b/mobile/test/data/snapshot_store_test.dart new file mode 100644 index 0000000..6f540bf --- /dev/null +++ b/mobile/test/data/snapshot_store_test.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/data/file_snapshot_store.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; + +import '../domain/schedule_engine_test.dart' show course; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp('hepan-test-'); + }); + tearDown(() async { + await directory.delete(recursive: true); + }); + test( + 'reopening restores courses, settings and calendar identifiers', + () async { + final original = AppSnapshot( + settings: const AppSettings(reminderMinutes: 30), + courses: [course()], + calendarEventIds: {'math/2026-09-09': '17'}, + ); + await FileSnapshotStore(directory).save(original); + final loaded = await FileSnapshotStore(directory).load(); + expect(loaded, isNotNull); + expect(loaded!.courses.single.name, '矩阵论'); + expect(loaded.settings.reminderMinutes, 30); + expect(loaded.calendarEventIds, {'math/2026-09-09': '17'}); + }, + ); + test( + 'damaged snapshot reports failure instead of returning empty courses', + () async { + await File('${directory.path}/hepan_snapshot.json') + .writeAsString('damaged'); + await expectLater( + FileSnapshotStore(directory).load(), + throwsFormatException, + ); + }, + ); + test('valid previous snapshot is available for explicit recovery', () async { + final store = FileSnapshotStore(directory); + await store.save( + AppSnapshot(settings: const AppSettings(reminderMinutes: 15)), + ); + await store.save( + AppSnapshot(settings: const AppSettings(reminderMinutes: 30)), + ); + await File('${directory.path}/hepan_snapshot.json') + .writeAsString('damaged'); + expect((await store.restoreBackup()).settings.reminderMinutes, 15); + }); +} diff --git a/mobile/test/support/fakes.dart b/mobile/test/support/fakes.dart new file mode 100644 index 0000000..f733913 --- /dev/null +++ b/mobile/test/support/fakes.dart @@ -0,0 +1,73 @@ +import 'package:hepan_everyday/data/snapshot_store.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; +import 'package:hepan_everyday/domain/course_occurrence.dart'; +import 'package:hepan_everyday/services/notification_service.dart'; +import 'package:hepan_everyday/services/calendar_service.dart'; + +class MemoryStore extends SnapshotStore { + MemoryStore([this.value]); + AppSnapshot? value; + bool failSave = false; + @override + Future load() async => value; + @override + Future save(AppSnapshot snapshot) async { + if (failSave) throw StateError('磁盘写入失败'); + value = snapshot; + } +} + +class FakeNotifications extends NotificationService { + int reconciliations = 0, cancellations = 0; + bool fail = false; + @override + Future reconcile( + List occurrences, + AppSettings settings, + DateTime now, + ) async { + reconciliations++; + if (fail) throw StateError('系统拒绝'); + return ReminderResult( + settings.remindersEnabled ? ReminderStatus.exact : ReminderStatus.off, + scheduledCount: occurrences.length, + ); + } + + @override + Future cancelOwned() async { + cancellations++; + } + + @override + Future requestPermissions() async {} +} + +class FakeCalendar extends CalendarService { + bool access = true; + int synchronizations = 0, deletions = 0; + @override + Future requestAccess() async => access; + @override + Future> listWritableCalendars() async => [ + const DeviceCalendar('local', '测试日历'), + ]; + @override + Future> sync( + List occurrences, + String calendarId, + Map knownEventIds, + ) async { + synchronizations++; + return { + for (final e in occurrences) + e.key: knownEventIds[e.key] ?? 'event-${e.key}', + }; + } + + @override + Future deleteOwned(Map eventIds) async { + deletions++; + } +}