feat: persist timetable and validate confirmed imports
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<String, dynamic> 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<dynamic> 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<AppSnapshot?> load() async => value;
|
||||
@override
|
||||
Future<void> save(AppSnapshot snapshot) async {
|
||||
if (failSave) throw StateError('磁盘写入失败');
|
||||
value = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNotifications extends NotificationService {
|
||||
int reconciliations = 0, cancellations = 0;
|
||||
bool fail = false;
|
||||
@override
|
||||
Future<ReminderResult> reconcile(
|
||||
List<CourseOccurrence> occurrences,
|
||||
AppSettings settings,
|
||||
DateTime now,
|
||||
) async {
|
||||
reconciliations++;
|
||||
if (fail) throw StateError('系统拒绝');
|
||||
return ReminderResult(
|
||||
settings.remindersEnabled ? ReminderStatus.exact : ReminderStatus.off,
|
||||
scheduledCount: occurrences.length,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelOwned() async {
|
||||
cancellations++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {}
|
||||
}
|
||||
|
||||
class FakeCalendar extends CalendarService {
|
||||
bool access = true;
|
||||
int synchronizations = 0, deletions = 0;
|
||||
@override
|
||||
Future<bool> requestAccess() async => access;
|
||||
@override
|
||||
Future<List<DeviceCalendar>> listWritableCalendars() async => [
|
||||
const DeviceCalendar('local', '测试日历'),
|
||||
];
|
||||
@override
|
||||
Future<Map<String, String>> sync(
|
||||
List<CourseOccurrence> occurrences,
|
||||
String calendarId,
|
||||
Map<String, String> knownEventIds,
|
||||
) async {
|
||||
synchronizations++;
|
||||
return {
|
||||
for (final e in occurrences)
|
||||
e.key: knownEventIds[e.key] ?? 'event-${e.key}',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteOwned(Map<String, String> eventIds) async {
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user