fix: import unpublished exam rooms and add opt-in school HTML feedback
This commit is contained in:
@@ -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