diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart new file mode 100644 index 0000000..861917c --- /dev/null +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -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 = []; + Timer? _poll, _itemTimeout, _batchTimeout; + Future? _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 openTimetable() async { + await _logoutFuture; + if (_disposed) return; + _reset(); + query = null; + _active = true; + hasOpened = true; + awaitingAuthentication = !_connected; + message = manualMessage; + await _navigate(loginUri); + } + + Future 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 _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 _evidence(Uri uri, bool capture) async => + UndergraduatePageEvidence.decode( + await web.runJavaScriptReturningResult( + capture ? undergraduateCaptureScript : undergraduateStatusProbeScript, + ), + uri, + ); + Future _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 _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 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 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 logout() => + _logoutFuture ??= _logout().whenComplete(() => _logoutFuture = null); + Future _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(''); + } + hasOpened = false; + logoutFailed = false; + query = null; + } catch (_) { + logoutFailed = true; + message = '退出未完成,请重试。'; + } + _emit(); + } + + @override + void dispose() { + _disposed = true; + _active = false; + _generation++; + _cancelTimers(); + super.dispose(); + } +} diff --git a/mobile/test/school/undergraduate_exam_batch_test.dart b/mobile/test/school/undergraduate_exam_batch_test.dart new file mode 100644 index 0000000..49fc0ac --- /dev/null +++ b/mobile/test/school/undergraduate_exam_batch_test.dart @@ -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 finish() async { + platform.delegate.started(platform.web.url!); + platform.delegate.finished(platform.web.url!); + for (var i = 0; i < 40; i++) { + await Future.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('E', '${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('超时')); + }); +} diff --git a/mobile/test/school/undergraduate_session_test.dart b/mobile/test/school/undergraduate_session_test.dart new file mode 100644 index 0000000..7b0c86f --- /dev/null +++ b/mobile/test/school/undergraduate_session_test.dart @@ -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(); + 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(); + 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/'); + }); +} diff --git a/mobile/test/support/fake_school_web.dart b/mobile/test/support/fake_school_web.dart index b48e184..dbad84e 100644 --- a/mobile/test/support/fake_school_web.dart +++ b/mobile/test/support/fake_school_web.dart @@ -4,13 +4,18 @@ import 'dart:convert'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; class FakeSchoolPlatform extends WebViewPlatform { + int controllerCreations = 0; late FakeSchoolWeb web; late FakeSchoolDelegate delegate; final cookies = FakeSchoolCookies(); @override PlatformWebViewController createPlatformWebViewController( PlatformWebViewControllerCreationParams params, - ) => web = FakeSchoolWeb(params); + ) { + controllerCreations++; + return web = FakeSchoolWeb(params); + } + @override PlatformNavigationDelegate createPlatformNavigationDelegate( PlatformNavigationDelegateCreationParams params,