feat: model undergraduate schools terms and exam queries

This commit is contained in:
Codex
2026-10-14 23:14:48 +08:00
parent 3f28e0a1b6
commit 48367b8273
8 changed files with 176 additions and 27 deletions
+14 -2
View File
@@ -3,6 +3,7 @@ import 'course.dart';
import 'semester.dart'; import 'semester.dart';
import 'exam_schedule.dart'; import 'exam_schedule.dart';
import 'school_term.dart'; import 'school_term.dart';
import 'school_identity.dart';
class ImportMetadata { class ImportMetadata {
const ImportMetadata({ const ImportMetadata({
@@ -23,9 +24,12 @@ class AppSnapshot {
this.examSchedule, this.examSchedule,
SchoolTerm? schoolTerm, SchoolTerm? schoolTerm,
this.calendarCleanupPending = false, this.calendarCleanupPending = false,
SupportedSchool? selectedSchool,
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 ??
_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;
@@ -35,6 +39,7 @@ class AppSnapshot {
final SchoolTerm? schoolTerm; final SchoolTerm? schoolTerm;
final Map<String, String> calendarEventIds; final Map<String, String> calendarEventIds;
final bool calendarCleanupPending; final bool calendarCleanupPending;
final SupportedSchool? selectedSchool;
static SchoolTerm? _legacyTerm(List<Course> courses, ExamSchedule? exams) { static SchoolTerm? _legacyTerm(List<Course> courses, ExamSchedule? exams) {
final ids = courses final ids = courses
.map( .map(
@@ -42,7 +47,7 @@ class AppSnapshot {
) )
.whereType<String>() .whereType<String>()
.toSet(); .toSet();
if (exams != null) ids.add(exams.termId); if (exams?.school == SupportedSchool.uestcGraduate) ids.add(exams!.termId);
if (ids.length != 1) return null; if (ids.length != 1) return null;
final id = int.tryParse(ids.single); final id = int.tryParse(ids.single);
return id != null && id >= 1 && id <= 9999 ? SchoolTerm(id) : null; return id != null && id >= 1 && id <= 9999 ? SchoolTerm(id) : null;
@@ -58,6 +63,7 @@ class AppSnapshot {
bool clearExams = false, bool clearExams = false,
SchoolTerm? schoolTerm, SchoolTerm? schoolTerm,
bool? calendarCleanupPending, bool? calendarCleanupPending,
SupportedSchool? selectedSchool,
}) => AppSnapshot( }) => AppSnapshot(
settings: settings ?? this.settings, settings: settings ?? this.settings,
courses: courses ?? this.courses, courses: courses ?? this.courses,
@@ -69,11 +75,13 @@ class AppSnapshot {
calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarEventIds: calendarEventIds ?? this.calendarEventIds,
calendarCleanupPending: calendarCleanupPending:
calendarCleanupPending ?? this.calendarCleanupPending, calendarCleanupPending ?? this.calendarCleanupPending,
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,
'courses': courses.map((c) => c.toJson()).toList(), 'courses': courses.map((c) => c.toJson()).toList(),
'settings': { 'settings': {
'semester': settings.semester == null 'semester': settings.semester == null
@@ -144,7 +152,8 @@ class AppSnapshot {
totalWeeks: sem['totalWeeks'] as int, totalWeeks: sem['totalWeeks'] as int,
); );
final metadata = json['importMetadata']; final metadata = json['importMetadata'];
return AppSnapshot( final snapshot = AppSnapshot(
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(
@@ -181,6 +190,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 &&
values.any((c)=>c.endPeriod>11)) throw const FormatException('第12节需要本科课表来源');
return snapshot;
} catch (_) { } catch (_) {
throw const FormatException('保存文件内容无效,未覆盖已有数据'); throw const FormatException('保存文件内容无效,未覆盖已有数据');
} }
+8 -1
View File
@@ -11,12 +11,14 @@ class Course {
required this.endPeriod, required this.endPeriod,
required Set<int> weeks, required Set<int> weeks,
required this.source, required this.source,
this.courseCode = '',
this.notes = '',
}) : weeks = Set.unmodifiable(weeks) { }) : weeks = Set.unmodifiable(weeks) {
if ([id, name, teacher, room].any((value) => value.trim().isEmpty) || if ([id, name, teacher, room].any((value) => value.trim().isEmpty) ||
weekday < 1 || weekday < 1 ||
weekday > 7 || weekday > 7 ||
startPeriod < 1 || startPeriod < 1 ||
endPeriod > 11 || endPeriod > 12 ||
endPeriod < startPeriod || endPeriod < startPeriod ||
weeks.isEmpty || weeks.isEmpty ||
weeks.any((week) => week < 1 || week > 30)) { weeks.any((week) => week < 1 || week > 30)) {
@@ -24,6 +26,7 @@ class Course {
} }
} }
final String id, name, teacher, room; final String id, name, teacher, room;
final String courseCode, notes;
final int weekday, startPeriod, endPeriod; final int weekday, startPeriod, endPeriod;
final Set<int> weeks; final Set<int> weeks;
final CourseSource source; final CourseSource source;
@@ -38,6 +41,8 @@ class Course {
'endPeriod': endPeriod, 'endPeriod': endPeriod,
'weeks': weeks.toList()..sort(), 'weeks': weeks.toList()..sort(),
'source': source.name, 'source': source.name,
'courseCode': courseCode,
'notes': notes,
}; };
static Course fromJson(Map<String, dynamic> json, {CourseSource? source}) { static Course fromJson(Map<String, dynamic> json, {CourseSource? source}) {
@@ -62,6 +67,8 @@ class Course {
endPeriod: json['endPeriod'] as int, endPeriod: json['endPeriod'] as int,
weeks: rawWeeks.cast<int>().toSet(), weeks: rawWeeks.cast<int>().toSet(),
source: source ?? CourseSource.values.byName(json['source'] as String), source: source ?? CourseSource.values.byName(json['source'] as String),
courseCode: (json['courseCode'] ?? '') as String,
notes: (json['notes'] ?? '') as String,
); );
} catch (_) { } catch (_) {
throw const FormatException('课程字段、节次或周次无效'); throw const FormatException('课程字段、节次或周次无效');
+31 -5
View File
@@ -1,3 +1,7 @@
import 'dart:convert';
import 'school_identity.dart';
import 'school_term.dart';
class ExamRecord { class ExamRecord {
const ExamRecord({ const ExamRecord({
required this.code, required this.code,
@@ -8,8 +12,12 @@ class ExamRecord {
this.room = '', this.room = '',
this.seat = '', this.seat = '',
this.time = '', this.time = '',
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 int? examTypeId;
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,
@@ -19,10 +27,11 @@ class ExamRecord {
'room': room, 'room': room,
'seat': seat, 'seat': seat,
'time': time, 'time': time,
'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) { String field(String key, {bool optional=false}) {
final value = 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('考试信息无效');
} }
@@ -38,6 +47,8 @@ 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),
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('考试课程无效');
@@ -51,15 +62,21 @@ class ExamSchedule {
required this.semesterLabel, required this.semesterLabel,
required this.termId, required this.termId,
required List<ExamRecord> records, required List<ExamRecord> records,
}) : records = List.unmodifiable(records); this.school = SupportedSchool.uestcGraduate,
Set<int> requestedExamTypes = const {},
}) : 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 Set<int> requestedExamTypes;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'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(),
}; };
factory ExamSchedule.fromJson(Map<String, dynamic> json) { factory ExamSchedule.fromJson(Map<String, dynamic> json) {
try {
final label = json['semesterLabel'], final label = json['semesterLabel'],
id = json['termId'], id = json['termId'],
rows = json['records']; rows = json['records'];
@@ -74,9 +91,18 @@ class ExamSchedule {
final records = rows final records = rows
.map((r) => ExamRecord.fromJson(Map<String, dynamic>.from(r as Map))) .map((r) => ExamRecord.fromJson(Map<String, dynamic>.from(r as Map)))
.toList(); .toList();
if (records.map((r) => r.code).toSet().length != records.length) { 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('考试课程重复'); throw const FormatException('考试课程重复');
} }
return ExamSchedule(semesterLabel: label, termId: id, records: records); return ExamSchedule(semesterLabel: label, termId: id, records: records,school:school,requestedExamTypes:types);
} catch (_) { throw const FormatException('考试列表无效'); }
} }
} }
+9
View File
@@ -0,0 +1,9 @@
enum SupportedSchool {
uestcGraduate('uestc_graduate', '电子科技大学(研究生)', '研究生课表与考试安排'),
uestcUndergraduate('uestc_undergraduate', '电子科技大学(本科生)', '手动导入课表 · 自动查询考试');
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('学校标识无效'));
}
+38 -11
View File
@@ -1,27 +1,54 @@
/// The graduate system's term number, independent of teaching-week dates. import 'school_identity.dart';
/// School-specific term numbers, independent of teaching-week dates.
class SchoolTerm { class SchoolTerm {
SchoolTerm(this.id) { SchoolTerm(this.id, {this.school = SupportedSchool.uestcGraduate}) {
if (id < 1 || id > 9999) throw ArgumentError('学校学期编号无效'); if (id < 1 || id > 9999 ||
(school == SupportedSchool.uestcUndergraduate && (id < 3 || (id-3)%20 != 0))) {
throw ArgumentError('学校学期编号无效');
}
} }
final int id; final int id;
final SupportedSchool school;
String get label { String get label {
if (school == SupportedSchool.uestcUndergraduate) {
final n = (id - 3) ~/ 20, year = 2013 + ((id - 3) ~/ 40);
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 ? '秋' : '春'}';
} }
static List<SchoolTerm> get choices => static List<SchoolTerm> get choices =>
List.generate(73, (index) => SchoolTerm(73 - index)); List.generate(73, (index) => SchoolTerm(73 - index));
Uri get timetableUri => static List<SchoolTerm> undergraduateChoices(DateTime now, {SchoolTerm? saved}) {
Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); final maxN = (now.year + 1 - 2013)*2+1;
Uri get examUri => final ids = {for(var n=0;n<=maxN;n++) 3+n*20,
Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {'xq': '$id'}); if(saved?.school == SupportedSchool.uestcUndergraduate) saved!.id}.toList()
Map<String, dynamic> toJson() => {'id': id}; ..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('本科查询不能使用研究生链接');
}
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'});
}
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('学校学期无效');
return SchoolTerm(json['id'] as int); try {
return SchoolTerm(json['id'] as int, 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; bool operator ==(Object other) => other is SchoolTerm && other.id == id && other.school == school;
@override @override
int get hashCode => id.hashCode; int get hashCode => Object.hash(id, school);
} }
@@ -0,0 +1,31 @@
import 'school_identity.dart';
import 'school_term.dart';
import 'exam_schedule.dart';
enum UndergraduateExamType {
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('考试类型无效'));
}
class UndergraduateExamQuery {
UndergraduateExamQuery({required this.term, required Set<UndergraduateExamType> types})
: types=Set.unmodifiable(types) {
if(term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) throw ArgumentError('请选择本科学期和考试类型');
}
final SchoolTerm term;
final Set<UndergraduateExamType> types;
List<UndergraduateExamType> 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}'});
}
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());
}
}
@@ -1,14 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../app/app_theme.dart'; import '../../app/app_theme.dart';
import '../../domain/school_identity.dart';
/// Only list schools whose login and import workflow is available. export '../../domain/school_identity.dart';
enum SupportedSchool {
uestcGraduate('电子科技大学(研究生)', '研究生课表与考试安排');
const SupportedSchool(this.label, this.description);
final String label, description;
}
class SchoolSelectionPage extends StatelessWidget { class SchoolSelectionPage extends StatelessWidget {
const SchoolSelectionPage({super.key, this.selected}); const SchoolSelectionPage({super.key, this.selected});
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/domain/school_identity.dart';
import 'package:hepan_everyday/domain/school_term.dart';
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
import 'package:hepan_everyday/domain/exam_schedule.dart';
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);
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(() => 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);
});
}