fix: harden school imports and add optional feedback diagnostics
This commit is contained in:
@@ -6,6 +6,7 @@ import '../domain/app_settings.dart';
|
||||
import '../data/snapshot_store.dart';
|
||||
import '../data/json_schedule_import.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/diagnostic_log.dart';
|
||||
import '../services/calendar_service.dart';
|
||||
import '../domain/schedule_engine.dart';
|
||||
import '../data/demo_schedule.dart';
|
||||
@@ -40,6 +41,10 @@ class AppController extends ChangeNotifier {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
lastError = error.toString();
|
||||
DiagnosticLog.instance.add(
|
||||
'operation_failed',
|
||||
fields: {'code': error.runtimeType.toString()},
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -57,6 +62,10 @@ class AppController extends ChangeNotifier {
|
||||
await store.save(next);
|
||||
snapshot = next;
|
||||
lastError = null;
|
||||
DiagnosticLog.instance.add(
|
||||
'snapshot_saved',
|
||||
fields: {'count': next.courses.length, 'term': next.schoolTerm?.id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
|
||||
@@ -171,6 +180,8 @@ class AppController extends ChangeNotifier {
|
||||
schoolTerm: draft.schoolTerm,
|
||||
clearExams:
|
||||
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
||||
snapshot.examSchedule?.school !=
|
||||
SupportedSchool.uestcUndergraduate &&
|
||||
_differentTerm(draft.schoolTerm),
|
||||
settings: _differentTerm(draft.schoolTerm)
|
||||
? snapshot.settings.copyWith(clearSemester: true)
|
||||
@@ -359,12 +370,23 @@ class AppController extends ChangeNotifier {
|
||||
clock(),
|
||||
);
|
||||
reminderErrorCode = null;
|
||||
DiagnosticLog.instance.add(
|
||||
'reminders_reconciled',
|
||||
fields: {
|
||||
'count': occurrences.length,
|
||||
'status': reminderResult.status.name,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
reminderErrorCode = error is ReminderSetupException
|
||||
? error.code
|
||||
: error is PlatformException
|
||||
? error.code
|
||||
: error.runtimeType.toString();
|
||||
DiagnosticLog.instance.add(
|
||||
'reminders_failed',
|
||||
fields: {'code': error.runtimeType.toString()},
|
||||
);
|
||||
reminderResult = ReminderResult(
|
||||
ReminderStatus.error,
|
||||
message: error is ReminderSetupException
|
||||
|
||||
@@ -46,6 +46,10 @@ class ExamRecord {
|
||||
return value;
|
||||
}
|
||||
|
||||
final typeId = json['examTypeId'];
|
||||
if (typeId != null && (typeId is! int || typeId < 1 || typeId > 3)) {
|
||||
throw const FormatException('考试类型无效');
|
||||
}
|
||||
final record = ExamRecord(
|
||||
code: field('code'),
|
||||
name: field('name'),
|
||||
@@ -58,7 +62,7 @@ class ExamRecord {
|
||||
date: field('date', optional: true),
|
||||
status: field('status', optional: true),
|
||||
notes: field('notes', optional: true),
|
||||
examTypeId: json['examTypeId'] as int?,
|
||||
examTypeId: typeId as int?,
|
||||
);
|
||||
if (record.code.isEmpty || record.name.isEmpty) {
|
||||
throw const FormatException('考试课程无效');
|
||||
@@ -107,7 +111,8 @@ class ExamSchedule {
|
||||
? SupportedSchool.uestcGraduate
|
||||
: SupportedSchool.fromId(json['schoolId'] as String);
|
||||
final rawTypes = json['requestedExamTypes'] ?? <int>[];
|
||||
if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3)) {
|
||||
if (rawTypes is! List ||
|
||||
rawTypes.any((t) => t is! int || t < 1 || t > 3)) {
|
||||
throw const FormatException('考试类型无效');
|
||||
}
|
||||
final types = rawTypes.cast<int>().toSet();
|
||||
|
||||
@@ -150,11 +150,13 @@ class ExamsPage extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (schedule.school == SupportedSchool.uestcUndergraduate)
|
||||
if (schedule.school == SupportedSchool.uestcUndergraduate) ...[
|
||||
Text(
|
||||
'查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (schedule.records.isEmpty)
|
||||
Text(
|
||||
schedule.school == SupportedSchool.uestcUndergraduate
|
||||
|
||||
@@ -192,6 +192,14 @@ class HttpFeedbackSender implements FeedbackSender {
|
||||
bytes.add(attachment.bytes);
|
||||
write('\r\n');
|
||||
}
|
||||
if (submission.diagnosticLog != null) {
|
||||
write(
|
||||
'--$boundary\r\nContent-Disposition: form-data; name="diagnostics"; filename="TPSchedule-diagnostics.txt"\r\n'
|
||||
'Content-Type: text/plain; charset=utf-8\r\n\r\n',
|
||||
);
|
||||
bytes.add(submission.diagnosticLog!);
|
||||
write('\r\n');
|
||||
}
|
||||
write('--$boundary--\r\n');
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import 'feedback_submission.dart';
|
||||
|
||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||
@@ -32,6 +33,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
var _picking = false;
|
||||
var _success = false;
|
||||
var _uncertain = false;
|
||||
var _includeLogs = false;
|
||||
String? _error;
|
||||
FeedbackSubmission? _pendingSubmission;
|
||||
|
||||
@@ -103,6 +105,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
if (_submitting || _picking) return;
|
||||
if (!_uncertain && !(_form.currentState?.validate() ?? false)) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||
? FeedbackSubmission.problem(
|
||||
@@ -111,6 +114,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
contact: _contact.text,
|
||||
attachments: _attachments,
|
||||
limits: widget.limits,
|
||||
diagnosticLog: _includeLogs
|
||||
? await DiagnosticLog.instance.export()
|
||||
: null,
|
||||
)
|
||||
: FeedbackSubmission.development(
|
||||
id: FeedbackSubmission.newId(),
|
||||
@@ -256,6 +262,24 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
: null,
|
||||
),
|
||||
if (_kind == FeedbackKind.problem) ...[
|
||||
CheckboxListTile(
|
||||
key: const Key('feedback-logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('附带近10分钟诊断日志'),
|
||||
subtitle: const Text(
|
||||
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
|
||||
),
|
||||
value: _includeLogs,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_includeLogs = value ?? false;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'图片或视频(选填)',
|
||||
|
||||
@@ -102,6 +102,7 @@ class FeedbackSubmission {
|
||||
this.contact,
|
||||
this.school,
|
||||
this.attachments = const [],
|
||||
this.diagnosticLog,
|
||||
});
|
||||
|
||||
factory FeedbackSubmission.problem({
|
||||
@@ -110,18 +111,26 @@ class FeedbackSubmission {
|
||||
String? contact,
|
||||
List<FeedbackAttachment> attachments = const [],
|
||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||
Uint8List? diagnosticLog,
|
||||
}) {
|
||||
final text = message.trim();
|
||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
|
||||
_validateContact(contact);
|
||||
validateAttachments(attachments, limits);
|
||||
if (diagnosticLog != null &&
|
||||
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
||||
throw const FeedbackException('日志附件不能超过256 KB');
|
||||
}
|
||||
return FeedbackSubmission._(
|
||||
id: id,
|
||||
kind: FeedbackKind.problem,
|
||||
message: text,
|
||||
contact: _optional(contact),
|
||||
attachments: List.unmodifiable(attachments),
|
||||
diagnosticLog: diagnosticLog == null
|
||||
? null
|
||||
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +160,7 @@ class FeedbackSubmission {
|
||||
final String? contact;
|
||||
final String? school;
|
||||
final List<FeedbackAttachment> attachments;
|
||||
final Uint8List? diagnosticLog;
|
||||
|
||||
static String newId() {
|
||||
final random = Random.secure();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../../data/json_schedule_import.dart';
|
||||
import '../../domain/exam_schedule.dart';
|
||||
import '../../domain/school_term.dart';
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import 'school_navigation_policy.dart';
|
||||
import 'school_page_adapter.dart';
|
||||
import 'school_exam_adapter.dart';
|
||||
@@ -40,7 +41,10 @@ class SchoolSession extends ChangeNotifier {
|
||||
await web.loadRequest(upgrade);
|
||||
} catch (_) {
|
||||
if (generation == _pageGeneration) {
|
||||
_failure('学校身份认证暂时无法打开,请重试。');
|
||||
_failure(
|
||||
'学校身份认证暂时无法打开,请重试。',
|
||||
code: 'authentication_load_failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
@@ -55,12 +59,15 @@ class SchoolSession extends ChangeNotifier {
|
||||
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
|
||||
);
|
||||
}
|
||||
if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
|
||||
if (request.isMainFrame) {
|
||||
_failure('学校跳转到了其他地址,请返回学校认证入口重试。', code: 'blocked_navigation');
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
onPageStarted: (url) {
|
||||
DiagnosticLog.instance.navigation('graduate', 'started', url);
|
||||
_poll?.cancel();
|
||||
_manualRead = false;
|
||||
_pageGeneration++;
|
||||
@@ -79,7 +86,11 @@ class SchoolSession extends ChangeNotifier {
|
||||
if (hasOpened &&
|
||||
generation == _pageGeneration &&
|
||||
error.url == current) {
|
||||
_failure('学校页面暂时未能加载,请检查网络后重试。');
|
||||
_failure(
|
||||
'学校页面暂时未能加载,请检查网络后重试。',
|
||||
code: 'web_resource_error',
|
||||
status: error.errorCode,
|
||||
);
|
||||
}
|
||||
},
|
||||
onHttpError: (error) async {
|
||||
@@ -89,7 +100,11 @@ class SchoolSession extends ChangeNotifier {
|
||||
generation == _pageGeneration &&
|
||||
error.request != null &&
|
||||
error.request!.uri == current) {
|
||||
_failure('学校请求未完成,请稍后重试。');
|
||||
_failure(
|
||||
'学校请求未完成,请稍后重试。',
|
||||
code: 'http_error',
|
||||
status: error.response?.statusCode,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -175,6 +190,14 @@ class SchoolSession extends ChangeNotifier {
|
||||
bool forceAuth = false,
|
||||
}) async {
|
||||
final serial = ++_openSerial;
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'phase': exams ? 'exams' : 'timetable',
|
||||
'term': term?.id,
|
||||
},
|
||||
);
|
||||
final closing = _logoutFuture;
|
||||
if (closing != null) await closing;
|
||||
if (serial != _openSerial) return;
|
||||
@@ -211,7 +234,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
await web.loadRequest(connected ? _target : authUrl);
|
||||
} catch (_) {
|
||||
if (serial == _openSerial && generation == _pageGeneration) {
|
||||
_failure('学校入口无法打开,请检查校园网络后重试。');
|
||||
_failure('学校入口无法打开,请检查校园网络后重试。', code: 'entry_load_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +267,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
await web.loadRequest(_target);
|
||||
} catch (_) {
|
||||
if (serial == _openSerial && generation == _pageGeneration) {
|
||||
_failure('学校学期查询页面暂时无法打开,请重试。');
|
||||
_failure('学校学期查询页面暂时无法打开,请重试。', code: 'query_load_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,11 +277,20 @@ class SchoolSession extends ChangeNotifier {
|
||||
_timeout?.cancel();
|
||||
_timeout = Timer(
|
||||
const Duration(seconds: 45),
|
||||
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。'),
|
||||
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
|
||||
);
|
||||
}
|
||||
|
||||
void _failure(String text) {
|
||||
void _failure(String text, {String code = 'flow_failure', int? status}) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_read_failed',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'code': code,
|
||||
'status': status,
|
||||
'term': selectedTerm?.id,
|
||||
},
|
||||
);
|
||||
if (!hasOpened || _routeClosed) return;
|
||||
_pageGeneration++;
|
||||
_reader.invalidate();
|
||||
@@ -305,7 +337,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
_poll?.cancel();
|
||||
if (_pollsLeft-- <= 0) {
|
||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。');
|
||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
|
||||
return;
|
||||
}
|
||||
_poll = Timer(const Duration(milliseconds: 750), () {
|
||||
@@ -320,6 +352,9 @@ class SchoolSession extends ChangeNotifier {
|
||||
);
|
||||
|
||||
Future<void> _loaded(String url, {bool resetPolling = true}) async {
|
||||
if (resetPolling) {
|
||||
DiagnosticLog.instance.navigation('graduate', 'finished', url);
|
||||
}
|
||||
if (!hasOpened || _routeClosed) return;
|
||||
final generation = _pageGeneration;
|
||||
final uri = Uri.tryParse(url);
|
||||
@@ -360,6 +395,15 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
try {
|
||||
final evidence = await _evidence(uri);
|
||||
DiagnosticLog.instance.add(
|
||||
'school_evidence',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'result': evidence.authenticated,
|
||||
'count': evidence.pages.length,
|
||||
'term': selectedTerm?.id,
|
||||
},
|
||||
);
|
||||
if (!hasOpened ||
|
||||
generation != _pageGeneration ||
|
||||
await web.currentUrl() != url) {
|
||||
@@ -384,14 +428,26 @@ class SchoolSession extends ChangeNotifier {
|
||||
if (requestingExams) {
|
||||
final draft = SchoolExamAdapter.extract(page.html);
|
||||
if (generation != _pageGeneration) return;
|
||||
if (draft.termId != selectedTerm?.id.toString()) continue;
|
||||
if (draft.termId != selectedTerm?.id.toString()) {
|
||||
DiagnosticLog.instance.add(
|
||||
'term_mismatch',
|
||||
fields: {'school': 'graduate'},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pendingExams = draft;
|
||||
} else {
|
||||
// The probe validates the live document's origin. The strict parser
|
||||
// gets only its recognized fragment with the canonical table path.
|
||||
final draft = await _reader.read(_target, () async => page.html);
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
if (draft.schoolTerm != selectedTerm) continue;
|
||||
if (draft.schoolTerm != selectedTerm) {
|
||||
DiagnosticLog.instance.add(
|
||||
'term_mismatch',
|
||||
fields: {'school': 'graduate'},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pendingDraft = draft;
|
||||
}
|
||||
_timeout?.cancel();
|
||||
@@ -399,9 +455,27 @@ class SchoolSession extends ChangeNotifier {
|
||||
connectionState = SchoolConnectionState.connected;
|
||||
finding = true;
|
||||
message = '读取成功,正在保存…';
|
||||
DiagnosticLog.instance.add(
|
||||
'school_parsed',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'phase': requestingExams ? 'exams' : 'timetable',
|
||||
'count':
|
||||
pendingDraft?.courses.length ??
|
||||
pendingExams?.records.length ??
|
||||
0,
|
||||
},
|
||||
);
|
||||
notifyListeners();
|
||||
return;
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_parse_rejected',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'code': error.runtimeType.toString(),
|
||||
},
|
||||
);
|
||||
if (generation != _pageGeneration) return;
|
||||
// A recognized table can still be incomplete during asynchronous load.
|
||||
}
|
||||
@@ -411,6 +485,14 @@ class SchoolSession extends ChangeNotifier {
|
||||
? null
|
||||
: _flow.onLoaded(uri, authenticated: evidence.authenticated);
|
||||
if (next != null) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_redirect_requested',
|
||||
fields: {
|
||||
'school': 'graduate',
|
||||
'phase': requestingExams ? 'exams' : 'timetable',
|
||||
'term': selectedTerm?.id,
|
||||
},
|
||||
);
|
||||
finding = true;
|
||||
_loading = true;
|
||||
message = _findingMessage;
|
||||
@@ -420,14 +502,17 @@ class SchoolSession extends ChangeNotifier {
|
||||
await web.loadRequest(next);
|
||||
} catch (_) {
|
||||
if (generation == _pageGeneration) {
|
||||
_failure('课表查询页面暂时无法打开,请重试。');
|
||||
_failure('课表查询页面暂时无法打开,请重试。', code: 'query_load_failed');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!finding) message = '请在学校网页完成认证;进入研究生系统后会自动读取。';
|
||||
} catch (_) {
|
||||
// JavaScript and frame access can fail briefly while the page is changing.
|
||||
} catch (error) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_probe_failed',
|
||||
fields: {'school': 'graduate', 'code': error.runtimeType.toString()},
|
||||
);
|
||||
} finally {
|
||||
if (_probeGeneration == generation) _probeGeneration = null;
|
||||
_scheduleProbe(url, generation);
|
||||
|
||||
@@ -21,6 +21,10 @@ List<UndergraduateActivity> parseActivityData(
|
||||
r'new\s+TaskActivity\s*\((.*?)\)\s*;',
|
||||
dotAll: true,
|
||||
).allMatches(script).toList();
|
||||
if (RegExp(r'\bnew\s+TaskActivity\s*\(').allMatches(script).length !=
|
||||
matches.length) {
|
||||
throw const FormatException('课程活动声明不完整');
|
||||
}
|
||||
final result = <UndergraduateActivity>[];
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
final m = matches[i], args = _strings(m[1]!);
|
||||
@@ -48,9 +52,17 @@ List<UndergraduateActivity> parseActivityData(
|
||||
final assignments = RegExp(r'\bindex\s*=\s*([^;]+);')
|
||||
.allMatches(body)
|
||||
.toList();
|
||||
final appends = RegExp(
|
||||
final appendPattern = RegExp(
|
||||
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
|
||||
).allMatches(body).length;
|
||||
);
|
||||
final appends = appendPattern.allMatches(body).length;
|
||||
final unconsumed = body
|
||||
.replaceAll(appendPattern, '')
|
||||
.replaceAll(RegExp(r'\bindex\s*=\s*[^;]+;'), '');
|
||||
if (RegExp(r'\btable\d+\.activities\s*\[|\bindex\s*=')
|
||||
.hasMatch(unconsumed)) {
|
||||
throw const FormatException('存在无法识别的课程节次赋值');
|
||||
}
|
||||
if (assignments.isEmpty || assignments.length != appends) {
|
||||
throw const FormatException('课程节次赋值不完整');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 '../../../services/diagnostic_log.dart';
|
||||
import 'navigation_policy.dart';
|
||||
import 'page_probe.dart';
|
||||
import 'timetable_adapter.dart';
|
||||
@@ -98,7 +99,9 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
uri,
|
||||
isMainFrame: request.isMainFrame,
|
||||
)) {
|
||||
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
|
||||
if (request.isMainFrame) {
|
||||
_failure('学校跳转未完成,请重新认证。', code: 'blocked_navigation');
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
if (request.isMainFrame &&
|
||||
@@ -107,7 +110,13 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
onPageStarted: (url) {
|
||||
DiagnosticLog.instance.navigation('undergraduate', 'started', url);
|
||||
// Within a batch, type-to-type loads retain their generation. Once
|
||||
// published, any new document invalidates the deferred save result.
|
||||
if (pendingExams != null) {
|
||||
_reset();
|
||||
}
|
||||
_document++;
|
||||
_poll?.cancel();
|
||||
_probeKey = null;
|
||||
@@ -125,7 +134,11 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
d = _document,
|
||||
current = await web.currentUrl();
|
||||
if (_valid(g) && d == _document && error.url == current) {
|
||||
_failure('学校页面加载未完成,请检查网络后重试。');
|
||||
_failure(
|
||||
'学校页面加载未完成,请检查网络后重试。',
|
||||
code: 'web_resource_error',
|
||||
status: error.errorCode,
|
||||
);
|
||||
}
|
||||
},
|
||||
onHttpError: (error) async {
|
||||
@@ -135,12 +148,20 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
if (_valid(g) &&
|
||||
d == _document &&
|
||||
error.request?.uri.toString() == current) {
|
||||
_failure('学校请求未完成,请稍后重试。');
|
||||
_failure(
|
||||
'学校请求未完成,请稍后重试。',
|
||||
code: 'http_error',
|
||||
status: error.response?.statusCode,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
Future<void> openTimetable() async {
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
||||
);
|
||||
await _logoutFuture;
|
||||
if (_disposed) return;
|
||||
_reset();
|
||||
@@ -153,6 +174,14 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> openExams(UndergraduateExamQuery value) async {
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'phase': 'exams',
|
||||
'term': value.term.id,
|
||||
},
|
||||
);
|
||||
await _logoutFuture;
|
||||
if (_disposed) return;
|
||||
_reset();
|
||||
@@ -196,7 +225,16 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _failure(String text) {
|
||||
void _failure(String text, {String code = 'read_failure', int? status}) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_read_failed',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'code': code,
|
||||
'status': status,
|
||||
'term': query?.term.id,
|
||||
},
|
||||
);
|
||||
if (_disposed) return;
|
||||
_reset();
|
||||
_failed = true;
|
||||
@@ -233,6 +271,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
uri,
|
||||
);
|
||||
Future<void> _loaded(String url) async {
|
||||
DiagnosticLog.instance.navigation('undergraduate', 'finished', url);
|
||||
final g = _generation, d = _document, key = '$_generation:$_document';
|
||||
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
|
||||
_probeKey = key;
|
||||
@@ -245,6 +284,14 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
final status = await _evidence(uri, false);
|
||||
DiagnosticLog.instance.add(
|
||||
'school_evidence',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'result': status.authenticated,
|
||||
'count': status.pages.length,
|
||||
},
|
||||
);
|
||||
if (!_valid(g) || d != _document) return;
|
||||
_loading = false;
|
||||
if (!status.authenticated) {
|
||||
@@ -283,6 +330,14 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_itemTimeout = null;
|
||||
if (_index == query!.orderedTypes.length) {
|
||||
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
|
||||
DiagnosticLog.instance.add(
|
||||
'school_parsed',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'phase': 'exams',
|
||||
'count': pendingExams!.records.length,
|
||||
},
|
||||
);
|
||||
_complete = true;
|
||||
_cancelTimers();
|
||||
message = '考试已全部读取,正在保存…';
|
||||
@@ -291,7 +346,9 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
await _requestExam(g);
|
||||
}
|
||||
} catch (error) {
|
||||
if (_valid(g) && d == _document) _failure('$error');
|
||||
if (_valid(g) && d == _document) {
|
||||
_failure('$error', code: error.runtimeType.toString());
|
||||
}
|
||||
} finally {
|
||||
if (_probeKey == key) _probeKey = null;
|
||||
}
|
||||
@@ -300,16 +357,24 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
Future<void> _requestExam(int g) async {
|
||||
if (!_valid(g) || query == null || _complete) return;
|
||||
final type = query!.orderedTypes[_index];
|
||||
DiagnosticLog.instance.add(
|
||||
'school_read_requested',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'term': query!.term.id,
|
||||
'type': type.id,
|
||||
},
|
||||
);
|
||||
_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}读取超时,请重试。');
|
||||
if (_valid(g)) _failure('${type.label}读取超时,请重试。', code: 'item_timeout');
|
||||
});
|
||||
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
|
||||
if (_valid(g)) _failure('考试读取超时,请重试。');
|
||||
if (_valid(g)) _failure('考试读取超时,请重试。', code: 'batch_timeout');
|
||||
});
|
||||
await _navigate(_expected!);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
|
||||
import 'app/app_controller.dart';
|
||||
import 'app/app_version.dart';
|
||||
import 'services/diagnostic_log.dart';
|
||||
import 'app/hepan_app.dart';
|
||||
import 'app/tp_startup.dart';
|
||||
import 'data/file_snapshot_store.dart';
|
||||
@@ -37,6 +39,7 @@ Widget buildApplication(
|
||||
);
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DiagnosticLog.instance.add('app_started', fields: {'version': appVersion});
|
||||
AppController? controller;
|
||||
await launchApplication(
|
||||
bootstrap: () async {
|
||||
@@ -45,8 +48,10 @@ Future<void> main() async {
|
||||
final gateway = PluginReminderGateway(
|
||||
onTap: (key) => selection.value = key,
|
||||
);
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
unawaited(DiagnosticLog.instance.attach(directory));
|
||||
final value = AppController(
|
||||
FileSnapshotStore(await getApplicationSupportDirectory()),
|
||||
FileSnapshotStore(directory),
|
||||
LocalNotificationService(gateway),
|
||||
AndroidCalendarService(MethodCalendarGateway()),
|
||||
DateTime.now,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
|
||||
class DiagnosticLog {
|
||||
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
|
||||
static final instance = DiagnosticLog();
|
||||
static const maxBytes = 256 * 1024;
|
||||
static const _keys = {
|
||||
'school',
|
||||
'phase',
|
||||
'page',
|
||||
'count',
|
||||
'term',
|
||||
'type',
|
||||
'status',
|
||||
'code',
|
||||
'version',
|
||||
'result',
|
||||
};
|
||||
final DateTime Function() clock;
|
||||
final _entries = <Map<String, dynamic>>[];
|
||||
File? _file;
|
||||
Future<void> _writes = Future.value();
|
||||
Timer? _timer;
|
||||
|
||||
Map<String, Object> _safe(Map fields) => {
|
||||
for (final e in fields.entries.take(6))
|
||||
if (_keys.contains(e.key) &&
|
||||
(e.value is bool ||
|
||||
e.value is int ||
|
||||
e.value is String &&
|
||||
RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value)))
|
||||
e.key as String: e.value as Object,
|
||||
};
|
||||
void _trim() {
|
||||
final now = clock().toUtc();
|
||||
_entries.removeWhere((e) {
|
||||
final at = DateTime.tryParse(e['at'] as String? ?? '');
|
||||
return at == null ||
|
||||
at.isAfter(now) ||
|
||||
now.difference(at) > const Duration(minutes: 10);
|
||||
});
|
||||
if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500);
|
||||
}
|
||||
|
||||
void add(String event, {Map<String, Object?> fields = const {}}) {
|
||||
if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return;
|
||||
_trim();
|
||||
final safe = _safe(fields);
|
||||
if (_entries.isNotEmpty) {
|
||||
final last = _entries.last;
|
||||
final at = DateTime.tryParse(last['at'] as String? ?? '');
|
||||
final previous = Map<String, dynamic>.from(last)
|
||||
..remove('at')
|
||||
..remove('event');
|
||||
if (last['event'] == event &&
|
||||
jsonEncode(previous) == jsonEncode(safe) &&
|
||||
at != null &&
|
||||
clock().toUtc().difference(at) < const Duration(seconds: 3)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_entries.add({
|
||||
'at': clock().toUtc().toIso8601String(),
|
||||
'event': event,
|
||||
...safe,
|
||||
});
|
||||
_trim();
|
||||
unawaited(flush());
|
||||
}
|
||||
|
||||
void navigation(String school, String phase, String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final page = uri == null
|
||||
? 'unknown'
|
||||
: uri.host == 'idas.uestc.edu.cn'
|
||||
? 'identity'
|
||||
: uri.host == 'yjsjy.uestc.edu.cn'
|
||||
? uri.path.contains('xskbcx')
|
||||
? 'timetable'
|
||||
: uri.path.contains('kskcxx')
|
||||
? 'exams'
|
||||
: 'graduate_portal'
|
||||
: uri.host == 'eams.uestc.edu.cn'
|
||||
? uri.path.contains('stdExamTable')
|
||||
? 'exams'
|
||||
: uri.path.contains('courseTable')
|
||||
? 'timetable'
|
||||
: 'undergraduate_portal'
|
||||
: 'external';
|
||||
add('navigation_$phase', fields: {'school': school, 'page': page});
|
||||
}
|
||||
|
||||
Future<void> attach(Directory directory) async {
|
||||
final file = File('${directory.path}/diagnostics-recent.json');
|
||||
try {
|
||||
if (await file.exists() && await file.length() <= maxBytes) {
|
||||
final saved = jsonDecode(await file.readAsString());
|
||||
if (saved is List) {
|
||||
final restored = <Map<String, dynamic>>[];
|
||||
for (final row in saved.take(500)) {
|
||||
if (row is Map &&
|
||||
row['at'] is String &&
|
||||
row['event'] is String &&
|
||||
RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) {
|
||||
restored.add({
|
||||
'at': row['at'],
|
||||
'event': row['event'],
|
||||
..._safe(row),
|
||||
});
|
||||
}
|
||||
}
|
||||
_entries.insertAll(0, restored);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* Diagnostics must never block startup or feedback. */
|
||||
}
|
||||
_file = file;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(minutes: 1),
|
||||
(_) => unawaited(flush()),
|
||||
);
|
||||
await flush();
|
||||
}
|
||||
|
||||
Future<void> flush() {
|
||||
_trim();
|
||||
final file = _file;
|
||||
if (file == null) return Future.value();
|
||||
final text = jsonEncode(_entries);
|
||||
_writes = _writes.then((_) async {
|
||||
try {
|
||||
await file.writeAsString(text, flush: true);
|
||||
} catch (_) {}
|
||||
});
|
||||
return _writes;
|
||||
}
|
||||
|
||||
Future<Uint8List> export() async {
|
||||
_trim();
|
||||
await flush();
|
||||
final header =
|
||||
'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n'
|
||||
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
|
||||
return Uint8List.fromList(
|
||||
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() => _timer?.cancel();
|
||||
}
|
||||
@@ -109,4 +109,24 @@ void main() {
|
||||
'{"schemaVersion":1,"courses":[${const JsonEncoder().convert(old)}]}';
|
||||
expect(() => JsonScheduleImport.parse(json), throwsFormatException);
|
||||
});
|
||||
test('graduate timetable changes preserve independently saved undergraduate exams', () async {
|
||||
await controller.initialize();
|
||||
await controller.applyExams(exams());
|
||||
await controller.applyImport(
|
||||
ImportDraft(
|
||||
courses: [course()],
|
||||
explicitlyEmpty: false,
|
||||
source: course().source,
|
||||
parserVersion: 'test',
|
||||
schoolTerm: SchoolTerm(72),
|
||||
),
|
||||
confirmedEmpty: false,
|
||||
);
|
||||
expect(controller.lastError, isNull);
|
||||
expect(
|
||||
controller.snapshot.examSchedule?.school,
|
||||
SupportedSchool.uestcUndergraduate,
|
||||
);
|
||||
expect(controller.snapshot.examSchedule?.termId, '503');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/course.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'optional exam types reject invalid values even in graduate snapshots',
|
||||
() {
|
||||
final saved = ExamSchedule(
|
||||
semesterLabel: '2026秋',
|
||||
termId: '73',
|
||||
records: [const ExamRecord(code: 'A', name: '课程', examTypeId: 99)],
|
||||
).toJson();
|
||||
expect(() => ExamSchedule.fromJson(saved), throwsFormatException);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'managed undergraduate identifiers cannot lose their school or term',
|
||||
() {
|
||||
|
||||
@@ -33,6 +33,27 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('diagnostics use a separate UTF8 attachment field', () async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
await HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
).send(
|
||||
FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: '问题',
|
||||
diagnosticLog: Uint8List.fromList(
|
||||
utf8.encode('TPSchedule diagnostic log\nlogin_opened'),
|
||||
),
|
||||
),
|
||||
);
|
||||
final body = utf8.decode(transport.body!);
|
||||
expect(
|
||||
body,
|
||||
contains('name="diagnostics"; filename="TPSchedule-diagnostics.txt"'),
|
||||
);
|
||||
expect(body, contains('login_opened'));
|
||||
});
|
||||
test(
|
||||
'matching uncertain delivery is distinct and never exposes server detail',
|
||||
() async {
|
||||
|
||||
@@ -31,10 +31,29 @@ Future<void> reveal(
|
||||
backwards ? -250 : 250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('logs are opt in and included only when checked', (tester) async {
|
||||
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-logs')));
|
||||
expect(
|
||||
tester
|
||||
.widget<CheckboxListTile>(find.byKey(const Key('feedback-logs')))
|
||||
.value,
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('feedback-logs')));
|
||||
await tester.pump();
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions.single.diagnosticLog, isNotNull);
|
||||
expect(sender.submissions.single.attachments, isEmpty);
|
||||
});
|
||||
testWidgets('uncertain delivery retains original payload for status check', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -191,7 +210,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
|
||||
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
|
||||
await reveal(tester, find.byKey(const Key('feedback-add-media')));
|
||||
await tester.tap(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.pumpAndSettle();
|
||||
await reveal(tester, find.text('我要参与开发'), backwards: true);
|
||||
@@ -303,7 +322,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
|
||||
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
|
||||
await reveal(tester, find.byKey(const Key('feedback-add-media')));
|
||||
await tester.tap(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('screen.png'), findsOneWidget);
|
||||
@@ -312,7 +331,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions.single.attachments, hasLength(1));
|
||||
expect(find.text('screen.png'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byTooltip('移除 screen.png'));
|
||||
await reveal(tester, find.byTooltip('移除 screen.png'), backwards: true);
|
||||
await tester.tap(find.byTooltip('移除 screen.png'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('screen.png'), findsNothing);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hepan_everyday/services/diagnostic_log.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||
@@ -26,6 +29,30 @@ void main() {
|
||||
platform.delegate.finished(url);
|
||||
}
|
||||
|
||||
test('login diagnostic breadcrumbs distinguish probe and network failures without raw details', () async {
|
||||
await session.openTimetable(term: SchoolTerm(73));
|
||||
session.setReadingActive(true);
|
||||
platform.web.javascript = (_) async => throw StateError('PRIVATE_TICKET');
|
||||
navigate('https://yjsjy.uestc.edu.cn/pyxx/home/index');
|
||||
await flushSchoolEvents();
|
||||
var log = utf8.decode(await DiagnosticLog.instance.export());
|
||||
expect(log, contains('school_probe_failed'));
|
||||
expect(log, isNot(contains('PRIVATE_TICKET')));
|
||||
platform.delegate.resourceError(
|
||||
WebResourceError(
|
||||
errorCode: -2,
|
||||
description: 'PRIVATE_NETWORK_DETAIL',
|
||||
isForMainFrame: true,
|
||||
url: platform.web.url,
|
||||
),
|
||||
);
|
||||
await flushSchoolEvents();
|
||||
log = utf8.decode(await DiagnosticLog.instance.export());
|
||||
expect(log, contains('web_resource_error'));
|
||||
expect(log, contains('"status":-2'));
|
||||
expect(log, isNot(contains('PRIVATE_NETWORK_DETAIL')));
|
||||
});
|
||||
|
||||
test('graduate logout function automatically opens timetable without an exit anchor', () async {
|
||||
await session.openTimetable(term: SchoolTerm(73));
|
||||
platform.web.javascript = (script) async =>
|
||||
|
||||
@@ -82,6 +82,10 @@ void main() {
|
||||
await finish();
|
||||
expect(session.pendingExams!.records, hasLength(12));
|
||||
expect(session.pendingExams!.requestedExamTypes, {1, 2, 3});
|
||||
final completedGeneration = session.pageGeneration;
|
||||
platform.delegate.started('https://eams.uestc.edu.cn/eams/');
|
||||
expect(session.pendingExams, isNull);
|
||||
expect(session.pageGeneration, greaterThan(completedGeneration));
|
||||
},
|
||||
);
|
||||
test('last type failure never publishes partial results', () async {
|
||||
|
||||
@@ -9,6 +9,21 @@ import 'package:hepan_everyday/data/schedule_file_import.dart';
|
||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||
|
||||
void main() {
|
||||
test('unrecognized activity writes and incomplete declarations reject the entire timetable', () {
|
||||
const valid =
|
||||
'activity = new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
|
||||
'index = 0*unitCount+0; table0.activities[index][table0.activities[index].length]=activity;';
|
||||
for (final extra in [
|
||||
'table0.activities[11][table0.activities[11].length]=activity;',
|
||||
'activity = new TaskActivity("T", "教师", "C", "另一课程(C.02)", "R", "教室", "010")\n'
|
||||
'index = 0*unitCount+1; table0.activities[index][table0.activities[index].length]=activity;',
|
||||
]) {
|
||||
expect(
|
||||
() => parseActivityData('$valid $extra', unitCount: 12),
|
||||
throwsFormatException,
|
||||
);
|
||||
}
|
||||
});
|
||||
final uri = Uri.parse(
|
||||
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/services/diagnostic_log.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'logs retain only ten minutes across reopening and omit login secrets',
|
||||
() async {
|
||||
final directory = await Directory.systemTemp.createTemp('tp-log-test');
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
var now = DateTime.utc(2026, 10, 15, 10);
|
||||
final log = DiagnosticLog(clock: () => now);
|
||||
addTearDown(log.dispose);
|
||||
await log.attach(directory);
|
||||
log.add('old_event');
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
log.navigation(
|
||||
'graduate',
|
||||
'finished',
|
||||
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73?ticket=SECRET&username=PERSON',
|
||||
);
|
||||
log.add(
|
||||
'read_failed',
|
||||
fields: {'code': 'no_timetable', 'password': 'SECRET'},
|
||||
);
|
||||
await log.flush();
|
||||
final restored = DiagnosticLog(clock: () => now);
|
||||
addTearDown(restored.dispose);
|
||||
await restored.attach(directory);
|
||||
final text = utf8.decode(await restored.export());
|
||||
expect(text, contains('navigation_finished'));
|
||||
expect(text, contains('timetable'));
|
||||
expect(text, contains('no_timetable'));
|
||||
for (final value in [
|
||||
'old_event',
|
||||
'SECRET',
|
||||
'PERSON',
|
||||
'ticket',
|
||||
'"password"',
|
||||
'https://',
|
||||
]) {
|
||||
expect(text, isNot(contains(value)));
|
||||
}
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
expect(
|
||||
utf8.decode(await restored.export()),
|
||||
isNot(contains('no_timetable')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/app/app_controller.dart';
|
||||
import 'package:hepan_everyday/app/app_theme.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/exam_schedule.dart';
|
||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||
import 'package:hepan_everyday/features/exams/exams_page.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('query description has breathing room above exam cards', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController(
|
||||
MemoryStore(),
|
||||
FakeNotifications(),
|
||||
FakeCalendar(),
|
||||
DateTime.now,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.snapshot = AppSnapshot(
|
||||
examSchedule: ExamSchedule(
|
||||
semesterLabel: '2025-2026第二学期',
|
||||
termId: '503',
|
||||
school: SupportedSchool.uestcUndergraduate,
|
||||
requestedExamTypes: {1},
|
||||
records: [const ExamRecord(code: 'C1', name: '测试考试', examTypeId: 1)],
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ExamsPage(controller: controller, onSync: () {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
final description = tester.getRect(find.text('查询类型:期末考试'));
|
||||
final card = tester.getRect(find.byType(SurfaceCard).first);
|
||||
expect(card.top - description.bottom, greaterThanOrEqualTo(12));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user