From 141436f545a832c5e7eff0ffa2a167c295ba98b2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 23:19:11 +0800 Subject: [PATCH] feat: keep undergraduate exams independent of course terms --- mobile/lib/app/app_controller.dart | 22 ++- mobile/lib/data/json_schedule_import.dart | 25 +++- mobile/lib/domain/app_snapshot.dart | 20 ++- mobile/lib/domain/exam_schedule.dart | 99 ++++++++---- mobile/lib/domain/school_identity.dart | 4 +- mobile/lib/domain/school_term.dart | 53 +++++-- .../lib/domain/undergraduate_exam_query.dart | 42 ++++-- .../app/undergraduate_controller_test.dart | 112 ++++++++++++++ .../domain/undergraduate_models_test.dart | 141 ++++++++++++++---- 9 files changed, 419 insertions(+), 99 deletions(-) create mode 100644 mobile/test/app/undergraduate_controller_test.dart diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index d47ac89..e0a01b6 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -11,6 +11,7 @@ import '../domain/schedule_engine.dart'; import '../data/demo_schedule.dart'; import '../domain/exam_schedule.dart'; import '../domain/school_term.dart'; +import '../domain/school_identity.dart'; class AppController extends ChangeNotifier { AppController(this.store, this.notifications, this.calendar, this.clock); @@ -89,6 +90,15 @@ class AppController extends ChangeNotifier { Future leaveDemo() => _run(() async { isDemo = false; }); + Future selectSchool(SupportedSchool school) async { + var saved = false; + await _run(() async { + await _save(snapshot.copyWith(selectedSchool: school)); + saved = true; + }); + return saved; + } + Future updateSettings( AppSettings Function(AppSettings current) change, ) => _run(() async { @@ -148,11 +158,17 @@ class AppController extends ChangeNotifier { if (draft.courses.any((c) => c.source != draft.source)) { throw const FormatException('课程来源不一致'); } + if (draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate && + draft.courses.any((c) => c.endPeriod > 11)) { + throw const FormatException('第12节需要本科课表来源'); + } await _save( snapshot.copyWith( courses: draft.courses, schoolTerm: draft.schoolTerm, - clearExams: _differentTerm(draft.schoolTerm), + clearExams: + draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate && + _differentTerm(draft.schoolTerm), settings: _differentTerm(draft.schoolTerm) ? snapshot.settings.copyWith(clearSemester: true) : snapshot.settings, @@ -188,6 +204,10 @@ class AppController extends ChangeNotifier { }); Future applyExams(ExamSchedule value) => _run(() async { 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 changed = _differentTerm(term); await _save( diff --git a/mobile/lib/data/json_schedule_import.dart b/mobile/lib/data/json_schedule_import.dart index 64008c5..138578e 100644 --- a/mobile/lib/data/json_schedule_import.dart +++ b/mobile/lib/data/json_schedule_import.dart @@ -1,5 +1,6 @@ import '../domain/course.dart'; import '../domain/school_term.dart'; +import '../domain/school_identity.dart'; import 'dart:convert'; @@ -61,9 +62,31 @@ class JsonScheduleImport { ? SchoolTerm(int.parse(termIds.single)) : null; 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('文件中包含不同学期的课程'); } + final undergraduateIds = courses + .map( + (c) => + RegExp(r'^uestc-undergraduate-(\d+)-') + .firstMatch(c.id) + ?.group(1), + ) + .whereType() + .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( courses: List.unmodifiable(courses), explicitlyEmpty: json['explicitlyEmpty'] == true, diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index ca15d56..8c33322 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -28,8 +28,11 @@ class AppSnapshot { Map calendarEventIds = const {}, }) : settings = settings ?? AppSettings(), schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), - selectedSchool = selectedSchool ?? schoolTerm?.school ?? - _legacyTerm(courses, examSchedule)?.school ?? examSchedule?.school, + selectedSchool = + selectedSchool ?? + schoolTerm?.school ?? + _legacyTerm(courses, examSchedule)?.school ?? + examSchedule?.school, courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); final AppSettings settings; @@ -75,13 +78,13 @@ class AppSnapshot { calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarCleanupPending: calendarCleanupPending ?? this.calendarCleanupPending, - selectedSchool:selectedSchool ?? this.selectedSchool, + selectedSchool: selectedSchool ?? this.selectedSchool, ); Map toJson() => { 'schemaVersion': 1, 'examSchedule': examSchedule?.toJson(), 'schoolTerm': schoolTerm?.toJson(), - 'selectedSchoolId':selectedSchool?.id, + 'selectedSchoolId': selectedSchool?.id, 'courses': courses.map((c) => c.toJson()).toList(), 'settings': { 'semester': settings.semester == null @@ -153,7 +156,9 @@ class AppSnapshot { ); final metadata = json['importMetadata']; 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 ? null : SchoolTerm.fromJson( @@ -190,8 +195,9 @@ class AppSnapshot { (k, v) => MapEntry(k as String, v as String), ), ); - if(snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate && - values.any((c)=>c.endPeriod>11)) throw const FormatException('第12节需要本科课表来源'); + if (snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate && + values.any((c) => c.endPeriod > 11)) + throw const FormatException('第12节需要本科课表来源'); return snapshot; } catch (_) { throw const FormatException('保存文件内容无效,未覆盖已有数据'); diff --git a/mobile/lib/domain/exam_schedule.dart b/mobile/lib/domain/exam_schedule.dart index a65c8c0..aa56a6a 100644 --- a/mobile/lib/domain/exam_schedule.dart +++ b/mobile/lib/domain/exam_schedule.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import 'school_identity.dart'; import 'school_term.dart'; @@ -12,12 +13,16 @@ class ExamRecord { this.room = '', this.seat = '', 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 date, status, notes; final int? examTypeId; - String get undergraduateKey => jsonEncode([examTypeId,code,date,time,room,seat]); + String get undergraduateKey => + jsonEncode([examTypeId, code, date, time, room, seat]); Map toJson() => { 'code': code, 'name': name, @@ -27,10 +32,13 @@ class ExamRecord { 'room': room, 'seat': seat, 'time': time, - 'date':date, 'status':status, 'notes':notes, 'examTypeId':examTypeId, + 'date': date, + 'status': status, + 'notes': notes, + 'examTypeId': examTypeId, }; factory ExamRecord.fromJson(Map json) { - String field(String key, {bool optional=false}) { + String field(String key, {bool optional = false}) { final value = optional && !json.containsKey(key) ? '' : json[key]; if (value is! String || value.length > 1000) { throw const FormatException('考试信息无效'); @@ -47,8 +55,10 @@ class ExamRecord { room: field('room'), seat: field('seat'), time: field('time'), - date:field('date',optional:true), status:field('status',optional:true), notes:field('notes',optional:true), - examTypeId:json['examTypeId'] as int?, + date: field('date', optional: true), + status: field('status', optional: true), + notes: field('notes', optional: true), + examTypeId: json['examTypeId'] as int?, ); if (record.code.isEmpty || record.name.isEmpty) { throw const FormatException('考试课程无效'); @@ -64,7 +74,8 @@ class ExamSchedule { required List records, this.school = SupportedSchool.uestcGraduate, Set requestedExamTypes = const {}, - }) : records = List.unmodifiable(records), requestedExamTypes=Set.unmodifiable(requestedExamTypes); + }) : records = List.unmodifiable(records), + requestedExamTypes = Set.unmodifiable(requestedExamTypes); final String semesterLabel, termId; final List records; final SupportedSchool school; @@ -73,36 +84,58 @@ class ExamSchedule { 'semesterLabel': semesterLabel, 'termId': termId, '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 json) { try { - final label = json['semesterLabel'], - id = json['termId'], - rows = json['records']; - if (label is! String || - label.trim().isEmpty || - id is! String || - !RegExp(r'^\d+$').hasMatch(id) || - rows is! List || - rows.length > 500) { + final label = json['semesterLabel'], + id = json['termId'], + rows = json['records']; + if (label is! String || + label.trim().isEmpty || + id is! String || + !RegExp(r'^\d+$').hasMatch(id) || + rows is! List || + rows.length > 500) { + throw const FormatException('考试列表无效'); + } + final records = rows + .map((r) => ExamRecord.fromJson(Map.from(r as Map))) + .toList(); + final school = json['schoolId'] == null + ? SupportedSchool.uestcGraduate + : SupportedSchool.fromId(json['schoolId'] as String); + final rawTypes = json['requestedExamTypes'] ?? []; + if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3)) + throw const FormatException('考试类型无效'); + final types = rawTypes.cast().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('考试列表无效'); } - final records = rows - .map((r) => ExamRecord.fromJson(Map.from(r as Map))) - .toList(); - final school=json['schoolId']==null ? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String); - final rawTypes=json['requestedExamTypes'] ?? []; - if(rawTypes is! List || rawTypes.any((t)=>t is! int || t<1 || t>3)) throw const FormatException('考试类型无效'); - final types=rawTypes.cast().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('考试列表无效'); } } } diff --git a/mobile/lib/domain/school_identity.dart b/mobile/lib/domain/school_identity.dart index b465615..26d0470 100644 --- a/mobile/lib/domain/school_identity.dart +++ b/mobile/lib/domain/school_identity.dart @@ -5,5 +5,7 @@ enum SupportedSchool { const SupportedSchool(this.id, this.label, this.description); final String id, label, description; static SupportedSchool fromId(String id) => values.firstWhere( - (s) => s.id == id, orElse: () => throw const FormatException('学校标识无效')); + (s) => s.id == id, + orElse: () => throw const FormatException('学校标识无效'), + ); } diff --git a/mobile/lib/domain/school_term.dart b/mobile/lib/domain/school_term.dart index 5eb792d..b865f6f 100644 --- a/mobile/lib/domain/school_term.dart +++ b/mobile/lib/domain/school_term.dart @@ -3,8 +3,10 @@ import 'school_identity.dart'; /// School-specific term numbers, independent of teaching-week dates. class SchoolTerm { SchoolTerm(this.id, {this.school = SupportedSchool.uestcGraduate}) { - if (id < 1 || id > 9999 || - (school == SupportedSchool.uestcUndergraduate && (id < 3 || (id-3)%20 != 0))) { + if (id < 1 || + id > 9999 || + (school == SupportedSchool.uestcUndergraduate && + (id < 3 || (id - 3) % 20 != 0))) { throw ArgumentError('学校学期编号无效'); } } @@ -13,7 +15,7 @@ class SchoolTerm { String get label { if (school == SupportedSchool.uestcUndergraduate) { 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); return '$year-${year + 1}年${id.isOdd ? '秋' : '春'}'; @@ -21,34 +23,55 @@ class SchoolTerm { static List get choices => List.generate(73, (index) => SchoolTerm(73 - index)); - static List undergraduateChoices(DateTime now, {SchoolTerm? saved}) { - final maxN = (now.year + 1 - 2013)*2+1; - 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(); + static List undergraduateChoices( + DateTime now, { + SchoolTerm? saved, + }) { + final maxN = (now.year + 1 - 2013) * 2 + 1; + 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() { - if (school != SupportedSchool.uestcGraduate) throw StateError('本科查询不能使用研究生链接'); + if (school != SupportedSchool.uestcGraduate) + throw StateError('本科查询不能使用研究生链接'); } + Uri get timetableUri { _graduateOnly(); return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); } + Uri get examUri { _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 toJson() => {'id': id, 'schoolId': school.id}; factory SchoolTerm.fromJson(Map json) { if (json['id'] is! int) throw const FormatException('学校学期无效'); try { - return SchoolTerm(json['id'] as int, school: json['schoolId'] == null - ? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String)); - } catch (_) { throw const FormatException('学校学期无效'); } + return SchoolTerm( + json['id'] as int, + school: json['schoolId'] == null + ? SupportedSchool.uestcGraduate + : SupportedSchool.fromId(json['schoolId'] as String), + ); + } catch (_) { + throw const FormatException('学校学期无效'); + } } @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 int get hashCode => Object.hash(id, school); } diff --git a/mobile/lib/domain/undergraduate_exam_query.dart b/mobile/lib/domain/undergraduate_exam_query.dart index 8bf827f..7f6a954 100644 --- a/mobile/lib/domain/undergraduate_exam_query.dart +++ b/mobile/lib/domain/undergraduate_exam_query.dart @@ -3,29 +3,47 @@ import 'school_term.dart'; import 'exam_schedule.dart'; enum UndergraduateExamType { - finalExam(1,'期末考试'), midterm(2,'期中考试'), makeup(3,'补考/缓考'); + finalExam(1, '期末考试'), + midterm(2, '期中考试'), + makeup(3, '补考/缓考'); + const UndergraduateExamType(this.id, this.label); final int id; final String label; - static UndergraduateExamType fromId(int id) => values.firstWhere((t)=>t.id==id, - orElse:()=>throw const FormatException('考试类型无效')); + static UndergraduateExamType fromId(int id) => values.firstWhere( + (t) => t.id == id, + orElse: () => throw const FormatException('考试类型无效'), + ); } class UndergraduateExamQuery { - UndergraduateExamQuery({required this.term, required Set types}) - : types=Set.unmodifiable(types) { - if(term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) throw ArgumentError('请选择本科学期和考试类型'); + UndergraduateExamQuery({ + required this.term, + required Set types, + }) : types = Set.unmodifiable(types) { + if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) + throw ArgumentError('请选择本科学期和考试类型'); } final SchoolTerm term; final Set types; - List get orderedTypes => UndergraduateExamType.values.where(types.contains).toList(); + List get orderedTypes => + UndergraduateExamType.values.where(types.contains).toList(); Uri uriFor(UndergraduateExamType type) { - 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}'}); + 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}'}, + ); } + static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule) { - if(schedule?.school != SupportedSchool.uestcUndergraduate) return null; - return UndergraduateExamQuery(term:SchoolTerm(int.parse(schedule!.termId),school:schedule.school), - types:schedule.requestedExamTypes.map(UndergraduateExamType.fromId).toSet()); + if (schedule?.school != SupportedSchool.uestcUndergraduate) return null; + return UndergraduateExamQuery( + term: SchoolTerm(int.parse(schedule!.termId), school: schedule.school), + types: schedule.requestedExamTypes + .map(UndergraduateExamType.fromId) + .toSet(), + ); } } diff --git a/mobile/test/app/undergraduate_controller_test.dart b/mobile/test/app/undergraduate_controller_test.dart new file mode 100644 index 0000000..ed133cc --- /dev/null +++ b/mobile/test/app/undergraduate_controller_test.dart @@ -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); + }); +} diff --git a/mobile/test/domain/undergraduate_models_test.dart b/mobile/test/domain/undergraduate_models_test.dart index b57583d..6556f91 100644 --- a/mobile/test/domain/undergraduate_models_test.dart +++ b/mobile/test/domain/undergraduate_models_test.dart @@ -7,37 +7,120 @@ import 'package:hepan_everyday/domain/app_snapshot.dart'; import 'package:hepan_everyday/domain/course.dart'; 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', () { - expect([483,503,523,543,563].map((id)=>under(id).label).toList(), - ['2025-2026第一学期','2025-2026第二学期','2026-2027第一学期','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([483, 503, 523, 543, 563].map((id) => under(id).label).toList(), [ + '2025-2026第一学期', + '2025-2026第二学期', + '2026-2027第一学期', + '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(523), isNot(SchoolTerm(523))); - expect(()=>under(523).examUri, throwsStateError); - expect(SchoolTerm.fromJson({'id':73}).school, SupportedSchool.uestcGraduate); - expect(SchoolTerm.undergraduateChoices(DateTime(2026,10)).first.id, 583); - }); - 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); + expect(() => under(523).examUri, throwsStateError); + expect( + SchoolTerm.fromJson({'id': 73}).school, + SupportedSchool.uestcGraduate, + ); + expect(SchoolTerm.undergraduateChoices(DateTime(2026, 10)).first.id, 583); }); + 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, + ); + }, + ); }