feat: release undergraduate integration 0.7.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# TPSchedule
|
# 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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
0.6.2将学校连接入口改为「选择学校」,目前提供「电子科技大学(研究生)」。选中后沿用原认证及导入,取消不开始登录;旧学校学期和连接刷新继续复用。详见[0.6.2学校选择验证记录](docs/tpschedule-0.6.2-validation.md)。
|
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。
|
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。
|
||||||
|
|
||||||
## 安装与学校登录
|
## 安装与学校登录
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Android 首版验证记录
|
# 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)。
|
日期: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)。
|
||||||
|
|
||||||
## 已验证
|
## 已验证
|
||||||
|
|||||||
@@ -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/jzsso/login。
|
||||||
课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。
|
课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。
|
||||||
|
|||||||
@@ -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
@@ -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
|
工具链、测试与构建入口见根目录README及 `scripts/android-env.ps1`、`scripts/build-android.ps1`。本目录运行 `flutter test --no-pub`、`flutter analyze --no-pub`;用Node运行 `test/school/*probe_js_test.cjs` 验证网页捕获。
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
|
||||||
|
|
||||||
|
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
const appVersion = '0.6.2';
|
const appVersion = '0.7.0';
|
||||||
const appBuildNumber = 17;
|
const appBuildNumber = 18;
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ class _Shell extends StatefulWidget {
|
|||||||
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||||
int _tab = 0;
|
int _tab = 0;
|
||||||
bool _openingSchool = false;
|
bool _openingSchool = false;
|
||||||
// Earlier imports all belong to UESTC graduate; reuse their existing term
|
// Restore the saved school; legacy graduate imports infer their source.
|
||||||
// and session without requiring students to choose again on every refresh.
|
|
||||||
SupportedSchool? get _knownSchool =>
|
SupportedSchool? get _knownSchool =>
|
||||||
widget.controller.snapshot.selectedSchool ??
|
widget.controller.snapshot.selectedSchool ??
|
||||||
(widget.undergraduate?.canLogout == true
|
(widget.undergraduate?.canLogout == true
|
||||||
@@ -199,6 +198,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
chooseTerm: chooseTerm || term == null,
|
chooseTerm: chooseTerm || term == null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!mounted) return;
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()),
|
MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()),
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import '../features/school/undergraduate/exam_adapter.dart';
|
|||||||
|
|
||||||
class ExamFileImport {
|
class ExamFileImport {
|
||||||
static ExamSchedule parse(String content) {
|
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');
|
throw const FormatException('文件不能超过2MB');
|
||||||
|
}
|
||||||
final doc = html.parse(content.replaceFirst('\uFEFF', ''));
|
final doc = html.parse(content.replaceFirst('\uFEFF', ''));
|
||||||
if (doc.querySelector('#stdExamTableForm') != null)
|
if (doc.querySelector('#stdExamTableForm') != null) {
|
||||||
return UndergraduateExamAdapter.extract(content);
|
return UndergraduateExamAdapter.extract(content);
|
||||||
if (doc.querySelector('#sample-table-1') != null)
|
}
|
||||||
|
if (doc.querySelector('#sample-table-1') != null) {
|
||||||
return SchoolExamAdapter.extract(content);
|
return SchoolExamAdapter.extract(content);
|
||||||
|
}
|
||||||
throw const FormatException('未识别到学校排考页面');
|
throw const FormatException('未识别到学校排考页面');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ class ExamSchedule {
|
|||||||
? SupportedSchool.uestcGraduate
|
? SupportedSchool.uestcGraduate
|
||||||
: SupportedSchool.fromId(json['schoolId'] as String);
|
: SupportedSchool.fromId(json['schoolId'] as String);
|
||||||
final rawTypes = json['requestedExamTypes'] ?? <int>[];
|
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('考试类型无效');
|
throw const FormatException('考试类型无效');
|
||||||
|
}
|
||||||
final types = rawTypes.cast<int>().toSet();
|
final types = rawTypes.cast<int>().toSet();
|
||||||
SchoolTerm(int.parse(id), school: school);
|
SchoolTerm(int.parse(id), school: school);
|
||||||
if (school == SupportedSchool.uestcUndergraduate &&
|
if (school == SupportedSchool.uestcUndergraduate &&
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ class SchoolTerm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _graduateOnly() {
|
void _graduateOnly() {
|
||||||
if (school != SupportedSchool.uestcGraduate)
|
if (school != SupportedSchool.uestcGraduate) {
|
||||||
throw StateError('本科查询不能使用研究生链接');
|
throw StateError('本科查询不能使用研究生链接');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri get timetableUri {
|
Uri get timetableUri {
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ class UndergraduateExamQuery {
|
|||||||
required this.term,
|
required this.term,
|
||||||
required Set<UndergraduateExamType> types,
|
required Set<UndergraduateExamType> types,
|
||||||
}) : types = Set.unmodifiable(types) {
|
}) : types = Set.unmodifiable(types) {
|
||||||
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty)
|
if (term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) {
|
||||||
throw ArgumentError('请选择本科学期和考试类型');
|
throw ArgumentError('请选择本科学期和考试类型');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final SchoolTerm term;
|
final SchoolTerm term;
|
||||||
final Set<UndergraduateExamType> types;
|
final Set<UndergraduateExamType> types;
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
final m = matches[i], args = _strings(m[1]!);
|
final m = matches[i], args = _strings(m[1]!);
|
||||||
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
||||||
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
|
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('教师、教室或课程序号缺失');
|
throw const FormatException('教师、教室或课程序号缺失');
|
||||||
|
}
|
||||||
final bitmap = args[6];
|
final bitmap = args[6];
|
||||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||||
bitmap.length > 64 ||
|
bitmap.length > 64 ||
|
||||||
(bitmap.length > 31 && bitmap.substring(31).contains('1')))
|
(bitmap.length > 31 && bitmap.substring(31).contains('1'))) {
|
||||||
throw const FormatException('上课周次超出1–30周');
|
throw const FormatException('上课周次超出1–30周');
|
||||||
|
}
|
||||||
final weeks = {
|
final weeks = {
|
||||||
for (var w = 1; w < bitmap.length && w <= 30; w++)
|
for (var w = 1; w < bitmap.length && w <= 30; w++)
|
||||||
if (bitmap[w] == '1') w,
|
if (bitmap[w] == '1') w,
|
||||||
@@ -49,15 +51,17 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
final appends = RegExp(
|
final appends = RegExp(
|
||||||
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
|
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
|
||||||
).allMatches(body).length;
|
).allMatches(body).length;
|
||||||
if (assignments.isEmpty || assignments.length != appends)
|
if (assignments.isEmpty || assignments.length != appends) {
|
||||||
throw const FormatException('课程节次赋值不完整');
|
throw const FormatException('课程节次赋值不完整');
|
||||||
|
}
|
||||||
for (final assignment in assignments) {
|
for (final assignment in assignments) {
|
||||||
final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$')
|
final expression = RegExp(r'^\s*(\d+)\s*\*\s*unitCount\s*\+\s*(\d+)\s*$')
|
||||||
.firstMatch(assignment[1]!);
|
.firstMatch(assignment[1]!);
|
||||||
if (expression == null) throw const FormatException('不支持的节次表达式');
|
if (expression == null) throw const FormatException('不支持的节次表达式');
|
||||||
final day = int.parse(expression[1]!), period = int.parse(expression[2]!);
|
final day = int.parse(expression[1]!), period = int.parse(expression[2]!);
|
||||||
if (day > 6 || period >= unitCount)
|
if (day > 6 || period >= unitCount) {
|
||||||
throw const FormatException('课程星期或节次无效');
|
throw const FormatException('课程星期或节次无效');
|
||||||
|
}
|
||||||
indices.add(day * unitCount + period);
|
indices.add(day * unitCount + period);
|
||||||
}
|
}
|
||||||
result.add(
|
result.add(
|
||||||
@@ -113,8 +117,9 @@ List<String> _strings(String input) {
|
|||||||
final count = c == 'u' ? 4 : 2;
|
final count = c == 'u' ? 4 : 2;
|
||||||
if (p + count > input.length) throw const FormatException('字符串转义无效');
|
if (p + count > input.length) throw const FormatException('字符串转义无效');
|
||||||
final hex = input.substring(p, p + count);
|
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('字符串转义无效');
|
throw const FormatException('字符串转义无效');
|
||||||
|
}
|
||||||
b.writeCharCode(int.parse(hex, radix: 16));
|
b.writeCharCode(int.parse(hex, radix: 16));
|
||||||
p += count;
|
p += count;
|
||||||
} else if (escapes.containsKey(c)) {
|
} else if (escapes.containsKey(c)) {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
|
|||||||
}
|
}
|
||||||
if (!current()) return;
|
if (!current()) return;
|
||||||
if (saved && s.saved(generation: generation)) {
|
if (saved && s.saved(generation: generation)) {
|
||||||
|
if (!mounted) return;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (route?.isCurrent == true) Navigator.pop(context);
|
if (route?.isCurrent == true) Navigator.pop(context);
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ class UndergraduateExamAdapter {
|
|||||||
UndergraduateExamType? expectedType,
|
UndergraduateExamType? expectedType,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
if (utf8.encode(content).length > 2 * 1024 * 1024)
|
if (utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||||
throw const FormatException('考试页面超过2MB');
|
throw const FormatException('考试页面超过2MB');
|
||||||
|
}
|
||||||
final doc = html.parse(content);
|
final doc = html.parse(content);
|
||||||
if (doc.querySelector('input[type="password"]') != null)
|
if (doc.querySelector('input[type="password"]') != null) {
|
||||||
throw const FormatException('请先完成学校登录');
|
throw const FormatException('请先完成学校登录');
|
||||||
|
}
|
||||||
final form = doc.querySelector('#stdExamTableForm');
|
final form = doc.querySelector('#stdExamTableForm');
|
||||||
final scope = form ?? doc;
|
final scope = form ?? doc;
|
||||||
final termValues = scope
|
final termValues = scope
|
||||||
@@ -58,22 +60,26 @@ class UndergraduateExamAdapter {
|
|||||||
final query = Uri.splitQueryString(
|
final query = Uri.splitQueryString(
|
||||||
m[1]!.replaceFirst(RegExp(r'^&'), ''),
|
m[1]!.replaceFirst(RegExp(r'^&'), ''),
|
||||||
);
|
);
|
||||||
if (query['semester.id'] != null)
|
if (query['semester.id'] != null) {
|
||||||
termValues.add(query['semester.id']!);
|
termValues.add(query['semester.id']!);
|
||||||
if (query['examType.id'] != null)
|
}
|
||||||
|
if (query['examType.id'] != null) {
|
||||||
typeValues.add(query['examType.id']!);
|
typeValues.add(query['examType.id']!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (termValues.length != 1 || typeValues.length != 1)
|
if (termValues.length != 1 || typeValues.length != 1) {
|
||||||
throw const FormatException('排考结果学期或类型不明确');
|
throw const FormatException('排考结果学期或类型不明确');
|
||||||
|
}
|
||||||
final term = SchoolTerm(
|
final term = SchoolTerm(
|
||||||
int.parse(termValues.single),
|
int.parse(termValues.single),
|
||||||
school: SupportedSchool.uestcUndergraduate,
|
school: SupportedSchool.uestcUndergraduate,
|
||||||
);
|
);
|
||||||
final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
|
final type = UndergraduateExamType.fromId(int.parse(typeValues.single));
|
||||||
if (expectedTerm != null && expectedTerm != term ||
|
if (expectedTerm != null && expectedTerm != term ||
|
||||||
expectedType != null && expectedType != type)
|
expectedType != null && expectedType != type) {
|
||||||
throw const FormatException('排考结果与所选学期或类型不一致');
|
throw const FormatException('排考结果与所选学期或类型不一致');
|
||||||
|
}
|
||||||
Element? table;
|
Element? table;
|
||||||
for (final candidate in doc.querySelectorAll('table')) {
|
for (final candidate in doc.querySelectorAll('table')) {
|
||||||
final cells = candidate
|
final cells = candidate
|
||||||
@@ -116,12 +122,14 @@ class UndergraduateExamAdapter {
|
|||||||
examTypeId: rowType,
|
examTypeId: rowType,
|
||||||
notes: cells[8],
|
notes: cells[8],
|
||||||
);
|
);
|
||||||
if (record.code.isEmpty || record.name.isEmpty)
|
if (record.code.isEmpty || record.name.isEmpty) {
|
||||||
throw const FormatException('考试课程缺失');
|
throw const FormatException('考试课程缺失');
|
||||||
|
}
|
||||||
records.putIfAbsent(record.undergraduateKey, () => record);
|
records.putIfAbsent(record.undergraduateKey, () => record);
|
||||||
}
|
}
|
||||||
if (records.isEmpty && !explicitEmpty)
|
if (records.isEmpty && !explicitEmpty) {
|
||||||
throw const FormatException('尚未识别到明确的排考结果');
|
throw const FormatException('尚未识别到明确的排考结果');
|
||||||
|
}
|
||||||
return ExamSchedule.fromJson(
|
return ExamSchedule.fromJson(
|
||||||
ExamSchedule(
|
ExamSchedule(
|
||||||
semesterLabel: term.label,
|
semesterLabel: term.label,
|
||||||
@@ -158,14 +166,16 @@ class UndergraduateExamAdapter {
|
|||||||
part.termId != '${query.term.id}' ||
|
part.termId != '${query.term.id}' ||
|
||||||
part.requestedExamTypes.length != 1 ||
|
part.requestedExamTypes.length != 1 ||
|
||||||
!expected.contains(part.requestedExamTypes.single) ||
|
!expected.contains(part.requestedExamTypes.single) ||
|
||||||
!seen.add(part.requestedExamTypes.single))
|
!seen.add(part.requestedExamTypes.single)) {
|
||||||
throw const FormatException('考试查询批次不一致');
|
throw const FormatException('考试查询批次不一致');
|
||||||
|
}
|
||||||
for (final record in part.records) {
|
for (final record in part.records) {
|
||||||
records.putIfAbsent(record.undergraduateKey, () => record);
|
records.putIfAbsent(record.undergraduateKey, () => record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seen.length != expected.length || records.length > 500)
|
if (seen.length != expected.length || records.length > 500) {
|
||||||
throw const FormatException('考试未全部读取完成或记录超限');
|
throw const FormatException('考试未全部读取完成或记录超限');
|
||||||
|
}
|
||||||
return ExamSchedule.fromJson(
|
return ExamSchedule.fromJson(
|
||||||
ExamSchedule(
|
ExamSchedule(
|
||||||
semesterLabel: query.term.label,
|
semesterLabel: query.term.label,
|
||||||
|
|||||||
@@ -82,32 +82,37 @@ class UndergraduatePageEvidence {
|
|||||||
!UndergraduateNavigationPolicy.canNavigate(
|
!UndergraduateNavigationPolicy.canNavigate(
|
||||||
current,
|
current,
|
||||||
isMainFrame: true,
|
isMainFrame: true,
|
||||||
))
|
)) {
|
||||||
throw const FormatException('页面证据无效');
|
throw const FormatException('页面证据无效');
|
||||||
|
}
|
||||||
final pages = value['pages'];
|
final pages = value['pages'];
|
||||||
if (pages is! List || pages.length > 32)
|
if (pages is! List || pages.length > 32) {
|
||||||
throw const FormatException('页面片段过多');
|
throw const FormatException('页面片段过多');
|
||||||
|
}
|
||||||
var bytes = 0;
|
var bytes = 0;
|
||||||
final result = <UndergraduateFragment>[];
|
final result = <UndergraduateFragment>[];
|
||||||
for (final page in pages) {
|
for (final page in pages) {
|
||||||
if (page is! Map ||
|
if (page is! Map ||
|
||||||
page['url'] is! String ||
|
page['url'] is! String ||
|
||||||
page['html'] is! String ||
|
page['html'] is! String ||
|
||||||
!const ['timetable', 'exams'].contains(page['kind']))
|
!const ['timetable', 'exams'].contains(page['kind'])) {
|
||||||
throw const FormatException('页面片段无效');
|
throw const FormatException('页面片段无效');
|
||||||
|
}
|
||||||
final uri = Uri.parse(page['url'] as String),
|
final uri = Uri.parse(page['url'] as String),
|
||||||
body = page['html'] as String;
|
body = page['html'] as String;
|
||||||
bytes += utf8.encode(body).length;
|
bytes += utf8.encode(body).length;
|
||||||
if (bytes > 2 * 1024 * 1024 ||
|
if (bytes > 2 * 1024 * 1024 ||
|
||||||
!UndergraduateNavigationPolicy.isDataPage(current) ||
|
!UndergraduateNavigationPolicy.isDataPage(current) ||
|
||||||
!UndergraduateNavigationPolicy.isDataPage(uri) ||
|
!UndergraduateNavigationPolicy.isDataPage(uri) ||
|
||||||
current.origin != uri.origin)
|
current.origin != uri.origin) {
|
||||||
throw const FormatException('页面来源或大小无效');
|
throw const FormatException('页面来源或大小无效');
|
||||||
|
}
|
||||||
result.add(UndergraduateFragment(page['kind'] as String, uri, body));
|
result.add(UndergraduateFragment(page['kind'] as String, uri, body));
|
||||||
}
|
}
|
||||||
if (!UndergraduateNavigationPolicy.isDataPage(current) &&
|
if (!UndergraduateNavigationPolicy.isDataPage(current) &&
|
||||||
(value['authenticated'] == true || result.isNotEmpty))
|
(value['authenticated'] == true || result.isNotEmpty)) {
|
||||||
throw const FormatException('认证页面不能作为数据来源');
|
throw const FormatException('认证页面不能作为数据来源');
|
||||||
|
}
|
||||||
return UndergraduatePageEvidence(
|
return UndergraduatePageEvidence(
|
||||||
value['authenticated'] == true,
|
value['authenticated'] == true,
|
||||||
List.unmodifiable(result),
|
List.unmodifiable(result),
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
if (request.isMainFrame &&
|
if (request.isMainFrame &&
|
||||||
UndergraduateNavigationPolicy.isLogout(uri))
|
UndergraduateNavigationPolicy.isLogout(uri)) {
|
||||||
invalidateAuthentication();
|
invalidateAuthentication();
|
||||||
|
}
|
||||||
return NavigationDecision.navigate;
|
return NavigationDecision.navigate;
|
||||||
},
|
},
|
||||||
onPageStarted: (_) {
|
onPageStarted: (_) {
|
||||||
@@ -123,8 +124,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
final g = _generation,
|
final g = _generation,
|
||||||
d = _document,
|
d = _document,
|
||||||
current = await web.currentUrl();
|
current = await web.currentUrl();
|
||||||
if (_valid(g) && d == _document && error.url == current)
|
if (_valid(g) && d == _document && error.url == current) {
|
||||||
_failure('学校页面加载未完成,请检查网络后重试。');
|
_failure('学校页面加载未完成,请检查网络后重试。');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onHttpError: (error) async {
|
onHttpError: (error) async {
|
||||||
final g = _generation,
|
final g = _generation,
|
||||||
@@ -132,8 +134,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
current = await web.currentUrl();
|
current = await web.currentUrl();
|
||||||
if (_valid(g) &&
|
if (_valid(g) &&
|
||||||
d == _document &&
|
d == _document &&
|
||||||
error.request?.uri.toString() == current)
|
error.request?.uri.toString() == current) {
|
||||||
_failure('学校请求未完成,请稍后重试。');
|
_failure('学校请求未完成,请稍后重试。');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -184,7 +187,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_active = true;
|
_active = true;
|
||||||
if (hasOpened && !_complete && !_failed) {
|
if (hasOpened && !_loading && !_complete && !_failed) {
|
||||||
unawaited(
|
unawaited(
|
||||||
web.currentUrl().then((url) {
|
web.currentUrl().then((url) {
|
||||||
if (url != null && _active) _loaded(url);
|
if (url != null && _active) _loaded(url);
|
||||||
@@ -216,8 +219,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
void _schedulePoll(int g, int document, String url) {
|
void _schedulePoll(int g, int document, String url) {
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
_poll = Timer(const Duration(milliseconds: 450), () {
|
_poll = Timer(const Duration(milliseconds: 450), () {
|
||||||
if (_valid(g) && _document == document && !_failed && !_complete)
|
if (_valid(g) && _document == document && !_failed && !_complete) {
|
||||||
unawaited(_loaded(url));
|
unawaited(_loaded(url));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +241,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
if (!_valid(g) || d != _document || current != url) return;
|
if (!_valid(g) || d != _document || current != url) return;
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
if (uri == null ||
|
if (uri == null ||
|
||||||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true))
|
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
final status = await _evidence(uri, false);
|
final status = await _evidence(uri, false);
|
||||||
if (!_valid(g) || d != _document) return;
|
if (!_valid(g) || d != _document) return;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@@ -319,8 +324,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
final evidence = await _evidence(current, true);
|
final evidence = await _evidence(current, true);
|
||||||
if (!_valid(g) || d != _document) return;
|
if (!_valid(g) || d != _document) return;
|
||||||
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
|
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
|
||||||
if (pages.length != 1)
|
if (pages.length != 1) {
|
||||||
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
||||||
|
}
|
||||||
final draft = UndergraduateTimetableAdapter.extract(
|
final draft = UndergraduateTimetableAdapter.extract(
|
||||||
pages.single.html,
|
pages.single.html,
|
||||||
pages.single.url,
|
pages.single.url,
|
||||||
@@ -345,8 +351,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool saved({required int generation}) {
|
bool saved({required int generation}) {
|
||||||
if (!_valid(generation) || pendingDraft == null && pendingExams == null)
|
if (!_valid(generation) || pendingDraft == null && pendingExams == null) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
pendingDraft = null;
|
pendingDraft = null;
|
||||||
pendingExams = null;
|
pendingExams = null;
|
||||||
message = '已导入';
|
message = '已导入';
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ class UndergraduateTimetableAdapter {
|
|||||||
uri.userInfo.isNotEmpty ||
|
uri.userInfo.isNotEmpty ||
|
||||||
!uri.path.startsWith('/eams/') ||
|
!uri.path.startsWith('/eams/') ||
|
||||||
source == CourseSource.demo ||
|
source == CourseSource.demo ||
|
||||||
utf8.encode(content).length > 2 * 1024 * 1024)
|
utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||||
throw const FormatException('不是本科课表页面');
|
throw const FormatException('不是本科课表页面');
|
||||||
|
}
|
||||||
final doc = html.parse(content),
|
final doc = html.parse(content),
|
||||||
table = html
|
table = html
|
||||||
.parse(content)
|
.parse(content)
|
||||||
.querySelector('#manualArrangeCourseTable');
|
.querySelector('#manualArrangeCourseTable');
|
||||||
if (table == null || doc.querySelector('input[type="password"]') != null)
|
if (table == null || doc.querySelector('input[type="password"]') != null) {
|
||||||
throw const FormatException('请先在学校网页打开课表');
|
throw const FormatException('请先在学校网页打开课表');
|
||||||
|
}
|
||||||
final termIds = doc
|
final termIds = doc
|
||||||
.querySelectorAll('input[name="semester.id"]')
|
.querySelectorAll('input[name="semester.id"]')
|
||||||
.map((e) => e.attributes['value'])
|
.map((e) => e.attributes['value'])
|
||||||
@@ -51,8 +53,9 @@ class UndergraduateTimetableAdapter {
|
|||||||
: <UndergraduateActivity>[];
|
: <UndergraduateActivity>[];
|
||||||
final explicitEmpty =
|
final explicitEmpty =
|
||||||
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
|
activities.isEmpty && RegExp(r'暂无课程|无课程安排|没有课程').hasMatch(table.text);
|
||||||
if (activities.isEmpty && !explicitEmpty)
|
if (activities.isEmpty && !explicitEmpty) {
|
||||||
throw const FormatException('课表尚未加载完整');
|
throw const FormatException('课表尚未加载完整');
|
||||||
|
}
|
||||||
final roster = <String, ({String code, String notes})>{};
|
final roster = <String, ({String code, String notes})>{};
|
||||||
for (final grid in doc.querySelectorAll('table')) {
|
for (final grid in doc.querySelectorAll('table')) {
|
||||||
final rows = grid.querySelectorAll('tr');
|
final rows = grid.querySelectorAll('tr');
|
||||||
@@ -64,8 +67,9 @@ class UndergraduateTimetableAdapter {
|
|||||||
note = headers.indexOf('选课备注');
|
note = headers.indexOf('选课备注');
|
||||||
for (final row in rows.skip(1)) {
|
for (final row in rows.skip(1)) {
|
||||||
final cells = row.children;
|
final cells = row.children;
|
||||||
if (cells.length != headers.length)
|
if (cells.length != headers.length) {
|
||||||
throw const FormatException('课程名单不完整');
|
throw const FormatException('课程名单不完整');
|
||||||
|
}
|
||||||
roster[cells[seq].text.trim()] = (
|
roster[cells[seq].text.trim()] = (
|
||||||
code: cells[code].text.trim(),
|
code: cells[code].text.trim(),
|
||||||
notes: note >= 0 ? cells[note].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('没有识别到有效上课安排');
|
throw const FormatException('没有识别到有效上课安排');
|
||||||
|
}
|
||||||
return ImportDraft(
|
return ImportDraft(
|
||||||
courses: List.unmodifiable(courses),
|
courses: List.unmodifiable(courses),
|
||||||
explicitlyEmpty: explicitEmpty,
|
explicitlyEmpty: explicitEmpty,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.6.2+17
|
version: 0.7.0+18
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
test('only recognized explicit empty exam tables are empty success', () {
|
test('only recognized explicit empty exam tables are empty success', () {
|
||||||
final empty =
|
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() +
|
].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
||||||
'</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
|
||||||
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
|
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
|
||||||
expect(
|
expect(
|
||||||
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
|
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
|
||||||
@@ -92,7 +90,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE'];
|
final original = Platform.environment['UESTC_UNDERGRADUATE_EXAM_FIXTURE'];
|
||||||
if (original != null)
|
if (original != null) {
|
||||||
test('actual supplied exam export belongs to 503', () {
|
test('actual supplied exam export belongs to 503', () {
|
||||||
final result = UndergraduateExamAdapter.extract(
|
final result = UndergraduateExamAdapter.extract(
|
||||||
File(original).readAsStringSync(),
|
File(original).readAsStringSync(),
|
||||||
@@ -100,4 +98,5 @@ void main() {
|
|||||||
expect(result.records, hasLength(4));
|
expect(result.records, hasLength(4));
|
||||||
expect(result.termId, '503');
|
expect(result.termId, '503');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ void main() {
|
|||||||
session = UndergraduateSession();
|
session = UndergraduateSession();
|
||||||
});
|
});
|
||||||
tearDown(() => session.dispose());
|
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(
|
test(
|
||||||
'manual session is lazy and never captures until the student taps import',
|
'manual session is lazy and never captures until the student taps import',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
final original =
|
final original =
|
||||||
Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE'];
|
Platform.environment['UESTC_UNDERGRADUATE_TIMETABLE_FIXTURE'];
|
||||||
if (original != null)
|
if (original != null) {
|
||||||
test(
|
test(
|
||||||
'actual supplied undergraduate HTML has complete normalized arrangements',
|
'actual supplied undergraduate HTML has complete normalized arrangements',
|
||||||
() {
|
() {
|
||||||
@@ -141,4 +141,5 @@ void main() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ void main() {
|
|||||||
.readAsStringSync()
|
.readAsStringSync()
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
platform.delegate.finished(platform.web.url!);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Builder(
|
home: Builder(
|
||||||
|
|||||||
Reference in New Issue
Block a user