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
@@ -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';
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,
);
},
);
}