Files
TPSchedule/docs/superpowers/plans/2026-10-14-uestc-undergraduate.md
T

287 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TPSchedule 本科生接入 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为电子科技大学本科生提供网页登录后手动导入课表、按学期及多种考试类型自动导入考试,并保持现有研究生数据和流程兼容。
**Architecture:** 保留研究生连接器,新建本科解析器、受限页面捕获和独立连接器。学校标识及学期作为共用领域对象;本科考试保存在独立于课表学期的数据中,整批成功后一次写入。沿用 Flutter 页面、原子存储、课程提醒及日历服务。
**Tech Stack:** 现有 Flutter 3.47.7 / Dart 3.13.5、`html`、`webview_flutter`、Flutter Test、Android API 36;不增加产品依赖。
**Spec:** `F:/program/app/docs/superpowers/specs/2026-10-14-uestc-undergraduate-design.md`(已确认;执行者必须同时阅读)。
## Global Constraints
- 工程根目录 `F:/program/app`;应用 TPSchedule,包名 `com.tpschedule.app`;发布版本计划 `0.7.0+18`,当前 `0.6.2+17`。
- 不修改密钥、签名配置及0.5.4登记冻结材料、V1.0软著材料;签名证书SHA256必须保持 `AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A`。
- 学校稳定标识 `uestc_graduate`、`uestc_undergraduate`;保存格式仍为 `schemaVersion: 1`,旧字段缺少学校标识时默认研究生。
- 本科课表登录地址 `https://eams.uestc.edu.cn/eams/`;只有点击「导入当前课表」才捕获和解析课表。
- 本科考试523对应2026–2027第一学期,相邻学期差20;考试类型1期末、2期中、3补考/缓考;至少选一种,首次默认523及期末。
- 本科12节,研究生及示例11节;第12节22:00–22:45,前11节作息不变;七天同屏、相同节数课程条等长、教师和教室完整。
- 周次1–30;考试合并记录≤500;单文件及一次捕获全部片段合计≤2MB;同源文档遍历最多4层、16个文档。超限拒绝,不能截断。
- 读取单类型45秒、整批120秒,均不计算人工认证时间;任何失败、取消或过期回调不得写入临时结果。重新认证后全部类型重新读取。
- 本科考试不清课表、不改课表学期/开始日、不触发课程变更协调;现有回到前台服务检查保持。普通启动不创建学校WebView。
- 界面沿用蓝色、现有Dock及换周动画;不新增考试提醒、服务端功能或自制密码界面;不执行附件脚本、考试申请或取消操作。
## Review Focus
1. 页面初始化默认学期523、实际结果503:使用实际结果证据,不能把历史考试写到新学期(任务4、5)。
2. 验证码等待超过120秒及登录后切换账号:等待不超时,重新认证丢弃旧片段整批重读(任务6)。
3. 多类型读取的最后一项失败、返回旧页面或重复点击:保留旧考试及查询选项,过期结果不得保存(任务6、7)。
4. 第12节仅出现在其他周、多人教师及教室变化:整学期保持12行,教师/教室完整,停课周不生成提醒(任务3、8)。
5. 学校选择保存失败、损坏旧文件及本科考试与研究生课表共存:不错误导航/迁移/清空,保留恢复入口(任务1、2、9)。
## 执行约定与文件职责
所有下列路径相对工程根目录;测试命令在 `mobile` 目录运行。执行前读取适用的 `AGENTS.md`(若存在)、检查工作树和基线,不覆盖用户的 `output/` 材料。需要隔离时按 using-git-worktrees 技能创建工作树;尚未获得计划执行确认时只允许文档及只读检查。
新文件:
| 文件 | 职责 |
| --- | --- |
| `mobile/lib/domain/school_identity.dart` | 共用学校枚举、稳定标识和名称 |
| `mobile/lib/domain/undergraduate_exam_query.dart` | 本科考试类型、有效查询、标签及URL |
| `mobile/lib/features/school/undergraduate/activity_data.dart` | 受限活动字面量/索引解析,不执行JS |
| `mobile/lib/features/school/undergraduate/timetable_adapter.dart` | 本科课表HTML到ImportDraft |
| `mobile/lib/features/school/undergraduate/exam_adapter.dart` | 本科考试HTML到ExamSchedule及整批合并 |
| `mobile/lib/features/school/undergraduate/navigation_policy.dart` | 本科官方导航与数据来源检查 |
| `mobile/lib/features/school/undergraduate/page_probe.dart` | 状态/捕获脚本与证据解码 |
| `mobile/lib/features/school/undergraduate/session.dart` | 延迟WebView、手动课表、自动考试批次 |
| `mobile/lib/features/school/undergraduate/auth_page.dart` | 本科浏览器、进度、导入确认与保存 |
| `mobile/lib/features/school/undergraduate/exam_query_picker.dart` | 学期单选、类型多选及取消 |
| `mobile/lib/data/exam_file_import.dart` | 本科/研究生考试文件分派 |
既有领域模型负责兼容序列化;`AppController`负责串行原子提交;`HepanApp`和`main.dart`负责连接器分派/生命周期;课表、考试、我的页面负责数据来源及入口。每个任务先写测试并观察预期失败,再实现、验证、提交;不把通用文件整段重写。
测试初始化(同一个PowerShell会话):
```powershell
$env:HEPAN_TOOLCHAIN_ROOT = 'F:/program/app/.tools'
. ./scripts/android-env.ps1
Set-Location mobile
```
Flutter命令使用已有工具链;此环境如出现SDK执行权限限制,使用授权后的执行方式重试,不能把环境失败算成业务测试失败。Git提交只暂存该任务文件;沿用已有身份,无全局身份时使用命令局部 `-c user.name=Codex -c user.email=codex@local`。
---
### Task 1: 学校、学期与兼容数据模型
**Files:** Create `domain/school_identity.dart`、`domain/undergraduate_exam_query.dart`;Modify `domain/school_term.dart`、`domain/course.dart`、`domain/exam_schedule.dart`、`domain/app_snapshot.dart`、`features/school/school_selection_page.dart`(均位于`mobile/lib`)。Test `mobile/test/app/school_term_test.dart`、新建 `mobile/test/domain/undergraduate_models_test.dart`、既有 `mobile/test/data/snapshot_store_test.dart`。
**Interfaces:**
- `enum SupportedSchool`移动到领域文件,保留 `uestcGraduate`,新增 `uestcUndergraduate`;字段 `String id, label, description`,`static SupportedSchool fromId(String id)`未知值抛FormatException。学校选择文件重导出枚举,兼容既有调用。
- `SchoolTerm(int id, {SupportedSchool school = SupportedSchool.uestcGraduate})`,字段 `int id; SupportedSchool school`;JSON增 `schoolId`,缺失默认研究生;相等/hash同时使用二者。原 `choices`、研究生URI保持;本科调用研究生URI getter必须抛StateError。
- `static List<SchoolTerm> undergraduateChoices(DateTime now, {SchoolTerm? saved})`:按设计编号公式,2013第一学期至设备当前年份下一学年的第二学期,降序并包含有效saved。
- `enum UndergraduateExamType`含 `finalExam(1,'期末考试')`、`midterm(2,'期中考试')`、`makeup(3,'补考/缓考')`,字段 `int id; String label`。
- `UndergraduateExamQuery({required SchoolTerm term, required Set<UndergraduateExamType> types})`:不可变、仅本科学期、至少一种;`List<UndergraduateExamType> get orderedTypes`、`Uri uriFor(UndergraduateExamType type)`、`static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule)`。
- `Course`新增兼容可选 `String courseCode = '', notes = ''`,通用节次允许12。学校对应的11/12节合法性在ImportDraft/快照边界验证;未标本科的第12节数据不得保存。
- `ExamRecord`新增 `String date = '', status = '', notes = ''`、`int? examTypeId`;原有字段语义保持,保存完整安排于 `time`。`ExamSchedule`新增 `SupportedSchool school = SupportedSchool.uestcGraduate`、`Set<int> requestedExamTypes = const {}`;本科必须有效学期及非空1–3类型集合,记录类型须属于该集合,按复合字段去重;研究生保留原code唯一规则。
- `AppSnapshot`新增 `SupportedSchool? selectedSchool`及copyWith,JSON字段名为 `selectedSchoolId`(稳定字符串或null);`ExamSchedule`的学校JSON字段名为 `schoolId`,类型字段为 `requestedExamTypes`(排序的整数数组)。旧数据按可靠学校来源恢复,不能将本科考试编号推断为研究生课表学期。新字段缺失保持旧默认,有明确未知标识则拒绝。
- [x] **Step 1 — 写失败测试。** 验证本科483/503/523/543/563标签、523期末URL精确等于用户地址、空类型/编号504拒绝、学校不同编号相同不相等、`{'id':73}`仍为研究生;本科同课程期中/期末均保留,研究生重复code仍拒绝;旧快照round-trip兼容,未知学校/损坏类型拒绝;本科12合法、未标本科12非法,旧课程新增字段为空。
测试名 `undergraduate semester 523 uses the final exam URL` 的核心断言:
```dart
final term = SchoolTerm(523, school: SupportedSchool.uestcUndergraduate);
final query = UndergraduateExamQuery(term: term, types: {UndergraduateExamType.finalExam});
expect(term.label, '2026-2027第一学期');
expect(query.uriFor(UndergraduateExamType.finalExam).toString(),
'https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id=523&examType.id=1');
expect(SchoolTerm.fromJson({'id': 73}).school, SupportedSchool.uestcGraduate);
```
- [x] **Step 2 — 验证失败。** `flutter.bat test --no-pub test/app/school_term_test.dart test/domain/undergraduate_models_test.dart test/data/snapshot_store_test.dart`,预期缺少新接口/行为失败,不能只因找不到工具链失败。
- [x] **Step 3 — 实现上述接口及序列化边界。** 不更改旧枚举CourseSource及schemaVersion;本科年份用 `(id-3)~/20` 推导,位于模型中的验证不依赖界面。
- [x] **Step 4 — 重跑上述测试。** 预期全部PASS,包括既有研究生测试。
- [x] **Step 5 — 提交。** `feat: model undergraduate schools terms and exam queries`,只暂存本任务文件。
### Task 2: 原子保存与课表/考试学期分离
**Files:** Modify `mobile/lib/app/app_controller.dart`、`mobile/lib/data/json_schedule_import.dart`;Test 新建 `mobile/test/app/undergraduate_controller_test.dart`、既有 `mobile/test/app/app_controller_test.dart`、`mobile/test/data/json_schedule_import_test.dart`。复用 `mobile/test/support/fakes.dart`。
**Interfaces:** Consumes任务1类型。新增 `Future<bool> AppController.selectSchool(SupportedSchool school)`:通过既有串行队列保存,成功true;失败false且lastError可见,不提前更新内存。保留 `applyImport(ImportDraft,{required bool confirmedEmpty})`、`applyExams(ExamSchedule)`签名。
- [x] **Step 1 — 写失败测试。** 本科503考试替换考试列表后原研究生73课表、日期、颜色、提醒偏好及calendarEventIds相同;通知/日历fake调用数不增加。首次本科导课清开始日但保留偏好和独立考试;同本科同学期保留开始日;旧研究生跨学期规则仍成立。存储失败保存前后JSON相同、selectSchool返回false;本科JSON缺少/矛盾学校、混学期、非法12拒绝;旧版本1JSON正常。
测试名 `historical undergraduate exams preserve the course semester`,使用既有controller/fake初始化后:
```dart
final before = controller.snapshot;
await controller.applyExams(ExamSchedule(
semesterLabel: '2025-2026第二学期', termId: '503', records: [],
school: SupportedSchool.uestcUndergraduate, requestedExamTypes: {1}));
expect(controller.snapshot.courses, before.courses);
expect(controller.snapshot.schoolTerm, before.schoolTerm);
expect(controller.snapshot.settings.semester, before.settings.semester);
expect(controller.snapshot.examSchedule!.termId, '503');
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/app/undergraduate_controller_test.dart test/app/app_controller_test.dart test/data/json_schedule_import_test.dart`;预期当前applyExams清课表等断言失败。
- [x] **Step 3 — 实现分支。** 本科applyExams仅更新examSchedule,本科applyImport不清独立考试;新课表来源/学期才清开始日。研究生分支只接收研究生SchoolTerm。JSON显式schoolTerm用于本科来源,不由本科考试推断课表来源。保存成功后才更新内存及协调课程服务;偏好保存不协调服务。
- [x] **Step 4 — 重跑。** 上述测试PASS;恢复备份/损坏存储既有测试不得退化。
- [x] **Step 5 — 提交。** `feat: keep undergraduate exams independent of course terms`。
### Task 3: 本科课表解析和离线课表导入
**Files:** Create本科 `activity_data.dart`、`timetable_adapter.dart`及 `mobile/test/fixtures/school/undergraduate-timetable.html`(脱敏);Modify `mobile/lib/data/schedule_file_import.dart`。Test 新建 `mobile/test/school/undergraduate_timetable_adapter_test.dart`、既有 `mobile/test/school/import_boundaries_test.dart`。
**Interfaces:** `UndergraduateTimetableAdapter.extract(String html, Uri uri, {CourseSource source = CourseSource.school}) -> ImportDraft`。内部 `parseActivityData(String script,{required int unitCount}) -> List<UndergraduateActivity>`;`UndergraduateActivity`保存teacher/name/courseCode/room、`Set<int> indices, weeks`及是否停课,只供适配器使用。
- [x] **Step 1 — 写脱敏等价fixture及失败测试。** 保留附件10门课程、19条活动/18上课+1停课、3条涉及12节、周位串及索引结构;assert每门课程名称/教师/房间/周次/星期/节次映射,停课周6不产生对应occurrence。另加转义引号/Unicode字符串、同课程不同教室和周、多教师、连续节次分组、重复去重、含中文括号名称、名单备注保留;非法索引、缺教师/房间、超范围周、登录页/加载空表/学期矛盾报错;明确空课提示才explicitlyEmpty。
测试名 `undergraduate activity data preserves period twelve` 的核心断言:
```dart
final draft = UndergraduateTimetableAdapter.extract(
File('test/fixtures/school/undergraduate-timetable.html').readAsStringSync(),
Uri.parse('https://eams.uestc.edu.cn/eams/courseTableForStd.action'));
expect(draft.schoolTerm, SchoolTerm(523, school: SupportedSchool.uestcUndergraduate));
expect(draft.courses.map((c) => c.courseCode).toSet(), hasLength(10));
expect(draft.courses.where((c) => c.endPeriod == 12), hasLength(3));
expect(draft.courses.any((c) => c.room == '停课'), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_timetable_adapter_test.dart test/school/import_boundaries_test.dart`,新解析器尚不存在,预期FAIL。
- [x] **Step 3 — 实现。** 用HTML DOM寻找manualArrangeCourseTable及课程名单表,解析实际semester.id;受限字符串tokenizer处理TaskActivity参数及 `index = 常量 * unitCount + 常量`、对应activities赋值。按相同教师/房间/周次的连续索引分组,停课按课程序号/星期/节次扣周;稳定ID用完整规范化安排的确定性摘要,不包含学生姓名/学号。名单补课程序号/备注,不能执行任何脚本。页面和文件均检查UTF8字节总量≤2MB。
- [x] **Step 4 — 分派并验证。** ScheduleFileImport按本科课表明确特征分派,其余保持旧graduate/JSON;重跑任务测试PASS。直接读取用户原附件的本地验证只记录数量/映射结果,不将原文件或个人信息提交。
- [x] **Step 5 — 提交。** `feat: parse undergraduate timetable activity data`,含脱敏fixture及测试,不含原附件。
### Task 4: 本科考试解析及文件分派
**Files:** Create本科 `exam_adapter.dart`、`mobile/lib/data/exam_file_import.dart`、`mobile/test/fixtures/school/undergraduate-exams.html`(脱敏)、`mobile/test/school/undergraduate_exam_adapter_test.dart`;既有 `mobile/test/school/exam_adapter_test.dart`保持回归。
**Interfaces:** `UndergraduateExamAdapter.extract(String html,{SchoolTerm? expectedTerm, UndergraduateExamType? expectedType}) -> ExamSchedule`;`merge(UndergraduateExamQuery query,List<ExamSchedule> parts) -> ExamSchedule`;`ExamFileImport.parse(String html) -> ExamSchedule`。extract离线从实际结果字段取学期及类型;merge要求所选类型恰好全部成功、同school/term并去完全重复。
- [x] **Step 1 — 写失败测试。** fixture4条考试归503,外层默认523不能污染;日期、完整时间、地点、座位、情况、类型、说明round-trip。期中及期末同课程均保留;全重复仅一次;缺一类型/mismatched term/type/超500报错;九列表头+有效查询证据+明确无考试提示为空成功,只有表头/登录页不能当空;异常字段不猜时间。ExamFileImport明确分派本科/研究生,未知HTML拒绝。
测试名 `actual result term wins over the outer default` 的核心断言:
```dart
final exams = UndergraduateExamAdapter.extract(
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync());
expect(exams.termId, '503');
expect(exams.records, hasLength(4));
expect(exams.requestedExamTypes, {1});
expect(exams.records.first.date, '2026-06-22');
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_exam_adapter_test.dart test/school/exam_adapter_test.dart`,预期FAIL。
- [x] **Step 3 — 实现。** 按九列表头定位实际排考表,读取表单隐藏字段和结果params;两者矛盾拒绝,忽略控件初始化脚本。通过任务1复合身份合并、类型集合保存于ExamSchedule;不给空日期生成伪造日期。
- [x] **Step 4 — 重跑。** 上述测试PASS;用户原考试附件本地验证4条且503,无原HTML进仓库。
- [x] **Step 5 — 提交。** `feat: parse and merge undergraduate exam schedules`。
### Task 5: 本科导航、认证证据与按需捕获
**Files:** Create本科 `navigation_policy.dart`、`page_probe.dart`;Test `mobile/test/school/undergraduate_page_probe_test.dart`、`mobile/test/school/undergraduate_page_probe_js_test.cjs`。复用既有Node JS probe测试的DOM模拟模式。
**Interfaces:** `UndergraduateNavigationPolicy.canNavigate(Uri uri,{required bool isMainFrame}) -> bool`、`isDataPage(Uri uri) -> bool`、`isLogout(Uri uri) -> bool`、`secureIdentityRedirect(Uri uri) -> Uri?`。`undergraduateStatusProbeScript`只返回认证状态证据;`undergraduateCaptureScript`返回限定数据片段。`UndergraduatePageEvidence.decode(Object raw,Uri current) -> UndergraduatePageEvidence`字段 `bool authenticated; List<UndergraduateFragment> pages`;fragment字段 `String kind, html; Uri url`,kind仅timetable/exams;异常/超限抛FormatException。
- [x] **Step 1 — 写失败测试。** exact HTTPS eams/idas/eportal导航、HTTP idas指定登录地址升级;用户信息/自定义端口/相似恶意域名拒绝。状态脚本不包含课表HTML、密码或验证码。capture同源iframe/AJAX DOM实际value523/503覆盖旧attribute;跨源/登录文档不捕获;总量超2MB、深度>4、文档>16拒绝而非部分导入;decode顶层URL不一致/跨源片段拒绝;未知认证回调给明确错误。
测试名 `capture accepts only undergraduate data pages` 的核心断言:
```dart
expect(UndergraduateNavigationPolicy.isDataPage(
Uri.parse('https://eams.uestc.edu.cn/eams/')), isTrue);
expect(UndergraduateNavigationPolicy.isDataPage(
Uri.parse('https://idas.uestc.edu.cn/authserver/login')), isFalse);
expect(UndergraduateNavigationPolicy.canNavigate(
Uri.parse('https://eams.uestc.edu.cn.attacker.test/eams/'), isMainFrame: true), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_page_probe_test.dart` 和 `node --test test/school/undergraduate_page_probe_js_test.cjs`,预期缺接口FAIL。
- [x] **Step 3 — 实现。** 同步JS返回JSON字符串,不用WebView不会等待的Promise;认证证据需学校系统页面的已登录菜单/退出入口,不能仅依据host。捕获仅识别表、actual输入字段、名单和必要活动数据,含frame位置;结果参数与实际隐藏值保留。只序列化数据相关节点,禁止整个登录HTML;页内JS只读取文本/DOM,不执行原脚本。
- [x] **Step 4 — 重跑。** 两种测试PASS;既有研究生probe/policy测试保持。
- [x] **Step 5 — 提交。** `feat: capture undergraduate pages with scoped evidence`。
### Task 6: 手动课表连接及自动考试批次
**Files:** Create本科 `session.dart`;Test `mobile/test/school/undergraduate_session_test.dart`、`mobile/test/school/undergraduate_exam_batch_test.dart`,复用/按需扩展 `mobile/test/support/fake_school_web.dart`。
**Interfaces:** `UndergraduateSession extends ChangeNotifier`,延迟 `WebViewController get web`;`Future<void> openTimetable()`、`openExams(UndergraduateExamQuery query)`、`readCurrentTimetable()`、`reauthenticate()`、`logout()`;`void setReadingActive(bool active)`、`invalidateAuthentication()`、`bool saved({required int generation})`。只读状态 `hasOpened, canLogout, busy, awaitingAuthentication, statusLabel, message, pageGeneration`,`ImportDraft? pendingDraft`、`ExamSchedule? pendingExams`及当前query。session不依赖AppController、不自行存储。
- [x] **Step 1 — 写失败测试。** 构造session不创建原生WebView;openTimetable只载eams根,登录状态probe不捕获课表,点击才capture并产生pendingDraft;切换页面时旧capture被丢弃。考试选择1+2+3时逐项精确GET,前两项不产生pendingExams,完成才一个合并结果;第三项失败/显式取消/加载错误保留旧状态无pending;单项45秒/整批120秒边界。等待登录模拟超过120秒不失败;过期认证丢弃已读片段,认证后从类型1重新读取。重复点击、旧finished回调、route关闭、网页logout、dispose不能推进旧批次;fake Cookie清理失败保持可重试退出状态。
测试名 `closing the route invalidates pending batch results`,fake驱动已打开批次后:
```dart
final openedGeneration = session.pageGeneration;
session.setReadingActive(false);
expect(session.pageGeneration, greaterThan(openedGeneration));
expect(session.pendingExams, isNull);
expect(session.saved(generation: openedGeneration), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_session_test.dart test/school/undergraduate_exam_batch_test.dart`,预期FAIL。计时使用测试虚拟时间,不真实等120秒。
- [x] **Step 3 — 实现。** 为每次打开/查询分配generation,任何await后校验generation和routeActive;start/finish匹配当前URL及同源证据。自动GET采用loadRequest,finished后有限轮询DOM直到可识别结果;计时及轮询在离页/登录等待/取消时清除。认证过期停读,重新认证启动新generation重读全部;全部完成调用任务4merge一次。manual capture只有readCurrentTimetable调用。saved只消费匹配generation结果;旧结果不能消费新pending。
- [x] **Step 4 — 重跑。** 新session测试及 `test/school/school_session_flow_test.dart`、`test/school/lazy_school_session_test.dart`均PASS。
- [x] **Step 5 — 提交。** `feat: connect undergraduate manual timetable and exam batches`。
### Task 7: 本科浏览器页面、查询选项和导入确认
**Files:** Create本科 `auth_page.dart`、`exam_query_picker.dart`;Modify `mobile/lib/features/profile/import_preview.dart`;Test `mobile/test/widgets/undergraduate_auth_test.dart`、`mobile/test/widgets/undergraduate_exam_picker_test.dart`。必要fake平台widget只为浏览器容器提供可测替代,不绕过真实session。
**Interfaces:** `UndergraduateAuthPage({required UndergraduateSession session,required AppController controller})`;`Future<UndergraduateExamQuery?> showUndergraduateExamQueryPicker(BuildContext context,{UndergraduateExamQuery? initial,required DateTime now})`。返回null取消,返回validquery才登录;跨school/term课表替换和空课表使用ImportPreview确认,已导入同本科同学期非空课表可直接保存。
- [x] **Step 1 — 写失败测试。** picker一个学期、三种类型多选、禁止空选择、cancel不请求、沿用503及已选类型。手动页显示导入按钮,登录后不自动保存;跨学校/学期取消保留快照;空课表未勾确认不能保存;save失败仍在页面且pending可重试。成功返回课表;考试整批完成保存一次并返回,partial不保存;关页后旧回调不pop其他页面;双击导入只处理一次。
Widget测试名 `the manual importer waits for the import button`,登录后fake页面加载并pump后:
```dart
expect(find.text('导入当前课表'), findsOneWidget);
expect(session.pendingDraft, isNull);
expect(controller.snapshot.courses, isEmpty);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/widgets/undergraduate_auth_test.dart test/widgets/undergraduate_exam_picker_test.dart`,预期FAIL。
- [x] **Step 3 — 实现。** init/dispose启停读取并监听session;UI操作和保存用busy保护+mounted/generation检查。课表复制当前generation的draft,确认后调用applyImport并检查lastError;考试调用applyExams后检查错误,成功才saved/pop。替换确认说明学校/学期及开始日需要重设;本科网页选学期文案不引导研究生编号选择器。读失败显示类型、重试、修改查询选项;认证等待提示原网页操作。
- [x] **Step 4 — 重跑。** Widget测试PASS,无未处理异步错误/overflow。
- [x] **Step 5 — 提交。** `feat: add undergraduate import and exam selection screens`。
### Task 8: 接入共用页面、12节及服务计算
**Files:** Modify `mobile/lib/app/hepan_app.dart`、`mobile/lib/main.dart`、`mobile/lib/features/school/school_session.dart`(只增加失效协调方法)、`mobile/lib/features/school/school_selection_page.dart`、`mobile/lib/features/profile/profile_page.dart`、`mobile/lib/features/timetable/timetable_page.dart`、`mobile/lib/features/timetable/week_grid.dart`、`mobile/lib/features/timetable/course_details.dart`、`mobile/lib/features/exams/exams_page.dart`、`mobile/lib/domain/lesson_times.dart`。Test `mobile/test/widgets/school_selection_test.dart`、`mobile/test/widgets/undergraduate_navigation_test.dart`、`mobile/test/widgets/undergraduate_grid_test.dart`、`mobile/test/domain/schedule_engine_test.dart`、`mobile/test/services/notification_service_test.dart`、`mobile/test/services/calendar_service_test.dart`。
**Interfaces:** `HepanApp`新增可选本科session/builder参数,保留原 `school`、`schoolBuilder`测试接口。`WeekGrid`新增 `int periodCount = 11`,只接受11/12;`lessonSlots`末尾加 `LessonSlot(12,1320,1365)`。研究生 `void invalidateAuthentication()`仅取消读取并清连接状态/待导入结果,不创建WebView。考试页新增可选查询选项回调,文件入口改任务4的ExamFileImport。
- [x] **Step 1 — 写失败测试。** 两学校都出现,cancel无副作用;本科选择保存失败不进入浏览器,重启恢复选择。课表刷新/更新学期仍manual,考试refresh复用上次query、查询选项可改;不同学校的term不能进入gradURL。共享退出使两连接失效但未使用WebView不创建,旧数据按用户原退出选项处理。source不同banner准确。
- [x] **Step 2 — 写显示及时间失败测试。** 本科无课当前周也12行,研究生/示例11;周切换后第12节可见,七天固定宽,教师/教室全量且同节数卡片等高;详情显示全称、courseCode、notes。第12节单节occurrence22:00–22:45,9–12连排19:30–22:45,多提醒offset及calendar结束时间均正确、停课周无occurrence。使用360dp及大字体检查溢出。
Widget测试名 `graduate rows stay at eleven after adding period twelve`,分别pump已导入学校的页面后断言:
```dart
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 11); // graduate
// 再pump本科快照:
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 12);
expect(lessonSlots.last.startMinute, 1320);
expect(lessonSlots.last.endMinute, 1365);
```
- [x] **Step 3 — 运行。** `flutter.bat test --no-pub test/widgets/school_selection_test.dart test/widgets/undergraduate_navigation_test.dart test/widgets/undergraduate_grid_test.dart test/domain/schedule_engine_test.dart test/services/notification_service_test.dart test/services/calendar_service_test.dart`,预期新导航/12节相关FAIL。
- [x] **Step 4 — 实现。** main只构造Dart session,浏览器延迟;Shell先await selectSchool成功,再分派,取消/失败保持状态。本科考试首次显示picker,refresh用ExamSchedule成功选项;历史课程schoolTerm不被考试改写。退出仅操作已有连接并协调失效,失败保留重试入口。课表periodCount来源于实际schoolTerm,demo强制11;所有布局循环使用截取的slot数量而非新增全局长度。考试页显示自身school/term/types/date/status/notes,空结果文案按设计;任务3/4文件导入沿用preview。保留前台服务检查、Dock/手势及动画。
- [x] **Step 5 — 重跑。** 上述测试PASS;追加运行已有 `test/widgets/complete_metadata_test.dart test/widgets/conflict_metadata_test.dart test/widgets/course_length_test.dart test/widgets/week_swipe_test.dart test/widgets/week_transition_test.dart test/widgets/startup_slow_test.dart test/widgets/logout_recovery_test.dart`,确保教师全量、动画、启动和退出恢复不退化。
- [x] **Step 6 — 提交。** `feat: integrate undergraduate school workflows and period twelve`。
### Task 9: 全量验证、发布与签名/登记备份核对
**Files:** Modify `mobile/pubspec.yaml`、`mobile/lib/app/app_version.dart`、`README.md`、`mobile/README.md`、`docs/school-integration-status.md`、`docs/android-mvp-validation.md`、`dist/README.md`、`dist/TPSchedule-备案信息.txt`;生成发布/调试APK、源码ZIP、脱敏截图及 `.tools/`验证日志。不改用户 `output/` 和登记备份。
**Interfaces:** 上述全部任务完成且回归通过;复用 `scripts/build-android.ps1`、`scripts/signing-info.py`及已有Android16模拟器/发布流程,不重建密钥、不新增依赖。
- [x] **Step 1 — 升版本并完整验证。** 两个版本位置统一0.7.0+18;在mobile运行 `flutter.bat test --no-pub --enable-impeller --reporter expanded`、`flutter.bat analyze --no-pub`,预期全量PASS、No issues found。运行本科及既有研究生Node probe测试。失败追根定位、只在新增变更或失败修复后重跑必要检查,不能跳过。
- [x] **Step 2 — 构建与身份验证。** 先保留当前0.6.2 APK及摘要再构建;根目录运行 `./scripts/build-android.ps1 -ToolchainRoot 'F:/program/app/.tools' -Mode release` 和同命令 `-Mode debug`;构建返回0且APK存在。使用signing-info脚本比较旧/新签名,证书必须等于Global Constraints值;读取包名及versionCode18。校验 `dist/backups/TPSchedule-0.5.4-registration-20261014/SHA256清单.json`全部18项内容摘要不变。
- [x] **Step 3 — Android16/API36检查。** 在模拟器覆盖安装,核对旧数据/选择学校/取消/本科登录按钮/考试多选入口。脱敏离线fixture导入检查12行、课程详情、四条503考试、历史考试后课表不变、重启持久化、换周动画/Dock及无新增启动黑屏;抓关键页面截图。真实账号/验证码及学校回调未实测的部分在记录中明确,不能用模拟测试宣称真实导入通过。
- [x] **Step 4 — 记录和源码包。** 写真实测试总数、静态检查、APK SHA256及限制;更新学校接入说明、本科手动操作和12节作息。源码ZIP使用明确允许的源文件集合,排除原附件、用户个人HTML、`.tools`、密钥、构建缓存、`output`和登记材料;验证解包目录/版本及文件数。签名公开信息可保留,私钥不得进入包。
- [x] **Step 5 — 最终审查并交付。** 按 requesting-code-review 技能审查全量差异(只读),修复会导致错误导入/丢数据/提醒时间错误的发现后验证;核对git diff --check及仅预期改动,再提交 `feat: release undergraduate integration 0.7.0`。沿用已授权本地master集成方式;如果使用隔离工作树,合并前检查没有覆盖用户工作。最终提供APK/源码链接、操作说明和真实登录仍需账户本人验证的边界,不等待上线部署。
## 自审与交接
覆盖映射:设计1–3及7→任务5–8;设计4→任务1;设计5→任务3/8;设计6→任务4/6;设计8→任务1/2/8;设计9→任务3/4/8;设计10→任务9。Review Focus五项均已分配具体测试;本科自动考试不复用研究生自动课表状态机,学校身份不使用枚举index,保存入口全部走串行原子队列。
执行方式建议:**本会话直接执行**,原因是9项任务共享学校/学期及保存接口,顺序实施能减少接口交接成本;完成后做一次独立全量审查。另一选择是逐任务由子代理实施与独立审查,审查更频繁、上下文成本更高。
本文件供用户审阅;确认计划并选定执行方式后,才进入实现。