feat: extend optional HTML feedback to graduate imports in 0.7.8

This commit is contained in:
Codex
2026-10-24 15:15:29 +08:00
parent 9a198fe466
commit a946aa6ec0
24 changed files with 513 additions and 36 deletions
+34
View File
@@ -37,6 +37,40 @@ Future<void> reveal(
}
void main() {
testWidgets('graduate HTML can be selected, consented and sent with logs', (
tester,
) async {
final store = SchoolPageDiagnostic.instance;
addTearDown(store.clear);
store.remember(
'<select id="drpxq"><option value="73" selected>2026-2027秋</option></select>'
'<table id="tbl"><tr><td id="t11">研究生课程排查内容</td></tr></table>',
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
);
final sender = RecordingSender();
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')));
await tester.tap(find.byKey(const Key('feedback-logs')));
await tester.pump();
await tester.tap(find.byKey(const Key('feedback-html')));
await tester.pumpAndSettle();
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(sender.submissions, hasLength(1));
expect(sender.submissions.single.diagnosticLog, isNotNull);
expect(
utf8.decode(sender.submissions.single.diagnosticHtml!),
contains('研究生课程排查内容'),
);
expect(store.export(), isNull);
});
testWidgets('HTML is opt in, confirmed, and stays identical on retry', (
tester,
) async {
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'package:hepan_everyday/services/diagnostic_log.dart';
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
@@ -29,6 +31,132 @@ void main() {
platform.delegate.finished(url);
}
test(
'graduate auto read retains HTML after leaving and clears on next query',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
platform.web.javascript = (_) async =>
schoolPageEvidence(topUrl: platform.web.url!, html: fixture());
navigate(SchoolSession.timetableUrl.toString());
await flushSchoolEvents();
expect(session.pendingDraft, isNotNull);
expect(cache.export(), isNotNull);
session.setReadingActive(false);
expect(cache.export(), isNotNull);
await session.openExams(term: SchoolTerm(73));
expect(cache.export(), isNull);
},
);
test(
'failed graduate manual parse retains HTML and logout clears it',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
platform.web.url = SchoolSession.timetableUrl.toString();
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: fixture().replaceFirst('1-3周', '未知周'),
);
await expectLater(
session.readTimetable(),
throwsA(isA<UnsupportedSchoolLayout>()),
);
expect(cache.export(), isNotNull);
expect(utf8.decode(cache.export()!), contains('未知周'));
session.invalidateAuthentication();
expect(cache.export(), isNull);
},
);
test('graduate exam read retains the recognized exam fragment', () async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openExams(term: SchoolTerm(73));
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: examHtml(),
kind: 'exams',
);
navigate(SchoolSession.examUrl.toString());
await flushSchoolEvents();
expect(session.pendingExams, isNotNull);
expect(cache.export(), isNotNull);
expect(
SchoolExamAdapter.extract(utf8.decode(cache.export()!))
.records
.single
.name,
'矩阵理论',
);
await session.logout();
expect(cache.export(), isNull);
});
test(
'stale graduate probe cannot replace the current diagnostic attachment',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
final old = Completer<Object>();
platform.web.javascript = (_) => old.future;
const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index';
navigate(home);
await flushSchoolEvents();
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: fixture().replaceAll('示例楼B212', '当前教室A101'),
);
navigate(SchoolSession.timetableUrl.toString());
await flushSchoolEvents();
final current = cache.export();
expect(current, isNotNull);
old.complete(schoolPageEvidence(topUrl: home, html: fixture()));
await flushSchoolEvents();
expect(cache.export(), current);
expect(utf8.decode(cache.export()!), contains('当前教室A101'));
},
);
for (final manual in [false, true]) {
test(
'logout during final URL check rejects stale ${manual ? 'manual' : 'automatic'} HTML',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
final url = SchoolSession.timetableUrl.toString();
platform.web.url = url;
platform.web.javascript = (_) async =>
schoolPageEvidence(topUrl: url, html: fixture());
final waitingUrl = Completer<String?>();
var reads = 0;
platform.web.readCurrentUrl = () async {
reads++;
if (reads == (manual ? 2 : 3)) return waitingUrl.future;
return platform.web.url;
};
final operation = manual
? expectLater(
session.readTimetable(),
throwsA(isA<UnsupportedSchoolLayout>()),
)
: session.readCurrentPage();
await flushSchoolEvents();
expect(reads, manual ? 2 : 3);
session.invalidateAuthentication();
waitingUrl.complete(url);
await operation;
expect(cache.export(), isNull);
expect(session.pendingDraft, isNull);
},
);
}
test('login diagnostic breadcrumbs distinguish probe and network failures without raw details', () async {
await session.openTimetable(term: SchoolTerm(73));
session.setReadingActive(true);
@@ -4,8 +4,118 @@ 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';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:hepan_everyday/features/school/school_exam_adapter.dart';
import '../school/recognized_timetable_test.dart' show fixture;
import '../school/exam_adapter_test.dart' show examHtml;
void exportContractFixture(String name, List<int> bytes) {
final directory = Platform.environment['TPSCHEDULE_DIAGNOSTIC_EXPORT_DIR'];
if (directory != null) File('$directory/$name').writeAsBytesSync(bytes);
}
void main() {
test(
'graduate timetable preserves term, periods and course parser evidence',
() {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
final uri = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73',
);
store.remember(
'''<header>ACCOUNT_SECRET</header>${fixture()}
<input name="ticket" value="AUTH_SECRET"><script>const cookie="AUTH_SECRET";</script>''',
uri,
);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
exportContractFixture('client-graduate-timetable.html', store.export()!);
final draft = SchoolPageAdapter().extract(output, uri);
expect(draft.schoolTerm!.id, 73);
expect(draft.courses, hasLength(4));
expect(
draft.courses
.singleWhere((c) => c.weekday == 2 && c.startPeriod == 3)
.teacher,
'示例教师乙、示例教师丙',
);
expect(output, isNot(contains('SECRET')));
expect(output, isNot(contains('name="ticket"')));
},
);
test('graduate exams preserve pending arrangements and selected term', () {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
store.remember(
examHtml(room: '示例考场'),
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=73'),
);
expect(store.export(), isNotNull);
exportContractFixture('client-graduate-exams.html', store.export()!);
final exam = SchoolExamAdapter.extract(utf8.decode(store.export()!));
expect(exam.termId, '73');
expect(exam.records.single.room, '示例考场');
expect(exam.records.single.time, isEmpty);
});
test(
'graduate HTTP frame and malformed parser fields remain diagnosable',
() {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
final uri = Uri.parse('http://yjsjy.uestc.edu.cn/pyxx/home/index');
final malformed = fixture().replaceFirst('1-3周', '未知周');
store.remember(malformed, uri);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
expect(output, contains('未知周'));
expect(
() => SchoolPageAdapter().extract(
output,
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
),
throwsA(isA<UnsupportedSchoolLayout>()),
);
for (final address in [
'https://yjsjy.uestc.edu.cn.evil.test/pyxx/home/index',
'https://yjsjy.uestc.edu.cn:444/pyxx/home/index',
'http://yjsjy.uestc.edu.cn:8080/pyxx/home/index',
'https://user@yjsjy.uestc.edu.cn/pyxx/home/index',
'https://yjsjy.uestc.edu.cn/other/index',
]) {
store.remember(fixture(), Uri.parse(address));
expect(store.export(), isNull, reason: address);
}
store.remember('<input type="password">${fixture()}', uri);
expect(store.export(), isNull);
},
);
test('graduate export removes events, URLs and account controls and expires at ten minutes', () {
var now = DateTime.utc(2026, 10, 24);
final store = SchoolPageDiagnostic(clock: () => now);
addTearDown(store.clear);
store.remember(
fixture()
.replaceFirst(
'<select id="drpxq">',
'<select id="drpxq" onchange="AUTH_SECRET()">',
)
.replaceFirst(
'示例教师甲',
'<a href="https://evil.test/?ticket=AUTH_SECRET" onclick="AUTH_SECRET()">示例教师甲</a><input name="cookie" value="AUTH_SECRET">',
),
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
expect(output, isNot(contains('AUTH_SECRET')));
expect(output, isNot(contains('evil.test')));
expect(output, isNot(contains('onchange')));
expect(output, contains('示例教师甲'));
now = now.add(const Duration(minutes: 10));
expect(store.export(), isNull);
});
final uri = Uri.parse(
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
);
+2 -1
View File
@@ -47,6 +47,7 @@ class FakeSchoolWeb extends PlatformWebViewController {
String? documentUrl;
Future<Object> Function(String)? javascript;
Future<void> Function(Uri)? request;
Future<String?> Function()? readCurrentUrl;
@override
Future<void> setJavaScriptMode(JavaScriptMode mode) async {}
@override
@@ -61,7 +62,7 @@ class FakeSchoolWeb extends PlatformWebViewController {
}
@override
Future<String?> currentUrl() async => url;
Future<String?> currentUrl() => readCurrentUrl?.call() ?? Future.value(url);
@override
Future<Object> runJavaScriptReturningResult(String script) =>
script == 'location.href'