feat: connect undergraduate manual timetable and exam batches
This commit is contained in:
@@ -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/');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user