feat: add undergraduate import and exam selection screens
This commit is contained in:
@@ -134,7 +134,9 @@ class AppController extends ChangeNotifier {
|
|||||||
Future<void> applyImport(
|
Future<void> applyImport(
|
||||||
ImportDraft draft, {
|
ImportDraft draft, {
|
||||||
required bool confirmedEmpty,
|
required bool confirmedEmpty,
|
||||||
|
bool Function()? canApply,
|
||||||
}) => _run(() async {
|
}) => _run(() async {
|
||||||
|
if (canApply != null && !canApply()) throw StateError('学校页面已变化,请重新导入。');
|
||||||
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
|
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
|
||||||
throw const FormatException('未检测到课程,已有课表保持不变');
|
throw const FormatException('未检测到课程,已有课表保持不变');
|
||||||
}
|
}
|
||||||
@@ -202,30 +204,32 @@ class AppController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
await _cleanupCalendar();
|
await _cleanupCalendar();
|
||||||
});
|
});
|
||||||
Future<void> applyExams(ExamSchedule value) => _run(() async {
|
Future<void> applyExams(ExamSchedule value, {bool Function()? canApply}) =>
|
||||||
final validated = ExamSchedule.fromJson(value.toJson());
|
_run(() async {
|
||||||
if (validated.school == SupportedSchool.uestcUndergraduate) {
|
if (canApply != null && !canApply()) throw StateError('学校查询已取消,请重新读取。');
|
||||||
await _save(snapshot.copyWith(examSchedule: validated));
|
final validated = ExamSchedule.fromJson(value.toJson());
|
||||||
return;
|
if (validated.school == SupportedSchool.uestcUndergraduate) {
|
||||||
}
|
await _save(snapshot.copyWith(examSchedule: validated));
|
||||||
final term = SchoolTerm(int.parse(validated.termId));
|
return;
|
||||||
final changed = _differentTerm(term);
|
}
|
||||||
await _save(
|
final term = SchoolTerm(int.parse(validated.termId));
|
||||||
snapshot.copyWith(
|
final changed = _differentTerm(term);
|
||||||
examSchedule: validated,
|
await _save(
|
||||||
schoolTerm: term,
|
snapshot.copyWith(
|
||||||
courses: changed ? [] : null,
|
examSchedule: validated,
|
||||||
clearMetadata: changed,
|
schoolTerm: term,
|
||||||
settings: changed
|
courses: changed ? [] : null,
|
||||||
? snapshot.settings.copyWith(clearSemester: true)
|
clearMetadata: changed,
|
||||||
: null,
|
settings: changed
|
||||||
),
|
? snapshot.settings.copyWith(clearSemester: true)
|
||||||
);
|
: null,
|
||||||
if (changed) {
|
),
|
||||||
isDemo = false;
|
);
|
||||||
await _reconcile();
|
if (changed) {
|
||||||
}
|
isDemo = false;
|
||||||
});
|
await _reconcile();
|
||||||
|
}
|
||||||
|
});
|
||||||
bool _differentTerm(SchoolTerm? next) =>
|
bool _differentTerm(SchoolTerm? next) =>
|
||||||
next != null &&
|
next != null &&
|
||||||
((snapshot.schoolTerm != null && snapshot.schoolTerm != next) ||
|
((snapshot.schoolTerm != null && snapshot.schoolTerm != next) ||
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ class ImportPreview extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.draft,
|
required this.draft,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
this.canApply,
|
||||||
});
|
});
|
||||||
final ImportDraft draft;
|
final ImportDraft draft;
|
||||||
final AppController controller;
|
final AppController controller;
|
||||||
|
final bool Function()? canApply;
|
||||||
@override
|
@override
|
||||||
State<ImportPreview> createState() => _ImportPreviewState();
|
State<ImportPreview> createState() => _ImportPreviewState();
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,16 @@ class _ImportPreviewState extends State<ImportPreview> {
|
|||||||
style: const TextStyle(color: river),
|
style: const TextStyle(color: river),
|
||||||
),
|
),
|
||||||
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
||||||
|
if (widget.controller.snapshot.courses.isNotEmpty &&
|
||||||
|
widget.controller.snapshot.schoolTerm != draft.schoolTerm &&
|
||||||
|
draft.schoolTerm != null)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'将替换当前课表;导入新学校或新学期后,请重新设置学期开始日。',
|
||||||
|
style: TextStyle(fontSize: 12, height: 1.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (draft.schoolTerm != null)
|
if (draft.schoolTerm != null)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: EdgeInsets.only(top: 8),
|
||||||
@@ -106,6 +118,7 @@ class _ImportPreviewState extends State<ImportPreview> {
|
|||||||
await widget.controller.applyImport(
|
await widget.controller.applyImport(
|
||||||
draft,
|
draft,
|
||||||
confirmedEmpty: _clearConfirmed,
|
confirmedEmpty: _clearConfirmed,
|
||||||
|
canApply: widget.canApply,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (widget.controller.lastError == null) {
|
if (widget.controller.lastError == null) {
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../../../app/app_controller.dart';
|
||||||
|
import '../../../data/json_schedule_import.dart';
|
||||||
|
import '../../../domain/exam_schedule.dart';
|
||||||
|
import '../../profile/import_preview.dart';
|
||||||
|
import 'session.dart';
|
||||||
|
import 'exam_query_picker.dart';
|
||||||
|
|
||||||
|
class UndergraduateAuthPage extends StatefulWidget {
|
||||||
|
const UndergraduateAuthPage({
|
||||||
|
super.key,
|
||||||
|
required this.session,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
final UndergraduateSession session;
|
||||||
|
final AppController controller;
|
||||||
|
@override
|
||||||
|
State<UndergraduateAuthPage> createState() => _UndergraduateAuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
|
||||||
|
bool _saving = false;
|
||||||
|
Object? _handled;
|
||||||
|
String? _saveError;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.session.addListener(_changed);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
widget.session.setReadingActive(true);
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changed() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
final s = widget.session, result = s.pendingDraft ?? s.pendingExams;
|
||||||
|
if (_saving || result == null || identical(_handled, result)) return;
|
||||||
|
_handled = result;
|
||||||
|
_saving = true;
|
||||||
|
final generation = s.pageGeneration;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _save(s.pendingDraft, s.pendingExams, generation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(
|
||||||
|
ImportDraft? draft,
|
||||||
|
ExamSchedule? exams,
|
||||||
|
int generation,
|
||||||
|
) async {
|
||||||
|
final s = widget.session,
|
||||||
|
controller = widget.controller,
|
||||||
|
route = ModalRoute.of(context);
|
||||||
|
bool current() =>
|
||||||
|
mounted &&
|
||||||
|
route?.isActive == true &&
|
||||||
|
generation == s.pageGeneration &&
|
||||||
|
identical(draft, s.pendingDraft) &&
|
||||||
|
identical(exams, s.pendingExams);
|
||||||
|
try {
|
||||||
|
if (!current()) return;
|
||||||
|
var saved = false;
|
||||||
|
if (draft != null) {
|
||||||
|
final replacement =
|
||||||
|
controller.snapshot.courses.isNotEmpty &&
|
||||||
|
controller.snapshot.schoolTerm != draft.schoolTerm;
|
||||||
|
if (replacement || draft.courses.isEmpty) {
|
||||||
|
final confirmed = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => ImportPreview(
|
||||||
|
draft: draft,
|
||||||
|
controller: controller,
|
||||||
|
canApply: current,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!current()) return;
|
||||||
|
saved = confirmed == true;
|
||||||
|
} else {
|
||||||
|
await controller.applyImport(
|
||||||
|
draft,
|
||||||
|
confirmedEmpty: false,
|
||||||
|
canApply: current,
|
||||||
|
);
|
||||||
|
saved = controller.lastError == null;
|
||||||
|
}
|
||||||
|
} else if (exams != null) {
|
||||||
|
await controller.applyExams(exams, canApply: current);
|
||||||
|
saved = controller.lastError == null;
|
||||||
|
}
|
||||||
|
if (!current()) return;
|
||||||
|
if (saved && s.saved(generation: generation)) {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
if (route?.isCurrent == true) Navigator.pop(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(exams == null ? '课表已导入,请核对学期开始日。' : '考试信息已更新'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (controller.lastError != null) {
|
||||||
|
_saveError = controller.lastError;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_saving = false;
|
||||||
|
if (mounted) _changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.session.removeListener(_changed);
|
||||||
|
widget.session.setReadingActive(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeQuery() async {
|
||||||
|
final value = await showUndergraduateExamQueryPicker(
|
||||||
|
context,
|
||||||
|
initial: widget.session.query,
|
||||||
|
now: widget.controller.clock(),
|
||||||
|
);
|
||||||
|
if (!mounted || value == null) return;
|
||||||
|
_handled = null;
|
||||||
|
_saveError = null;
|
||||||
|
await widget.session.openExams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = widget.session;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(s.requestingExams ? '本科考试查询' : '本科课表导入'),
|
||||||
|
actions: [
|
||||||
|
if (s.requestingExams)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '查询选项',
|
||||||
|
onPressed: _saving ? null : _changeQuery,
|
||||||
|
icon: const Icon(Icons.tune_rounded),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '重新认证',
|
||||||
|
onPressed: _saving
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_handled = null;
|
||||||
|
_saveError = null;
|
||||||
|
s.reauthenticate();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (s.busy || _saving) const LinearProgressIndicator(minHeight: 2),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
child: Text(
|
||||||
|
s.message,
|
||||||
|
style: const TextStyle(fontSize: 12, height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (s.requestingExams && s.query != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Text(
|
||||||
|
'${s.query!.term.label} · ${s.query!.orderedTypes.map((t) => t.label).join('、')}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_saveError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(_saveError!, style: const TextStyle(fontSize: 12)),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saving
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_saveError = null;
|
||||||
|
_handled = null;
|
||||||
|
_changed();
|
||||||
|
},
|
||||||
|
child: const Text('重试保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: WebViewWidget(controller: s.web)),
|
||||||
|
if (!s.requestingExams)
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _saving || s.busy
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_handled = null;
|
||||||
|
_saveError = null;
|
||||||
|
s.readCurrentTimetable();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.download_rounded),
|
||||||
|
label: const Text('导入当前课表'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../domain/school_identity.dart';
|
||||||
|
import '../../../domain/school_term.dart';
|
||||||
|
import '../../../domain/undergraduate_exam_query.dart';
|
||||||
|
|
||||||
|
Future<UndergraduateExamQuery?> showUndergraduateExamQueryPicker(
|
||||||
|
BuildContext context, {
|
||||||
|
UndergraduateExamQuery? initial,
|
||||||
|
required DateTime now,
|
||||||
|
}) => showDialog<UndergraduateExamQuery>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _ExamPicker(initial: initial, now: now),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _ExamPicker extends StatefulWidget {
|
||||||
|
const _ExamPicker({this.initial, required this.now});
|
||||||
|
final UndergraduateExamQuery? initial;
|
||||||
|
final DateTime now;
|
||||||
|
@override
|
||||||
|
State<_ExamPicker> createState() => _ExamPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExamPickerState extends State<_ExamPicker> {
|
||||||
|
late SchoolTerm _term =
|
||||||
|
widget.initial?.term ??
|
||||||
|
SchoolTerm(523, school: SupportedSchool.uestcUndergraduate);
|
||||||
|
late final Set<UndergraduateExamType> _types = {
|
||||||
|
...?widget.initial?.types,
|
||||||
|
if (widget.initial == null) UndergraduateExamType.finalExam,
|
||||||
|
};
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AlertDialog(
|
||||||
|
title: const Text('本科考试查询'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('选择一个学期和需要查询的考试类型'),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
DropdownButton<SchoolTerm>(
|
||||||
|
isExpanded: true,
|
||||||
|
value: _term,
|
||||||
|
items: SchoolTerm.undergraduateChoices(widget.now, saved: _term)
|
||||||
|
.map((t) => DropdownMenuItem(value: t, child: Text(t.label)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (t) {
|
||||||
|
if (t != null) setState(() => _term = t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final type in UndergraduateExamType.values)
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(type.label),
|
||||||
|
value: _types.contains(type),
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
if (v == true) {
|
||||||
|
_types.add(type);
|
||||||
|
} else {
|
||||||
|
_types.remove(type);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _types.isEmpty
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(
|
||||||
|
context,
|
||||||
|
UndergraduateExamQuery(term: _term, types: _types),
|
||||||
|
),
|
||||||
|
child: const Text('开始查询'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'dart:convert';
|
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 {
|
||||||
|
@override
|
||||||
|
PlatformWebViewWidget createPlatformWebViewWidget(
|
||||||
|
PlatformWebViewWidgetCreationParams params,
|
||||||
|
) => FakeSchoolWidget(params);
|
||||||
int controllerCreations = 0;
|
int controllerCreations = 0;
|
||||||
late FakeSchoolWeb web;
|
late FakeSchoolWeb web;
|
||||||
late FakeSchoolDelegate delegate;
|
late FakeSchoolDelegate delegate;
|
||||||
@@ -26,6 +33,13 @@ class FakeSchoolPlatform extends WebViewPlatform {
|
|||||||
) => cookies;
|
) => cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeSchoolWidget extends PlatformWebViewWidget {
|
||||||
|
FakeSchoolWidget(super.params) : super.implementation();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
const SizedBox.expand(key: Key('fake-school-browser'));
|
||||||
|
}
|
||||||
|
|
||||||
class FakeSchoolWeb extends PlatformWebViewController {
|
class FakeSchoolWeb extends PlatformWebViewController {
|
||||||
FakeSchoolWeb(super.params) : super.implementation();
|
FakeSchoolWeb(super.params) : super.implementation();
|
||||||
final loads = <String>[];
|
final loads = <String>[];
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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/auth_page.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||||
|
import 'package:hepan_everyday/app/app_controller.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_term.dart';
|
||||||
|
|
||||||
|
import '../support/fake_school_web.dart';
|
||||||
|
import '../support/fakes.dart';
|
||||||
|
import '../school/undergraduate_session_test.dart' show underEvidence;
|
||||||
|
import '../domain/schedule_engine_test.dart' show course;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeSchoolPlatform platform;
|
||||||
|
late UndergraduateSession session;
|
||||||
|
late AppController controller;
|
||||||
|
late MemoryStore store;
|
||||||
|
setUp(() {
|
||||||
|
platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
session = UndergraduateSession();
|
||||||
|
store = MemoryStore();
|
||||||
|
});
|
||||||
|
tearDown(() {
|
||||||
|
session.dispose();
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
Future<void> open(WidgetTester tester) async {
|
||||||
|
controller = AppController(
|
||||||
|
store,
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
() => DateTime.utc(2026, 10, 14),
|
||||||
|
);
|
||||||
|
await controller.initialize(reconcileServices: false);
|
||||||
|
await session.openTimetable();
|
||||||
|
platform.web.javascript = (s) async => underEvidence(
|
||||||
|
platform.web.url!,
|
||||||
|
html: s == undergraduateCaptureScript
|
||||||
|
? File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: TextButton(
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => UndergraduateAuthPage(
|
||||||
|
session: session,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('manual importer waits for the import button and then saves', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
expect(find.text('导入当前课表'), findsOneWidget);
|
||||||
|
expect(session.pendingDraft, isNull);
|
||||||
|
expect(controller.snapshot.courses, isEmpty);
|
||||||
|
await tester.tap(find.text('导入当前课表'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
|
expect(find.text('打开'), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('cross school replacement can be cancelled without saving', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
store.value = AppSnapshot(courses: [course()], schoolTerm: SchoolTerm(73));
|
||||||
|
await open(tester);
|
||||||
|
final previous = controller.snapshot.toJson();
|
||||||
|
await tester.tap(find.text('导入当前课表'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('确认你的课表'), findsOneWidget);
|
||||||
|
await tester.ensureVisible(find.text('取消').last);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('取消').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.toJson(), previous);
|
||||||
|
expect(find.text('导入当前课表'), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('failed saving stays on the browser and can be retried', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
store.failSave = true;
|
||||||
|
await tester.tap(find.text('导入当前课表'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.courses, isEmpty);
|
||||||
|
expect(find.text('重试保存'), findsOneWidget);
|
||||||
|
store.failSave = false;
|
||||||
|
await tester.tap(find.text('重试保存'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
|
});
|
||||||
|
testWidgets('an obsolete deferred save does not strand a newer import', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
await tester.tap(find.text('导入当前课表'));
|
||||||
|
await session.openTimetable();
|
||||||
|
await session.readCurrentTimetable();
|
||||||
|
platform.delegate.finished(platform.web.url!);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.courses, hasLength(18));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/undergraduate/exam_query_picker.dart';
|
||||||
|
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_term.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('exam picker keeps one semester and requires at least one type', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
UndergraduateExamQuery? result;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
result = await showUndergraduateExamQueryPicker(
|
||||||
|
context,
|
||||||
|
now: DateTime(2026, 10),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('选择'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('选择'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('2026-2027第一学期'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('期末考试'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(find.widgetWithText(FilledButton, '开始查询'))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('期中考试'));
|
||||||
|
await tester.tap(find.text('补考/缓考'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('开始查询'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(result!.term.id, 523);
|
||||||
|
expect(result!.types, {
|
||||||
|
UndergraduateExamType.midterm,
|
||||||
|
UndergraduateExamType.makeup,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
testWidgets('saved term and exam types persist and cancel returns null', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final initial = UndergraduateExamQuery(
|
||||||
|
term: SchoolTerm(503, school: SupportedSchool.uestcUndergraduate),
|
||||||
|
types: {UndergraduateExamType.midterm},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: TextButton(
|
||||||
|
onPressed: () => showUndergraduateExamQueryPicker(
|
||||||
|
context,
|
||||||
|
initial: initial,
|
||||||
|
now: DateTime(2026, 10),
|
||||||
|
),
|
||||||
|
child: const Text('选择'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('选择'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('2025-2026第二学期'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CheckboxListTile>(
|
||||||
|
find.widgetWithText(CheckboxListTile, '期中考试'),
|
||||||
|
)
|
||||||
|
.value,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('开始查询'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user