Preserve courses with pending rooms and report precise graduate import errors

This commit is contained in:
david1465833828
2026-10-28 19:56:02 +08:00
parent 8713bce0ee
commit 21b0df9e33
17 changed files with 340 additions and 53 deletions
+2 -2
View File
@@ -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));
}
+33 -7
View File
@@ -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'),
),
);
}
+2
View File
@@ -21,6 +21,8 @@ class DiagnosticLog {
'code',
'version',
'result',
'row',
'column',
};
final DateTime Function() clock;
final _entries = <Map<String, dynamic>>[];
+1 -1
View File
@@ -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,8 +43,9 @@ void main() {
platform.web.javascript = (s) async => underEvidence(
platform.web.url!,
html: s == undergraduateCaptureScript
? File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync()
? timetableHtml ??
File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync()
: null,
);
platform.delegate.finished(platform.web.url!);
@@ -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 {