fix: harden school imports and add optional feedback diagnostics

This commit is contained in:
Codex
2026-10-15 10:37:32 +08:00
parent 674df1ceb3
commit 7299f4d8b6
25 changed files with 722 additions and 42 deletions
@@ -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 {
+23 -4
View File
@@ -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));
});
}