diff --git a/README.md b/README.md index 58819ce..8ed9886 100644 --- a/README.md +++ b/README.md @@ -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。 ## 安装与学校登录 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index 3d45df2..f4325fc 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -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)。 ## 已验证 diff --git a/docs/school-integration-status.md b/docs/school-integration-status.md index 4bc31ad..968edeb 100644 --- a/docs/school-integration-status.md +++ b/docs/school-integration-status.md @@ -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。 diff --git a/docs/tpschedule-0.7.0-validation.md b/docs/tpschedule-0.7.0-validation.md new file mode 100644 index 0000000..b77c846 --- /dev/null +++ b/docs/tpschedule-0.7.0-validation.md @@ -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材料、原签名以及反馈服务器部署文件均保持。反馈服务器仍由用户手动部署。 + +## 实现决策 + +保存接口增加可选页面有效性检查,并在串行保存队列内部执行;仅在界面检查会留下排队期间页面已变动的窗口。若误判会要求重新导入,原有调用保持兼容。最终独立审查结果记录于实现进度及后续交付记录。 diff --git a/mobile/README.md b/mobile/README.md index cf0d59f..3041900 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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材料不得用当前源码重生成。 diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index d58171c..c20019c 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appVersion = '0.6.2'; -const appBuildNumber = 17; +const appVersion = '0.7.0'; +const appBuildNumber = 18; diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index 4514ab0..566a7c4 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -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(builder: (_) => widget.schoolBuilder!()), diff --git a/mobile/lib/data/exam_file_import.dart b/mobile/lib/data/exam_file_import.dart index f6922a3..30e4fa3 100644 --- a/mobile/lib/data/exam_file_import.dart +++ b/mobile/lib/data/exam_file_import.dart @@ -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('未识别到学校排考页面'); } } diff --git a/mobile/lib/domain/exam_schedule.dart b/mobile/lib/domain/exam_schedule.dart index aa56a6a..6d79aa4 100644 --- a/mobile/lib/domain/exam_schedule.dart +++ b/mobile/lib/domain/exam_schedule.dart @@ -107,8 +107,9 @@ class ExamSchedule { ? 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)) + 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 && diff --git a/mobile/lib/domain/school_term.dart b/mobile/lib/domain/school_term.dart index b865f6f..7e1e82d 100644 --- a/mobile/lib/domain/school_term.dart +++ b/mobile/lib/domain/school_term.dart @@ -39,8 +39,9 @@ class SchoolTerm { } void _graduateOnly() { - if (school != SupportedSchool.uestcGraduate) + if (school != SupportedSchool.uestcGraduate) { throw StateError('本科查询不能使用研究生链接'); + } } Uri get timetableUri { diff --git a/mobile/lib/domain/undergraduate_exam_query.dart b/mobile/lib/domain/undergraduate_exam_query.dart index 7f6a954..02e84a8 100644 --- a/mobile/lib/domain/undergraduate_exam_query.dart +++ b/mobile/lib/domain/undergraduate_exam_query.dart @@ -21,8 +21,9 @@ class UndergraduateExamQuery { required this.term, required Set 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 types; diff --git a/mobile/lib/features/school/undergraduate/activity_data.dart b/mobile/lib/features/school/undergraduate/activity_data.dart index 66e1af4..ad3379a 100644 --- a/mobile/lib/features/school/undergraduate/activity_data.dart +++ b/mobile/lib/features/school/undergraduate/activity_data.dart @@ -26,13 +26,15 @@ List 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 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 _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)) { diff --git a/mobile/lib/features/school/undergraduate/auth_page.dart b/mobile/lib/features/school/undergraduate/auth_page.dart index a1c67f0..2aeaad2 100644 --- a/mobile/lib/features/school/undergraduate/auth_page.dart +++ b/mobile/lib/features/school/undergraduate/auth_page.dart @@ -98,6 +98,7 @@ class _UndergraduateAuthPageState extends State { } 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( diff --git a/mobile/lib/features/school/undergraduate/exam_adapter.dart b/mobile/lib/features/school/undergraduate/exam_adapter.dart index 383727d..e1f0007 100644 --- a/mobile/lib/features/school/undergraduate/exam_adapter.dart +++ b/mobile/lib/features/school/undergraduate/exam_adapter.dart @@ -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, diff --git a/mobile/lib/features/school/undergraduate/page_probe.dart b/mobile/lib/features/school/undergraduate/page_probe.dart index 3e45166..c78fc53 100644 --- a/mobile/lib/features/school/undergraduate/page_probe.dart +++ b/mobile/lib/features/school/undergraduate/page_probe.dart @@ -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 = []; 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), diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart index 9c3417a..a05ea10 100644 --- a/mobile/lib/features/school/undergraduate/session.dart +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -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 = '已导入'; diff --git a/mobile/lib/features/school/undergraduate/timetable_adapter.dart b/mobile/lib/features/school/undergraduate/timetable_adapter.dart index 5e0c514..71438c3 100644 --- a/mobile/lib/features/school/undergraduate/timetable_adapter.dart +++ b/mobile/lib/features/school/undergraduate/timetable_adapter.dart @@ -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 { : []; final explicitEmpty = activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text); - if (activities.isEmpty && !explicitEmpty) + if (activities.isEmpty && !explicitEmpty) { throw const FormatException('课表尚未加载完整'); + } final roster = {}; 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, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1482e7c..2bfa186 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/mobile/test/school/undergraduate_exam_adapter_test.dart b/mobile/test/school/undergraduate_exam_adapter_test.dart index 7fd55ff..85b7eb3 100644 --- a/mobile/test/school/undergraduate_exam_adapter_test.dart +++ b/mobile/test/school/undergraduate_exam_adapter_test.dart @@ -68,8 +68,7 @@ void main() { ); test('only recognized explicit empty exam tables are empty success', () { final empty = - '
' + - [ + '
${[ '课程序号', '课程名称', '考试日期', @@ -79,8 +78,7 @@ void main() { '考试情况', '考试类型', '其它说明', - ].map((s) => '').join() + - '
$s
暂无考试安排
'; + ].map((s) => '$s').join()}暂无考试安排'; 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(), @@ -100,4 +98,5 @@ void main() { expect(result.records, hasLength(4)); expect(result.termId, '503'); }); + } } diff --git a/mobile/test/school/undergraduate_session_test.dart b/mobile/test/school/undergraduate_session_test.dart index 7b0c86f..8c53d0b 100644 --- a/mobile/test/school/undergraduate_session_test.dart +++ b/mobile/test/school/undergraduate_session_test.dart @@ -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 { diff --git a/mobile/test/school/undergraduate_timetable_adapter_test.dart b/mobile/test/school/undergraduate_timetable_adapter_test.dart index 5b68627..7055593 100644 --- a/mobile/test/school/undergraduate_timetable_adapter_test.dart +++ b/mobile/test/school/undergraduate_timetable_adapter_test.dart @@ -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', () { @@ -141,4 +141,5 @@ void main() { ); }, ); + } } diff --git a/mobile/test/widgets/undergraduate_auth_test.dart b/mobile/test/widgets/undergraduate_auth_test.dart index 09ce202..cd5f3c6 100644 --- a/mobile/test/widgets/undergraduate_auth_test.dart +++ b/mobile/test/widgets/undergraduate_auth_test.dart @@ -46,6 +46,7 @@ void main() { .readAsStringSync() : null, ); + platform.delegate.finished(platform.web.url!); await tester.pumpWidget( MaterialApp( home: Builder(