feat: keep undergraduate exams independent of course terms

This commit is contained in:
Codex
2026-10-14 23:19:11 +08:00
parent 48367b8273
commit 141436f545
9 changed files with 419 additions and 99 deletions
+21 -1
View File
@@ -11,6 +11,7 @@ import '../domain/schedule_engine.dart';
import '../data/demo_schedule.dart'; import '../data/demo_schedule.dart';
import '../domain/exam_schedule.dart'; import '../domain/exam_schedule.dart';
import '../domain/school_term.dart'; import '../domain/school_term.dart';
import '../domain/school_identity.dart';
class AppController extends ChangeNotifier { class AppController extends ChangeNotifier {
AppController(this.store, this.notifications, this.calendar, this.clock); AppController(this.store, this.notifications, this.calendar, this.clock);
@@ -89,6 +90,15 @@ class AppController extends ChangeNotifier {
Future<void> leaveDemo() => _run(() async { Future<void> leaveDemo() => _run(() async {
isDemo = false; isDemo = false;
}); });
Future<bool> selectSchool(SupportedSchool school) async {
var saved = false;
await _run(() async {
await _save(snapshot.copyWith(selectedSchool: school));
saved = true;
});
return saved;
}
Future<void> updateSettings( Future<void> updateSettings(
AppSettings Function(AppSettings current) change, AppSettings Function(AppSettings current) change,
) => _run(() async { ) => _run(() async {
@@ -148,11 +158,17 @@ class AppController extends ChangeNotifier {
if (draft.courses.any((c) => c.source != draft.source)) { if (draft.courses.any((c) => c.source != draft.source)) {
throw const FormatException('课程来源不一致'); throw const FormatException('课程来源不一致');
} }
if (draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
draft.courses.any((c) => c.endPeriod > 11)) {
throw const FormatException('第12节需要本科课表来源');
}
await _save( await _save(
snapshot.copyWith( snapshot.copyWith(
courses: draft.courses, courses: draft.courses,
schoolTerm: draft.schoolTerm, schoolTerm: draft.schoolTerm,
clearExams: _differentTerm(draft.schoolTerm), clearExams:
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
_differentTerm(draft.schoolTerm),
settings: _differentTerm(draft.schoolTerm) settings: _differentTerm(draft.schoolTerm)
? snapshot.settings.copyWith(clearSemester: true) ? snapshot.settings.copyWith(clearSemester: true)
: snapshot.settings, : snapshot.settings,
@@ -188,6 +204,10 @@ class AppController extends ChangeNotifier {
}); });
Future<void> applyExams(ExamSchedule value) => _run(() async { Future<void> applyExams(ExamSchedule value) => _run(() async {
final validated = ExamSchedule.fromJson(value.toJson()); final validated = ExamSchedule.fromJson(value.toJson());
if (validated.school == SupportedSchool.uestcUndergraduate) {
await _save(snapshot.copyWith(examSchedule: validated));
return;
}
final term = SchoolTerm(int.parse(validated.termId)); final term = SchoolTerm(int.parse(validated.termId));
final changed = _differentTerm(term); final changed = _differentTerm(term);
await _save( await _save(
+24 -1
View File
@@ -1,5 +1,6 @@
import '../domain/course.dart'; import '../domain/course.dart';
import '../domain/school_term.dart'; import '../domain/school_term.dart';
import '../domain/school_identity.dart';
import 'dart:convert'; import 'dart:convert';
@@ -61,9 +62,31 @@ class JsonScheduleImport {
? SchoolTerm(int.parse(termIds.single)) ? SchoolTerm(int.parse(termIds.single))
: null; : null;
if (termIds.length > 1 || if (termIds.length > 1 ||
(term != null && termIds.any((id) => id != term.id.toString()))) { (term != null &&
termIds.any(
(id) =>
id != term.id.toString() ||
term.school != SupportedSchool.uestcGraduate,
))) {
throw const FormatException('文件中包含不同学期的课程'); throw const FormatException('文件中包含不同学期的课程');
} }
final undergraduateIds = courses
.map(
(c) =>
RegExp(r'^uestc-undergraduate-(\d+)-')
.firstMatch(c.id)
?.group(1),
)
.whereType<String>()
.toSet();
if (undergraduateIds.isNotEmpty &&
(term?.school != SupportedSchool.uestcUndergraduate ||
undergraduateIds.any((id) => id != term!.id.toString())) ||
courses.any((c) => c.endPeriod > 11) &&
(source == CourseSource.demo ||
term?.school != SupportedSchool.uestcUndergraduate)) {
throw const FormatException('本科课表来源或学期无效');
}
return ImportDraft( return ImportDraft(
courses: List.unmodifiable(courses), courses: List.unmodifiable(courses),
explicitlyEmpty: json['explicitlyEmpty'] == true, explicitlyEmpty: json['explicitlyEmpty'] == true,
+13 -7
View File
@@ -28,8 +28,11 @@ class AppSnapshot {
Map<String, String> calendarEventIds = const {}, Map<String, String> calendarEventIds = const {},
}) : settings = settings ?? AppSettings(), }) : settings = settings ?? AppSettings(),
schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule),
selectedSchool = selectedSchool ?? schoolTerm?.school ?? selectedSchool =
_legacyTerm(courses, examSchedule)?.school ?? examSchedule?.school, selectedSchool ??
schoolTerm?.school ??
_legacyTerm(courses, examSchedule)?.school ??
examSchedule?.school,
courses = List.unmodifiable(courses), courses = List.unmodifiable(courses),
calendarEventIds = Map.unmodifiable(calendarEventIds); calendarEventIds = Map.unmodifiable(calendarEventIds);
final AppSettings settings; final AppSettings settings;
@@ -75,13 +78,13 @@ class AppSnapshot {
calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarEventIds: calendarEventIds ?? this.calendarEventIds,
calendarCleanupPending: calendarCleanupPending:
calendarCleanupPending ?? this.calendarCleanupPending, calendarCleanupPending ?? this.calendarCleanupPending,
selectedSchool:selectedSchool ?? this.selectedSchool, selectedSchool: selectedSchool ?? this.selectedSchool,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'schemaVersion': 1, 'schemaVersion': 1,
'examSchedule': examSchedule?.toJson(), 'examSchedule': examSchedule?.toJson(),
'schoolTerm': schoolTerm?.toJson(), 'schoolTerm': schoolTerm?.toJson(),
'selectedSchoolId':selectedSchool?.id, 'selectedSchoolId': selectedSchool?.id,
'courses': courses.map((c) => c.toJson()).toList(), 'courses': courses.map((c) => c.toJson()).toList(),
'settings': { 'settings': {
'semester': settings.semester == null 'semester': settings.semester == null
@@ -153,7 +156,9 @@ class AppSnapshot {
); );
final metadata = json['importMetadata']; final metadata = json['importMetadata'];
final snapshot = AppSnapshot( final snapshot = AppSnapshot(
selectedSchool:json['selectedSchoolId']==null ? null : SupportedSchool.fromId(json['selectedSchoolId'] as String), selectedSchool: json['selectedSchoolId'] == null
? null
: SupportedSchool.fromId(json['selectedSchoolId'] as String),
schoolTerm: json['schoolTerm'] == null schoolTerm: json['schoolTerm'] == null
? null ? null
: SchoolTerm.fromJson( : SchoolTerm.fromJson(
@@ -190,8 +195,9 @@ class AppSnapshot {
(k, v) => MapEntry(k as String, v as String), (k, v) => MapEntry(k as String, v as String),
), ),
); );
if(snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate && if (snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
values.any((c)=>c.endPeriod>11)) throw const FormatException('第12节需要本科课表来源'); values.any((c) => c.endPeriod > 11))
throw const FormatException('第12节需要本科课表来源');
return snapshot; return snapshot;
} catch (_) { } catch (_) {
throw const FormatException('保存文件内容无效,未覆盖已有数据'); throw const FormatException('保存文件内容无效,未覆盖已有数据');
+66 -33
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'school_identity.dart'; import 'school_identity.dart';
import 'school_term.dart'; import 'school_term.dart';
@@ -12,12 +13,16 @@ class ExamRecord {
this.room = '', this.room = '',
this.seat = '', this.seat = '',
this.time = '', this.time = '',
this.date = '', this.status = '', this.notes = '', this.examTypeId, this.date = '',
this.status = '',
this.notes = '',
this.examTypeId,
}); });
final String code, name, assessment, candidate, sequence, room, seat, time; final String code, name, assessment, candidate, sequence, room, seat, time;
final String date, status, notes; final String date, status, notes;
final int? examTypeId; final int? examTypeId;
String get undergraduateKey => jsonEncode([examTypeId,code,date,time,room,seat]); String get undergraduateKey =>
jsonEncode([examTypeId, code, date, time, room, seat]);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'code': code, 'code': code,
'name': name, 'name': name,
@@ -27,10 +32,13 @@ class ExamRecord {
'room': room, 'room': room,
'seat': seat, 'seat': seat,
'time': time, 'time': time,
'date':date, 'status':status, 'notes':notes, 'examTypeId':examTypeId, 'date': date,
'status': status,
'notes': notes,
'examTypeId': examTypeId,
}; };
factory ExamRecord.fromJson(Map<String, dynamic> json) { factory ExamRecord.fromJson(Map<String, dynamic> json) {
String field(String key, {bool optional=false}) { String field(String key, {bool optional = false}) {
final value = optional && !json.containsKey(key) ? '' : json[key]; final value = optional && !json.containsKey(key) ? '' : json[key];
if (value is! String || value.length > 1000) { if (value is! String || value.length > 1000) {
throw const FormatException('考试信息无效'); throw const FormatException('考试信息无效');
@@ -47,8 +55,10 @@ class ExamRecord {
room: field('room'), room: field('room'),
seat: field('seat'), seat: field('seat'),
time: field('time'), time: field('time'),
date:field('date',optional:true), status:field('status',optional:true), notes:field('notes',optional:true), date: field('date', optional: true),
examTypeId:json['examTypeId'] as int?, status: field('status', optional: true),
notes: field('notes', optional: true),
examTypeId: json['examTypeId'] as int?,
); );
if (record.code.isEmpty || record.name.isEmpty) { if (record.code.isEmpty || record.name.isEmpty) {
throw const FormatException('考试课程无效'); throw const FormatException('考试课程无效');
@@ -64,7 +74,8 @@ class ExamSchedule {
required List<ExamRecord> records, required List<ExamRecord> records,
this.school = SupportedSchool.uestcGraduate, this.school = SupportedSchool.uestcGraduate,
Set<int> requestedExamTypes = const {}, Set<int> requestedExamTypes = const {},
}) : records = List.unmodifiable(records), requestedExamTypes=Set.unmodifiable(requestedExamTypes); }) : records = List.unmodifiable(records),
requestedExamTypes = Set.unmodifiable(requestedExamTypes);
final String semesterLabel, termId; final String semesterLabel, termId;
final List<ExamRecord> records; final List<ExamRecord> records;
final SupportedSchool school; final SupportedSchool school;
@@ -73,36 +84,58 @@ class ExamSchedule {
'semesterLabel': semesterLabel, 'semesterLabel': semesterLabel,
'termId': termId, 'termId': termId,
'records': records.map((r) => r.toJson()).toList(), 'records': records.map((r) => r.toJson()).toList(),
'schoolId':school.id, 'requestedExamTypes':requestedExamTypes.toList()..sort(), 'schoolId': school.id,
'requestedExamTypes': requestedExamTypes.toList()..sort(),
}; };
factory ExamSchedule.fromJson(Map<String, dynamic> json) { factory ExamSchedule.fromJson(Map<String, dynamic> json) {
try { try {
final label = json['semesterLabel'], final label = json['semesterLabel'],
id = json['termId'], id = json['termId'],
rows = json['records']; rows = json['records'];
if (label is! String || if (label is! String ||
label.trim().isEmpty || label.trim().isEmpty ||
id is! String || id is! String ||
!RegExp(r'^\d+$').hasMatch(id) || !RegExp(r'^\d+$').hasMatch(id) ||
rows is! List || rows is! List ||
rows.length > 500) { rows.length > 500) {
throw const FormatException('考试列表无效');
}
final records = rows
.map((r) => ExamRecord.fromJson(Map<String, dynamic>.from(r as Map)))
.toList();
final school = json['schoolId'] == null
? SupportedSchool.uestcGraduate
: SupportedSchool.fromId(json['schoolId'] as String);
final rawTypes = json['requestedExamTypes'] ?? <int>[];
if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3))
throw const FormatException('考试类型无效');
final types = rawTypes.cast<int>().toSet();
SchoolTerm(int.parse(id), school: school);
if (school == SupportedSchool.uestcUndergraduate &&
(types.isEmpty ||
records.any((r) => !types.contains(r.examTypeId)))) {
throw const FormatException('本科考试类型无效');
}
if (records
.map(
(r) => school == SupportedSchool.uestcGraduate
? r.code
: r.undergraduateKey,
)
.toSet()
.length !=
records.length) {
throw const FormatException('考试课程重复');
}
return ExamSchedule(
semesterLabel: label,
termId: id,
records: records,
school: school,
requestedExamTypes: types,
);
} catch (_) {
throw const FormatException('考试列表无效'); throw const FormatException('考试列表无效');
} }
final records = rows
.map((r) => ExamRecord.fromJson(Map<String, dynamic>.from(r as Map)))
.toList();
final school=json['schoolId']==null ? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String);
final rawTypes=json['requestedExamTypes'] ?? <int>[];
if(rawTypes is! List || rawTypes.any((t)=>t is! int || t<1 || t>3)) throw const FormatException('考试类型无效');
final types=rawTypes.cast<int>().toSet();
SchoolTerm(int.parse(id),school:school);
if(school==SupportedSchool.uestcUndergraduate && (types.isEmpty || records.any((r)=>!types.contains(r.examTypeId)))) {
throw const FormatException('本科考试类型无效');
}
if (records.map((r) => school==SupportedSchool.uestcGraduate ? r.code : r.undergraduateKey).toSet().length != records.length) {
throw const FormatException('考试课程重复');
}
return ExamSchedule(semesterLabel: label, termId: id, records: records,school:school,requestedExamTypes:types);
} catch (_) { throw const FormatException('考试列表无效'); }
} }
} }
+3 -1
View File
@@ -5,5 +5,7 @@ enum SupportedSchool {
const SupportedSchool(this.id, this.label, this.description); const SupportedSchool(this.id, this.label, this.description);
final String id, label, description; final String id, label, description;
static SupportedSchool fromId(String id) => values.firstWhere( static SupportedSchool fromId(String id) => values.firstWhere(
(s) => s.id == id, orElse: () => throw const FormatException('学校标识无效')); (s) => s.id == id,
orElse: () => throw const FormatException('学校标识无效'),
);
} }
+38 -15
View File
@@ -3,8 +3,10 @@ import 'school_identity.dart';
/// School-specific term numbers, independent of teaching-week dates. /// School-specific term numbers, independent of teaching-week dates.
class SchoolTerm { class SchoolTerm {
SchoolTerm(this.id, {this.school = SupportedSchool.uestcGraduate}) { SchoolTerm(this.id, {this.school = SupportedSchool.uestcGraduate}) {
if (id < 1 || id > 9999 || if (id < 1 ||
(school == SupportedSchool.uestcUndergraduate && (id < 3 || (id-3)%20 != 0))) { id > 9999 ||
(school == SupportedSchool.uestcUndergraduate &&
(id < 3 || (id - 3) % 20 != 0))) {
throw ArgumentError('学校学期编号无效'); throw ArgumentError('学校学期编号无效');
} }
} }
@@ -13,7 +15,7 @@ class SchoolTerm {
String get label { String get label {
if (school == SupportedSchool.uestcUndergraduate) { if (school == SupportedSchool.uestcUndergraduate) {
final n = (id - 3) ~/ 20, year = 2013 + ((id - 3) ~/ 40); final n = (id - 3) ~/ 20, year = 2013 + ((id - 3) ~/ 40);
return '$year-${year+1}${n.isEven ? '第一学期' : '第二学期'}'; return '$year-${year + 1}${n.isEven ? '第一学期' : '第二学期'}';
} }
final year = 1990 + ((id - 1) ~/ 2); final year = 1990 + ((id - 1) ~/ 2);
return '$year-${year + 1}年${id.isOdd ? '秋' : '春'}'; return '$year-${year + 1}年${id.isOdd ? '秋' : '春'}';
@@ -21,34 +23,55 @@ class SchoolTerm {
static List<SchoolTerm> get choices => static List<SchoolTerm> get choices =>
List.generate(73, (index) => SchoolTerm(73 - index)); List.generate(73, (index) => SchoolTerm(73 - index));
static List<SchoolTerm> undergraduateChoices(DateTime now, {SchoolTerm? saved}) { static List<SchoolTerm> undergraduateChoices(
final maxN = (now.year + 1 - 2013)*2+1; DateTime now, {
final ids = {for(var n=0;n<=maxN;n++) 3+n*20, SchoolTerm? saved,
if(saved?.school == SupportedSchool.uestcUndergraduate) saved!.id}.toList() }) {
..sort((a,b)=>b.compareTo(a)); final maxN = (now.year + 1 - 2013) * 2 + 1;
return ids.where((id)=>id<=9999).map((id)=>SchoolTerm(id,school:SupportedSchool.uestcUndergraduate)).toList(); final ids = {
for (var n = 0; n <= maxN; n++) 3 + n * 20,
if (saved?.school == SupportedSchool.uestcUndergraduate) saved!.id,
}.toList()..sort((a, b) => b.compareTo(a));
return ids
.where((id) => id <= 9999)
.map((id) => SchoolTerm(id, school: SupportedSchool.uestcUndergraduate))
.toList();
} }
void _graduateOnly() { void _graduateOnly() {
if (school != SupportedSchool.uestcGraduate) throw StateError('本科查询不能使用研究生链接'); if (school != SupportedSchool.uestcGraduate)
throw StateError('本科查询不能使用研究生链接');
} }
Uri get timetableUri { Uri get timetableUri {
_graduateOnly(); _graduateOnly();
return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id');
} }
Uri get examUri { Uri get examUri {
_graduateOnly(); _graduateOnly();
return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {'xq': '$id'}); return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {
'xq': '$id',
});
} }
Map<String, dynamic> toJson() => {'id': id, 'schoolId': school.id}; Map<String, dynamic> toJson() => {'id': id, 'schoolId': school.id};
factory SchoolTerm.fromJson(Map<String, dynamic> json) { factory SchoolTerm.fromJson(Map<String, dynamic> json) {
if (json['id'] is! int) throw const FormatException('学校学期无效'); if (json['id'] is! int) throw const FormatException('学校学期无效');
try { try {
return SchoolTerm(json['id'] as int, school: json['schoolId'] == null return SchoolTerm(
? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String)); json['id'] as int,
} catch (_) { throw const FormatException('学校学期无效'); } school: json['schoolId'] == null
? SupportedSchool.uestcGraduate
: SupportedSchool.fromId(json['schoolId'] as String),
);
} catch (_) {
throw const FormatException('学校学期无效');
}
} }
@override @override
bool operator ==(Object other) => other is SchoolTerm && other.id == id && other.school == school; bool operator ==(Object other) =>
other is SchoolTerm && other.id == id && other.school == school;
@override @override
int get hashCode => Object.hash(id, school); int get hashCode => Object.hash(id, school);
} }
+30 -12
View File
@@ -3,29 +3,47 @@ import 'school_term.dart';
import 'exam_schedule.dart'; import 'exam_schedule.dart';
enum UndergraduateExamType { enum UndergraduateExamType {
finalExam(1,'期末考试'), midterm(2,'期中考试'), makeup(3,'补考/缓考'); finalExam(1, '期末考试'),
midterm(2, '期中考试'),
makeup(3, '补考/缓考');
const UndergraduateExamType(this.id, this.label); const UndergraduateExamType(this.id, this.label);
final int id; final int id;
final String label; final String label;
static UndergraduateExamType fromId(int id) => values.firstWhere((t)=>t.id==id, static UndergraduateExamType fromId(int id) => values.firstWhere(
orElse:()=>throw const FormatException('考试类型无效')); (t) => t.id == id,
orElse: () => throw const FormatException('考试类型无效'),
);
} }
class UndergraduateExamQuery { class UndergraduateExamQuery {
UndergraduateExamQuery({required this.term, required Set<UndergraduateExamType> types}) UndergraduateExamQuery({
: types=Set.unmodifiable(types) { required this.term,
if(term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) throw ArgumentError('请选择本科学期和考试类型'); required Set<UndergraduateExamType> types,
}) : types = Set.unmodifiable(types) {
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty)
throw ArgumentError('请选择本科学期和考试类型');
} }
final SchoolTerm term; final SchoolTerm term;
final Set<UndergraduateExamType> types; final Set<UndergraduateExamType> types;
List<UndergraduateExamType> get orderedTypes => UndergraduateExamType.values.where(types.contains).toList(); List<UndergraduateExamType> get orderedTypes =>
UndergraduateExamType.values.where(types.contains).toList();
Uri uriFor(UndergraduateExamType type) { Uri uriFor(UndergraduateExamType type) {
if(!types.contains(type)) throw ArgumentError('考试类型不在查询选项中'); if (!types.contains(type)) throw ArgumentError('考试类型不在查询选项中');
return Uri.https('eams.uestc.edu.cn','/eams/stdExamTable!examTable.action',{'semester.id':'${term.id}','examType.id':'${type.id}'}); return Uri.https(
'eams.uestc.edu.cn',
'/eams/stdExamTable!examTable.action',
{'semester.id': '${term.id}', 'examType.id': '${type.id}'},
);
} }
static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule) { static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule) {
if(schedule?.school != SupportedSchool.uestcUndergraduate) return null; if (schedule?.school != SupportedSchool.uestcUndergraduate) return null;
return UndergraduateExamQuery(term:SchoolTerm(int.parse(schedule!.termId),school:schedule.school), return UndergraduateExamQuery(
types:schedule.requestedExamTypes.map(UndergraduateExamType.fromId).toSet()); term: SchoolTerm(int.parse(schedule!.termId), school: schedule.school),
types: schedule.requestedExamTypes
.map(UndergraduateExamType.fromId)
.toSet(),
);
} }
} }
@@ -0,0 +1,112 @@
import 'dart:convert';
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/school_identity.dart';
import 'package:hepan_everyday/domain/school_term.dart';
import 'package:hepan_everyday/domain/exam_schedule.dart';
import 'package:hepan_everyday/data/json_schedule_import.dart';
import '../domain/schedule_engine_test.dart' show course;
import '../support/fakes.dart';
void main() {
setUpAll(tzdata.initializeTimeZones);
late MemoryStore store;
late FakeNotifications notifications;
late FakeCalendar calendar;
late AppController controller;
setUp(() {
store = MemoryStore(
AppSnapshot(
courses: [course()],
schoolTerm: SchoolTerm(73),
settings: AppSettings(
semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20),
),
),
);
notifications = FakeNotifications();
calendar = FakeCalendar();
controller = AppController(
store,
notifications,
calendar,
() => DateTime.utc(2026, 9, 8),
);
});
tearDown(() => controller.dispose());
ExamSchedule exams() => ExamSchedule(
semesterLabel: '2025-2026第二学期',
termId: '503',
records: [],
school: SupportedSchool.uestcUndergraduate,
requestedExamTypes: {1},
);
test(
'historical undergraduate exams preserve courses and services',
() async {
await controller.initialize();
final before = controller.snapshot;
final count = notifications.reconciliations;
await controller.applyExams(exams());
expect(controller.lastError, isNull);
expect(controller.snapshot.courses, before.courses);
expect(controller.snapshot.schoolTerm, before.schoolTerm);
expect(controller.snapshot.settings.semester, before.settings.semester);
expect(notifications.reconciliations, count);
expect(controller.snapshot.examSchedule!.termId, '503');
},
);
test('school selection and failed exam writes are atomic', () async {
await controller.initialize();
store.failSave = true;
final old = controller.snapshot.toJson();
expect(
await controller.selectSchool(SupportedSchool.uestcUndergraduate),
isFalse,
);
expect(controller.snapshot.toJson(), old);
await controller.applyExams(exams());
expect(controller.snapshot.toJson(), old);
store.failSave = false;
expect(
await controller.selectSchool(SupportedSchool.uestcUndergraduate),
isTrue,
);
expect(store.value!.selectedSchool, SupportedSchool.uestcUndergraduate);
});
test('undergraduate course changes preserve independent exams and reset only new term dates', () async {
await controller.initialize();
await controller.applyExams(exams());
final term = SchoolTerm(523, school: SupportedSchool.uestcUndergraduate);
final draft = ImportDraft(
courses: [course()],
explicitlyEmpty: false,
source: course().source,
parserVersion: 'test',
schoolTerm: term,
);
await controller.applyImport(draft, confirmedEmpty: false);
expect(controller.snapshot.settings.semester, isNull);
expect(controller.snapshot.examSchedule!.termId, '503');
await controller.updateSettings(
(s) => s.copyWith(
semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20),
),
);
final date = controller.snapshot.settings.semester;
await controller.applyImport(draft, confirmedEmpty: false);
expect(controller.snapshot.settings.semester, date);
});
test('JSON period twelve needs an explicit undergraduate term', () {
final old = course().toJson()..['endPeriod'] = 12;
final json =
'{"schemaVersion":1,"courses":[${const JsonEncoder().convert(old)}]}';
expect(() => JsonScheduleImport.parse(json), throwsFormatException);
});
}
+112 -29
View File
@@ -7,37 +7,120 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/course.dart'; import 'package:hepan_everyday/domain/course.dart';
void main() { void main() {
SchoolTerm under(int id) => SchoolTerm(id, school: SupportedSchool.uestcUndergraduate); SchoolTerm under(int id) =>
SchoolTerm(id, school: SupportedSchool.uestcUndergraduate);
test('undergraduate semester labels and final exam URLs', () { test('undergraduate semester labels and final exam URLs', () {
expect([483,503,523,543,563].map((id)=>under(id).label).toList(), expect([483, 503, 523, 543, 563].map((id) => under(id).label).toList(), [
['2025-2026第一学期','2025-2026第二学期','2026-2027第一学期','2026-2027第二学期','2027-2028第一学期']); '2025-2026第一学期',
final query = UndergraduateExamQuery(term: under(523), types: {UndergraduateExamType.finalExam}); '2025-2026第二学期',
expect(query.uriFor(UndergraduateExamType.finalExam).toString(), 'https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id=523&examType.id=1'); '2026-2027第一学期',
expect(() => UndergraduateExamQuery(term: under(523),types: {}),throwsArgumentError); '2026-2027第二学期',
'2027-2028第一学期',
]);
final query = UndergraduateExamQuery(
term: under(523),
types: {UndergraduateExamType.finalExam},
);
expect(
query.uriFor(UndergraduateExamType.finalExam).toString(),
'https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id=523&examType.id=1',
);
expect(
() => UndergraduateExamQuery(term: under(523), types: {}),
throwsArgumentError,
);
expect(() => under(504), throwsArgumentError); expect(() => under(504), throwsArgumentError);
expect(under(523), isNot(SchoolTerm(523))); expect(under(523), isNot(SchoolTerm(523)));
expect(()=>under(523).examUri, throwsStateError); expect(() => under(523).examUri, throwsStateError);
expect(SchoolTerm.fromJson({'id':73}).school, SupportedSchool.uestcGraduate); expect(
expect(SchoolTerm.undergraduateChoices(DateTime(2026,10)).first.id, 583); SchoolTerm.fromJson({'id': 73}).school,
}); SupportedSchool.uestcGraduate,
test('undergraduate exam identity retains both types and rejects corruption', () { );
final schedule=ExamSchedule(semesterLabel:under(523).label,termId:'523',school:SupportedSchool.uestcUndergraduate, expect(SchoolTerm.undergraduateChoices(DateTime(2026, 10)).first.id, 583);
requestedExamTypes:{1,2},records:[const ExamRecord(code:'A',name:'课程',examTypeId:1), const ExamRecord(code:'A',name:'课程',examTypeId:2)]);
expect(ExamSchedule.fromJson(schedule.toJson()).records,hasLength(2));
expect(()=>ExamSchedule.fromJson({...schedule.toJson(),'requestedExamTypes':[]}),throwsFormatException);
expect(()=>ExamSchedule.fromJson({...schedule.toJson(),'schoolId':'unknown'}),throwsFormatException);
final old=ExamSchedule(semesterLabel:'2026秋',termId:'73',records:[const ExamRecord(code:'A',name:'课程'),const ExamRecord(code:'A',name:'课程')]);
expect(()=>ExamSchedule.fromJson(old.toJson()),throwsFormatException);
});
test('snapshots persist school selection and validate period twelve source', () {
final course=Course(id:'under-523-test',name:'课程',teacher:'教师',room:'教室',weekday:1,startPeriod:9,endPeriod:12,weeks:{1},source:CourseSource.school,courseCode:'A',notes:'备注');
final saved=AppSnapshot(selectedSchool:SupportedSchool.uestcUndergraduate,schoolTerm:under(523),courses:[course]);
final round=AppSnapshot.fromJson(saved.toJson());
expect(round.selectedSchool, SupportedSchool.uestcUndergraduate);
expect(round.courses.single.notes,'备注');
expect(()=>AppSnapshot.fromJson({...saved.toJson(),'schoolTerm':{'id':73}}),throwsFormatException);
expect(()=>AppSnapshot.fromJson({...saved.toJson(),'selectedSchoolId':'unknown'}),throwsFormatException);
final legacy=AppSnapshot(schoolTerm:SchoolTerm(73)).toJson()..remove('selectedSchoolId');
expect(AppSnapshot.fromJson(legacy).selectedSchool,SupportedSchool.uestcGraduate);
}); });
test(
'undergraduate exam identity retains both types and rejects corruption',
() {
final schedule = ExamSchedule(
semesterLabel: under(523).label,
termId: '523',
school: SupportedSchool.uestcUndergraduate,
requestedExamTypes: {1, 2},
records: [
const ExamRecord(code: 'A', name: '课程', examTypeId: 1),
const ExamRecord(code: 'A', name: '课程', examTypeId: 2),
],
);
expect(ExamSchedule.fromJson(schedule.toJson()).records, hasLength(2));
expect(
() => ExamSchedule.fromJson({
...schedule.toJson(),
'requestedExamTypes': [],
}),
throwsFormatException,
);
expect(
() => ExamSchedule.fromJson({
...schedule.toJson(),
'schoolId': 'unknown',
}),
throwsFormatException,
);
final old = ExamSchedule(
semesterLabel: '2026秋',
termId: '73',
records: [
const ExamRecord(code: 'A', name: '课程'),
const ExamRecord(code: 'A', name: '课程'),
],
);
expect(() => ExamSchedule.fromJson(old.toJson()), throwsFormatException);
},
);
test(
'snapshots persist school selection and validate period twelve source',
() {
final course = Course(
id: 'under-523-test',
name: '课程',
teacher: '教师',
room: '教室',
weekday: 1,
startPeriod: 9,
endPeriod: 12,
weeks: {1},
source: CourseSource.school,
courseCode: 'A',
notes: '备注',
);
final saved = AppSnapshot(
selectedSchool: SupportedSchool.uestcUndergraduate,
schoolTerm: under(523),
courses: [course],
);
final round = AppSnapshot.fromJson(saved.toJson());
expect(round.selectedSchool, SupportedSchool.uestcUndergraduate);
expect(round.courses.single.notes, '备注');
expect(
() => AppSnapshot.fromJson({
...saved.toJson(),
'schoolTerm': {'id': 73},
}),
throwsFormatException,
);
expect(
() => AppSnapshot.fromJson({
...saved.toJson(),
'selectedSchoolId': 'unknown',
}),
throwsFormatException,
);
final legacy = AppSnapshot(schoolTerm: SchoolTerm(73)).toJson()
..remove('selectedSchoolId');
expect(
AppSnapshot.fromJson(legacy).selectedSchool,
SupportedSchool.uestcGraduate,
);
},
);
} }