feat: deliver Riverside Schedule seven-day grid and school HTML import

This commit is contained in:
Codex
2026-10-13 11:18:50 +08:00
parent 91a2522043
commit 8403be05da
29 changed files with 1246 additions and 446 deletions
+3 -5
View File
@@ -1,7 +1,5 @@
# 学校页面验证状态
# 学校页面测试样本
当前没有登录后的研究生课表样本,也没有以人工构造表格冒充学校样本。
已根据2026-10-13提供的课表HTML实现SchoolPageAdapter。自动化测试中的fixture()只合成星期列、节次表格和课程记录,不含本人姓名、学号、脚本或登录信息。
2026-10-12 已核对公开认证页面:密码表单位于 `.login-main #pwdFromId`,使用 username/passwordText 输入、saltPassword 隐藏加密字段、pwdEncryptSalt 与 startLogin/checkForm/encryptPassword 的学校原流程。自定义界面只填充此经过运行时检查的表单,并等待学校验证码检查结束后触发学校登录按钮。表单不匹配时回到学校页面。
实际账号认证、验证码、研究生 SSO 跳转与课表识别还需本人设备登录验证。无需在聊天中提供账号密码。SchoolPageAdapter 当前拒绝全部未验证布局,保持已导入的离线课表不变。
本机可设置RIVERSIDE_HTML_FIXTURE环境变量指向本人保存的HTML,验证7门课程和11条安排;此文件不复制进源码或安装包。实时账号认证、验证码和研究生SSO仍需要本人设备验证。
@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:hepan_everyday/features/school/school_navigation_policy.dart';
import 'package:hepan_everyday/data/schedule_file_import.dart';
import 'package:hepan_everyday/domain/course.dart';
import 'recognized_timetable_test.dart' show fixture;
void main() {
final target = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1',
);
test('in-flight read cannot authorize a different origin', () async {
final reader = SchoolReader(SchoolPageAdapter()),
body = Completer<String>();
final valid = reader.read(target, () => body.future);
final rejected = reader.read(
Uri.parse('https://evil.example/pyxx/pygl/xskbcx/index/1'),
() async => fixture(),
);
body.complete(fixture());
await expectLater(rejected, throwsA(isA<UnsupportedSchoolLayout>()));
await valid;
});
test('invalidated read cannot become a fresh authenticated draft', () async {
final reader = SchoolReader(SchoolPageAdapter()),
body = Completer<String>();
final stale = reader.read(target, () => body.future);
final check = expectLater(stale, throwsA(isA<UnsupportedSchoolLayout>()));
reader.invalidate();
final fresh = await reader.read(target, () async => fixture());
body.complete(fixture());
await check;
expect(fresh.courses.length, 4);
expect(reader.busy, isFalse);
});
test('official numeric semester routes are allowed but alternate endpoints are not', () {
expect(
SchoolNavigationPolicy.isTimetable(
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
),
isTrue,
);
for (final url in [
'http://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1',
'https://user@yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1',
'https://yjsjy.uestc.edu.cn:444/pyxx/pygl/xskbcx/index/1',
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73/other',
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73evil',
]) {
expect(SchoolNavigationPolicy.isTimetable(Uri.parse(url)), isFalse);
}
});
test('BOM saved HTML file imports without claiming live authentication', () {
final draft = ScheduleFileImport.parse('\uFEFF${fixture()}');
expect(draft.courses.length, 4);
expect(draft.semesterLabel, '2026-2027秋');
expect(draft.source, CourseSource.file);
expect(draft.courses.every((c) => c.source == CourseSource.file), isTrue);
expect(
() => ScheduleFileImport.parse('<html>登录</html>'),
throwsA(isA<UnsupportedSchoolLayout>()),
);
});
}
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:hepan_everyday/domain/course.dart';
String fixture() {
const records = {
't51': '101/示例集成电路设计(1班)/32/2/示例教师甲/1-3周/1~2节/示例楼B212<br>101/示例集成电路设计(1班)/32/2/示例教师甲/6-6周/1~2节/示例楼B212<br>101/示例集成电路设计(1班)/32/2/示例教师甲/7-9周/1~2节/示例楼B212',
't13': '101/示例集成电路设计(1班)/32/2/示例教师甲/1-5周/3~4节/示例楼B212',
't23': '102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/1-5周/3~4节/示例楼B102<br>102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/7-9周/3~4节/示例楼B102',
't29': '103/示例现代化理论与实践(3班)/36/2/示例教师丁/1-12周/9~11节/示例楼B107',
};
return '<select id="drpxq"><option value="73" selected>2026-2027秋</option></select><table id="tbl"><tr><th>节次</th>${['一', '二', '三', '四', '五', '六', '日'].map((d) => '<th>星期$d</th>').join()}</tr>${[1, 3, 5, 7, 9].map((p) => '<tr><td id="t0$p">$p-${p == 9 ? 11 : p + 1}节</td>${List.generate(7, (i) => '<td id="t${i + 1}$p">${records['t${i + 1}$p'] ?? ''}</td>').join()}</tr>').join()}</table>';
}
void main() {
final url = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1');
test('school DOM merges week segments without losing full metadata or evening periods', () {
final draft = SchoolPageAdapter().extract(fixture(), url);
expect(draft.courses.length, 4);
final friday = draft.courses.singleWhere((c) => c.weekday == 5);
expect(friday.weeks, {1, 2, 3, 6, 7, 8, 9});
final signal = draft.courses.singleWhere(
(c) => c.weekday == 2 && c.startPeriod == 3,
);
expect(signal.teacher, '示例教师乙、示例教师丙');
expect(signal.room, '示例楼B102');
final evening = draft.courses.singleWhere((c) => c.startPeriod == 9);
expect(evening.endPeriod, 11);
expect(draft.source, CourseSource.school);
expect(draft.explicitlyEmpty, isFalse);
expect(
SchoolPageAdapter().extract(fixture(), url).courses.map((c) => c.id),
draft.courses.map((c) => c.id),
);
});
test('invalid cell, missing weekday, mismatched period and empty grid fail closed', () {
for (final body in [
fixture().replaceFirst('1-3周', '未知周'),
fixture().replaceFirst('id="t71"', 'id="missing"'),
fixture().replaceFirst('1~2节', '5~6节'),
fixture().replaceAll(RegExp(r'\d+/示例[^<]+'), ''),
fixture().replaceFirst('星期日', '星期八'),
]) {
expect(
() => SchoolPageAdapter().extract(body, url),
throwsA(isA<UnsupportedSchoolLayout>()),
);
}
});
test('saved HTML uses file provenance and does not authenticate a school session', () {
final draft = SchoolPageAdapter().extract(
fixture(),
url,
source: CourseSource.file,
);
expect(draft.source, CourseSource.file);
expect(draft.courses.every((c) => c.source == CourseSource.file), isTrue);
});
test('actual attached HTML imports eleven arrangements (local optional verification)', () {
final file = File(
Platform.environment['RIVERSIDE_HTML_FIXTURE'] ?? 'not-present',
);
if (!file.existsSync()) return;
final draft = SchoolPageAdapter().extract(file.readAsStringSync(), url);
expect(draft.courses.length, 11);
expect(draft.courses.map((c) => c.name).toSet().length, 7);
expect(
draft.courses
.singleWhere((c) => c.weekday == 2 && c.startPeriod == 9)
.endPeriod,
11,
);
expect(
draft.courses
.singleWhere((c) => c.weekday == 2 && c.startPeriod == 3)
.teacher
.split('、')
.length,
2,
);
});
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/features/timetable/week_grid.dart';
import 'package:hepan_everyday/domain/schedule_engine.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
void main() {
setUpAll(tzdata.initializeTimeZones);
for (final scale in [1.0, 2.0]) {
testWidgets(
'seven days and full teacher/room fit equal duration cards at scale $scale',
(tester) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1);
Course c(String id, int day, String teacher, String room) => Course(
id: id,
name: '很长的课程名称应当可以省略但点击后显示完整课程名称$id',
teacher: teacher,
room: room,
weekday: day,
startPeriod: 3,
endPeriod: 4,
weeks: {1},
source: CourseSource.file,
);
final events = ScheduleEngine.occurrences([
c('a', 1, '张晓玲、杨拥军', '品学楼B102'),
c('b', 7, '李老师', '立人楼教学实验中心B412'),
], term);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(
size: const Size(360, 800),
textScaler: TextScaler.linear(scale),
),
child: Scaffold(
body: SingleChildScrollView(
child: WeekGrid(
events: events,
monday: term.firstMonday,
today: term.startDate,
settings: const AppSettings(),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.getRect(find.text('周一')).left, greaterThanOrEqualTo(0));
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360));
final first = tester.getRect(
find.bySemanticsLabel('${events.first.course.name},张晓玲、杨拥军,品学楼B102'),
);
final last = tester.getRect(
find.bySemanticsLabel('${events.last.course.name},李老师,立人楼教学实验中心B412'),
);
expect(first.height, last.height);
for (final text in ['张晓玲、杨拥军', '品学楼B102', '李老师', '立人楼教学实验中心B412']) {
final finder = find.text(text);
final widget = tester.widget<Text>(finder);
expect(widget.maxLines, isNull);
expect(widget.overflow, isNot(TextOverflow.ellipsis));
final paragraph = tester.renderObject<RenderParagraph>(finder);
expect(paragraph.didExceedMaxLines, isFalse);
final rect = tester.getRect(finder);
final card = text == '张晓玲、杨拥军' || text == '品学楼B102' ? first : last;
expect(rect.bottom, lessThanOrEqualTo(card.bottom));
expect(rect.left, greaterThanOrEqualTo(card.left));
expect(rect.right, lessThanOrEqualTo(card.right));
}
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text(events.first.course.name));
await tester.tap(find.text(events.first.course.name));
await tester.pumpAndSettle();
expect(find.text(events.first.course.name), findsNWidgets(2));
},
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/features/timetable/week_grid.dart';
import 'package:hepan_everyday/domain/schedule_engine.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'conflicts keep readable full-width metadata without covering courses',
(tester) async {
tester.view.physicalSize = const Size(320, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1);
final courses = List.generate(
5,
(i) => Course(
id: '$i',
name: '并行课程$i',
teacher: '示例教师$i',
room: '示例教学楼B10$i',
weekday: 1,
startPeriod: 1,
endPeriod: 2,
weeks: {1},
source: CourseSource.file,
),
);
final events = ScheduleEngine.occurrences(courses, term);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SingleChildScrollView(
child: WeekGrid(
events: events,
monday: term.firstMonday,
today: term.startDate,
settings: const AppSettings(),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('冲突列表'), findsOneWidget);
expect(find.text('1–2节'), findsNWidgets(5));
final rects = <Rect>[];
for (final c in courses) {
final card = tester.getRect(
find.bySemanticsLabel('${c.name},${c.teacher},${c.room}'),
);
rects.add(card);
expect(card.width, greaterThan(30));
for (final text in [c.teacher, c.room]) {
final finder = find.text(text);
final paragraph = tester.renderObject<RenderParagraph>(finder);
for (final box in paragraph.getBoxesForSelection(
TextSelection(baseOffset: 0, extentOffset: text.length),
)) {
expect(box.right, lessThanOrEqualTo(paragraph.size.width + 0.01));
}
expect(tester.getRect(finder).bottom, lessThanOrEqualTo(card.bottom));
}
}
for (var i = 0; i < rects.length; i++) {
for (var j = i + 1; j < rects.length; j++) {
expect(
rects[i].intersect(rects[j]).height,
lessThanOrEqualTo(0.01),
reason: '${rects[i]} / ${rects[j]}',
);
}
}
expect(tester.takeException(), isNull);
},
);
testWidgets(
'a lunch-gap arrangement has the same length as another two-lesson card',
(tester) async {
final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1);
Course c(String id, int day, int first) => Course(
id: id,
name: id,
teacher: '教师',
room: '教室',
weekday: day,
startPeriod: first,
endPeriod: first + 1,
weeks: {1},
source: CourseSource.file,
);
final events = ScheduleEngine.occurrences([
c('A', 1, 3),
c('B', 2, 4),
], term);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: WeekGrid(
events: events,
monday: term.firstMonday,
today: term.startDate,
settings: const AppSettings(),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
tester.getRect(find.bySemanticsLabel('A,教师,教室')).height,
tester.getRect(find.bySemanticsLabel('B,教师,教室')).height,
);
},
);
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/widgets/glass_dock.dart';
import 'package:hepan_everyday/app/hepan_app.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'navigation_test.dart' show uiController;
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'glass cursor drags in both directions, taps work, body swipes do not navigate',
(tester) async {
final c = uiController();
await c.initialize();
await c.showDemo();
await tester.pumpWidget(HepanApp(controller: c));
await tester.pumpAndSettle();
expect(find.text('Riverside Schedule'), findsOneWidget);
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800));
await tester.drag(find.text('周三'), const Offset(-250, 0));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsNothing);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(100, 0),
);
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(find.text('连接学校课表'), const Offset(250, 0));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(-100, 0),
);
await tester.pumpAndSettle();
expect(find.text('示例课表'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('我的'));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
c.dispose();
},
);
testWidgets('cancelled cursor drag keeps selected tab', (tester) async {
int selected = 0;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) => Scaffold(
body: Center(
child: GlassDock(
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
),
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('dock-cursor'))),
);
await gesture.moveBy(const Offset(90, 0));
await tester.pump();
await gesture.cancel();
await tester.pumpAndSettle();
expect(selected, 0);
});
}
+2 -1
View File
@@ -46,7 +46,8 @@ void main() {
expect(find.text('示例课表'), findsOneWidget);
expect(find.text('11'), findsOneWidget);
await tester.ensureVisible(find.text('周一'));
await tester.drag(find.text('周一'), const Offset(-650, 0));
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360));
await tester.drag(find.text('周一'), const Offset(-200, 0));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('周日'), findsOneWidget);
+2 -2
View File
@@ -45,7 +45,7 @@ void main() {
final a = tester.getRect(find.bySemanticsLabel('A,T,R'));
final b = tester.getRect(find.bySemanticsLabel('B,T,R'));
final cRect = tester.getRect(find.bySemanticsLabel('C,T,R'));
expect(a.right, lessThanOrEqualTo(b.left));
expect(b.right, lessThanOrEqualTo(cRect.left));
expect(a.overlaps(b), isFalse);
expect(b.overlaps(cRect), isFalse);
});
}