feat: connect undergraduate manual timetable and exam batches

This commit is contained in:
Codex
2026-10-14 23:55:28 +08:00
parent d101ffbf85
commit 68fe71e29a
4 changed files with 719 additions and 1 deletions
@@ -0,0 +1,409 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../data/json_schedule_import.dart';
import '../../../domain/exam_schedule.dart';
import '../../../domain/undergraduate_exam_query.dart';
import 'navigation_policy.dart';
import 'page_probe.dart';
import 'timetable_adapter.dart';
import 'exam_adapter.dart';
class UndergraduateSession extends ChangeNotifier {
static final loginUri = Uri.parse('https://eams.uestc.edu.cn/eams/');
static const manualMessage = '请在学校网页打开课表并选择学期,然后点击导入当前课表。';
WebViewController? _web;
WebViewController get web => _web ??= _createWeb();
bool _disposed = false,
_active = false,
_connected = false,
_loading = false,
_manualCapture = false,
_failed = false,
_complete = false;
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
String message = manualMessage;
int _generation = 0, _document = 0, _index = 0;
String? _probeKey;
Uri? _expected;
UndergraduateExamQuery? query;
ImportDraft? pendingDraft;
ExamSchedule? pendingExams;
final _parts = <ExamSchedule>[];
Timer? _poll, _itemTimeout, _batchTimeout;
Future<void>? _logoutFuture;
int get pageGeneration => _generation;
bool get requestingExams => query != null;
bool get canLogout => hasOpened || logoutFailed;
bool get busy => _loading || _manualCapture || _itemTimeout != null;
String get statusLabel => logoutFailed
? '退出未完成'
: _failed
? '读取未完成'
: _connected
? '已连接'
: hasOpened
? '等待学校登录'
: '未连接';
void _emit() {
if (!_disposed) notifyListeners();
}
bool _valid(int g) => !_disposed && _active && _generation == g;
void _cancelTimers() {
_poll?.cancel();
_itemTimeout?.cancel();
_batchTimeout?.cancel();
_poll = null;
_itemTimeout = null;
_batchTimeout = null;
}
void _reset() {
_generation++;
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
pendingDraft = null;
pendingExams = null;
_complete = false;
_failed = false;
_manualCapture = false;
_loading = false;
_probeKey = null;
}
WebViewController _createWeb() => WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) async {
final uri = Uri.tryParse(request.url);
final upgrade = uri != null && request.isMainFrame
? UndergraduateNavigationPolicy.secureIdentityRedirect(uri)
: null;
if (upgrade != null) {
try {
await web.loadRequest(upgrade);
} catch (_) {
_failure('身份认证页面暂时无法打开');
}
return NavigationDecision.prevent;
}
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(
uri,
isMainFrame: request.isMainFrame,
)) {
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri))
invalidateAuthentication();
return NavigationDecision.navigate;
},
onPageStarted: (_) {
_document++;
_poll?.cancel();
_probeKey = null;
pendingDraft = null;
if (!requestingExams) _generation++;
if (_active) {
_loading = true;
_emit();
}
},
onPageFinished: _loaded,
onWebResourceError: (error) async {
if (error.isForMainFrame != true || error.url == null) return;
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current)
_failure('学校页面加载未完成,请检查网络后重试。');
},
onHttpError: (error) async {
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current)
_failure('学校请求未完成,请稍后重试。');
},
),
);
Future<void> openTimetable() async {
await _logoutFuture;
if (_disposed) return;
_reset();
query = null;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = manualMessage;
await _navigate(loginUri);
}
Future<void> openExams(UndergraduateExamQuery value) async {
await _logoutFuture;
if (_disposed) return;
_reset();
query = value;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = '请在学校网页完成登录,随后自动查找考试。';
if (_connected) {
await _requestExam(_generation);
} else {
await _navigate(loginUri);
}
}
Future<void> _navigate(Uri uri) async {
final g = _generation;
_loading = true;
_emit();
try {
await web.loadRequest(uri);
} catch (_) {
if (_valid(g)) _failure('学校页面打开未完成,请重试。');
}
}
void setReadingActive(bool active) {
if (_disposed) return;
if (!active) {
_active = false;
_reset();
return;
}
_active = true;
if (hasOpened && !_complete && !_failed) {
unawaited(
web.currentUrl().then((url) {
if (url != null && _active) _loaded(url);
}),
);
}
}
void _failure(String text) {
if (_disposed) return;
_reset();
_failed = true;
awaitingAuthentication = false;
message = '读取未完成:$text';
_emit();
}
void _waitForLogin() {
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
_connected = false;
awaitingAuthentication = true;
message = requestingExams ? '请在学校网页完成登录和验证码,随后重新读取全部考试。' : manualMessage;
_emit();
}
void _schedulePoll(int g, int document, String url) {
_poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete)
unawaited(_loaded(url));
});
}
Future<UndergraduatePageEvidence> _evidence(Uri uri, bool capture) async =>
UndergraduatePageEvidence.decode(
await web.runJavaScriptReturningResult(
capture ? undergraduateCaptureScript : undergraduateStatusProbeScript,
),
uri,
);
Future<void> _loaded(String url) async {
final g = _generation, d = _document, key = '$_generation:$_document';
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
_probeKey = key;
try {
final current = await web.currentUrl();
if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url);
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true))
return;
final status = await _evidence(uri, false);
if (!_valid(g) || d != _document) return;
_loading = false;
if (!status.authenticated) {
_waitForLogin();
_schedulePoll(g, d, url);
return;
}
_connected = true;
awaitingAuthentication = false;
if (!requestingExams) {
message = manualMessage;
_emit();
return;
}
if (_expected == null || uri.toString() != _expected.toString()) {
await _requestExam(g);
return;
}
final evidence = await _evidence(uri, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'exams').toList();
if (pages.isEmpty) {
_schedulePoll(g, d, url);
return;
}
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
final part = UndergraduateExamAdapter.extract(
pages.single.html,
expectedTerm: query!.term,
expectedType: query!.orderedTypes[_index],
);
if (!_valid(g) || d != _document) return;
_parts.add(part);
_index++;
_itemTimeout?.cancel();
_itemTimeout = null;
if (_index == query!.orderedTypes.length) {
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
_complete = true;
_cancelTimers();
message = '考试已全部读取,正在保存…';
_emit();
} else {
await _requestExam(g);
}
} catch (error) {
if (_valid(g) && d == _document) _failure('$error');
} finally {
if (_probeKey == key) _probeKey = null;
}
}
Future<void> _requestExam(int g) async {
if (!_valid(g) || query == null || _complete) return;
final type = query!.orderedTypes[_index];
_expected = query!.uriFor(type);
_loading = true;
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
_emit();
_itemTimeout?.cancel();
_itemTimeout = Timer(const Duration(seconds: 45), () {
if (_valid(g)) _failure('${type.label}读取超时,请重试。');
});
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
if (_valid(g)) _failure('考试读取超时,请重试。');
});
await _navigate(_expected!);
}
Future<void> readCurrentTimetable() async {
if (requestingExams || _manualCapture || !_active) return;
final g = _generation, d = _document;
_manualCapture = true;
_emit();
try {
final current = Uri.parse(await web.currentUrl() ?? '');
final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1)
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
final draft = UndergraduateTimetableAdapter.extract(
pages.single.html,
pages.single.url,
);
if (!_valid(g) || d != _document) return;
pendingDraft = draft;
_connected = true;
awaitingAuthentication = false;
message = '课表已识别,正在确认导入…';
_emit();
} catch (error) {
if (_valid(g) && d == _document) {
message = '导入未完成:$error';
_emit();
}
} finally {
if (_valid(g)) {
_manualCapture = false;
_emit();
}
}
}
bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null)
return false;
pendingDraft = null;
pendingExams = null;
message = '已导入';
_emit();
return true;
}
Future<void> reauthenticate() async {
await _logoutFuture;
if (_disposed) return;
_reset();
_active = true;
_connected = false;
awaitingAuthentication = true;
hasOpened = true;
message = '请在学校网页重新登录。';
await _navigate(loginUri);
}
void invalidateAuthentication() {
_reset();
_connected = false;
awaitingAuthentication = false;
message = '学校登录已退出';
_emit();
}
Future<void> logout() =>
_logoutFuture ??= _logout().whenComplete(() => _logoutFuture = null);
Future<void> _logout() async {
final opened = hasOpened;
_active = false;
invalidateAuthentication();
if (!opened && !logoutFailed) return;
try {
await WebViewCookieManager().clearCookies();
if (_web != null) {
await _web!.clearCache();
await _web!.clearLocalStorage();
await _web!.loadHtmlString('<html></html>');
}
hasOpened = false;
logoutFailed = false;
query = null;
} catch (_) {
logoutFailed = true;
message = '退出未完成,请重试。';
}
_emit();
}
@override
void dispose() {
_disposed = true;
_active = false;
_generation++;
_cancelTimers();
super.dispose();
}
}
@@ -0,0 +1,199 @@
import 'dart:io';
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/domain/school_term.dart';
import 'package:hepan_everyday/domain/school_identity.dart';
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
import '../support/fake_school_web.dart';
import 'undergraduate_session_test.dart' show underEvidence;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeSchoolPlatform platform;
late UndergraduateSession session;
final query = UndergraduateExamQuery(
term: SchoolTerm(503, school: SupportedSchool.uestcUndergraduate),
types: UndergraduateExamType.values.toSet(),
);
setUp(() {
platform = FakeSchoolPlatform();
WebViewPlatform.instance = platform;
session = UndergraduateSession();
});
tearDown(() => session.dispose());
String exam(int type) {
final text = File('test/fixtures/school/undergraduate-exams.html')
.readAsStringSync();
return text
.replaceAll('examType.id=1', 'examType.id=$type')
.replaceAll(
'name="examType.id" value="1"',
'name="examType.id" value="$type"',
)
.replaceAll(
'期末考试',
type == 2
? '期中考试'
: type == 3
? '补缓考'
: '期末考试',
);
}
Future<void> finish() async {
platform.delegate.started(platform.web.url!);
platform.delegate.finished(platform.web.url!);
for (var i = 0; i < 40; i++) {
await Future<void>.value();
}
}
test(
'all selected exam types complete before publishing one result',
() async {
await session.openExams(query);
platform.web.javascript = (s) async {
final id = int.tryParse(
Uri.parse(platform.web.url!).queryParameters['examType.id'] ?? '',
);
return underEvidence(
platform.web.url!,
html: s == undergraduateCaptureScript && id != null ? exam(id) : null,
kind: 'exams',
);
};
await finish();
expect(
platform.web.loads.last,
query.uriFor(UndergraduateExamType.finalExam).toString(),
);
await finish();
expect(session.pendingExams, isNull);
expect(
platform.web.loads.last,
query.uriFor(UndergraduateExamType.midterm).toString(),
);
await finish();
expect(session.pendingExams, isNull);
await finish();
expect(session.pendingExams!.records, hasLength(12));
expect(session.pendingExams!.requestedExamTypes, {1, 2, 3});
},
);
test('last type failure never publishes partial results', () async {
await session.openExams(query);
platform.web.javascript = (s) async {
final id = int.tryParse(
Uri.parse(platform.web.url!).queryParameters['examType.id'] ?? '',
);
return underEvidence(
platform.web.url!,
html: s == undergraduateCaptureScript && id != null
? (id == 3 ? exam(1) : exam(id))
: null,
kind: 'exams',
);
};
await finish();
await finish();
await finish();
await finish();
expect(session.pendingExams, isNull);
expect(session.message, contains('未完成'));
});
testWidgets('login waiting is excluded and active reads have timeouts', (
tester,
) async {
await session.openExams(query);
platform.web.javascript = (_) async =>
underEvidence(platform.web.url!, authenticated: false);
await finish();
await tester.pump(const Duration(seconds: 121));
expect(session.awaitingAuthentication, isTrue);
expect(session.message, isNot(contains('超时')));
platform.web.javascript = (_) async => underEvidence(platform.web.url!);
await finish();
await tester.pump(const Duration(seconds: 46));
expect(session.message, contains('超时'));
expect(session.pendingExams, isNull);
});
test('cancelled batch ignores late page callbacks', () async {
await session.openExams(query);
final generation = session.pageGeneration;
session.setReadingActive(false);
platform.web.javascript = (_) async =>
underEvidence(platform.web.url!, html: exam(1), kind: 'exams');
await finish();
expect(session.pendingExams, isNull);
expect(session.pageGeneration, greaterThan(generation));
});
test(
'expired authentication discards earlier parts and restarts every type',
() async {
await session.openExams(query);
var renewed = false;
platform.web.javascript = (s) async {
final uri = Uri.parse(platform.web.url!),
id = int.tryParse(uri.queryParameters['examType.id'] ?? '');
return underEvidence(
uri.toString(),
authenticated: uri.host == 'eams.uestc.edu.cn',
html: s == undergraduateCaptureScript && id != null
? exam(id).replaceAll('<td>E', '<td>${renewed ? 'NEW' : 'OLD'}E')
: null,
kind: 'exams',
);
};
await finish();
await finish();
expect(
platform.web.loads.last,
query.uriFor(UndergraduateExamType.midterm).toString(),
);
platform.web.url = 'https://idas.uestc.edu.cn/authserver/login';
await finish();
expect(session.awaitingAuthentication, isTrue);
renewed = true;
platform.web.url = 'https://eams.uestc.edu.cn/eams/';
await finish();
expect(
platform.web.loads.last,
query.uriFor(UndergraduateExamType.finalExam).toString(),
);
await finish();
await finish();
await finish();
expect(
session.pendingExams!.records.every((r) => r.code.startsWith('NEW')),
isTrue,
);
},
);
testWidgets('the overall batch timeout limits individually timely requests', (
tester,
) async {
await session.openExams(query);
platform.web.javascript = (s) async {
final id = int.tryParse(
Uri.parse(platform.web.url!).queryParameters['examType.id'] ?? '',
);
return underEvidence(
platform.web.url!,
html: s == undergraduateCaptureScript && id != null ? exam(id) : null,
kind: 'exams',
);
};
await finish();
await tester.pump(const Duration(seconds: 40));
await finish();
await tester.pump(const Duration(seconds: 40));
await finish();
await tester.pump(const Duration(seconds: 41));
expect(session.pendingExams, isNull);
expect(session.message, contains('超时'));
});
}
@@ -0,0 +1,105 @@
import 'dart:io';
import 'dart:convert';
import 'dart:async';
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 '../support/fake_school_web.dart';
String underEvidence(
String url, {
bool authenticated = true,
String? html,
String kind = 'timetable',
}) => jsonEncode({
'topUrl': url,
'authenticated': authenticated,
'pages': [
if (html != null) {'kind': kind, 'url': url, 'html': html},
],
});
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeSchoolPlatform platform;
late UndergraduateSession session;
setUp(() {
platform = FakeSchoolPlatform();
WebViewPlatform.instance = platform;
session = UndergraduateSession();
});
tearDown(() => session.dispose());
test(
'manual session is lazy and never captures until the student taps import',
() async {
expect(platform.controllerCreations, 0);
await session.openTimetable();
expect(platform.web.loads, ['https://eams.uestc.edu.cn/eams/']);
final content = File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync();
var captures = 0;
platform.web.javascript = (s) async {
if (s == undergraduateCaptureScript) {
captures++;
return underEvidence(platform.web.url!, html: content);
}
return underEvidence(platform.web.url!);
};
platform.delegate.finished(platform.web.url!);
await flushSchoolEvents();
expect(captures, 0);
expect(session.pendingDraft, isNull);
await session.readCurrentTimetable();
expect(captures, 1);
expect(session.pendingDraft!.courses, hasLength(18));
},
);
test('closed routes reject old asynchronous capture and save', () async {
await session.openTimetable();
final gate = Completer<Object>();
platform.web.javascript = (_) => gate.future;
final read = session.readCurrentTimetable();
await flushSchoolEvents();
final generation = session.pageGeneration;
session.setReadingActive(false);
gate.complete(
underEvidence(
platform.web.url!,
html: File('test/fixtures/school/undergraduate-timetable.html')
.readAsStringSync(),
),
);
await read;
expect(session.pendingDraft, isNull);
expect(session.saved(generation: generation), isFalse);
});
test(
'unused logout stays lazy and failed cookie clearing can be retried',
() async {
await session.logout();
expect(platform.controllerCreations, 0);
await session.openTimetable();
platform.cookies.fail = true;
await session.logout();
expect(session.canLogout, isTrue);
platform.cookies.fail = false;
await session.logout();
expect(session.canLogout, isFalse);
},
);
test('new login waits for an outstanding cookie clear', () async {
await session.openTimetable();
platform.cookies.clearing = Completer<bool>();
final closing = session.logout();
await flushSchoolEvents();
final reopening = session.openTimetable();
await flushSchoolEvents();
platform.cookies.clearing!.complete(true);
await closing;
await reopening;
expect(session.hasOpened, isTrue);
expect(platform.web.loads.last, 'https://eams.uestc.edu.cn/eams/');
});
}
+6 -1
View File
@@ -4,13 +4,18 @@ import 'dart:convert';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
class FakeSchoolPlatform extends WebViewPlatform { class FakeSchoolPlatform extends WebViewPlatform {
int controllerCreations = 0;
late FakeSchoolWeb web; late FakeSchoolWeb web;
late FakeSchoolDelegate delegate; late FakeSchoolDelegate delegate;
final cookies = FakeSchoolCookies(); final cookies = FakeSchoolCookies();
@override @override
PlatformWebViewController createPlatformWebViewController( PlatformWebViewController createPlatformWebViewController(
PlatformWebViewControllerCreationParams params, PlatformWebViewControllerCreationParams params,
) => web = FakeSchoolWeb(params); ) {
controllerCreations++;
return web = FakeSchoolWeb(params);
}
@override @override
PlatformNavigationDelegate createPlatformNavigationDelegate( PlatformNavigationDelegate createPlatformNavigationDelegate(
PlatformNavigationDelegateCreationParams params, PlatformNavigationDelegateCreationParams params,