diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 73ed250..fb54168 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -53,6 +53,7 @@ class AppController extends ChangeNotifier { Future _save(AppSnapshot next) async { _checkStorage(); + AppSnapshot.validateCourseSource(next.courses, next.schoolTerm); await store.save(next); snapshot = next; lastError = null; diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index b079cf6..4514ab0 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -15,6 +15,9 @@ import '../features/timetable/timetable_page.dart'; import '../features/profile/profile_page.dart'; import '../features/school/school_session.dart'; import '../features/school/school_selection_page.dart'; +import '../features/school/undergraduate/session.dart'; +import '../features/school/undergraduate/exam_query_picker.dart'; +import '../domain/undergraduate_exam_query.dart'; class HepanApp extends StatelessWidget { const HepanApp({ @@ -22,12 +25,16 @@ class HepanApp extends StatelessWidget { required this.controller, this.schoolBuilder, this.school, + this.undergraduate, + this.undergraduateBuilder, this.notificationSelection, this.feedbackSender, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; + final UndergraduateSession? undergraduate; + final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender? feedbackSender; @override @@ -42,6 +49,8 @@ class HepanApp extends StatelessWidget { controller: controller, schoolBuilder: schoolBuilder, school: school, + undergraduate: undergraduate, + undergraduateBuilder: undergraduateBuilder, notificationSelection: notificationSelection, feedbackSender: feedbackSender ?? configuredFeedbackSender(), ), @@ -53,12 +62,16 @@ class _Shell extends StatefulWidget { required this.controller, this.schoolBuilder, this.school, + this.undergraduate, + this.undergraduateBuilder, this.notificationSelection, required this.feedbackSender, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; + final UndergraduateSession? undergraduate; + final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender feedbackSender; @override @@ -68,11 +81,13 @@ class _Shell extends StatefulWidget { class _ShellState extends State<_Shell> with WidgetsBindingObserver { int _tab = 0; bool _openingSchool = false; - SupportedSchool? _selectedSchool; // Earlier imports all belong to UESTC graduate; reuse their existing term // and session without requiring students to choose again on every refresh. SupportedSchool? get _knownSchool => - _selectedSchool ?? + widget.controller.snapshot.selectedSchool ?? + (widget.undergraduate?.canLogout == true + ? SupportedSchool.uestcUndergraduate + : null) ?? (widget.controller.snapshot.schoolTerm != null || widget.school?.canLogout == true ? SupportedSchool.uestcGraduate @@ -149,14 +164,15 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ), ); if (!mounted || selected == null) return; - setState(() => _selectedSchool = selected); + final saved = await widget.controller.selectSchool(selected); + if (!mounted || !saved) return; } if (!mounted) return; - // The only current connector is the existing UESTC graduate workflow. - // A new catalog option must provide its own connector here. switch (selected) { case SupportedSchool.uestcGraduate: await _openSchool(exams: exams, chooseTerm: chooseTerm); + case SupportedSchool.uestcUndergraduate: + await _openUndergraduate(exams: exams, chooseTerm: chooseTerm); } } finally { _openingSchool = false; @@ -168,14 +184,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { bool chooseTerm = false, }) async { if (widget.schoolBuilder != null) { - final term = widget.controller.snapshot.schoolTerm; + final savedTerm = widget.controller.snapshot.schoolTerm; + final term = savedTerm?.school == SupportedSchool.uestcGraduate + ? savedTerm + : null; if (exams) { - widget.school?.openExams( + await widget.school?.openExams( term: term, chooseTerm: chooseTerm || term == null, ); } else { - widget.school?.openTimetable( + await widget.school?.openTimetable( term: term, chooseTerm: chooseTerm || term == null, ); @@ -201,6 +220,51 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { } } + Future _openUndergraduate({ + required bool exams, + required bool chooseTerm, + }) async { + final session = widget.undergraduate, builder = widget.undergraduateBuilder; + if (session == null || builder == null) { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('学校连接'), + content: const Text('本科连接暂未准备好,请重试。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('知道了'), + ), + ], + ), + ); + return; + } + if (exams) { + var query = UndergraduateExamQuery.fromSchedule( + widget.controller.snapshot.examSchedule, + ); + if (query == null || chooseTerm) { + query = await showUndergraduateExamQueryPicker( + context, + initial: query, + now: widget.controller.clock(), + ); + if (!mounted || query == null) return; + } + await session.openExams(query); + } else { + await session.openTimetable(); + } + if (!mounted) return; + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => builder()), + ); + session.setReadingActive(false); + } + Future _logout() async { final choice = await showDialog( context: context, @@ -224,7 +288,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ), ); if (choice == null || choice == 0) return; - await widget.school?.logout(); + if (_knownSchool == SupportedSchool.uestcUndergraduate && + widget.undergraduate?.canLogout == true) { + await widget.undergraduate!.logout(); + widget.school?.invalidateAuthentication(); + } else if (widget.school?.canLogout == true) { + await widget.school!.logout(); + widget.undergraduate?.invalidateAuthentication(); + } else if (widget.undergraduate?.canLogout == true) { + await widget.undergraduate!.logout(); + widget.school?.invalidateAuthentication(); + } if (choice == 2) { await widget.controller.clearCourses(); await widget.controller.clearExams(); @@ -261,6 +335,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { animation: Listenable.merge([ widget.controller, if (widget.school != null) widget.school!, + if (widget.undergraduate != null) widget.undergraduate!, ]), builder: (context, _) => Scaffold( body: Container( @@ -316,6 +391,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { onSync: () { _school(exams: true); }, + onQueryOptions: + _knownSchool == + SupportedSchool.uestcUndergraduate + ? () => _school(exams: true, chooseTerm: true) + : null, ), ProfilePage( controller: widget.controller, @@ -323,11 +403,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { onLogin: () => _school(selectSchool: true), schoolName: _knownSchool?.label, onUpdateTerm: () => _school(chooseTerm: true), - authStatus: widget.school?.statusLabel ?? '未登录', - logoutLabel: widget.school?.logoutFailed == true + authStatus: + _knownSchool == + SupportedSchool.uestcUndergraduate + ? widget.undergraduate?.statusLabel ?? '未登录' + : widget.school?.statusLabel ?? '未登录', + logoutLabel: + widget.school?.logoutFailed == true || + widget.undergraduate?.logoutFailed == true ? '重试退出学校登录' : '退出学校登录', - onLogout: widget.school?.canLogout == true + onLogout: + widget.school?.canLogout == true || + widget.undergraduate?.canLogout == true ? _logout : null, ), diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index 8c33322..54650c5 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -43,6 +43,26 @@ class AppSnapshot { final Map calendarEventIds; final bool calendarCleanupPending; final SupportedSchool? selectedSchool; + static void validateCourseSource(List courses, SchoolTerm? term) { + if (term?.school != SupportedSchool.uestcUndergraduate && + courses.any((c) => c.endPeriod > 11)) { + throw const FormatException('第12节需要本科课表来源'); + } + for (final course in courses) { + final under = RegExp(r'^uestc-undergraduate-(\d+)-') + .firstMatch(course.id); + if (under != null && + (term?.school != SupportedSchool.uestcUndergraduate || + under[1] != term?.id.toString())) { + throw const FormatException('本科课程学校或学期不一致'); + } + if (term?.school == SupportedSchool.uestcUndergraduate && + RegExp(r'^uestc-\d+-').hasMatch(course.id)) { + throw const FormatException('本科课表包含研究生课程标识'); + } + } + } + static SchoolTerm? _legacyTerm(List courses, ExamSchedule? exams) { final ids = courses .map( @@ -195,9 +215,7 @@ class AppSnapshot { (k, v) => MapEntry(k as String, v as String), ), ); - if (snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate && - values.any((c) => c.endPeriod > 11)) - throw const FormatException('第12节需要本科课表来源'); + validateCourseSource(values, snapshot.schoolTerm); return snapshot; } catch (_) { throw const FormatException('保存文件内容无效,未覆盖已有数据'); diff --git a/mobile/lib/domain/lesson_times.dart b/mobile/lib/domain/lesson_times.dart index 7704c1e..1cf3451 100644 --- a/mobile/lib/domain/lesson_times.dart +++ b/mobile/lib/domain/lesson_times.dart @@ -15,6 +15,7 @@ const lessonSlots = [ LessonSlot(9, 1170, 1215), LessonSlot(10, 1220, 1265), LessonSlot(11, 1270, 1315), + LessonSlot(12, 1320, 1365), ]; String minuteLabel(int minute) => '${(minute ~/ 60).toString().padLeft(2, '0')}:${(minute % 60).toString().padLeft(2, '0')}'; diff --git a/mobile/lib/features/exams/exams_page.dart b/mobile/lib/features/exams/exams_page.dart index 31d46e7..8d99559 100644 --- a/mobile/lib/features/exams/exams_page.dart +++ b/mobile/lib/features/exams/exams_page.dart @@ -6,12 +6,20 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; -import '../school/school_exam_adapter.dart'; +import '../../data/exam_file_import.dart'; +import '../../domain/school_identity.dart'; +import '../../domain/undergraduate_exam_query.dart'; class ExamsPage extends StatelessWidget { - const ExamsPage({super.key, required this.controller, required this.onSync}); + const ExamsPage({ + super.key, + required this.controller, + required this.onSync, + this.onQueryOptions, + }); final AppController controller; final VoidCallback onSync; + final VoidCallback? onQueryOptions; Future _import(BuildContext context) async { try { final file = await FilePicker.pickFile( @@ -28,7 +36,7 @@ class ExamsPage extends StatelessWidget { } bytes.add(chunk); } - final draft = SchoolExamAdapter.extract(utf8.decode(bytes.takeBytes())); + final draft = ExamFileImport.parse(utf8.decode(bytes.takeBytes())); if (!context.mounted) return; final confirmed = await showDialog( context: context, @@ -95,6 +103,12 @@ class ExamsPage extends StatelessWidget { onPressed: onSync, icon: const Icon(Icons.sync_rounded), ), + if (onQueryOptions != null) + IconButton( + tooltip: '查询选项', + onPressed: onQueryOptions, + icon: const Icon(Icons.tune_rounded), + ), IconButton( tooltip: '导入考试 HTML', onPressed: () => _import(context), @@ -132,11 +146,21 @@ class ExamsPage extends StatelessWidget { ), ] else ...[ Text( - '${schedule.semesterLabel} · ${schedule.records.length} 门课程', + '${schedule.school.label} · ${schedule.semesterLabel} · ${schedule.records.length} 条考试', style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)), ), const SizedBox(height: 18), - if (schedule.records.isEmpty) const Text('该学期暂无考试课程'), + if (schedule.school == SupportedSchool.uestcUndergraduate) + Text( + '查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}', + style: const TextStyle(fontSize: 12), + ), + if (schedule.records.isEmpty) + Text( + schedule.school == SupportedSchool.uestcUndergraduate + ? '所选考试类型暂未安排' + : '该学期暂无考试课程', + ), for (final exam in schedule.records) Padding( padding: const EdgeInsets.only(bottom: 14), @@ -152,9 +176,25 @@ class ExamsPage extends StatelessWidget { ), ), const SizedBox(height: 14), + if (exam.date.isNotEmpty) + _field(Icons.calendar_today_rounded, '考试日期', exam.date), _field(Icons.schedule_rounded, '考试时间', exam.time), _field(Icons.location_on_outlined, '考场', exam.room), _field(Icons.event_seat_outlined, '座位号', exam.seat), + if (exam.examTypeId != null) + _field( + Icons.category_outlined, + '考试类型', + UndergraduateExamType.fromId(exam.examTypeId!).label, + ), + if (exam.status.isNotEmpty) + _field( + Icons.assignment_turned_in_outlined, + '考试情况', + exam.status, + ), + if (exam.notes.isNotEmpty) + _field(Icons.info_outline_rounded, '说明', exam.notes), if (exam.assessment.isNotEmpty) _field( Icons.assignment_outlined, diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index 63c959a..357d464 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -506,6 +506,25 @@ class SchoolSession extends ChangeNotifier { return future.whenComplete(() => _logoutFuture = null); } + void invalidateAuthentication() { + _openSerial++; + _pageGeneration++; + _reader.invalidate(); + _flow.reset(); + _poll?.cancel(); + _timeout?.cancel(); + _readingActive = false; + _routeClosed = true; + hasOpened = false; + pendingDraft = null; + pendingExams = null; + finding = false; + _loading = false; + connectionState = SchoolConnectionState.signedOut; + message = '学校登录已退出'; + notifyListeners(); + } + Future _performLogout() async { _openSerial++; _pageGeneration++; diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart index 861917c..9c3417a 100644 --- a/mobile/lib/features/school/undergraduate/session.dart +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -368,6 +368,8 @@ class UndergraduateSession extends ChangeNotifier { void invalidateAuthentication() { _reset(); + _active = false; + hasOpened = false; _connected = false; awaitingAuthentication = false; message = '学校登录已退出'; diff --git a/mobile/lib/features/timetable/course_details.dart b/mobile/lib/features/timetable/course_details.dart index b454c05..9050caa 100644 --- a/mobile/lib/features/timetable/course_details.dart +++ b/mobile/lib/features/timetable/course_details.dart @@ -24,6 +24,10 @@ void showCourseDetails(BuildContext context, CourseOccurrence event) { const SizedBox(height: 20), _line(Icons.person_outline_rounded, event.course.teacher), _line(Icons.location_on_outlined, event.course.room), + if (event.course.courseCode.isNotEmpty) + _line(Icons.tag_rounded, '课程代码:${event.course.courseCode}'), + if (event.course.notes.isNotEmpty) + _line(Icons.info_outline_rounded, event.course.notes), _line( Icons.schedule_rounded, '${minuteLabel(event.start.hour * 60 + event.start.minute)}–${minuteLabel(event.end.hour * 60 + event.end.minute)} · 第 ${event.course.startPeriod}–${event.course.endPeriod} 节', diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index d0a68ae..65a5a9d 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; import '../../domain/schedule_engine.dart'; +import '../../domain/school_identity.dart'; import '../../widgets/directional_week_transition.dart'; import '../profile/settings_page.dart'; import 'week_grid.dart'; @@ -84,13 +85,24 @@ class _TimetablePageState extends State { Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), child: Text( - c.snapshot.schoolTerm!.label, + '${c.snapshot.schoolTerm!.school.label} · ${c.snapshot.schoolTerm!.label}', style: TextStyle( fontSize: 12, color: ink.withValues(alpha: 0.5), ), ), ), + if (!c.isDemo && + c.snapshot.schoolTerm != null && + c.snapshot.selectedSchool != null && + c.snapshot.selectedSchool != c.snapshot.schoolTerm!.school) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Text( + '当前仍为${c.snapshot.schoolTerm!.school.label}课表;新课表导入成功后才会替换。', + style: const TextStyle(fontSize: 12, color: river), + ), + ), const SizedBox(height: 10), SurfaceCard( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), @@ -182,6 +194,12 @@ class _TimetablePageState extends State { monday: monday, today: today, settings: c.visibleSettings, + periodCount: + !c.isDemo && + c.snapshot.schoolTerm?.school == + SupportedSchool.uestcUndergraduate + ? 12 + : 11, ), ), const SizedBox(height: 12), diff --git a/mobile/lib/features/timetable/week_grid.dart b/mobile/lib/features/timetable/week_grid.dart index a186c5d..8ba83dc 100644 --- a/mobile/lib/features/timetable/week_grid.dart +++ b/mobile/lib/features/timetable/week_grid.dart @@ -14,7 +14,9 @@ class WeekGrid extends StatelessWidget { required this.monday, required this.today, required this.settings, - }); + this.periodCount = 11, + }) : assert(periodCount == 11 || periodCount == 12); + final int periodCount; final List events; final DateTime monday, today; final AppSettings settings; @@ -111,7 +113,7 @@ class WeekGrid extends StatelessWidget { (period - 1) * rowHeight + (period > 4 ? gap : 0) + (period > 8 ? gap : 0); - final height = 11 * rowHeight + 2 * gap; + final height = periodCount * rowHeight + 2 * gap; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -120,7 +122,7 @@ class WeekGrid extends StatelessWidget { height: header + height, child: Stack( children: [ - for (final slot in lessonSlots) + for (final slot in lessonSlots.take(periodCount)) Positioned( top: header + top(slot.period), left: 0, @@ -244,7 +246,7 @@ class WeekGrid extends StatelessWidget { ), ), ), - for (var p = 1; p <= 11; p++) + for (var p = 1; p <= periodCount; p++) Positioned( left: 0, right: 0, diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1ac8209..addf1e7 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,15 +12,25 @@ import 'services/local_notification_service.dart'; import 'services/android_calendar_service.dart'; import 'features/school/school_session.dart'; import 'features/school/school_auth_page.dart'; +import 'features/school/undergraduate/session.dart'; +import 'features/school/undergraduate/auth_page.dart'; Widget buildApplication( AppController controller, { ValueNotifier? notificationSelection, SchoolSession? school, + UndergraduateSession? undergraduate, }) => HepanApp( controller: controller, notificationSelection: notificationSelection, school: school, + undergraduate: undergraduate, + undergraduateBuilder: undergraduate == null + ? null + : () => UndergraduateAuthPage( + session: undergraduate, + controller: controller, + ), schoolBuilder: school == null ? null : () => SchoolAuthPage(session: school, controller: controller), @@ -47,6 +57,7 @@ Future main() async { value, notificationSelection: selection, school: SchoolSession(), + undergraduate: UndergraduateSession(), ); }, onHomeReady: () { diff --git a/mobile/test/domain/schedule_engine_test.dart b/mobile/test/domain/schedule_engine_test.dart index b1c07a8..19a9231 100644 --- a/mobile/test/domain/schedule_engine_test.dart +++ b/mobile/test/domain/schedule_engine_test.dart @@ -106,7 +106,7 @@ void main() { }); test('invalid lesson, weekday and week values are rejected', () { expect(() => course(first: 0), throwsArgumentError); - expect(() => course(last: 12), throwsArgumentError); + expect(() => course(last: 13), throwsArgumentError); expect(() => course(day: 8), throwsArgumentError); expect(() => course(weeks: {0}), throwsArgumentError); expect( diff --git a/mobile/test/domain/undergraduate_models_test.dart b/mobile/test/domain/undergraduate_models_test.dart index 6556f91..2c0199c 100644 --- a/mobile/test/domain/undergraduate_models_test.dart +++ b/mobile/test/domain/undergraduate_models_test.dart @@ -7,6 +7,37 @@ import 'package:hepan_everyday/domain/app_snapshot.dart'; import 'package:hepan_everyday/domain/course.dart'; void main() { + test( + 'managed undergraduate identifiers cannot lose their school or term', + () { + final c = Course( + id: 'uestc-undergraduate-523-managed', + name: '课程', + teacher: '教师', + room: '教室', + weekday: 1, + startPeriod: 1, + endPeriod: 2, + weeks: {1}, + source: CourseSource.school, + ); + final saved = AppSnapshot( + courses: [c], + schoolTerm: SchoolTerm(523, school: SupportedSchool.uestcUndergraduate), + ).toJson(); + expect( + () => AppSnapshot.fromJson({ + ...saved, + 'schoolTerm': {'id': 73}, + }), + throwsFormatException, + ); + expect( + () => AppSnapshot.fromJson({...saved, 'schoolTerm': null}), + throwsFormatException, + ); + }, + ); SchoolTerm under(int id) => SchoolTerm(id, school: SupportedSchool.uestcUndergraduate); test('undergraduate semester labels and final exam URLs', () { diff --git a/mobile/test/widgets/undergraduate_grid_test.dart b/mobile/test/widgets/undergraduate_grid_test.dart new file mode 100644 index 0000000..f058e07 --- /dev/null +++ b/mobile/test/widgets/undergraduate_grid_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; +import 'package:hepan_everyday/domain/school_identity.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; +import 'package:hepan_everyday/features/timetable/timetable_page.dart'; +import 'package:hepan_everyday/features/timetable/week_grid.dart'; +import 'package:hepan_everyday/services/local_notification_service.dart'; +import 'package:hepan_everyday/services/android_calendar_service.dart'; + +import '../support/fakes.dart'; +import '../domain/schedule_engine_test.dart' show course; +import '../services/notification_service_test.dart' show AlarmGateway; +import '../services/calendar_service_test.dart' show CalendarProvider; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets('row count follows imported school across empty weeks and demo', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = AppController( + MemoryStore(), + FakeNotifications(), + FakeCalendar(), + () => DateTime.utc(2026, 9, 7), + ); + addTearDown(controller.dispose); + final settings = AppSettings( + semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20), + ); + controller.snapshot = AppSnapshot( + settings: settings, + schoolTerm: SchoolTerm(523, school: SupportedSchool.uestcUndergraduate), + courses: [ + course(first: 9, last: 12, weeks: {2}), + ], + selectedSchool: SupportedSchool.uestcGraduate, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TimetablePage(controller: controller)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(WeekGrid)).periodCount, 12); + expect( + find.byWidgetPredicate( + (w) => w is Text && w.data == '12' && w.style?.color == null, + ), + findsOneWidget, + ); + expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360)); + await controller.showDemo(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TimetablePage(controller: controller)), + ), + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(WeekGrid)).periodCount, 11); + }); + test('period twelve drives reminders and calendar ending at 22:45', () async { + final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1), + events = ScheduleEngine.occurrences([ + course(day: 1, first: 12, last: 12), + ], Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1)); + expect(events.single.start.hour, 22); + expect(events.single.end.hour, 22); + expect(events.single.end.minute, 45); + final night = ScheduleEngine.occurrences([ + course(day: 1, first: 9, last: 12), + ], term).single; + expect(night.start.hour, 19); + expect(night.start.minute, 30); + expect(night.end.minute, 45); + final gateway = AlarmGateway(); + await LocalNotificationService(gateway).reconcile( + events, + AppSettings(semester: term, reminderOffsets: [5, 15]), + DateTime.utc(2026, 9, 6), + ); + expect( + gateway.alarms.values + .map((a) => '${a.fireAt.hour}:${a.fireAt.minute}') + .toSet(), + {'21:55', '21:45'}, + ); + final provider = CalendarProvider(); + await AndroidCalendarService(provider).sync(events, 'local', {}); + expect( + provider.drafts.values.single.endMs, + events.single.end.millisecondsSinceEpoch, + ); + }); +} diff --git a/mobile/test/widgets/undergraduate_navigation_test.dart b/mobile/test/widgets/undergraduate_navigation_test.dart new file mode 100644 index 0000000..10121c3 --- /dev/null +++ b/mobile/test/widgets/undergraduate_navigation_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:hepan_everyday/app/hepan_app.dart'; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/school_identity.dart'; +import 'package:hepan_everyday/domain/exam_schedule.dart'; +import 'package:hepan_everyday/domain/undergraduate_exam_query.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; +import 'package:hepan_everyday/features/school/undergraduate/session.dart'; + +import '../support/fakes.dart'; +import '../support/fake_school_web.dart'; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'undergraduate selection persists and enters its manual workflow', + (tester) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final store = MemoryStore(), + controller = AppController( + MemoryStore(), + FakeNotifications(), + FakeCalendar(), + () => DateTime.utc(2026, 10, 14), + ); + final under = UndergraduateSession(); + addTearDown(controller.dispose); + addTearDown(under.dispose); + await controller.initialize(); + await tester.pumpWidget( + HepanApp( + controller: controller, + undergraduate: under, + undergraduateBuilder: () => const Scaffold(body: Text('本科网页')), + ), + ); + await tester.pumpAndSettle(); + expect(platform.controllerCreations, 0); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '选择学校')); + await tester.pumpAndSettle(); + expect(find.text('电子科技大学(研究生)'), findsOneWidget); + expect(find.text('电子科技大学(本科生)'), findsOneWidget); + await tester.tap(find.text('电子科技大学(本科生)')); + await tester.pumpAndSettle(); + expect(find.text('本科网页'), findsOneWidget); + expect( + controller.snapshot.selectedSchool, + SupportedSchool.uestcUndergraduate, + ); + expect(platform.web.loads, ['https://eams.uestc.edu.cn/eams/']); + expect(under.requestingExams, isFalse); + store.value = AppSnapshot.fromJson(controller.snapshot.toJson()); + expect(store.value!.selectedSchool, SupportedSchool.uestcUndergraduate); + }, + ); + testWidgets('failed school preference does not create a browser', ( + tester, + ) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final store = MemoryStore(), under = UndergraduateSession(); + final controller = AppController( + store, + FakeNotifications(), + FakeCalendar(), + DateTime.now, + ); + addTearDown(controller.dispose); + addTearDown(under.dispose); + await controller.initialize(); + store.failSave = true; + await tester.pumpWidget( + HepanApp( + controller: controller, + undergraduate: under, + undergraduateBuilder: () => const Scaffold(body: Text('本科网页')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '选择学校')); + await tester.pumpAndSettle(); + await tester.tap(find.text('电子科技大学(本科生)')); + await tester.pumpAndSettle(); + expect(platform.controllerCreations, 0); + expect(controller.snapshot.selectedSchool, isNull); + expect(find.text('本科网页'), findsNothing); + }); + testWidgets('exam refresh reuses the successful undergraduate query', ( + tester, + ) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final under = UndergraduateSession(); + final store = MemoryStore( + AppSnapshot( + selectedSchool: SupportedSchool.uestcUndergraduate, + examSchedule: ExamSchedule( + semesterLabel: '2025-2026第二学期', + termId: '503', + school: SupportedSchool.uestcUndergraduate, + requestedExamTypes: {1, 2}, + records: [], + ), + ), + ); + final controller = AppController( + store, + FakeNotifications(), + FakeCalendar(), + DateTime.now, + ); + addTearDown(controller.dispose); + addTearDown(under.dispose); + await controller.initialize(); + await tester.pumpWidget( + HepanApp( + controller: controller, + undergraduate: under, + undergraduateBuilder: () => const Scaffold(body: Text('本科网页')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('考试')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('从学校更新考试')); + await tester.pumpAndSettle(); + expect(find.text('本科网页'), findsOneWidget); + expect(under.query!.term.id, 503); + expect(under.query!.types, { + UndergraduateExamType.finalExam, + UndergraduateExamType.midterm, + }); + }); + testWidgets('query option cancellation starts no login', (tester) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final under = UndergraduateSession(); + final controller = AppController( + MemoryStore( + AppSnapshot(selectedSchool: SupportedSchool.uestcUndergraduate), + ), + FakeNotifications(), + FakeCalendar(), + DateTime.now, + ); + addTearDown(controller.dispose); + addTearDown(under.dispose); + await controller.initialize(); + await tester.pumpWidget( + HepanApp( + controller: controller, + undergraduate: under, + undergraduateBuilder: () => const Scaffold(body: Text('本科网页')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('考试')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('查询选项')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(platform.controllerCreations, 0); + }); + testWidgets( + 'shared logout invalidates both schools without creating another browser', + (tester) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final under = UndergraduateSession(), grad = SchoolSession(); + await grad.openTimetable(); + await under.openTimetable(); + final controller = AppController( + MemoryStore( + AppSnapshot(selectedSchool: SupportedSchool.uestcUndergraduate), + ), + FakeNotifications(), + FakeCalendar(), + DateTime.now, + ); + addTearDown(controller.dispose); + addTearDown(under.dispose); + addTearDown(grad.dispose); + await controller.initialize(); + await tester.pumpWidget( + HepanApp(controller: controller, school: grad, undergraduate: under), + ); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + await tester.tap(find.text('退出学校登录')); + await tester.pumpAndSettle(); + await tester.tap(find.text('保留课表和考试')); + await tester.pumpAndSettle(); + expect(platform.controllerCreations, 2); + expect(under.canLogout, isFalse); + expect(grad.canLogout, isFalse); + }, + ); +}