fix: import unpublished exam rooms and add opt-in school HTML feedback
This commit is contained in:
+2
-2
@@ -1,8 +1,8 @@
|
||||
# TPSchedule Flutter工程
|
||||
|
||||
当前版本0.7.4+22,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
当前版本0.7.5+23,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
|
||||
0.7.4修复本科重复登录HTTP继续链接及恢复后History API导致的网址不同步,导入绑定实际文档地址并严格校验同一学校来源。完整页恢复保留,真实账号仍需复测,见 `../docs/tpschedule-0.7.4-validation.md`。
|
||||
0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。
|
||||
|
||||
## 本科使用
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.7.4';
|
||||
const appBuildNumber = 22;
|
||||
const appVersion = '0.7.5';
|
||||
const appBuildNumber = 23;
|
||||
|
||||
@@ -120,10 +120,11 @@ class HttpFeedbackSender implements FeedbackSender {
|
||||
throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交');
|
||||
}
|
||||
if (response.statusCode == 422 && errorCode == 'validation_error') {
|
||||
if (submission.diagnosticLog != null &&
|
||||
if ((submission.diagnosticLog != null ||
|
||||
submission.diagnosticHtml != null) &&
|
||||
result?['detail'] == 'Unexpected file field.') {
|
||||
throw const FeedbackException(
|
||||
'反馈服务器尚未更新日志附件协议,请先更新服务器,或取消附带日志后提交。填写内容已保留。',
|
||||
'反馈服务器尚未更新日志或网页附件协议,请先更新服务器,或取消附带排查附件后提交。填写内容已保留。',
|
||||
);
|
||||
}
|
||||
throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交');
|
||||
@@ -206,6 +207,14 @@ class HttpFeedbackSender implements FeedbackSender {
|
||||
bytes.add(submission.diagnosticLog!);
|
||||
write('\r\n');
|
||||
}
|
||||
if (submission.diagnosticHtml != null) {
|
||||
write(
|
||||
'--$boundary\r\nContent-Disposition: form-data; name="diagnostic_html"; filename="TPSchedule-school-page.html"\r\n'
|
||||
'Content-Type: text/html; charset=utf-8\r\n\r\n',
|
||||
);
|
||||
bytes.add(submission.diagnosticHtml!);
|
||||
write('\r\n');
|
||||
}
|
||||
write('--$boundary--\r\n');
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import '../../services/school_page_diagnostic.dart';
|
||||
import 'feedback_submission.dart';
|
||||
|
||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||
@@ -34,6 +35,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
var _success = false;
|
||||
var _uncertain = false;
|
||||
var _includeLogs = false;
|
||||
var _includeHtml = false;
|
||||
String? _error;
|
||||
FeedbackSubmission? _pendingSubmission;
|
||||
|
||||
@@ -107,6 +109,20 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final html =
|
||||
_pendingSubmission == null &&
|
||||
_kind == FeedbackKind.problem &&
|
||||
_includeHtml
|
||||
? SchoolPageDiagnostic.instance.export()
|
||||
: null;
|
||||
if (_pendingSubmission == null &&
|
||||
_kind == FeedbackKind.problem &&
|
||||
_includeHtml &&
|
||||
html == null) {
|
||||
throw const FeedbackException(
|
||||
'最近10分钟没有可用的本科导入页面。请返回学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
|
||||
);
|
||||
}
|
||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||
? FeedbackSubmission.problem(
|
||||
id: FeedbackSubmission.newId(),
|
||||
@@ -117,6 +133,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
diagnosticLog: _includeLogs
|
||||
? await DiagnosticLog.instance.export()
|
||||
: null,
|
||||
diagnosticHtml: html,
|
||||
)
|
||||
: FeedbackSubmission.development(
|
||||
id: FeedbackSubmission.newId(),
|
||||
@@ -128,6 +145,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
_error = null;
|
||||
});
|
||||
await widget.sender.send(_pendingSubmission!);
|
||||
if (_pendingSubmission?.diagnosticHtml != null) {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
}
|
||||
if (mounted) setState(() => _success = true);
|
||||
} on FeedbackDeliveryUncertain catch (error) {
|
||||
if (mounted) {
|
||||
@@ -275,6 +295,58 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
: (value) {
|
||||
setState(() {
|
||||
_includeLogs = value ?? false;
|
||||
if (!_includeLogs) _includeHtml = false;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
key: const Key('feedback-html'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('附带最近一次本科导入页面HTML'),
|
||||
subtitle: const Text(
|
||||
'需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
|
||||
),
|
||||
value: _includeHtml,
|
||||
onChanged: _submitting || !_includeLogs
|
||||
? null
|
||||
: (value) async {
|
||||
var accepted = value ?? false;
|
||||
if (accepted) {
|
||||
accepted =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('附带网页排查资料?'),
|
||||
content: const Text(
|
||||
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(
|
||||
context,
|
||||
false,
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
),
|
||||
child: const Text('同意附带'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_includeHtml = accepted;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@ class FeedbackSubmission {
|
||||
this.school,
|
||||
this.attachments = const [],
|
||||
this.diagnosticLog,
|
||||
this.diagnosticHtml,
|
||||
});
|
||||
|
||||
factory FeedbackSubmission.problem({
|
||||
@@ -112,6 +113,7 @@ class FeedbackSubmission {
|
||||
List<FeedbackAttachment> attachments = const [],
|
||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||
Uint8List? diagnosticLog,
|
||||
Uint8List? diagnosticHtml,
|
||||
}) {
|
||||
final text = message.trim();
|
||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||
@@ -122,6 +124,10 @@ class FeedbackSubmission {
|
||||
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
||||
throw const FeedbackException('日志附件不能超过256 KB');
|
||||
}
|
||||
if (diagnosticHtml != null &&
|
||||
(diagnosticHtml.isEmpty || diagnosticHtml.length > 2 * 1024 * 1024)) {
|
||||
throw const FeedbackException('网页排查附件不能超过2 MB');
|
||||
}
|
||||
return FeedbackSubmission._(
|
||||
id: id,
|
||||
kind: FeedbackKind.problem,
|
||||
@@ -131,6 +137,9 @@ class FeedbackSubmission {
|
||||
diagnosticLog: diagnosticLog == null
|
||||
? null
|
||||
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
||||
diagnosticHtml: diagnosticHtml == null
|
||||
? null
|
||||
: Uint8List.fromList(diagnosticHtml).asUnmodifiableView(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ class FeedbackSubmission {
|
||||
final String? school;
|
||||
final List<FeedbackAttachment> attachments;
|
||||
final Uint8List? diagnosticLog;
|
||||
final Uint8List? diagnosticHtml;
|
||||
|
||||
static String newId() {
|
||||
final random = Random.secure();
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
class UndergraduateActivityException extends FormatException {
|
||||
const UndergraduateActivityException(
|
||||
this.code,
|
||||
String message,
|
||||
this.activityNumber,
|
||||
) : super(message);
|
||||
final String code;
|
||||
final int activityNumber;
|
||||
}
|
||||
|
||||
class UndergraduateActivity {
|
||||
UndergraduateActivity(
|
||||
this.teacher,
|
||||
@@ -30,8 +40,26 @@ List<UndergraduateActivity> parseActivityData(
|
||||
final m = matches[i], args = _strings(m[1]!);
|
||||
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
||||
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
|
||||
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty) {
|
||||
throw const FormatException('教师、教室或课程序号缺失');
|
||||
if (suffix == null) {
|
||||
throw UndergraduateActivityException(
|
||||
'course_sequence_invalid',
|
||||
'课程序号格式无法识别,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[1].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'teacher_missing',
|
||||
'课程活动未提供教师信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[5].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'room_missing',
|
||||
'课程活动未提供教室信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
final bitmap = args[6];
|
||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||
|
||||
@@ -102,11 +102,26 @@ class UndergraduateExamAdapter {
|
||||
final explicitEmpty = RegExp(r'暂无考试|无考试安排|没有考试|暂无记录|没有符合条件的记录|没有数据|无数据')
|
||||
.hasMatch(table.text);
|
||||
for (final row in table.querySelectorAll('tr').skip(1)) {
|
||||
final cells = row.children
|
||||
.map((c) => c.text.replaceAll(RegExp(r'\s+'), ' ').trim())
|
||||
.toList();
|
||||
if (explicitEmpty && row.children.length == 1) continue;
|
||||
final cells = <String>[];
|
||||
for (final cell in row.children) {
|
||||
final value = cell.text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final span = int.tryParse(cell.attributes['colspan'] ?? '1');
|
||||
if ((cell.attributes['rowspan'] ?? '1') != '1') {
|
||||
throw const FormatException('考试记录行合并结构无法识别');
|
||||
}
|
||||
if (span == 1) {
|
||||
cells.add(value);
|
||||
} else if (span == 2 &&
|
||||
cells.length == 4 &&
|
||||
RegExp(r'^[\[【]?考试情况尚未发布[\]】]?$').hasMatch(value)) {
|
||||
// EAMS merges the room and seat columns until it publishes them.
|
||||
cells.addAll([value, '']);
|
||||
} else {
|
||||
throw const FormatException('考试记录列合并结构无法识别');
|
||||
}
|
||||
}
|
||||
if (cells.length != 9) {
|
||||
if (explicitEmpty && cells.length == 1) continue;
|
||||
throw const FormatException('考试记录字段不完整');
|
||||
}
|
||||
final rowType = _type(cells[7]);
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../../../data/json_schedule_import.dart';
|
||||
import '../../../domain/exam_schedule.dart';
|
||||
import '../../../domain/undergraduate_exam_query.dart';
|
||||
import '../../../services/diagnostic_log.dart';
|
||||
import '../../../services/school_page_diagnostic.dart';
|
||||
import 'activity_data.dart';
|
||||
import 'navigation_policy.dart';
|
||||
import 'page_probe.dart';
|
||||
import 'timetable_adapter.dart';
|
||||
@@ -206,6 +208,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
Future<void> openTimetable() async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
||||
@@ -222,6 +225,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> openExams(UndergraduateExamQuery value) async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {
|
||||
@@ -434,6 +438,10 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
|
||||
SchoolPageDiagnostic.instance.remember(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
);
|
||||
final part = UndergraduateExamAdapter.extract(
|
||||
pages.single.html,
|
||||
expectedTerm: query!.term,
|
||||
@@ -519,6 +527,10 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
if (pages.length != 1) {
|
||||
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
||||
}
|
||||
SchoolPageDiagnostic.instance.remember(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
);
|
||||
final draft = UndergraduateTimetableAdapter.extract(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
@@ -531,6 +543,18 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_emit();
|
||||
} catch (error) {
|
||||
if (_valid(g) && d == _document) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_read_failed',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'phase': 'timetable',
|
||||
'code': error is UndergraduateActivityException
|
||||
? error.code
|
||||
: error.runtimeType.toString(),
|
||||
if (error is UndergraduateActivityException)
|
||||
'count': error.activityNumber,
|
||||
},
|
||||
);
|
||||
message = '导入未完成:$error';
|
||||
_emit();
|
||||
}
|
||||
@@ -554,6 +578,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> reauthenticate() async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
await _logoutFuture;
|
||||
if (_disposed) return;
|
||||
_reset();
|
||||
@@ -566,6 +591,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void invalidateAuthentication() {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
_reset();
|
||||
_active = false;
|
||||
hasOpened = false;
|
||||
@@ -601,6 +627,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
_disposed = true;
|
||||
_active = false;
|
||||
_generation++;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:html/parser.dart' as html;
|
||||
|
||||
/// An opt-in mail attachment, separate from persistent structured logs.
|
||||
/// Keeps only course/exam evidence, never the surrounding account/login page.
|
||||
class SchoolPageDiagnostic {
|
||||
SchoolPageDiagnostic({DateTime Function()? clock})
|
||||
: clock = clock ?? DateTime.now;
|
||||
static final instance = SchoolPageDiagnostic();
|
||||
static const maxBytes = 2 * 1024 * 1024;
|
||||
static const marker = '<!doctype html><!-- TPSchedule school diagnostic -->';
|
||||
final DateTime Function() clock;
|
||||
Uint8List? _bytes;
|
||||
DateTime? _at;
|
||||
Timer? _expiry;
|
||||
|
||||
void clear() {
|
||||
_expiry?.cancel();
|
||||
_expiry = null;
|
||||
_bytes = null;
|
||||
_at = null;
|
||||
}
|
||||
|
||||
Uint8List? export() {
|
||||
final at = _at;
|
||||
if (at == null ||
|
||||
clock().difference(at).isNegative ||
|
||||
clock().difference(at) >= const Duration(minutes: 10)) {
|
||||
clear();
|
||||
}
|
||||
return _bytes == null ? null : Uint8List.fromList(_bytes!);
|
||||
}
|
||||
|
||||
void remember(String content, Uri uri) {
|
||||
clear();
|
||||
try {
|
||||
if (uri.scheme != 'https' ||
|
||||
uri.host != 'eams.uestc.edu.cn' ||
|
||||
uri.port != 443 ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
!uri.path.startsWith('/eams/') ||
|
||||
utf8.encode(content).length > maxBytes) {
|
||||
return;
|
||||
}
|
||||
final doc = html.parse(content);
|
||||
if (doc.querySelector('input[type="password"]') != null) return;
|
||||
final tables = doc
|
||||
.querySelectorAll('table')
|
||||
.where(
|
||||
(t) =>
|
||||
t.id == 'manualArrangeCourseTable' ||
|
||||
t.querySelector('tr')?.text.contains('课程序号') == true,
|
||||
)
|
||||
.toList();
|
||||
if (tables.isEmpty) return;
|
||||
final fields = <String, Set<String>>{};
|
||||
void field(String key, String value) {
|
||||
if (const ['semester.id', 'examType.id'].contains(key) &&
|
||||
RegExp(r'^\d{1,6}$').hasMatch(value)) {
|
||||
fields.putIfAbsent(key, () => {}).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (final input in doc.querySelectorAll('input')) {
|
||||
field(input.attributes['name'] ?? '', input.attributes['value'] ?? '');
|
||||
}
|
||||
for (final node in doc.querySelectorAll('script,input[name="params"]')) {
|
||||
final value = node.localName == 'input'
|
||||
? node.attributes['value'] ?? ''
|
||||
: node.text;
|
||||
for (final m in RegExp(
|
||||
r'(semester\.id|examType\.id)=(\d{1,6})(?=[&"\x27\s]|$)',
|
||||
).allMatches(value)) {
|
||||
field(m[1]!, m[2]!);
|
||||
}
|
||||
}
|
||||
final script = doc
|
||||
.querySelectorAll('script')
|
||||
.map((s) => s.text)
|
||||
.join('\n');
|
||||
// Only retain the literal course statements our parser reads. All other
|
||||
// scripts, URL-bearing attributes and form/account fields are omitted.
|
||||
final statements = _courseStatements(script);
|
||||
for (final table in tables) {
|
||||
for (final node in table.querySelectorAll(
|
||||
'script,style,link,iframe,object,embed,form,input,textarea,select,button,img,svg,video,audio,source,meta,base',
|
||||
)) {
|
||||
node.remove();
|
||||
}
|
||||
for (final node in [table, ...table.querySelectorAll('*')]) {
|
||||
final attrs = Map<String, String>.from(node.attributes);
|
||||
node.attributes.clear();
|
||||
if (attrs['id'] == 'manualArrangeCourseTable') {
|
||||
node.id = 'manualArrangeCourseTable';
|
||||
}
|
||||
for (final key in ['rowspan', 'colspan']) {
|
||||
final value = attrs[key];
|
||||
if (value != null && RegExp(r'^\d{1,2}$').hasMatch(value)) {
|
||||
node.attributes[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final safe =
|
||||
'$marker<html><head><meta charset="utf-8">'
|
||||
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
|
||||
'<title>TPSchedule school diagnostic</title></head><body>'
|
||||
'${fields.entries.expand((e) => e.value.map((v) => '<input type="hidden" name="${e.key}" value="$v">')).join()}'
|
||||
'${tables.map((t) => t.outerHtml).join()}'
|
||||
'<script type="application/x-tpschedule-course-data">${statements.replaceAll('<', r'\u003c')}</script></body></html>';
|
||||
final bytes = Uint8List.fromList(utf8.encode(safe));
|
||||
if (bytes.length > maxBytes) return;
|
||||
_bytes = bytes;
|
||||
_at = clock();
|
||||
_expiry = Timer(const Duration(minutes: 10), clear);
|
||||
} catch (_) {
|
||||
clear(); // Diagnostic collection must not interfere with importing.
|
||||
}
|
||||
}
|
||||
|
||||
// Split outside quoted strings without validating the course arguments: a
|
||||
// malformed argument/index is precisely what this attachment must preserve.
|
||||
static String _courseStatements(String script) {
|
||||
final out = <String>[];
|
||||
var start = 0;
|
||||
String? quote;
|
||||
var lineComment = false, blockComment = false;
|
||||
final allowed = RegExp(
|
||||
r'^(?:var\s+)?(?:unitCount\s*=|activity\s*=\s*new\s+TaskActivity\b|new\s+TaskActivity\b|index\s*=|table\d+\.activities\s*\[)',
|
||||
);
|
||||
void keep(int end) {
|
||||
final statement = script
|
||||
.substring(start, end)
|
||||
.replaceFirst(
|
||||
RegExp(r'^(?:\s|//[^\r\n]*(?:\r?\n|$)|/\*[\s\S]*?\*/)*'),
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (allowed.hasMatch(statement)) out.add(statement);
|
||||
start = end;
|
||||
}
|
||||
|
||||
for (var i = 0; i < script.length; i++) {
|
||||
final c = script[i];
|
||||
final next = i + 1 < script.length ? script[i + 1] : '';
|
||||
if (lineComment) {
|
||||
if (c == '\n' || c == '\r') lineComment = false;
|
||||
} else if (blockComment) {
|
||||
if (c == '*' && next == '/') {
|
||||
blockComment = false;
|
||||
i++;
|
||||
}
|
||||
} else if (quote != null) {
|
||||
if (c == '\\') {
|
||||
i++;
|
||||
} else if (c == quote) {
|
||||
quote = null;
|
||||
}
|
||||
} else if (c == '/' && next == '/') {
|
||||
lineComment = true;
|
||||
i++;
|
||||
} else if (c == '/' && next == '*') {
|
||||
blockComment = true;
|
||||
i++;
|
||||
} else if (c == '"' || c == "'" || c == '`') {
|
||||
quote = c;
|
||||
} else if (c == ';') {
|
||||
keep(i + 1);
|
||||
}
|
||||
}
|
||||
if (start < script.length) keep(script.length);
|
||||
return out.join('\n');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.4+22
|
||||
version: 0.7.5+23
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -33,6 +33,31 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('HTML diagnostics use independent attachment field with an immutable payload', () async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final html = Uint8List.fromList(
|
||||
utf8.encode('<!doctype html><!-- TPSchedule school diagnostic -->'),
|
||||
);
|
||||
final submission = FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: '无法导入',
|
||||
diagnosticHtml: html,
|
||||
);
|
||||
html.fillRange(0, html.length, 0);
|
||||
await HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
).send(submission);
|
||||
final body = utf8.decode(transport.body!);
|
||||
expect(
|
||||
body,
|
||||
contains(
|
||||
'name="diagnostic_html"; filename="TPSchedule-school-page.html"',
|
||||
),
|
||||
);
|
||||
expect(body, contains('TPSchedule school diagnostic'));
|
||||
expect(submission.attachments, isEmpty);
|
||||
});
|
||||
test(
|
||||
'old feedback service gets a specific diagnostic-upgrade hint',
|
||||
() async {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
|
||||
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||
|
||||
class RecordingSender implements FeedbackSender {
|
||||
final submissions = <FeedbackSubmission>[];
|
||||
@@ -35,6 +37,47 @@ Future<void> reveal(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('HTML is opt in, confirmed, and stays identical on retry', (
|
||||
tester,
|
||||
) async {
|
||||
final store = SchoolPageDiagnostic.instance;
|
||||
addTearDown(store.clear);
|
||||
store.remember(
|
||||
'<input name="semester.id" value="523"><table id="manualArrangeCourseTable"><tr><td>课程内容</td></tr></table>',
|
||||
Uri.parse('https://eams.uestc.edu.cn/eams/courseTableForStd.action'),
|
||||
);
|
||||
final sender = RecordingSender()..fail = true;
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '不能导入');
|
||||
await reveal(tester, find.byKey(const Key('feedback-html')));
|
||||
expect(
|
||||
tester
|
||||
.widget<CheckboxListTile>(find.byKey(const Key('feedback-html')))
|
||||
.value,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('feedback-logs')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('feedback-html')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('附带网页排查资料?'), findsOneWidget);
|
||||
await tester.tap(find.text('同意附带'));
|
||||
await tester.pumpAndSettle();
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
utf8.decode(sender.submissions.single.diagnosticHtml!),
|
||||
contains('课程内容'),
|
||||
);
|
||||
store.clear();
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
identical(sender.submissions.first, sender.submissions.last),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
testWidgets('logs are opt in and included only when checked', (tester) async {
|
||||
final sender = RecordingSender();
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
@@ -52,6 +95,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions.single.diagnosticLog, isNotNull);
|
||||
expect(sender.submissions.single.diagnosticHtml, isNull);
|
||||
expect(sender.submissions.single.attachments, isEmpty);
|
||||
});
|
||||
testWidgets('uncertain delivery retains original payload for status check', (
|
||||
@@ -186,6 +230,8 @@ void main() {
|
||||
backwards: true,
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
|
||||
// Validation removes the error label after the tap, changing list height.
|
||||
await tester.pumpAndSettle();
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -12,6 +12,29 @@ void main() {
|
||||
String fixture() =>
|
||||
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync();
|
||||
final term = SchoolTerm(503, school: SupportedSchool.uestcUndergraduate);
|
||||
test('unpublished room and seat merged by school keep the dated exam', () {
|
||||
final page = fixture().replaceFirst(
|
||||
RegExp(r'<td>示例考场</td>\s*<td>1</td>'),
|
||||
'<td colspan="2">[考试情况尚未发布]</td>',
|
||||
);
|
||||
expect(page, contains('colspan="2"'));
|
||||
final result = UndergraduateExamAdapter.extract(page);
|
||||
expect(result.records, hasLength(4));
|
||||
final record = result.records.first;
|
||||
expect(record.date, '2026-06-22');
|
||||
expect(record.time, contains('14:00-16:00'));
|
||||
expect(record.room, '[考试情况尚未发布]');
|
||||
expect(record.seat, '');
|
||||
expect(record.status, '正常');
|
||||
expect(record.examTypeId, 1);
|
||||
});
|
||||
test('unexpected merged exam columns remain a failed import', () {
|
||||
final page = fixture().replaceFirst(
|
||||
'<td>示例考场</td>',
|
||||
'<td colspan="3">[考试情况尚未发布]</td>',
|
||||
);
|
||||
expect(() => UndergraduateExamAdapter.extract(page), throwsFormatException);
|
||||
});
|
||||
test('actual exam result term wins over the outer default', () {
|
||||
final schedule = UndergraduateExamAdapter.extract(fixture());
|
||||
expect(schedule.termId, '503');
|
||||
@@ -68,17 +91,7 @@ void main() {
|
||||
);
|
||||
test('only recognized explicit empty exam tables are empty success', () {
|
||||
final empty =
|
||||
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${[
|
||||
'课程序号',
|
||||
'课程名称',
|
||||
'考试日期',
|
||||
'考试安排',
|
||||
'考试地点',
|
||||
'座位号',
|
||||
'考试情况',
|
||||
'考试类型',
|
||||
'其它说明',
|
||||
].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
||||
'<form id="stdExamTableForm"><input name="semester.id" value="503"><input name="examType.id" value="1"></form><table><tr>${['课程序号', '课程名称', '考试日期', '考试安排', '考试地点', '座位号', '考试情况', '考试类型', '其它说明'].map((s) => '<td>$s</td>').join()}</tr><tr><td colspan="9">暂无考试安排</td></tr></table>';
|
||||
expect(UndergraduateExamAdapter.extract(empty).records, isEmpty);
|
||||
expect(
|
||||
() => UndergraduateExamAdapter.extract(empty.replaceAll('暂无考试安排', '')),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||
import 'package:hepan_everyday/domain/school_term.dart';
|
||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
|
||||
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||
|
||||
import '../support/fake_school_web.dart';
|
||||
import 'undergraduate_session_test.dart' show underEvidence;
|
||||
@@ -123,6 +124,7 @@ void main() {
|
||||
await finish();
|
||||
await tester.pump(const Duration(seconds: 46));
|
||||
expect(session.message, contains('超时'));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
expect(session.pendingExams, isNull);
|
||||
});
|
||||
test('cancelled batch ignores late page callbacks', () async {
|
||||
@@ -199,5 +201,6 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 41));
|
||||
expect(session.pendingExams, isNull);
|
||||
expect(session.message, contains('超时'));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||
|
||||
import '../support/fake_school_web.dart';
|
||||
|
||||
@@ -31,6 +32,21 @@ void main() {
|
||||
session = UndergraduateSession();
|
||||
});
|
||||
tearDown(() => session.dispose());
|
||||
test('failed import retains diagnostic evidence separately and logout removes it', () async {
|
||||
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||
await session.openTimetable();
|
||||
final content = File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync()
|
||||
.replaceFirst('"教师1"', '""');
|
||||
platform.web.javascript = (_) async =>
|
||||
underEvidence(platform.web.url!, html: content);
|
||||
await session.readCurrentTimetable();
|
||||
expect(session.pendingDraft, isNull);
|
||||
expect(session.message, contains('教师'));
|
||||
expect(SchoolPageDiagnostic.instance.export(), isNotNull);
|
||||
session.invalidateAuthentication();
|
||||
expect(SchoolPageDiagnostic.instance.export(), isNull);
|
||||
});
|
||||
test('live URL cannot cross origin or change again during capture', () async {
|
||||
await session.openTimetable();
|
||||
final content = File('test/fixtures/school/undergraduate-timetable.html')
|
||||
|
||||
@@ -9,6 +9,27 @@ import 'package:hepan_everyday/data/schedule_file_import.dart';
|
||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||
|
||||
void main() {
|
||||
test('missing course fields identify the failing field without disclosing values', () {
|
||||
const base =
|
||||
'new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
||||
'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(
|
||||
() => parseActivityData(entry.value, unitCount: 12),
|
||||
throwsA(
|
||||
isA<UndergraduateActivityException>().having(
|
||||
(e) => e.code,
|
||||
'reason',
|
||||
entry.key,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
test('unrecognized activity writes and incomplete declarations reject the entire timetable', () {
|
||||
const valid =
|
||||
'activity = new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
|
||||
|
||||
void main() {
|
||||
final uri = Uri.parse(
|
||||
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
|
||||
);
|
||||
test(
|
||||
'leading comments do not remove course declarations from the evidence',
|
||||
() {
|
||||
final store = SchoolPageDiagnostic();
|
||||
addTearDown(store.clear);
|
||||
final source = File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync()
|
||||
.replaceFirst(
|
||||
'var unitCount = 12;',
|
||||
'// units; keep this declaration\nvar unitCount = 12;',
|
||||
)
|
||||
.replaceAll(
|
||||
'activity = new TaskActivity',
|
||||
'/* course; declaration */activity = new TaskActivity',
|
||||
);
|
||||
expect(
|
||||
UndergraduateTimetableAdapter.extract(source, uri).courses,
|
||||
hasLength(18),
|
||||
);
|
||||
store.remember(source, uri);
|
||||
expect(
|
||||
UndergraduateTimetableAdapter.extract(
|
||||
utf8.decode(store.export()!),
|
||||
uri,
|
||||
).courses,
|
||||
hasLength(18),
|
||||
);
|
||||
},
|
||||
);
|
||||
test('malformed activity and index statements remain malformed in the exported evidence', () {
|
||||
final store = SchoolPageDiagnostic();
|
||||
addTearDown(store.clear);
|
||||
final fixture = File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync();
|
||||
for (final bad in [
|
||||
fixture.replaceFirst('"教师1"', 'null'),
|
||||
fixture.replaceFirst('2*unitCount+0', 'unknown()'),
|
||||
]) {
|
||||
expect(
|
||||
() => UndergraduateTimetableAdapter.extract(bad, uri),
|
||||
throwsFormatException,
|
||||
);
|
||||
store.remember(bad, uri);
|
||||
final output = utf8.decode(store.export()!);
|
||||
expect(
|
||||
() => UndergraduateTimetableAdapter.extract(output, uri),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(output, contains(bad.contains('null') ? 'null' : 'unknown()'));
|
||||
}
|
||||
});
|
||||
test('diagnostic snapshot preserves parser evidence but removes login material and active HTML', () {
|
||||
var now = DateTime.utc(2026, 10, 16, 5);
|
||||
final store = SchoolPageDiagnostic(clock: () => now);
|
||||
final fixture = File('test/fixtures/school/undergraduate-timetable.html')
|
||||
.readAsStringSync();
|
||||
store.remember(
|
||||
'''$fixture<input name="password" value="SECRET"><input name="params" value="ticket=SECRET"><a href="https://evil.test/?ticket=SECRET" onclick="steal()">链接</a><script>var token="SECRET";</script>''',
|
||||
uri,
|
||||
);
|
||||
final output = utf8.decode(store.export()!);
|
||||
expect(output, isNot(contains('SECRET')));
|
||||
expect(output, isNot(contains('onclick')));
|
||||
expect(output, isNot(contains('evil.test')));
|
||||
expect(output, contains('Content-Security-Policy'));
|
||||
expect(
|
||||
UndergraduateTimetableAdapter.extract(output, uri).courses,
|
||||
hasLength(18),
|
||||
);
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
expect(store.export(), isNull);
|
||||
});
|
||||
test('only trusted school evidence can be kept and logout clears it', () {
|
||||
final store = SchoolPageDiagnostic();
|
||||
store.remember('<table id="manualArrangeCourseTable"></table>', uri);
|
||||
expect(store.export(), isNotNull);
|
||||
store.clear();
|
||||
expect(store.export(), isNull);
|
||||
store.remember(
|
||||
'<table>secret</table>',
|
||||
Uri.parse('https://idas.uestc.edu.cn/authserver/login'),
|
||||
);
|
||||
expect(store.export(), isNull);
|
||||
store.remember(
|
||||
'<input type="password"><table id="manualArrangeCourseTable"></table>',
|
||||
uri,
|
||||
);
|
||||
expect(store.export(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||
import 'package:hepan_everyday/app/app_controller.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/school_term.dart';
|
||||
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
|
||||
|
||||
import '../support/fake_school_web.dart';
|
||||
import '../support/fakes.dart';
|
||||
@@ -82,6 +83,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.snapshot.courses, hasLength(18));
|
||||
expect(find.text('打开'), findsOneWidget);
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
testWidgets('cross school replacement can be cancelled without saving', (
|
||||
tester,
|
||||
@@ -100,6 +102,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.snapshot.toJson(), previous);
|
||||
expect(find.text('导入当前课表'), findsOneWidget);
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
testWidgets('failed saving stays on the browser and can be retried', (
|
||||
tester,
|
||||
@@ -114,6 +117,7 @@ void main() {
|
||||
await tester.tap(find.text('重试保存'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.snapshot.courses, hasLength(18));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
testWidgets('an obsolete deferred save does not strand a newer import', (
|
||||
tester,
|
||||
@@ -125,5 +129,6 @@ void main() {
|
||||
platform.delegate.finished(platform.web.url!);
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.snapshot.courses, hasLength(18));
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user