feat: release undergraduate integration 0.7.0

This commit is contained in:
Codex
2026-10-15 01:14:06 +08:00
parent b28f333720
commit 674df1ceb3
22 changed files with 173 additions and 63 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
# TPSchedule
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.2+17,Android 7.0 及以上。
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.0+18,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
@@ -24,6 +24,8 @@
0.6.2将学校连接入口改为「选择学校」,目前提供「电子科技大学(研究生)」。选中后沿用原认证及导入,取消不开始登录;旧学校学期和连接刷新继续复用。详见[0.6.2学校选择验证记录](docs/tpschedule-0.6.2-validation.md)。
0.7.0新增「电子科技大学(本科生)」。本科课表由学生在学校网页选择学期、打开课表,再点击「导入当前课表」;考试支持一个学期及多种考试类型整批读取。查询历史本科考试保留当前课表。本科12节,第12节22:00–22:45;研究生及示例仍为11节。学校选择与成功查询选项持久保存,刷新沿用;详见[本科接入验证记录](docs/tpschedule-0.7.0-validation.md)。
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
## 安装与学校登录
+2
View File
@@ -1,5 +1,7 @@
# Android 首版验证记录
当前本科接入版为0.7.0+18,验证见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
## 已验证
+13 -1
View File
@@ -1,4 +1,16 @@
# 学校接入状态 · RSchedule 0.3.1
# 学校接入状态 · TPSchedule 0.7.0
## 本科接入
学校列表现有电子科技大学(研究生)、电子科技大学(本科生),连接目标使用稳定标识并保存本机。研究生沿用专用SSO及自动读取;本科使用独立连接器,学校浏览器均延迟到真正访问时创建。
本科课表从 `https://eams.uestc.edu.cn/eams/` 登录,学生自行打开课表、选择学期,再点击「导入当前课表」。同源框架及实际学期字段用于解析;停课周不生成课程,完整教师/教室、跨周变更与备注保留。新课表学期需设置开始日;同学校同学期更新保留日期和偏好。本科12节,第12节22:00–22:45。
本科考试访问 `https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id={id}&examType.id={type}`。523为2026–2027第一学期,相邻学期编号差20;1期末、2期中、3补考/缓考。选择一个学期和至少一种类型,全部成功后一次保存;失败、取消、超时保留旧数据。重新认证后全部类型重读,验证码等待不算超时。成功选项用于刷新;历史本科考试不改变课表及开始日。
真实本科课表附件已验证10门课程、18段安排、3段含第12节;考试附件4条属于503,未被默认523污染。仓库只保留脱敏样例。真实账号在线认证、回调和验证码仍需本人验证;附件和模拟回调不替代这项确认。
## 研究生接入说明(沿用原0.3.1流程)
专用入口:https://yjsjy.uestc.edu.cn/pyxx/jzsso/login。
课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。
+34
View File
@@ -0,0 +1,34 @@
# TPSchedule 0.7.0+18:本科生接入验证
2026-10-15。工程位于F:/program/app,原应用名、包名com.tpschedule.app和签名保持。
## 实现
- 学校列表新增电子科技大学(本科生),学校选择使用稳定标识保存;取消或保存失败不开始登录。
- 本科课表在学校原网页登录、选择学期并打开课表后,点击「导入当前课表」。课表刷新和更新学期沿用手动流程。HTML/HTM离线导入也支持本科结构。
- 本科考试选择一个学期、多选期末/期中/补考缓考,按类型1→2→3读取,全部成功才保存;失败、取消、超时保留旧数据。认证恢复时丢弃旧片段、整批重读。成功查询选项持久保存供刷新使用。
- 523对应2026–2027第一学期,每相邻学期编号差20。考试学期独立保存,历史本科考试不改变当前课程、课表学期和开始日。
- 本科12节,第12节22:00–22:45;研究生及示例11节。七天同屏,教师和教室完整显示,相同节数课程条等长;课程详情保留课程代码和备注。提醒和日历结束时间覆盖第12节。
- 旧保存格式仍为schemaVersion1,新增字段兼容默认研究生;学校/学期损坏和本科第12节来源缺失拒绝保存或恢复。页面变动/取消时在保存队列内部再检查有效性,避免旧回调写入。
- Android实际检查发现网页加载前的一次状态探测可能读到旧空白文档;已补回归并改为加载完成后探测,再重建发布和调试包。
## 检查结果
- 完整Flutter套件241项通过;flutter analyze无问题。研究生及本科两个Node页面捕获脚本测试通过。
- 原本科课表附件解析10门课程、18段上课安排,3段包含第12节;逐节逐周对照保留全部有效安排,停课周扣除。原考试附件4条属于503,外层默认523没有污染实际学期。仓库仅保存脱敏等价样例。
- 核心回归覆盖人工登录等待超过120秒、单项45秒/整批120秒超时、最后一项失败、整批重新认证、关闭页面、过期回调、保存失败、旧研究生导入、来源损坏、第12节提醒/日历、换周动画、完整教师、课程条长度及慢启动。
- Android16/API36模拟器覆盖安装后11条原课程仍在。随后通过安卓文件选择器导入脱敏本科HTML:预览18条、提示新学期开始日、设置2026-09-07后显示12行及22:00–22:45;多教师完整显示。历史503考试导入4条,课表523、18条课程及开始日保持;考试选项取消不改成功选项,重启保持学校与数据。
- 发布包及调试包构建成功;包名com.tpschedule.app、versionName0.7.0、versionCode18。新旧证书逐字节相同,原私有签名文件摘要相同,登记冻结清单18项复核无变化。
- 模拟器使用软件兼容图形模式;未修改安装包的默认手机渲染设置。既有SDK XML版本提示仍出现,构建成功。
## 交付与实际边界
发布APK:59991887字节,SHA256 `D813F9FB6D33F4DE9A1116DF35EE66BBF8475E09DD1D536C50DFE017DFDBD1BE`。调试包、源码包及截图在dist中;保留TPSchedule-0.6.2.apk。
截图含学校列表、本科浏览器入口、考试选项、学期开始日提示、本科课表、12节及考试列表。真实本科账号、验证码、学校在线回调与自动查询尚未用本人账号验证;模拟器仅核对入口、离线数据与界面,不代表认证成功。需学生在学校原网页完成这部分验证,不要求在聊天中提供密码。
0.5.4登记冻结、V1.0材料、原签名以及反馈服务器部署文件均保持。反馈服务器仍由用户手动部署。
## 实现决策
保存接口增加可选页面有效性检查,并在串行保存队列内部执行;仅在界面检查会留下排队期间页面已变动的窗口。若误判会要求重新导入,原有调用保持兼容。最终独立审查结果记录于实现进度及后续交付记录。
+11 -11
View File
@@ -1,17 +1,17 @@
# hepan_everyday
# TPSchedule Flutter工程
A new Flutter project.
当前版本0.7.0+18,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
## Getting Started
## 本科使用
This project is a starting point for a Flutter application.
在「我的 → 选择学校」选电子科技大学(本科生),通过学校原网页登录,打开课表并选学期,然后点击「导入当前课表」。导入新学校/学期后在课表页设置学期开始日。本科课表刷新和更新学期仍需在网页操作;本科考试页可选一个学期、多选期末/期中/补考缓考并自动读取。成功选项用于后续刷新,历史考试不改变课表。
A few resources to get you started if this is your first Flutter project:
本科显示12节,末节22:00–22:45;研究生和示例为11节。离线HTML/HTM及原JSON导入保持,教师/教室完整显示,课程名称详情可展开。
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
## 开发与验证
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
工具链、测试与构建入口见根目录README及 `scripts/android-env.ps1`、`scripts/build-android.ps1`。本目录运行 `flutter test --no-pub`、`flutter analyze --no-pub`;用Node运行 `test/school/*probe_js_test.cjs` 验证网页捕获。
本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.6.2';
const appBuildNumber = 17;
const appVersion = '0.7.0';
const appBuildNumber = 18;
+2 -2
View File
@@ -81,8 +81,7 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0;
bool _openingSchool = false;
// Earlier imports all belong to UESTC graduate; reuse their existing term
// and session without requiring students to choose again on every refresh.
// Restore the saved school; legacy graduate imports infer their source.
SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ??
(widget.undergraduate?.canLogout == true
@@ -199,6 +198,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
chooseTerm: chooseTerm || term == null,
);
}
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()),
+6 -3
View File
@@ -8,13 +8,16 @@ import '../features/school/undergraduate/exam_adapter.dart';
class ExamFileImport {
static ExamSchedule parse(String content) {
if (utf8.encode(content).length > 2 * 1024 * 1024)
if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('文件不能超过2MB');
}
final doc = html.parse(content.replaceFirst('\uFEFF', ''));
if (doc.querySelector('#stdExamTableForm') != null)
if (doc.querySelector('#stdExamTableForm') != null) {
return UndergraduateExamAdapter.extract(content);
if (doc.querySelector('#sample-table-1') != null)
}
if (doc.querySelector('#sample-table-1') != null) {
return SchoolExamAdapter.extract(content);
}
throw const FormatException('未识别到学校排考页面');
}
}
+2 -1
View File
@@ -107,8 +107,9 @@ class ExamSchedule {
? 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))
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 &&
+2 -1
View File
@@ -39,9 +39,10 @@ class SchoolTerm {
}
void _graduateOnly() {
if (school != SupportedSchool.uestcGraduate)
if (school != SupportedSchool.uestcGraduate) {
throw StateError('本科查询不能使用研究生链接');
}
}
Uri get timetableUri {
_graduateOnly();
@@ -21,9 +21,10 @@ class UndergraduateExamQuery {
required this.term,
required Set<UndergraduateExamType> types,
}) : types = Set.unmodifiable(types) {
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty)
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) {
throw ArgumentError('请选择本科学期和考试类型');
}
}
final SchoolTerm term;
final Set<UndergraduateExamType> types;
List<UndergraduateExamType> get orderedTypes =>
@@ -26,13 +26,15 @@ List<UndergraduateActivity> parseActivityData(
final m = matches[i], args = _strings(m[1]!);
if (args.length != 7) throw const FormatException('课程活动字段不完整');
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty)
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty) {
throw const FormatException('教师、教室或课程序号缺失');
}
final bitmap = args[6];
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
bitmap.length > 64 ||
(bitmap.length > 31 && bitmap.substring(31).contains('1')))
(bitmap.length > 31 && bitmap.substring(31).contains('1'))) {
throw const FormatException('上课周次超出1–30周');
}
final weeks = {
for (var w = 1; w < bitmap.length && w <= 30; w++)
if (bitmap[w] == '1') w,
@@ -49,15 +51,17 @@ List<UndergraduateActivity> parseActivityData(
final appends = RegExp(
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
).allMatches(body).length;
if (assignments.isEmpty || assignments.length != appends)
if (assignments.isEmpty || assignments.length != appends) {
throw const FormatException('课程节次赋值不完整');
}
for (final assignment in assignments) {
final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$')
.firstMatch(assignment[1]!);
if (expression == null) throw const FormatException('不支持的节次表达式');
final day = int.parse(expression[1]!), period = int.parse(expression[2]!);
if (day > 6 || period >= unitCount)
if (day > 6 || period >= unitCount) {
throw const FormatException('课程星期或节次无效');
}
indices.add(day * unitCount + period);
}
result.add(
@@ -113,8 +117,9 @@ List<String> _strings(String input) {
final count = c == 'u' ? 4 : 2;
if (p + count > input.length) throw const FormatException('字符串转义无效');
final hex = input.substring(p, p + count);
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex))
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex)) {
throw const FormatException('字符串转义无效');
}
b.writeCharCode(int.parse(hex, radix: 16));
p += count;
} else if (escapes.containsKey(c)) {
@@ -98,6 +98,7 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
}
if (!current()) return;
if (saved && s.saved(generation: generation)) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (route?.isCurrent == true) Navigator.pop(context);
messenger.showSnackBar(
@@ -26,11 +26,13 @@ class UndergraduateExamAdapter {
UndergraduateExamType? expectedType,
}) {
try {
if (utf8.encode(content).length > 2 * 1024 * 1024)
if (utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('考试页面超过2MB');
}
final doc = html.parse(content);
if (doc.querySelector('input[type="password"]') != null)
if (doc.querySelector('input[type="password"]') != null) {
throw const FormatException('请先完成学校登录');
}
final form = doc.querySelector('#stdExamTableForm');
final scope = form ?? doc;
final termValues = scope
@@ -58,22 +60,26 @@ class UndergraduateExamAdapter {
final query = Uri.splitQueryString(
m[1]!.replaceFirst(RegExp(r'^&'), ''),
);
if (query['semester.id'] != null)
if (query['semester.id'] != null) {
termValues.add(query['semester.id']!);
if (query['examType.id'] != null)
}
if (query['examType.id'] != null) {
typeValues.add(query['examType.id']!);
}
}
if (termValues.length != 1 || typeValues.length != 1)
}
if (termValues.length != 1 || typeValues.length != 1) {
throw const FormatException('排考结果学期或类型不明确');
}
final term = SchoolTerm(
int.parse(termValues.single),
school: SupportedSchool.uestcUndergraduate,
);
final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
if (expectedTerm != null && expectedTerm != term ||
expectedType != null && expectedType != type)
expectedType != null && expectedType != type) {
throw const FormatException('排考结果与所选学期或类型不一致');
}
Element? table;
for (final candidate in doc.querySelectorAll('table')) {
final cells = candidate
@@ -116,12 +122,14 @@ class UndergraduateExamAdapter {
examTypeId: rowType,
notes: cells[8],
);
if (record.code.isEmpty || record.name.isEmpty)
if (record.code.isEmpty || record.name.isEmpty) {
throw const FormatException('考试课程缺失');
}
records.putIfAbsent(record.undergraduateKey, () => record);
}
if (records.isEmpty && !explicitEmpty)
if (records.isEmpty && !explicitEmpty) {
throw const FormatException('尚未识别到明确的排考结果');
}
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: term.label,
@@ -158,14 +166,16 @@ class UndergraduateExamAdapter {
part.termId != '${query.term.id}' ||
part.requestedExamTypes.length != 1 ||
!expected.contains(part.requestedExamTypes.single) ||
!seen.add(part.requestedExamTypes.single))
!seen.add(part.requestedExamTypes.single)) {
throw const FormatException('考试查询批次不一致');
}
for (final record in part.records) {
records.putIfAbsent(record.undergraduateKey, () => record);
}
}
if (seen.length != expected.length || records.length > 500)
if (seen.length != expected.length || records.length > 500) {
throw const FormatException('考试未全部读取完成或记录超限');
}
return ExamSchedule.fromJson(
ExamSchedule(
semesterLabel: query.term.label,
@@ -82,32 +82,37 @@ class UndergraduatePageEvidence {
!UndergraduateNavigationPolicy.canNavigate(
current,
isMainFrame: true,
))
)) {
throw const FormatException('页面证据无效');
}
final pages = value['pages'];
if (pages is! List || pages.length > 32)
if (pages is! List || pages.length > 32) {
throw const FormatException('页面片段过多');
}
var bytes = 0;
final result = <UndergraduateFragment>[];
for (final page in pages) {
if (page is! Map ||
page['url'] is! String ||
page['html'] is! String ||
!const ['timetable', 'exams'].contains(page['kind']))
!const ['timetable', 'exams'].contains(page['kind'])) {
throw const FormatException('页面片段无效');
}
final uri = Uri.parse(page['url'] as String),
body = page['html'] as String;
bytes += utf8.encode(body).length;
if (bytes > 2 * 1024 * 1024 ||
!UndergraduateNavigationPolicy.isDataPage(current) ||
!UndergraduateNavigationPolicy.isDataPage(uri) ||
current.origin != uri.origin)
current.origin != uri.origin) {
throw const FormatException('页面来源或大小无效');
}
result.add(UndergraduateFragment(page['kind'] as String, uri, body));
}
if (!UndergraduateNavigationPolicy.isDataPage(current) &&
(value['authenticated'] == true || result.isNotEmpty))
(value['authenticated'] == true || result.isNotEmpty)) {
throw const FormatException('认证页面不能作为数据来源');
}
return UndergraduatePageEvidence(
value['authenticated'] == true,
List.unmodifiable(result),
@@ -102,8 +102,9 @@ class UndergraduateSession extends ChangeNotifier {
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri))
UndergraduateNavigationPolicy.isLogout(uri)) {
invalidateAuthentication();
}
return NavigationDecision.navigate;
},
onPageStarted: (_) {
@@ -123,8 +124,9 @@ class UndergraduateSession extends ChangeNotifier {
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current)
if (_valid(g) && d == _document && error.url == current) {
_failure('学校页面加载未完成,请检查网络后重试。');
}
},
onHttpError: (error) async {
final g = _generation,
@@ -132,8 +134,9 @@ class UndergraduateSession extends ChangeNotifier {
current = await web.currentUrl();
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current)
error.request?.uri.toString() == current) {
_failure('学校请求未完成,请稍后重试。');
}
},
),
);
@@ -184,7 +187,7 @@ class UndergraduateSession extends ChangeNotifier {
return;
}
_active = true;
if (hasOpened && !_complete && !_failed) {
if (hasOpened && !_loading && !_complete && !_failed) {
unawaited(
web.currentUrl().then((url) {
if (url != null && _active) _loaded(url);
@@ -216,8 +219,9 @@ class UndergraduateSession extends ChangeNotifier {
void _schedulePoll(int g, int document, String url) {
_poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete)
if (_valid(g) && _document == document && !_failed && !_complete) {
unawaited(_loaded(url));
}
});
}
@@ -237,8 +241,9 @@ class UndergraduateSession extends ChangeNotifier {
if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url);
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true))
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) {
return;
}
final status = await _evidence(uri, false);
if (!_valid(g) || d != _document) return;
_loading = false;
@@ -319,8 +324,9 @@ class UndergraduateSession extends ChangeNotifier {
final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1)
if (pages.length != 1) {
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
}
final draft = UndergraduateTimetableAdapter.extract(
pages.single.html,
pages.single.url,
@@ -345,8 +351,9 @@ class UndergraduateSession extends ChangeNotifier {
}
bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null)
if (!_valid(generation) || pendingDraft == null && pendingExams == null) {
return false;
}
pendingDraft = null;
pendingExams = null;
message = '已导入';
@@ -21,14 +21,16 @@ class UndergraduateTimetableAdapter {
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
source == CourseSource.demo ||
utf8.encode(content).length > 2 * 1024 * 1024)
utf8.encode(content).length > 2 * 1024 * 1024) {
throw const FormatException('不是本科课表页面');
}
final doc = html.parse(content),
table = html
.parse(content)
.querySelector('#manualArrangeCourseTable');
if (table == null || doc.querySelector('input[type="password"]') != null)
if (table == null || doc.querySelector('input[type="password"]') != null) {
throw const FormatException('请先在学校网页打开课表');
}
final termIds = doc
.querySelectorAll('input[name="semester.id"]')
.map((e) => e.attributes['value'])
@@ -51,8 +53,9 @@ class UndergraduateTimetableAdapter {
: <UndergraduateActivity>[];
final explicitEmpty =
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
if (activities.isEmpty && !explicitEmpty)
if (activities.isEmpty && !explicitEmpty) {
throw const FormatException('课表尚未加载完整');
}
final roster = <String, ({String code, String notes})>{};
for (final grid in doc.querySelectorAll('table')) {
final rows = grid.querySelectorAll('tr');
@@ -64,8 +67,9 @@ class UndergraduateTimetableAdapter {
note = headers.indexOf('选课备注');
for (final row in rows.skip(1)) {
final cells = row.children;
if (cells.length != headers.length)
if (cells.length != headers.length) {
throw const FormatException('课程名单不完整');
}
roster[cells[seq].text.trim()] = (
code: cells[code].text.trim(),
notes: note >= 0 ? cells[note].text.trim() : '',
@@ -162,8 +166,9 @@ class UndergraduateTimetableAdapter {
);
}
}
if (courses.isEmpty && !explicitEmpty)
if (courses.isEmpty && !explicitEmpty) {
throw const FormatException('没有识别到有效上课安排');
}
return ImportDraft(
courses: List.unmodifiable(courses),
explicitlyEmpty: explicitEmpty,
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.6.2+17
version: 0.7.0+18
environment:
sdk: ^3.13.5
dependencies:
@@ -68,8 +68,7 @@ void main() {
);
test('only recognized explicit empty exam tables are empty success', () {
final empty =
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>' +
[
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${[
'课程序号',
'课程名称',
'考试日期',
@@ -79,8 +78,7 @@ void main() {
'考试情况',
'考试类型',
'其它说明',
].map((s) => '<td>$s</td>').join() +
'</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
expect(
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
@@ -92,7 +90,7 @@ void main() {
);
});
final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE'];
if (original != null)
if (original != null) {
test('actual supplied exam export belongs to 503', () {
final result = UndergraduateExamAdapter.extract(
File(original).readAsStringSync(),
@@ -101,3 +99,4 @@ void main() {
expect(result.termId, '503');
});
}
}
@@ -31,6 +31,26 @@ void main() {
session = UndergraduateSession();
});
tearDown(() => session.dispose());
test(
'activating a loading browser never probes the previous blank document',
() async {
await session.openTimetable();
var probes = 0;
platform.web.javascript = (_) async {
probes++;
return underEvidence('about:blank', authenticated: false);
};
session.setReadingActive(true);
await flushSchoolEvents();
expect(probes, 0);
platform.web.javascript = (_) async => underEvidence(platform.web.url!);
platform.delegate.started(platform.web.url!);
platform.delegate.finished(platform.web.url!);
await flushSchoolEvents();
expect(session.busy, isFalse);
expect(session.message, UndergraduateSession.manualMessage);
},
);
test(
'manual session is lazy and never captures until the student taps import',
() async {
@@ -124,7 +124,7 @@ void main() {
});
final original =
Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE'];
if (original != null)
if (original != null) {
test(
'actual supplied undergraduate HTML has complete normalized arrangements',
() {
@@ -142,3 +142,4 @@ void main() {
},
);
}
}
@@ -46,6 +46,7 @@ void main() {
.readAsStringSync()
: null,
);
platform.delegate.finished(platform.web.url!);
await tester.pumpWidget(
MaterialApp(
home: Builder(