Preserve courses with pending rooms and report precise graduate import errors
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# TPSchedule
|
||||
|
||||
当前Android版本0.7.9+35:点按更快到位,缩短回弹和页面过渡,见[发布记录](docs/tpschedule-0.7.9-release.md)。构建34:未选中灰色、选中蓝色,增强乳白透射与滑块对图标本身的折射,见[本次记录](docs/tpschedule-0.7.8-dock-symbol-optics.md)。构建33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。
|
||||
当前Android版本0.7.10+36:缺教室保留课程并标为待定,研究生增加格式兼容和具体错误诊断,见[本次记录](docs/tpschedule-0.7.10-import-diagnostics.md)。构建35:点按更快到位,缩短回弹和页面过渡,见[发布记录](docs/tpschedule-0.7.9-release.md)。构建34:未选中灰色、选中蓝色,增强乳白透射与滑块对图标本身的折射,见[本次记录](docs/tpschedule-0.7.8-dock-symbol-optics.md)。构建33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。
|
||||
|
||||
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
||||
|
||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.9+35,Android 7.0 及以上。
|
||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.10+36,Android 7.0 及以上。
|
||||
|
||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Android 首版验证记录
|
||||
|
||||
当前版本为0.7.9+35,点按节奏及发布验证见[本次记录](tpschedule-0.7.9-release.md)。构建34的灰蓝导航、乳白透射及图标折射见[本次记录](tpschedule-0.7.8-dock-symbol-optics.md)。构建33的提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
||||
当前版本为0.7.10+36,导入日志定位及修复见[本次记录](tpschedule-0.7.10-import-diagnostics.md)。构建35的点按节奏及发布验证见[本次记录](tpschedule-0.7.9-release.md)。构建34的灰蓝导航、乳白透射及图标折射见[本次记录](tpschedule-0.7.8-dock-symbol-optics.md)。构建33的提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[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)。
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 0.7.10 导入问题定向修复
|
||||
|
||||
用户取消反馈学校选择:不增加学校选择步骤,保留现有日志 school 标记。用户已授权直接实现。
|
||||
|
||||
- [x] 本科日志定位为第 15 条活动 room_missing。用匿名样本复现空教室整表失败,保留完整周次/节次/教师,空教室显示“教室待定”,课程备注和导入提示解释来源。教师、课程号、周次和节次仍严格校验,不跳过错误课程。
|
||||
- [x] 研究生日志仅有 UnsupportedSchoolLayout,没有 HTML,不能确定具体字段。为解析各阶段增加可公开的原因码和行列号,保留失败快照;修正解析失败最终被误报为网络超时。对纯数字课程号限制及空教室补充严格格式下的兼容测试,不能声称这些就是同学的确切原因。
|
||||
- [x] 红绿回归、全量 Flutter 测试、静态分析、正式构建、独立审查、源码与 APK 发布完整性/原签名核对。本次作为 0.7.10+36,保留所有既有版本和登记冻结材料,不修改线上服务器。
|
||||
|
||||
验证重点:空教室不丢课程;正常教室/教师不变;畸形时间仍拒绝;未知研究生格式不清空旧课表;具体错误及行列码可见但不输出课程内容;换页后不能沿用旧错误原因。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 0.7.10:无 HTML 导入日志排查
|
||||
|
||||
日期:2026-10-28。版本 0.7.10+36,Android 包名 com.tpschedule.app。
|
||||
|
||||
## 日志结论
|
||||
|
||||
两份日志均标记为 TPSchedule 0.7.8+26,而非刚完成的 0.7.9。
|
||||
|
||||
- TPSchedule-diagnostics.txt:2026-10-25 北京时间 09:08 左右,school=undergraduate。网页完整内容恢复已应用,随后课表读取反复返回 room_missing、activityNumber=15。可确认第 15 条 TaskActivity 的教室字符串为空,被原解析器当作整表错误拒绝。
|
||||
- TPSchedule-diagnostic5s.txt:2026-10-27 北京时间 09:02–09:03,school=graduate。认证后已自动访问课表,读到一个表格片段及学期 73;解析反复返回 UnsupportedSchoolLayout,最终 read_timeout。缺少 HTML 和细分原因码,无法判断究竟是字段、课程行、周次、表头还是空课表问题。不能把它归因于浏览器内核,也不能声称本轮已确定该同学的具体根因。
|
||||
|
||||
用户取消反馈中的学校选择:不增加选择步骤,继续依靠日志中的 school 标记。反馈界面、协议、服务器代码均不变。
|
||||
|
||||
## 修改
|
||||
|
||||
1. 本科课程的教室字符串为空时,保留全部课程、教师、周次和节次,显示“教室待定”,在课程备注中解释学校未提供教室。导入成功提示及既有导入预览会提醒用户。没有猜测实际教室,也没有跳过该课程;教师、课程标识、周次和节次异常继续拒绝整表,保留旧数据。
|
||||
2. 研究生解析取消纯数字课程号的额外限制,接受首位为字母/数字、其后为字母/数字/点/下划线/连字符的课程标识;相同严格结构下,空教室也明确标为待定。空教师、无效周次、错位节次、缺失单元格和空表仍拒绝。这些是已通过匿名样本验证的兼容修复,尚不能证明命中原研究生样本。
|
||||
3. 研究生拒绝原因细分为来源、大小、学期、表头、单元格、课程字段、周次、节次等常量原因码,并在适用时记录时段行号和星期列号。日志不包含课程名称、教师、教室、帐号、Cookie 或 HTML。
|
||||
4. 自动读取和手动读取保留原始解析错误。读取期限或轮询次数耗尽时,若当前页面曾有具体解析错误,则显示并记录该原因;不再一概写成查找/网络超时。错误按页面代次隔离,换页后不继承旧原因;成功解析后清除旧原因。
|
||||
|
||||
## 验证
|
||||
|
||||
- 原始匿名复现有 4 项失败,分别为空教室、研究生代码/空教室、具体原因、错误被超时覆盖;修复后通过。
|
||||
- Flutter 全量 337 项测试通过,静态分析无问题,正式 APK 构建通过;独立审查无需要修复的缺陷。
|
||||
- 对比缺教室前后的全部 courseCode/teacher/weekday/period/week 集合,确认没有丢课、少周或少节。
|
||||
- 本科导入界面的模拟流程验证课程入库及“部分课程教室待定”提示。
|
||||
- 模拟 45 秒读取期限后仍保留 weeks_invalid;新页面不沿用旧周次错误;诊断不含课程值。
|
||||
- 既有学校来源校验、学期隔离、失效页面保护、未知布局不清空旧课表及反馈功能测试保持通过。
|
||||
|
||||
本轮没有失败同学的 HTML,也未使用他们的帐号复测。请让同学先安装本修复版重试;研究生若仍失败,需新日志中的细分代码,最好自愿附带脱敏学校 HTML,才能继续定位实际页面差异。
|
||||
|
||||
## 交付与发布
|
||||
|
||||
TPSchedule-0.7.10.apk,同签名覆盖安装,构建号 36。原公钥、私钥、备案号和 0.5.4 的 18 项登记冻结材料保持,旧 APK/源码保持。
|
||||
|
||||
服务器无需更新。更新后台发布版本 0.7.10、构建号 36,下载链接使用上传后实际可访问的 HTTPS 地址;本地未执行线上发布。
|
||||
@@ -1,3 +1,3 @@
|
||||
const appVersion = '0.7.9';
|
||||
const appBuildNumber = 35;
|
||||
const appVersion = '0.7.10';
|
||||
const appBuildNumber = 36;
|
||||
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
||||
|
||||
@@ -43,6 +43,11 @@ class _ImportPreviewState extends State<ImportPreview> {
|
||||
style: const TextStyle(color: river),
|
||||
),
|
||||
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
||||
if (draft.courses.any((course) => course.room == '教室待定'))
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text('部分课程学校未提供教室,已标为“教室待定”;请以学校最新安排为准。'),
|
||||
),
|
||||
if (widget.controller.snapshot.courses.isNotEmpty &&
|
||||
widget.controller.snapshot.schoolTerm != draft.schoolTerm &&
|
||||
draft.schoolTerm != null)
|
||||
|
||||
@@ -9,8 +9,32 @@ import '../../domain/school_term.dart';
|
||||
import 'school_navigation_policy.dart';
|
||||
|
||||
class UnsupportedSchoolLayout implements Exception {
|
||||
const UnsupportedSchoolLayout({
|
||||
this.code = 'layout_unknown',
|
||||
this.row,
|
||||
this.column,
|
||||
});
|
||||
final String code;
|
||||
final int? row, column;
|
||||
String get description => switch (code) {
|
||||
'origin_invalid' => '页面来源不符合学校课表要求',
|
||||
'payload_too_large' => '课表页面超过读取大小限制',
|
||||
'table_missing' => '未找到课表表格',
|
||||
'semester_missing' => '课表学期信息缺失',
|
||||
'row_count_invalid' => '课表时段行数不符合预期',
|
||||
'weekday_header_invalid' => '星期表头格式不符合预期',
|
||||
'row_cell_count_invalid' => '课表时段单元格不完整',
|
||||
'period_header_invalid' => '课表节次表头无法识别',
|
||||
'cell_id_invalid' => '课程单元格的星期或节次标识异常',
|
||||
'course_record_invalid' => '课程名称、教师或课程字段格式无法识别',
|
||||
'period_mismatch' => '课程节次与所在时段不一致',
|
||||
'weeks_invalid' => '上课周次格式无法识别',
|
||||
'unsafe_markup' => '课程单元格包含不支持的网页内容',
|
||||
'empty_timetable' => '课表尚无可识别的课程安排',
|
||||
_ => '学校课表格式暂不支持',
|
||||
};
|
||||
@override
|
||||
String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
|
||||
String toString() => '$description($code),当前课表保持不变。请在反馈中附带学校HTML和日志。';
|
||||
}
|
||||
|
||||
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
||||
@@ -22,28 +46,42 @@ class SchoolPageAdapter {
|
||||
}) {
|
||||
try {
|
||||
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
||||
source == CourseSource.demo ||
|
||||
utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
source == CourseSource.demo) {
|
||||
throw const UnsupportedSchoolLayout(code: 'origin_invalid');
|
||||
}
|
||||
if (utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||
throw const UnsupportedSchoolLayout(code: 'payload_too_large');
|
||||
}
|
||||
final document = html.parse(content);
|
||||
final table = document.querySelector('#tbl');
|
||||
final selected = document.querySelector('#drpxq option[selected]');
|
||||
final term = selected?.attributes['value'];
|
||||
if (table == null ||
|
||||
term == null ||
|
||||
if (table == null) {
|
||||
throw const UnsupportedSchoolLayout(code: 'table_missing');
|
||||
}
|
||||
if (term == null ||
|
||||
!RegExp(r'^\d+$').hasMatch(term) ||
|
||||
selected!.text.trim().isEmpty) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'semester_missing');
|
||||
}
|
||||
final rows = table.querySelectorAll('tr');
|
||||
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
||||
if (rows.length != 6 || rows.first.children.length != 8) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
if (rows.length != 6) {
|
||||
throw const UnsupportedSchoolLayout(code: 'row_count_invalid');
|
||||
}
|
||||
if (rows.first.children.length != 8) {
|
||||
throw const UnsupportedSchoolLayout(
|
||||
code: 'weekday_header_invalid',
|
||||
row: 0,
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (rows.first.children[i].text.trim() != weekdays[i]) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'weekday_header_invalid',
|
||||
row: 0,
|
||||
column: i,
|
||||
);
|
||||
}
|
||||
}
|
||||
final courses = <String, Course>{};
|
||||
@@ -52,28 +90,79 @@ class SchoolPageAdapter {
|
||||
for (var row = 0; row < starts.length; row++) {
|
||||
final first = starts[row], last = first == 9 ? 11 : first + 1;
|
||||
final cells = rows[row + 1].children;
|
||||
if (cells.length != 8 ||
|
||||
cells.first.id != 't0$first' ||
|
||||
if (cells.length != 8) {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'row_cell_count_invalid',
|
||||
row: row + 1,
|
||||
);
|
||||
}
|
||||
if (cells.first.id != 't0$first' ||
|
||||
cells.first.text.trim() != '$first-$last节') {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'period_header_invalid',
|
||||
row: row + 1,
|
||||
);
|
||||
}
|
||||
for (var day = 1; day <= 7; day++) {
|
||||
final cell = cells[day];
|
||||
if (cell.id != 't$day$first') throw UnsupportedSchoolLayout();
|
||||
for (final line in _lines(cell)) {
|
||||
if (cell.id != 't$day$first') {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'cell_id_invalid',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final List<String> lines;
|
||||
try {
|
||||
lines = _lines(cell);
|
||||
} on UnsupportedSchoolLayout catch (error) {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: error.code,
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
for (final line in lines) {
|
||||
final record = RegExp(
|
||||
r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$',
|
||||
r'^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*/(.+?)/\s*(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)\s*/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.*)$',
|
||||
).firstMatch(line);
|
||||
if (record == null ||
|
||||
int.parse(record[7]!) != first ||
|
||||
if (record == null) {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'course_record_invalid',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
if (int.parse(record[7]!) != first ||
|
||||
int.parse(record[8]!) != last) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'period_mismatch',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final code = record[1]!,
|
||||
name = record[2]!.trim(),
|
||||
teacher = record[5]!.trim(),
|
||||
room = record[9]!.trim();
|
||||
final weeks = _weeks(record[6]!);
|
||||
rawRoom = record[9]!.trim(),
|
||||
room = rawRoom.isEmpty ? '教室待定' : rawRoom;
|
||||
if (name.isEmpty || teacher.isEmpty) {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: 'course_record_invalid',
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final Set<int> weeks;
|
||||
try {
|
||||
weeks = _weeks(record[6]!);
|
||||
} on UnsupportedSchoolLayout catch (error) {
|
||||
throw UnsupportedSchoolLayout(
|
||||
code: error.code,
|
||||
row: row + 1,
|
||||
column: day,
|
||||
);
|
||||
}
|
||||
final identity = jsonEncode([
|
||||
term,
|
||||
code,
|
||||
@@ -101,12 +190,15 @@ class SchoolPageAdapter {
|
||||
endPeriod: last,
|
||||
weeks: {...?previous?.weeks, ...weeks},
|
||||
source: source,
|
||||
notes: rawRoom.isEmpty ? '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。' : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty DOM can be an expired session or an incomplete response.
|
||||
if (courses.isEmpty) throw UnsupportedSchoolLayout();
|
||||
if (courses.isEmpty) {
|
||||
throw const UnsupportedSchoolLayout(code: 'empty_timetable');
|
||||
}
|
||||
return ImportDraft(
|
||||
courses: List.unmodifiable(courses.values),
|
||||
explicitlyEmpty: false,
|
||||
@@ -132,7 +224,7 @@ class SchoolPageAdapter {
|
||||
buffer.write('\n');
|
||||
} else {
|
||||
if (node.localName == 'script' || node.localName == 'style') {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'unsafe_markup');
|
||||
}
|
||||
for (final child in node.nodes) {
|
||||
visit(child);
|
||||
@@ -157,11 +249,13 @@ class SchoolPageAdapter {
|
||||
for (final part in text.split(RegExp('[,,、]'))) {
|
||||
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
|
||||
.firstMatch(part);
|
||||
if (range == null) throw UnsupportedSchoolLayout();
|
||||
if (range == null) {
|
||||
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
|
||||
}
|
||||
final first = int.parse(range[1]!),
|
||||
last = int.parse(range[2] ?? range[1]!);
|
||||
if (first < 1 || last > 30 || last < first) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
|
||||
}
|
||||
result.addAll(List.generate(last - first + 1, (i) => first + i));
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ class SchoolSession extends ChangeNotifier {
|
||||
bool _routeClosed = false;
|
||||
int _pollsLeft = 0;
|
||||
int? _probeGeneration;
|
||||
UnsupportedSchoolLayout? _lastLayoutFailure;
|
||||
int? _layoutFailureGeneration;
|
||||
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
||||
bool _loading = false,
|
||||
hasOpened = false,
|
||||
@@ -334,9 +336,16 @@ class SchoolSession extends ChangeNotifier {
|
||||
String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候';
|
||||
void _armTimeout() {
|
||||
_timeout?.cancel();
|
||||
_timeout = Timer(
|
||||
const Duration(seconds: 45),
|
||||
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
|
||||
_timeout = Timer(const Duration(seconds: 45), _finishReadFailure);
|
||||
}
|
||||
|
||||
void _finishReadFailure() {
|
||||
final error = _layoutFailureGeneration == _pageGeneration
|
||||
? _lastLayoutFailure
|
||||
: null;
|
||||
_failure(
|
||||
error == null ? '查找超时,请检查学校页面后重试。已有数据保持不变。' : '课表读取未完成:$error',
|
||||
code: error?.code ?? 'read_timeout',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,7 +405,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
_poll?.cancel();
|
||||
if (_pollsLeft-- <= 0) {
|
||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
|
||||
if (finding) _finishReadFailure();
|
||||
return;
|
||||
}
|
||||
_poll = Timer(const Duration(milliseconds: 750), () {
|
||||
@@ -500,6 +509,8 @@ class SchoolSession extends ChangeNotifier {
|
||||
// gets only its recognized fragment with the canonical table path.
|
||||
final draft = await _reader.read(_target, () async => page.html);
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
_lastLayoutFailure = null;
|
||||
_layoutFailureGeneration = null;
|
||||
if (draft.schoolTerm != selectedTerm) {
|
||||
DiagnosticLog.instance.add(
|
||||
'term_mismatch',
|
||||
@@ -528,11 +539,24 @@ class SchoolSession extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (generation != _pageGeneration) return;
|
||||
if (error is UnsupportedSchoolLayout) {
|
||||
_lastLayoutFailure = error;
|
||||
_layoutFailureGeneration = generation;
|
||||
message =
|
||||
'课表暂未识别:${error.description}。正在等待页面加载完整;若仍失败,请在反馈中附带学校HTML和日志。';
|
||||
}
|
||||
DiagnosticLog.instance.add(
|
||||
'school_parse_rejected',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'code': error.runtimeType.toString(),
|
||||
'code': error is UnsupportedSchoolLayout
|
||||
? error.code
|
||||
: error.runtimeType.toString(),
|
||||
if (error is UnsupportedSchoolLayout && error.row != null)
|
||||
'row': error.row,
|
||||
if (error is UnsupportedSchoolLayout && error.column != null)
|
||||
'column': error.column,
|
||||
},
|
||||
);
|
||||
if (generation != _pageGeneration) return;
|
||||
@@ -595,6 +619,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
liveUrl != uri.toString()) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
UnsupportedSchoolLayout? rejected;
|
||||
for (final page in evidence.pages) {
|
||||
if (page.kind != 'timetable') continue;
|
||||
SchoolPageDiagnostic.instance.remember(page.html, page.url);
|
||||
@@ -608,11 +633,12 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
if (draft.schoolTerm != selectedTerm) continue;
|
||||
return draft;
|
||||
} on UnsupportedSchoolLayout {
|
||||
} on UnsupportedSchoolLayout catch (error) {
|
||||
if (generation != _pageGeneration) rethrow;
|
||||
rejected = error;
|
||||
}
|
||||
}
|
||||
throw UnsupportedSchoolLayout();
|
||||
throw rejected ?? const UnsupportedSchoolLayout(code: 'table_missing');
|
||||
}
|
||||
|
||||
Future<void> readCurrentPage() async {
|
||||
|
||||
@@ -54,13 +54,6 @@ List<UndergraduateActivity> parseActivityData(
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[5].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'room_missing',
|
||||
'课程活动未提供教室信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
final bitmap = args[6];
|
||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||
bitmap.length > 64 ||
|
||||
@@ -109,7 +102,7 @@ List<UndergraduateActivity> parseActivityData(
|
||||
args[1].trim(),
|
||||
args[3].substring(0, suffix.start).trim(),
|
||||
suffix[1]!,
|
||||
args[5].trim(),
|
||||
args[5].trim().isEmpty ? '教室待定' : args[5].trim(),
|
||||
indices,
|
||||
weeks,
|
||||
),
|
||||
|
||||
@@ -103,7 +103,13 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
|
||||
if (route?.isCurrent == true) Navigator.pop(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(exams == null ? '课表已导入,请核对学期开始日。' : '考试信息已更新'),
|
||||
content: Text(
|
||||
exams != null
|
||||
? '考试信息已更新'
|
||||
: draft!.courses.any((c) => c.room == '教室待定')
|
||||
? '课表已导入,部分课程教室待定;请核对学期开始日。'
|
||||
: '课表已导入,请核对学期开始日。',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (controller.lastError != null) {
|
||||
|
||||
@@ -28,7 +28,8 @@ class UndergraduateTimetableAdapter {
|
||||
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
|
||||
@@ -161,7 +162,10 @@ class UndergraduateTimetableAdapter {
|
||||
weeks: group.weeks,
|
||||
source: source,
|
||||
courseCode: info?.code ?? a.courseCode,
|
||||
notes: info?.notes ?? '',
|
||||
notes: [
|
||||
if (info?.notes.isNotEmpty == true) info!.notes,
|
||||
if (a.room == '教室待定') '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ class DiagnosticLog {
|
||||
'code',
|
||||
'version',
|
||||
'result',
|
||||
'row',
|
||||
'column',
|
||||
};
|
||||
final DateTime Function() clock;
|
||||
final _entries = <Map<String, dynamic>>[];
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.9+35
|
||||
version: 0.7.10+36
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -16,6 +16,31 @@ String fixture() {
|
||||
|
||||
void main() {
|
||||
final url = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1');
|
||||
test('graduate course identifiers need not be numeric and missing rooms remain explicit', () {
|
||||
final body = fixture()
|
||||
.replaceAll('101/示例', 'G101/示例')
|
||||
.replaceAll('/示例楼B212', '/');
|
||||
final draft = SchoolPageAdapter().extract(body, url);
|
||||
expect(draft.courses, hasLength(4));
|
||||
final pending = draft.courses.where((c) => c.name.contains('集成电路'));
|
||||
expect(pending.every((c) => c.room == '教室待定'), isTrue);
|
||||
expect(pending.every((c) => c.notes.contains('学校未提供教室')), isTrue);
|
||||
});
|
||||
test('graduate malformed record provides a safe specific reason', () {
|
||||
expect(
|
||||
() => SchoolPageAdapter().extract(
|
||||
fixture().replaceFirst('1-3周', '未知周'),
|
||||
url,
|
||||
),
|
||||
throwsA(
|
||||
isA<UnsupportedSchoolLayout>().having(
|
||||
(e) => e.toString(),
|
||||
'reason',
|
||||
contains('上课周次'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test('school DOM merges week segments without losing full metadata or evening periods', () {
|
||||
final draft = SchoolPageAdapter().extract(fixture(), url);
|
||||
expect(draft.courses.length, 4);
|
||||
|
||||
@@ -31,6 +31,59 @@ void main() {
|
||||
platform.delegate.finished(url);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'graduate parser rejection keeps its reason instead of becoming a network timeout',
|
||||
(tester) async {
|
||||
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||
session.connectionState = SchoolConnectionState.connected;
|
||||
await session.openTimetable(term: SchoolTerm(73));
|
||||
platform.web.javascript = (_) async => schoolPageEvidence(
|
||||
topUrl: platform.web.url!,
|
||||
html: fixture().replaceFirst('1-3周', '未知周'),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
navigate(SchoolTerm(73).timetableUri.toString());
|
||||
await flushSchoolEvents();
|
||||
});
|
||||
expect(session.message, contains('上课周次'));
|
||||
await tester.pump(const Duration(seconds: 46));
|
||||
expect(session.message, contains('上课周次'));
|
||||
expect(session.message, isNot(contains('查找超时')));
|
||||
expect(session.pendingDraft, isNull);
|
||||
final log = utf8.decode(await DiagnosticLog.instance.export());
|
||||
expect(log, contains('"code":"weeks_invalid"'));
|
||||
expect(log, contains('"column":5'));
|
||||
expect(log, isNot(contains('示例集成电路')));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
},
|
||||
);
|
||||
testWidgets('a new graduate page never inherits an old parsing reason', (
|
||||
tester,
|
||||
) async {
|
||||
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||
session.connectionState = SchoolConnectionState.connected;
|
||||
await session.openTimetable(term: SchoolTerm(73));
|
||||
platform.web.javascript = (_) async => schoolPageEvidence(
|
||||
topUrl: platform.web.url!,
|
||||
html: fixture().replaceFirst('1-3周', '未知周'),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
navigate(SchoolTerm(73).timetableUri.toString());
|
||||
await flushSchoolEvents();
|
||||
});
|
||||
expect(session.message, contains('上课周次'));
|
||||
platform.web.javascript = (_) async =>
|
||||
schoolPageEvidence(topUrl: platform.web.url!);
|
||||
await tester.runAsync(() async {
|
||||
navigate(SchoolTerm(72).timetableUri.toString());
|
||||
await flushSchoolEvents();
|
||||
});
|
||||
await tester.pump(const Duration(seconds: 46));
|
||||
expect(session.message, contains('查找超时'));
|
||||
expect(session.message, isNot(contains('上课周次')));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
|
||||
test(
|
||||
'graduate auto read retains HTML after leaving and clears on next query',
|
||||
() async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
|
||||
import 'package:hepan_everyday/data/schedule_file_import.dart';
|
||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||
import 'package:hepan_everyday/domain/course.dart';
|
||||
|
||||
void main() {
|
||||
test('missing course fields identify the failing field without disclosing values', () {
|
||||
@@ -15,7 +16,6 @@ void main() {
|
||||
'index=0*unitCount+0;table0.activities[index][table0.activities[index].length]=activity;';
|
||||
for (final entry in {
|
||||
'teacher_missing': base.replaceFirst('"教师"', '""'),
|
||||
'room_missing': base.replaceFirst('"教室"', '""'),
|
||||
'course_sequence_invalid': base.replaceFirst('课程(C.01)', '课程'),
|
||||
}.entries) {
|
||||
expect(
|
||||
@@ -51,6 +51,24 @@ void main() {
|
||||
String fixture() =>
|
||||
File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync();
|
||||
test('missing room retains every scheduled occurrence and clearly marks it pending', () {
|
||||
final original = UndergraduateTimetableAdapter.extract(fixture(), uri);
|
||||
final draft = UndergraduateTimetableAdapter.extract(
|
||||
fixture().replaceFirst('"教室1"', '""'),
|
||||
uri,
|
||||
);
|
||||
Set<String> occurrences(Iterable<Course> courses) => <String>{
|
||||
for (final c in courses)
|
||||
for (final w in c.weeks)
|
||||
for (var p = c.startPeriod; p <= c.endPeriod; p++)
|
||||
'${c.courseCode}/${c.teacher}/${c.weekday}/$p/$w',
|
||||
};
|
||||
expect(occurrences(draft.courses), occurrences(original.courses));
|
||||
final pending = draft.courses.where((c) => c.room == '教室待定');
|
||||
expect(pending, isNotEmpty);
|
||||
expect(pending.every((c) => c.notes.contains('学校未提供教室')), isTrue);
|
||||
expect(draft.courses.any((c) => c.room == '教室2'), isTrue);
|
||||
});
|
||||
test('undergraduate activity data preserves teachers rooms weeks and period twelve', () {
|
||||
final draft = UndergraduateTimetableAdapter.extract(fixture(), uri);
|
||||
expect(draft.schoolTerm!.id, 523);
|
||||
|
||||
@@ -31,7 +31,7 @@ void main() {
|
||||
session.dispose();
|
||||
controller.dispose();
|
||||
});
|
||||
Future<void> open(WidgetTester tester) async {
|
||||
Future<void> open(WidgetTester tester, {String? timetableHtml}) async {
|
||||
controller = AppController(
|
||||
store,
|
||||
FakeNotifications(),
|
||||
@@ -43,7 +43,8 @@ void main() {
|
||||
platform.web.javascript = (s) async => underEvidence(
|
||||
platform.web.url!,
|
||||
html: s == undergraduateCaptureScript
|
||||
? File('test/fixtures/school/undergraduate-timetable.html')
|
||||
? timetableHtml ??
|
||||
File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync()
|
||||
: null,
|
||||
);
|
||||
@@ -85,6 +86,21 @@ void main() {
|
||||
expect(find.text('打开'), findsOneWidget);
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
testWidgets('missing room imports with a visible pending-room notice', (
|
||||
tester,
|
||||
) async {
|
||||
await open(
|
||||
tester,
|
||||
timetableHtml: File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync()
|
||||
.replaceFirst('"教室1"', '""'),
|
||||
);
|
||||
await tester.tap(find.text('导入当前课表'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.snapshot.courses.any((c) => c.room == '教室待定'), isTrue);
|
||||
expect(find.textContaining('部分课程教室待定'), findsOneWidget);
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
testWidgets('cross school replacement can be cancelled without saving', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user