diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 11773fa..356e4d9 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -100,6 +100,10 @@ runs: if: ${{ inputs.target == 'linux' }} run: flutter analyze --no-pub shell: bash + - name: Test + if: ${{ inputs.target == 'linux' && runner.arch == 'X64' }} + run: flutter test --no-pub + shell: bash - name: Decrypt Android signing if: >- ${{ inputs.target == 'android' && diff --git a/docs/eclass-exams.md b/docs/eclass-exams.md new file mode 100644 index 0000000..1ba61f0 --- /dev/null +++ b/docs/eclass-exams.md @@ -0,0 +1,72 @@ +# 延河课堂测试导入 + +## 接口与认证 + +测试列表与学习活动是两个独立的数据源。课程列表仍复用现有的 +`POST /api/my-courses`;每门课额外请求: + +```http +GET /api/courses/{courseId}/exam-list?page=1&page_size=100 +Accept: application/json +``` + +复用 `SchoolSession` 的 Cookie 存储、请求并发上限、取消和登录失效处理。 +不得在源码、测试或文档中保存真实的 Cookie、令牌、姓名或原始抓包。 + +实测省略前端的 `conditions`、`reloadPage` 参数仍能获取列表;默认排序可能 +与前端章节排序不同,导入不依赖返回顺序。 + +响应为: + +```json +{ + "page": 1, + "page_size": 100, + "pages": 1, + "total": 1, + "start": 1, + "end": 1, + "exams": [ + { + "id": 123, + "type": "exam", + "title": "示例测试", + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-08T14:30:00Z", + "submission_count": 0 + } + ] +} +``` + +从第一页开始,按 `pages` 结束;缺失时使用每页条数收敛,空页总是结束。 +没有测试的课程返回 `exams: []`。活动和测试分别容错,不因其中一路普通 +接口错误丢掉另一路的结果;取消和登录失效仍向上传递。 + +## 映射规则 + +- 测试 ID 使用 `eclass-exam-{id}`;保留既有活动 ID `eclass-{id}`,避免两种 + 资源编号相同导致覆盖,也避免迁移时重建已有作业。 +- `end_time` 非空时作为截止时间,沿用北京时间墙上时钟转换。 +- 无 `end_time`、有 `start_time` 时导入 `startOnly: true` 的日程,日期是 + 开放时间而不是伪造的截止时间;不应显示逾期。 +- 开始和截止时间均缺失时跳过,不能用同步时间制造不稳定日期。 +- `submission_count > 0` 表示已提交;不使用 `is_closed`、成绩或 + `submit_times` 代替提交状态。补考字段的完成语义尚未验证,不作假设。 +- 链接指向 `/course/{courseId}/exam`;未验证单个测试的安全详情跳转路径, + 不自动进入答题或启动限时测试。 +- 无截止日期日程使用与普通 DDL 相同的占位及渲染宽度规则,但以开始时间 + 为左边界,标线在左侧,背景向右侧淡出。 +- 时间轴纵向滚动范围取当前筛选数据的全局最高泳道;横向平移不改变 + 纵向范围,稀疏时间段允许保留空白泳道。卡片仍只按二维视口裁剪创建。 + 筛选、缩放或窗口尺寸改变导致范围缩小时,裁剪使用新范围内的有效偏移。 + +## 验证范围 + +接口分析时验证了课程级列表、分页参数、UTC 时间与页面北京时间显示的 +对应关系,以及提交计数与页面“未交”状态的对应关系。未假定匿名访问、 +独立 Cookie 的必要性或补考行为;认证继续完全交由现有会话实现。 + +自动化回归覆盖接口分页、资源 ID 碰撞、完成状态、无截止时间、空列表、 +独立容错,以及日程模型和时间轴布局。无本地 Flutter 环境时,由 GitHub +Actions 的 Linux x64 测试步骤运行;静态检查或推送成功不等于测试通过。 diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index e72e03b..bbb3c31 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -308,5 +308,7 @@ bool _sameDeadlineMetadata(AgendaEntry left, AgendaEntry right) { return left.course == right.course && left.title == right.title && left.startsAt == right.startsAt && - left.allDay == right.allDay; + left.allDay == right.allDay && + // When IDs differ, an opening time is not equivalent to a due time. + left.startOnly == right.startOnly; } diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index b1eef9c..ec407dd 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -27,10 +27,14 @@ class AgendaEntry { this.url, this.details, this.tailDays = defaultTailDays, + this.startOnly = false, }) { if (id.isEmpty || title.trim().isEmpty) { throw const FormatException('日程标题或编号缺失'); } + if (startOnly && kind != AgendaKind.deadline) { + throw const FormatException('只有截止日期可以只标记开放时间'); + } if (kind != AgendaKind.deadline && (endsAt == null || !endsAt!.isAfter(startsAt))) { throw const FormatException('结束时间必须晚于开始时间'); @@ -87,12 +91,20 @@ class AgendaEntry { /// at minimum zoom and keeps its screen width as the view zooms in. final int tailDays; + /// A deadline that only knows when it opened, with no due date at all — + /// an open-ended quiz, for instance. It can never be overdue, and the + /// timeline anchors its card at [startsAt] and grows it to the right instead + /// of hanging a tail to the left of a due time. Labels must not present it + /// as due. + final bool startOnly; + bool get isDeadline => kind == AgendaKind.deadline; DateTime get dueAt => allDay ? calendarDate(startsAt).add(const Duration(days: 1)) : startsAt; - bool overdue(DateTime now) => isDeadline && !done && dueAt.isBefore(now); + bool overdue(DateTime now) => + isDeadline && !startOnly && !done && dueAt.isBefore(now); bool isFinished(DateTime now) => isDeadline ? done : !endsAt!.isAfter(now); @@ -121,6 +133,7 @@ class AgendaEntry { url: url, details: details, tailDays: tailDays, + startOnly: startOnly, ); Map toJson() => { @@ -141,6 +154,7 @@ class AgendaEntry { 'url': url?.toString(), 'details': details?.toJson(), 'tailDays': tailDays, + 'startOnly': startOnly, }; factory AgendaEntry.fromJson(Map json) { @@ -181,6 +195,9 @@ class AgendaEntry { ? null : Uri.tryParse(rawUrl) ?? (throw FormatException('url 格式不正确')), tailDays: jsonIntFieldOrNull(json, 'tailDays') ?? defaultTailDays, + // Entries written before this flag existed simply had no open-ended + // deadlines, so a missing key means an ordinary deadline. + startOnly: jsonBoolField(json, 'startOnly', fallback: false), details: rawDetails == null ? null : AgendaEntryDetails.fromJson(jsonObject(rawDetails, 'details')), diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 241fbc3..956c79a 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -24,15 +24,25 @@ List layoutAgendaTimeline( Iterable entries, { required Duration minimumEventSpan, }) { + // An ordinary deadline hangs `tailDays` to the left of its due time. One + // that only records an opening time reserves the same span forwards from + // that opening time, so both reserve `tailDays` of lane space. + DateTime reservationStart(AgendaEntry entry) => + entry.isDeadline && !entry.startOnly + ? entry.dueAt.subtract(Duration(days: entry.tailDays)) + : entry.startsAt; + DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline + ? entry.startOnly + ? entry.startsAt.add(Duration(days: entry.tailDays)) + : entry.dueAt + : entry.endsAt!; final intervals = [ for (final entry in entries.where((entry) => !entry.deleted)) ( entry: entry, - start: entry.isDeadline - ? entry.dueAt.subtract(Duration(days: entry.tailDays)) - : entry.startsAt, - end: entry.isDeadline ? entry.dueAt : entry.endsAt!, + start: reservationStart(entry), + end: reservationEnd(entry), ), ]..sort((a, b) { final byStart = a.start.compareTo(b.start); diff --git a/lib/agenda/ui/agenda_detail.dart b/lib/agenda/ui/agenda_detail.dart index aadb6ad..26aab73 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -251,11 +251,18 @@ class _EntryTime extends StatelessWidget { final theme = Theme.of(context); final scheme = theme.colorScheme; final color = entry.overdue(now) ? scheme.error : scheme.onSurface; - final time = entry.isDeadline - ? entry.allDay - ? l10n.agendaDueBeforeEndOfDay - : clockText(entry.startsAt) - : localizedAgendaTime(l10n, entry); + // An open-ended deadline has no due date: it reads like a timed entry and + // is labelled as opening rather than as due. + final String time; + if (!entry.isDeadline) { + time = localizedAgendaTime(l10n, entry); + } else if (entry.startOnly) { + time = entry.allDay ? l10n.allDay : clockText(entry.startsAt); + } else { + time = entry.allDay + ? l10n.agendaDueBeforeEndOfDay + : clockText(entry.startsAt); + } final lastDay = entry.allDay && !entry.isDeadline ? entry.endsAt!.subtract(const Duration(days: 1)) : entry.startsAt; @@ -291,7 +298,7 @@ class _EntryTime extends StatelessWidget { TextSpan(text: time), if (entry.isDeadline && !entry.allDay) TextSpan( - text: ' ${l10n.due}', + text: ' ${entry.startOnly ? l10n.start : l10n.due}', style: theme.textTheme.bodyMedium?.copyWith(color: color), ), ], diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index 385bc71..d2539ce 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -177,6 +177,11 @@ class _AgendaEditorState extends State<_AgendaEditor> { // localized presentation must not keep overriding the edited values. details: null, tailDays: _tailDays, + // Editing keeps the open-ended flag, but only while the entry stays a + // deadline: an event or exam always has an end time. + startOnly: + _kind == AgendaKind.deadline && + (widget.entry?.startOnly ?? false), ), ); if (!mounted) return; @@ -320,7 +325,11 @@ class _AgendaEditorState extends State<_AgendaEditor> { ], ), _when( - _kind == AgendaKind.deadline ? l10n.due : l10n.start, + // An imported open-ended deadline records an opening time, so the + // field must not be labelled as a due time. + _kind == AgendaKind.deadline && widget.entry?.startOnly != true + ? l10n.due + : l10n.start, _start, true, ), diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index b514eec..e8ecadf 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -284,6 +284,18 @@ class _AgendaTimelineState extends State (deadlineWidth(entry) / pixelsPerHour * _microsecondsPerHour) .ceil(), ); + // An ordinary deadline paints leftwards from its due time; one that + // only has an opening time paints rightwards from that time. Both are + // `deadlineWidth` wide, so card culling follows the painted box rather + // than the wider reservation. This does not change the scroll range. + DateTime paintedStart(AgendaTimelinePlacement placement) => + placement.entry.isDeadline && !placement.entry.startOnly + ? placement.end.subtract(deadlineDuration(placement.entry)) + : placement.start; + DateTime paintedEnd(AgendaTimelinePlacement placement) => + placement.entry.isDeadline && placement.entry.startOnly + ? placement.start.add(deadlineDuration(placement.entry)) + : placement.layoutEnd; final minimumEventSpan = Duration( // The visual floor is fixed and does not scale with accessibility text. microseconds: @@ -291,6 +303,12 @@ class _AgendaTimelineState extends State .ceil(), ); final placements = _layout(minimumEventSpan); + // Panning in time must not resize the vertical scroll range. Reserve + // all lanes in the filtered data, even when their cards are off screen. + final laneCount = placements.fold( + 0, + (count, placement) => math.max(count, placement.lane + 1), + ); final left = _timeAt(0, width, pixelsPerHour); final right = _timeAt(width, width, pixelsPerHour); final painter = _TimelineGridPainter( @@ -508,33 +526,28 @@ class _AgendaTimelineState extends State child: LayoutBuilder( builder: (context, laneConstraints) { final height = laneConstraints.maxHeight; + final contentHeight = math.max( + height, + laneCount * laneHeight + 12, + ); + // Filtering, zooming or resizing can still + // shrink the range. Cull at the offset that the + // viewport will clamp to during this layout, + // not the stale offset from its previous size. final offset = _lanes.hasClients ? _lanes.offset + .clamp(0.0, contentHeight - height) + .toDouble() : 0.0; final placementsInTime = placements .where( (placement) => - placement.layoutEnd.isAfter(left) && - (placement.entry.isDeadline - ? placement.end.subtract( - deadlineDuration( - placement.entry, - ), - ) - : placement.start) - .isBefore(right), + paintedEnd(placement).isAfter(left) && + paintedStart(placement).isBefore( + right, + ), ) .toList(growable: false); - final displayedLaneCount = placementsInTime - .fold( - 0, - (count, placement) => - math.max(count, placement.lane + 1), - ); - final contentHeight = math.max( - height, - displayedLaneCount * laneHeight + 12, - ); final visible = placementsInTime .where( (placement) => @@ -642,7 +655,11 @@ class _AgendaTimelineState extends State time.difference(_center).inMicroseconds / _microsecondsPerHour * pixelsPerHour; - final actualRight = x(placement.layoutEnd); + // A start-only deadline keeps the usual painted width but grows rightwards + // from its opening time, so its right edge is derived instead of its left. + final actualRight = entry.isDeadline && entry.startOnly + ? x(placement.start) + deadlineWidth + : x(placement.layoutEnd); final actualLeft = entry.isDeadline ? actualRight - deadlineWidth : x(placement.start); diff --git a/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index 4fa3fcd..6d5dbd1 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -45,6 +45,14 @@ class AgendaTimelineEntry extends StatelessWidget { final deadlineFill = overdue ? warningBackground : colors.fill; final contentColor = overdue ? warningForeground : colors.text; final titleText = localizedAgendaTitle(l10n, entry); + // An open-ended deadline mirrors the ordinary deadline card: the accent + // line becomes its left edge, the fill fades out towards the right, and + // the text hugs the line on the left instead of the right. + final openEnded = entry.isDeadline && entry.startOnly; + final accentEdge = BorderSide( + color: overdue ? scheme.error : colors.accent, + width: 3, + ); final radius = entry.isDeadline ? null : BorderRadius.circular(8); final description = l10n.agendaTimelineSemanticsEntry( entry.kind.localizedLabel(l10n), @@ -67,7 +75,9 @@ class AgendaTimelineEntry extends StatelessWidget { titleText, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: entry.isDeadline ? TextAlign.right : TextAlign.left, + textAlign: entry.isDeadline && !openEnded + ? TextAlign.right + : TextAlign.left, style: TextStyle( fontSize: 14, height: 1.2, @@ -99,12 +109,20 @@ class AgendaTimelineEntry extends StatelessWidget { color: entry.isDeadline ? null : colors.fill, gradient: entry.isDeadline ? LinearGradient( - colors: [ - deadlineFill.withValues(alpha: 0), - deadlineFill, - deadlineFill, - ], - stops: const [0, 0.18, 1], + colors: openEnded + ? [ + deadlineFill, + deadlineFill, + deadlineFill.withValues(alpha: 0), + ] + : [ + deadlineFill.withValues(alpha: 0), + deadlineFill, + deadlineFill, + ], + stops: openEnded + ? const [0, 0.82, 1] + : const [0, 0.18, 1], ) : null, borderRadius: radius, @@ -116,12 +134,9 @@ class AgendaTimelineEntry extends StatelessWidget { decoration: BoxDecoration( borderRadius: radius, border: entry.isDeadline - ? Border( - right: BorderSide( - color: overdue ? scheme.error : colors.accent, - width: 3, - ), - ) + ? openEnded + ? Border(left: accentEdge) + : Border(right: accentEdge) : Border.all(color: colors.accent), ), child: Stack( @@ -147,8 +162,10 @@ class AgendaTimelineEntry extends StatelessWidget { ), Padding( padding: EdgeInsets.only( - left: entry.isDeadline ? deadlineWidth * 0.37 : 8, - right: 8, + left: entry.isDeadline && !openEnded + ? deadlineWidth * 0.37 + : 8, + right: openEnded ? deadlineWidth * 0.37 : 8, top: 4, bottom: 4, ), @@ -156,7 +173,8 @@ class AgendaTimelineEntry extends StatelessWidget { ? const SizedBox.shrink() : Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: entry.isDeadline + crossAxisAlignment: + entry.isDeadline && !openEnded ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ @@ -214,7 +232,8 @@ class AgendaTimelineEntry extends StatelessWidget { caption, maxLines: entry.isDeadline ? 1 : 2, overflow: TextOverflow.ellipsis, - textAlign: entry.isDeadline + textAlign: entry.isDeadline && + !openEnded ? TextAlign.right : TextAlign.left, style: TextStyle( @@ -289,6 +308,10 @@ String _day(DateTime date) => '${date.month}/${date.day}'; String _caption(AppLocalizations l10n, AgendaEntry entry) { if (entry.isDeadline) { + // An open-ended deadline has no due time, so it reports when it opened. + if (entry.startOnly) { + return l10n.agendaTimelineStarts(clockText(entry.startsAt)); + } return entry.allDay ? l10n.allDayUntil : l10n.dueAt(clockText(entry.dueAt)); } final end = entry.endsAt!; @@ -310,15 +333,27 @@ String _fullDate(AppLocalizations l10n, DateTime date) => String _fullTime(AppLocalizations l10n, DateTime date) => '${_fullDate(l10n, date)} ${clockText(date)}'; +/// The date line of a deadline preview. Open-ended deadlines never claim a +/// time is due; they state when the task opened instead. +String _previewDeadlineLine(AppLocalizations l10n, AgendaEntry entry) { + if (entry.startOnly) { + return l10n.agendaTimelineStarts( + entry.allDay + ? '${_fullDate(l10n, entry.startsAt)} ${l10n.allDay}' + : _fullTime(l10n, entry.startsAt), + ); + } + if (entry.allDay) { + return l10n.agendaTimelineDue( + '${_fullDate(l10n, entry.startsAt)} ${l10n.agendaDueBeforeEndOfDay}', + ); + } + return l10n.agendaTimelineDue(_fullTime(l10n, entry.dueAt)); +} + String _preview(AppLocalizations l10n, AgendaEntry entry, DateTime now) => [ '${entry.kind.localizedLabel(l10n)} · ${localizedAgendaTitle(l10n, entry)}', - if (entry.isDeadline) - entry.allDay - ? l10n.agendaTimelineDue( - '${_fullDate(l10n, entry.startsAt)} ' - '${l10n.agendaDueBeforeEndOfDay}', - ) - : l10n.agendaTimelineDue(_fullTime(l10n, entry.dueAt)) + if (entry.isDeadline) _previewDeadlineLine(l10n, entry) else if (entry.allDay) ...[ l10n.agendaTimelineStarts( '${_fullDate(l10n, entry.startsAt)} ${l10n.allDay}', diff --git a/lib/l10n/formats.dart b/lib/l10n/formats.dart index 8cb0ed6..5755257 100644 --- a/lib/l10n/formats.dart +++ b/lib/l10n/formats.dart @@ -158,7 +158,14 @@ extension LocalizedDeadlineScope on DeadlineClearScope { String localizedAgendaTime(AppLocalizations l10n, AgendaEntry entry) { if (entry.allDay) return l10n.allDay; - if (entry.isDeadline) return l10n.dueAt(clockText(entry.startsAt)); + if (entry.isDeadline) { + // Without a due date there is nothing to be due at; the opening time is + // all this entry knows. + if (entry.startOnly) { + return l10n.agendaTimelineStarts(clockText(entry.startsAt)); + } + return l10n.dueAt(clockText(entry.startsAt)); + } final end = entry.endsAt!; final endTime = calendarDate(entry.startsAt) == calendarDate(end) ? clockText(end) @@ -261,6 +268,9 @@ String localizedAgendaTimeCaption( ) { if (entry.isDeadline) { if (entry.done) return l10n.completed; + // Overdue is impossible without a due date, and calling the opening time + // a due date would be wrong. + if (entry.startOnly) return l10n.start; return entry.overdue(now) ? l10n.overdue : l10n.due; } if (entry.isFinished(now)) return l10n.ended; diff --git a/lib/school/learning/eclass_deadlines.dart b/lib/school/learning/eclass_deadlines.dart index ba45b93..502407c 100644 --- a/lib/school/learning/eclass_deadlines.dart +++ b/lib/school/learning/eclass_deadlines.dart @@ -29,17 +29,20 @@ Future> fetchEclassDeadlines(SchoolSession session) async { return [ for (final task in tasks) AgendaEntry( - id: 'eclass-${task.id}', + id: task.isExam ? 'eclass-exam-${task.id}' : 'eclass-${task.id}', title: task.title, kind: AgendaKind.deadline, startsAt: _eclassDate(task.deadline), + startOnly: task.startOnly, done: task.done, course: task.course, source: DeadlineSource.eclass, local: false, url: DeadlineSource.eclass.origin.resolve( - '/course/${Uri.encodeComponent(task.courseId)}' - '/learning-activity/full-screen#/${task.id}', + task.isExam + ? '/course/${Uri.encodeComponent(task.courseId)}/exam' + : '/course/${Uri.encodeComponent(task.courseId)}' + '/learning-activity/full-screen#/${task.id}', ), ), ]; @@ -75,11 +78,14 @@ Future> _courseTasks(SchoolSession session) async { } final outcomes = await Future.wait([ - for (final course in courses) _courseTasksFor(session, course), + for (final course in courses) ...[ + _courseTasksFor(session, course), + _courseTasksFor(session, course, exams: true), + ], ], eagerError: true); final result = <_EclassTask>[]; - final activityIds = {}; + final taskIds = <(bool, String)>{}; Object? firstFailure; StackTrace? firstStack; for (final outcome in outcomes) { @@ -89,7 +95,7 @@ Future> _courseTasks(SchoolSession session) async { continue; } for (final task in outcome.tasks!) { - if (activityIds.add(task.id)) result.add(task); + if (taskIds.add((task.isExam, task.id))) result.add(task); } } if (result.isEmpty && firstFailure != null) { @@ -100,11 +106,21 @@ Future> _courseTasks(SchoolSession session) async { Future<_CourseTasks> _courseTasksFor( SchoolSession session, - Map course, -) async { + Map course, { + bool exams = false, +}) async { try { final courseId = _identifier(course['id'], 'course.id'); final courseName = jsonStringField(course, 'name'); + // Keep the two endpoints independent: an unavailable exam list must not + // discard homework deadlines (or vice versa). + if (exams) { + return ( + tasks: await _courseExams(session, courseId, courseName), + error: null, + stack: null, + ); + } final response = await _eclassJson( session, '/api/courses/${Uri.encodeComponent(courseId)}/activities', @@ -150,6 +166,8 @@ Future<_CourseTasks> _courseTasksFor( course: task.course, deadline: task.deadline, activityType: task.activityType, + isExam: false, + startOnly: false, done: statuses[task.id] ?? (statuses.containsKey(task.id) @@ -169,6 +187,60 @@ Future<_CourseTasks> _courseTasksFor( } } +Future> _courseExams( + SchoolSession session, + String courseId, + String courseName, +) async { + const pageSize = 100; + final tasks = <_EclassTask>[]; + var page = 1; + while (true) { + final response = await _eclassJson( + session, + '/api/courses/${Uri.encodeComponent(courseId)}' + '/exam-list?page=$page&page_size=$pageSize', + ); + final rows = jsonListField(response, 'exams'); + for (final value in rows) { + final exam = jsonObject(value, 'exams[]'); + final end = _optionalExamDate(exam['end_time'], 'exam.end_time'); + final startOnly = end == null; + // Preserve open-ended practices as start-only entries rather than + // inventing a deadline from their opening time or per-attempt limit. + final anchor = + end ?? _optionalExamDate(exam['start_time'], 'exam.start_time'); + if (anchor == null) continue; + final submissions = + jsonIntOrNull(exam['submission_count'], 'exam.submission_count') ?? 0; + tasks.add(( + id: _identifier(exam['id'], 'exam.id'), + title: jsonStringOrNull(exam['title'], 'exam.title') ?? '未命名测试', + courseId: courseId, + course: courseName, + deadline: anchor, + activityType: 'exam', + isExam: true, + startOnly: startOnly, + // Neither submit_times nor is_closed reliably describes whether I + // submitted. Makeup completion semantics still need separate evidence. + done: submissions > 0, + )); + } + final pages = jsonIntOrNull(response['pages'], 'pages'); + if (rows.isEmpty || (pages != null ? page >= pages : rows.length < pageSize)) { + break; + } + page++; + } + return tasks; +} + +String? _optionalExamDate(Object? value, String field) { + final date = jsonStringOrNull(value, field); + return date == null || date.isEmpty || date == 'null' ? null : date; +} + Future> _homeworkSubmissionStatuses( SchoolSession session, String courseId, @@ -205,6 +277,8 @@ _EclassTask? _taskFromActivity( course: courseName, deadline: deadline, activityType: jsonStringOrNull(task['type'], 'activity.type') ?? '', + isExam: false, + startOnly: false, done: false, ); } @@ -257,6 +331,8 @@ typedef _EclassTask = ({ String course, String deadline, String activityType, + bool isExam, + bool startOnly, bool done, }); diff --git a/test/agenda_start_only_test.dart b/test/agenda_start_only_test.dart new file mode 100644 index 0000000..4b0d480 --- /dev/null +++ b/test/agenda_start_only_test.dart @@ -0,0 +1,328 @@ +import 'dart:convert'; + +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/timeline_layout.dart'; +import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/agenda/ui/timeline_entry.dart'; +import 'package:bitree/l10n/app_localizations.dart'; +import 'package:bitree/l10n/app_localizations_en.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/l10n/formats.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +final _date = DateTime.utc(2026, 11, 17); +final _opens = DateTime.utc(2026, 11, 17, 8); + +AgendaEntry _openEnded({ + String id = 'open', + DateTime? startsAt, + int tailDays = AgendaEntry.defaultTailDays, + bool done = false, +}) => AgendaEntry( + id: id, + title: '第三章测验', + kind: AgendaKind.deadline, + startsAt: startsAt ?? _opens, + tailDays: tailDays, + done: done, + startOnly: true, +); + +AgendaEntry _due({ + String id = 'due', + DateTime? startsAt, + int tailDays = AgendaEntry.defaultTailDays, +}) => AgendaEntry( + id: id, + title: '第五章作业', + kind: AgendaKind.deadline, + startsAt: startsAt ?? _opens, + tailDays: tailDays, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('storage', () { + test('The open-ended flag round-trips through storage', () { + final restored = AgendaEntry.fromJson( + jsonDecode(jsonEncode(_openEnded().toJson())) as Map, + ); + + expect(_openEnded().toJson()['startOnly'], isTrue); + expect(restored.startOnly, isTrue); + expect(restored.kind, AgendaKind.deadline); + expect(restored.startsAt, _opens); + }); + + test('Records written before the flag exist stay ordinary deadlines', () { + final legacy = _openEnded().toJson()..remove('startOnly'); + + expect(AgendaEntry.fromJson(legacy).startOnly, isFalse); + expect(AgendaEntry.fromJson(_due().toJson()).startOnly, isFalse); + }); + + test('copyWith preserves the flag', () { + expect(_openEnded().copyWith(done: true).startOnly, isTrue); + expect(_due().copyWith(done: true).startOnly, isFalse); + }); + + test('Only a deadline can be open-ended', () { + expect( + () => AgendaEntry( + id: 'exam', + title: '期中考试', + kind: AgendaKind.exam, + startsAt: _opens, + endsAt: _opens.add(const Duration(hours: 2)), + startOnly: true, + ), + throwsA(isA()), + ); + }); + }); + + group('status', () { + test('An open-ended deadline is never overdue', () { + final now = DateTime.utc(2026, 11, 17); + + expect( + _openEnded(startsAt: DateTime.utc(2020, 5, 1, 8)).overdue(now), + isFalse, + ); + expect( + _due(startsAt: DateTime.utc(2020, 5, 1, 8)).overdue(now), + isTrue, + ); + }); + + test('An open-ended deadline stays unfinished until it is ticked off', () { + final now = DateTime.utc(2030, 1, 1); + + expect(_openEnded().isFinished(now), isFalse); + expect(_openEnded(done: true).isFinished(now), isTrue); + }); + }); + + group('layout', () { + test('The reservation grows right from the opening time', () { + final placement = layoutAgendaTimeline( + [_openEnded(tailDays: 6)], + minimumEventSpan: const Duration(hours: 1), + ).single; + + expect(placement.start, _opens); + expect(placement.end, _opens.add(const Duration(days: 6))); + expect(placement.layoutEnd, _opens.add(const Duration(days: 6))); + }); + + test('A deadline with a due date still reserves its tail on the left', () { + final placement = layoutAgendaTimeline( + [_due(tailDays: 6)], + minimumEventSpan: const Duration(hours: 1), + ).single; + + expect(placement.start, _opens.subtract(const Duration(days: 6))); + expect(placement.end, _opens); + }); + }); + + group('labels', () { + final localized = [AppLocalizationsZh(), AppLocalizationsEn()]; + + test('An open-ended deadline is never presented as due', () { + for (final l10n in localized) { + final time = localizedAgendaTime(l10n, _openEnded()); + + expect(time, contains(l10n.start)); + expect(time, isNot(contains(l10n.due))); + expect( + localizedAgendaTimeCaption(l10n, _openEnded(), _date), + l10n.start, + ); + } + }); + + test('A deadline with a due date keeps its due wording', () { + for (final l10n in localized) { + expect(localizedAgendaTime(l10n, _due()), contains(l10n.due)); + expect(localizedAgendaTimeCaption(l10n, _due(), _date), l10n.due); + } + }); + }); + + group('card', () { + Future showCard(WidgetTester tester, AgendaEntry entry) async { + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Center( + child: SizedBox( + width: 240, + height: 120, + child: AgendaTimelineEntry( + entry: entry, + now: _date, + barWidth: 240, + deadlineWidth: 240, + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Finder bar(AgendaEntry entry) => + find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + + LinearGradient fill(WidgetTester tester, AgendaEntry entry) { + final ink = tester.widget( + find.descendant(of: bar(entry), matching: find.byType(Ink)), + ); + return (ink.decoration! as BoxDecoration).gradient! as LinearGradient; + } + + Border outline(WidgetTester tester, AgendaEntry entry) { + final foreground = tester.widget( + find.descendant( + of: bar(entry), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.position == DecorationPosition.foreground, + ), + ), + ); + return (foreground.decoration as BoxDecoration).border! as Border; + } + + testWidgets('The accent line moves to the left edge', (tester) async { + final open = _openEnded(); + await showCard(tester, open); + final border = outline(tester, open); + + expect(border.left.width, 3); + expect(border.right, BorderSide.none); + expect(border.top, BorderSide.none); + expect(border.bottom, BorderSide.none); + + final due = _due(); + await showCard(tester, due); + expect(outline(tester, due).right.width, 3); + expect(outline(tester, due).left, BorderSide.none); + expect(tester.takeException(), isNull); + }); + + testWidgets('The fill fades out towards the right', (tester) async { + final open = _openEnded(); + await showCard(tester, open); + final gradient = fill(tester, open); + + expect(gradient.colors.first.a, 1); + expect(gradient.colors[1].a, 1); + expect(gradient.colors.last.a, 0); + expect(gradient.stops, [0.0, 0.82, 1.0]); + + final due = _due(); + await showCard(tester, due); + final standard = fill(tester, due); + expect(standard.colors.first.a, 0); + expect(standard.colors.last.a, 1); + expect(standard.stops, [0.0, 0.18, 1.0]); + }); + + testWidgets('Text hugs the line on the left', (tester) async { + final open = _openEnded(); + await showCard(tester, open); + expect( + tester.widget(find.text(open.title)).textAlign, + TextAlign.left, + ); + + final due = _due(); + await showCard(tester, due); + expect( + tester.widget(find.text(due.title)).textAlign, + TextAlign.right, + ); + }); + }); + + group('timeline', () { + Future showTimeline( + WidgetTester tester, + List entries, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: AgendaTimeline( + controller: app, + entries: entries, + now: _date.add(const Duration(hours: 12)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Rect bar(WidgetTester tester, AgendaEntry entry) => tester.getRect( + find.byKey(ValueKey(('agenda-timeline-bar', entry.id))), + ); + + testWidgets('The card keeps the deadline width and grows rightwards', ( + tester, + ) async { + final due = _due(tailDays: 6); + final open = _openEnded(tailDays: 6); + await showTimeline(tester, [due, open]); + final dueRect = bar(tester, due); + final openRect = bar(tester, open); + + // Same painted width, anchored at the opening time instead: the two + // cards meet exactly where the due one ends. + expect(openRect.width, greaterThan(0)); + expect(openRect.width, closeTo(dueRect.width, .001)); + expect(openRect.left, closeTo(dueRect.right, .001)); + expect(openRect.right, greaterThan(dueRect.right)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('The card stays painted as the view zooms in', (tester) async { + final open = _openEnded(tailDays: 12); + await showTimeline(tester, [open]); + final before = bar(tester, open); + expect(before.width, closeTo(48 * (12 - .25), .001)); + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + final after = bar(tester, open); + + // The painted width is set by the minimum zoom, so the card keeps its + // screen width while the lane reservation behind it stretches. + expect( + find.byKey(ValueKey(('agenda-timeline-bar', open.id))), + findsOneWidget, + ); + expect(after.width, closeTo(before.width, .001)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + }); +} diff --git a/test/agenda_timeline_test.dart b/test/agenda_timeline_test.dart index 5003fdb..b51bd6e 100644 --- a/test/agenda_timeline_test.dart +++ b/test/agenda_timeline_test.dart @@ -29,6 +29,25 @@ Finder _bar(AgendaEntry entry) => find.bySemanticsLabel( Finder _paintedBar(AgendaEntry entry) => find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); +List _stackedDeadlines() => [ + for (var index = 0; index < 24; index++) + AgendaEntry( + id: 'stacked-${index.toString().padLeft(2, '0')}', + title: '重叠作业 $index', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 12)), + ), +]; + +ScrollPosition _lanePosition(WidgetTester tester) => tester + .state( + find.descendant( + of: find.byKey(const ValueKey('agenda-timeline-lanes')), + matching: find.byType(Scrollable), + ), + ) + .position; + Future _show( WidgetTester tester, List entries, { @@ -295,7 +314,7 @@ void main() { }, ); - testWidgets('Off-screen lanes do not add vertical blank pages', ( + testWidgets('Global lanes reserve space without rendering off-screen cards', ( tester, ) async { final current = _event('current', 11); @@ -318,8 +337,11 @@ void main() { matching: find.byType(Scrollable), ), ); - expect(scrollable.position.maxScrollExtent, 0); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); expect(_paintedBar(current), findsOneWidget); + for (final entry in offScreen) { + expect(_paintedBar(entry), findsNothing); + } expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); @@ -353,6 +375,145 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + testWidgets( + 'Diagonal touch panning keeps the global range and vertical direction', + (tester) async { + final entries = _stackedDeadlines(); + await _show(tester, entries); + final position = _lanePosition(tester); + final maximum = position.maxScrollExtent; + expect(maximum, greaterThan(0)); + position.jumpTo(maximum / 2); + await tester.pumpAndSettle(); + final initialOffset = position.pixels; + expect( + entries.any((entry) => _paintedBar(entry).evaluate().isNotEmpty), + isTrue, + ); + + final viewport = tester.getRect( + find.byKey(const ValueKey('agenda-timeline-lanes')), + ); + // Start in blank space to the right of the DDLs, not on an InkWell. + // At 48px/day these steps move every DDL entirely out of the time window. + final gesture = await tester.startGesture( + Offset(viewport.right - 40, viewport.bottom - 80), + kind: PointerDeviceKind.touch, + ); + var previousOffset = initialOffset; + for (var step = 1; step <= 16; step++) { + await gesture.moveBy( + const Offset(-20, -3), + timeStamp: Duration(milliseconds: step * 32), + ); + await tester.pump(const Duration(milliseconds: 32)); + expect(position.maxScrollExtent, closeTo(maximum, .001)); + expect( + position.pixels, + greaterThanOrEqualTo(previousOffset - .001), + ); + previousOffset = position.pixels; + } + expect(position.pixels, greaterThan(initialOffset)); + for (final entry in entries) { + expect(_paintedBar(entry), findsNothing); + } + + final offsetBeforeReturn = position.pixels; + for (var step = 17; step <= 32; step++) { + await gesture.moveBy( + const Offset(20, 3), + timeStamp: Duration(milliseconds: step * 32), + ); + await tester.pump(const Duration(milliseconds: 32)); + expect(position.maxScrollExtent, closeTo(maximum, .001)); + expect(position.pixels, lessThanOrEqualTo(previousOffset + .001)); + previousOffset = position.pixels; + } + expect(position.pixels, lessThan(offsetBeforeReturn)); + expect(position.pixels, greaterThan(0)); + expect( + entries.any((entry) => _paintedBar(entry).evaluate().isNotEmpty), + isTrue, + ); + // Cancel instead of releasing: this isolates drag/range behaviour from + // velocity estimation and from how long a simulated fling takes to stop. + await gesture.cancel(); + await tester.pumpAndSettle(); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(_paintedBar(entries.last), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets('Filtering a deep timeline culls at the newly clamped offset', ( + tester, + ) async { + final entries = _stackedDeadlines(); + final app = await _show(tester, entries); + var filtered = entries; + late StateSetter updateEntries; + // Retain the same timeline State when the filtered data changes. + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: StatefulBuilder( + builder: (_, setState) { + updateEntries = setState; + return AgendaTimeline( + controller: app, + entries: filtered, + now: _date.add(const Duration(hours: 12)), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final timelineState = tester.state(find.byType(AgendaTimeline)); + final position = _lanePosition(tester); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(position.pixels, greaterThan(0)); + expect(_paintedBar(entries.last), findsOneWidget); + + updateEntries(() => filtered = [entries.last]); + // Check the first layout, not pumpAndSettle: a later unrelated rebuild + // must not be required to recover cards culled using an obsolete offset. + await tester.pump(); + expect(tester.state(find.byType(AgendaTimeline)), same(timelineState)); + expect(position.maxScrollExtent, 0); + expect(position.pixels, 0); + expect(_paintedBar(entries.last), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Growing the viewport culls at the newly clamped offset', ( + tester, + ) async { + final entries = _stackedDeadlines(); + await _show(tester, entries); + final position = _lanePosition(tester); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(position.pixels, greaterThan(0)); + expect(_paintedBar(entries.first), findsNothing); + + tester.view.physicalSize = const Size(390, 2200); + await tester.pump(); + expect(position.maxScrollExtent, 0); + expect(position.pixels, 0); + expect(_paintedBar(entries.first), findsOneWidget); + expect(_paintedBar(entries.last), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets( 'Time ranges are visible on cards and long press previews complete endpoints', (tester) async { diff --git a/test/school_controller_test.dart b/test/school_controller_test.dart index afb4f12..5207f7d 100644 --- a/test/school_controller_test.dart +++ b/test/school_controller_test.dart @@ -245,6 +245,16 @@ void main() { ], }), ); + case '/zy-eclass.bit.edu.cn/api/courses/7/exam-list': + request.response.write( + jsonEncode({ + 'exams': >[], + 'page': 1, + 'page_size': 100, + 'pages': 1, + 'total': 0, + }), + ); case final path when path.startsWith('/zy-eclass.bit.edu.cn/api/activities/'): final id = request.uri.pathSegments.last; @@ -287,6 +297,98 @@ void main() { expect(second.done, isTrue); }); + test('Eclass exams are imported as DDLs, open ones as start-only', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final detailRequests = []; + server.listen((request) async { + request.response.headers.contentType = ContentType.json; + switch (request.uri.path) { + case '/zy-eclass.bit.edu.cn/user/courses': + request.response.write('{}'); + case '/zy-eclass.bit.edu.cn/api/my-courses': + request.response.write( + jsonEncode({ + 'courses': [ + {'id': 7, 'name': '数字电子技术基础'}, + ], + 'pages': 1, + }), + ); + case '/zy-eclass.bit.edu.cn/api/courses/7/activities': + request.response.write(jsonEncode({'activities': []})); + case '/zy-eclass.bit.edu.cn/api/courses/7/exam-list': + request.response.write( + jsonEncode({ + 'exams': [ + { + 'id': 1212, + 'title': '第三章测试', + 'type': 'exam', + 'start_time': '2026-11-01T00:00:00Z', + 'end_time': '2026-11-08T14:30:00Z', + 'submission_count': 1, + }, + { + 'id': 1210, + 'title': '期末开放测试', + 'type': 'exam', + 'start_time': '2026-12-20T16:00:00Z', + 'end_time': null, + 'submission_count': 0, + }, + ], + 'page': 1, + 'page_size': 100, + 'pages': 1, + 'total': 2, + }), + ); + case '/zy-eclass.bit.edu.cn/api/course/7/homework/submission-status': + request.response.write(jsonEncode({'homework_activities': []})); + case final path + when path.startsWith('/zy-eclass.bit.edu.cn/api/activities/'): + detailRequests.add(request.uri.pathSegments.last); + request.response.write(jsonEncode({'user_submit_count': 0})); + default: + throw StateError('Unexpected request: ${request.uri.path}'); + } + await request.response.close(); + }); + final app = TestAppController(); + await app.initialize(); + + final success = await HttpOverrides.runWithHttpOverrides( + () => app.school.importDeadlines( + source: DeadlineSource.eclass, + verify: (_) async => null, + ), + LocalHttpOverrides(server.port), + ); + + expect(success, isTrue); + // Exam rows carry their own completion, so no per-activity lookup runs. + expect(detailRequests, isEmpty); + expect(app.agenda.entries, hasLength(2)); + final closed = app.agenda.entries.singleWhere( + (entry) => entry.id == 'eclass-exam-1212', + ); + expect(closed.title, '第三章测试'); + expect(closed.course, '数字电子技术基础'); + expect(closed.kind, AgendaKind.deadline); + expect(closed.source, DeadlineSource.eclass); + expect(closed.startsAt, DateTime.utc(2026, 11, 8, 22, 30)); + expect(closed.startOnly, isFalse); + expect(closed.done, isTrue); + final open = app.agenda.entries.singleWhere( + (entry) => entry.id == 'eclass-exam-1210', + ); + expect(open.startsAt, DateTime.utc(2026, 12, 21, 0, 0)); + expect(open.startOnly, isTrue); + expect(open.overdue(DateTime.utc(2027, 6, 1)), isFalse); + expect(open.url?.path, '/course/7/exam'); + }); + test('Cancelling a DDL update releases the school controller', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); diff --git a/test/school_deadlines_test.dart b/test/school_deadlines_test.dart index 74d6de9..ec2993f 100644 --- a/test/school_deadlines_test.dart +++ b/test/school_deadlines_test.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/deadline_source.dart'; import 'package:bitree/agenda/model.dart'; +import 'package:bitree/core/async/cancelable_request.dart'; +import 'package:bitree/school/errors.dart'; import 'package:bitree/school/learning/eclass_deadlines.dart'; import 'package:bitree/school/learning/lexue_deadlines.dart'; import 'package:bitree/school/network.dart'; @@ -70,6 +72,18 @@ void main() { case '/zy-eclass.bit.edu.cn/api/activities/9060': request.response.headers.contentType = ContentType.json; request.response.write(jsonEncode({'user_submit_count': 1})); + case '/zy-eclass.bit.edu.cn/api/courses/1/exam-list': + case '/zy-eclass.bit.edu.cn/api/courses/20759/exam-list': + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'exams': >[], + 'page': 1, + 'page_size': 100, + 'pages': 1, + 'total': 0, + }), + ); default: request.response.statusCode = HttpStatus.notFound; request.response.write('unexpected request'); @@ -104,12 +118,21 @@ void main() { ); expect(paths, isNot(contains('/zy-eclass.bit.edu.cn/api/activities/9058'))); expect(paths, contains('/zy-eclass.bit.edu.cn/api/activities/9060')); + expect( + paths.where((path) => path.endsWith('/exam-list')), + unorderedEquals([ + '/zy-eclass.bit.edu.cn/api/courses/1/exam-list', + '/zy-eclass.bit.edu.cn/api/courses/20759/exam-list', + ]), + ); }); test('Eclass courses scan concurrently within the request limit', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); final activityCourses = []; + final examCourses = []; + final examQueries = >[]; final statusCourses = []; var active = 0; var maxActive = 0; @@ -167,6 +190,23 @@ void main() { return; } + final exams = RegExp( + r'^/zy-eclass\.bit\.edu\.cn/api/courses/(\d+)/exam-list$', + ).firstMatch(path); + if (exams != null) { + final courseId = exams[1]!; + examCourses.add(courseId); + examQueries.add(request.uri.queryParameters); + await sendJson(request, { + 'exams': >[], + 'page': 1, + 'page_size': 100, + 'pages': 1, + 'total': 0, + }); + return; + } + final status = RegExp( r'^/zy-eclass\.bit\.edu\.cn/api/course/(\d+)/homework/submission-status$', ).firstMatch(path); @@ -215,10 +255,364 @@ void main() { statusCourses, unorderedEquals(['100', '200', '201', '202', '203', '204']), ); + expect( + examCourses, + unorderedEquals(['100', '200', '201', '202', '203', '204']), + ); + expect(examQueries, hasLength(6)); + for (final query in examQueries) { + expect(query['page_size'], '100'); + expect(query['page'], '1'); + } expect(detailRequests, 0); expect(maxActive, inInclusiveRange(2, 4)); }); + test('Eclass exams become deadlines under their own id namespace', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + exams: (_, _) => [ + eclassExam(id: 1212, title: '第三章测试', submissions: 1), + eclassExam(id: 1211, title: '第四章测试'), + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.unexpected, isEmpty); + expect(scan.entries.map((entry) => entry.id).toList(), [ + 'eclass-exam-1212', + 'eclass-exam-1211', + ]); + final exam = scan.entryFor('eclass-exam-1212'); + expect(exam.title, '第三章测试'); + expect(exam.course, '数字电子技术基础'); + expect(exam.kind, AgendaKind.deadline); + expect(exam.source, DeadlineSource.eclass); + // 2026-11-08T14:30:00Z is 22:30 in Beijing wall clock. + expect(exam.startsAt, DateTime.utc(2026, 11, 8, 22, 30)); + expect(exam.startOnly, isFalse); + expect(exam.done, isTrue); + expect(scan.entryFor('eclass-exam-1211').done, isFalse); + expect(exam.url?.host, 'zy-eclass.bit.edu.cn'); + expect(exam.url?.path, '/course/7/exam'); + expect(exam.url?.fragment ?? '', isEmpty); + expect( + scan.examRequests.map((uri) => uri.queryParameters['page_size']), + everyElement('100'), + ); + }); + + test('Exam ids stay separate from activity ids and their completion', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + activities: (_) => [ + { + 'id': 1211, + 'type': 'homework', + 'title': '第五次作业', + 'end_time': '2026-11-05T15:59:00Z', + 'visible_end_at': null, + }, + ], + exams: (_, _) => [ + eclassExam( + id: 1211, + title: '第五章测试', + submissions: 1, + ), + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.unexpected, isEmpty); + expect(scan.entries, hasLength(2)); + // The homework id is untouched, so stored deadlines keep their identity. + final homework = scan.entryFor('eclass-1211'); + expect(homework.title, '第五次作业'); + expect(homework.done, isFalse); + final exam = scan.entryFor('eclass-exam-1211'); + expect(exam.title, '第五章测试'); + expect(exam.done, isTrue); + }); + + test('Exam completion follows submission_count alone', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + exams: (_, _) => [ + eclassExam( + id: 1, + title: '已交但未结束', + submissions: 1, + submitTimes: 0, + closed: false, + ), + eclassExam( + id: 2, + title: '未交但已关闭', + submissions: 0, + submitTimes: 1, + closed: true, + ), + eclassExam( + id: 3, + title: '只有补交记录', + submissions: 0, + makeupSubmissions: 3, + ), + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.entryFor('eclass-exam-1').done, isTrue); + expect(scan.entryFor('eclass-exam-2').done, isFalse); + expect(scan.entryFor('eclass-exam-3').done, isFalse); + }); + + test('Exams without an end time keep their start as a start-only entry', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + exams: (_, _) => [ + eclassExam( + id: 4, + title: '开放式测试', + endTime: null, + startTime: '2026-11-20T16:00:00Z', + ), + eclassExam( + id: 5, + title: '空截止时间的测试', + endTime: '', + startTime: '2026-11-21T00:00:00Z', + ), + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.unexpected, isEmpty); + expect(scan.entries.map((entry) => entry.id).toList(), [ + 'eclass-exam-4', + 'eclass-exam-5', + ]); + final open = scan.entryFor('eclass-exam-4'); + // 2026-11-20T16:00:00Z is 2026-11-21 00:00 in Beijing wall clock. + expect(open.startsAt, DateTime.utc(2026, 11, 21, 0, 0)); + expect(open.startOnly, isTrue); + // A start-only entry never counts as overdue, however late the clock runs. + expect(open.overdue(DateTime.utc(2027, 1, 1)), isFalse); + expect(scan.entryFor('eclass-exam-5').startOnly, isTrue); + }); + + test('Exams with neither an end nor a start time are skipped', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + exams: (_, _) => [ + eclassExam( + id: 6, + title: '未开放的测试', + endTime: null, + startTime: null, + ), + eclassExam(id: 7, title: '空时间的测试', endTime: '', startTime: ''), + eclassExam(id: 8, title: '有截止时间的测试'), + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.entries.map((entry) => entry.id).toList(), ['eclass-exam-8']); + expect(scan.entryFor('eclass-exam-8').startOnly, isFalse); + }); + + test('An empty exam list leaves activity deadlines untouched', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + activities: (_) => [ + { + 'id': 31, + 'type': 'homework', + 'title': '第一次作业', + 'end_time': '2026-11-04T15:59:00Z', + 'visible_end_at': null, + }, + ], + ), + ); + + expect(scan.error, isNull); + expect(scan.unexpected, isEmpty); + expect(scan.entries, hasLength(1)); + expect(scan.entries.single.id, 'eclass-31'); + }); + + test('Exam pages are followed until the reported page count', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + examPageCount: 3, + exams: (_, page) => [ + eclassExam(id: 9000 + page, title: '测试第$page 页'), + ], + ), + ); + + expect(scan.error, isNull); + expect( + scan.examRequests.map((uri) => uri.queryParameters['page']).toList(), + ['1', '2', '3'], + ); + expect(scan.entries, hasLength(3)); + expect(scan.entryFor('eclass-exam-9003').title, '测试第3 页'); + }); + + test('Exam paging falls back to the row count without a page count', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + reportPageCount: false, + exams: (_, page) => page == 1 + ? [ + for (var index = 0; index < 100; index++) + eclassExam(id: 1000 + index, title: '测试${1000 + index}'), + ] + : [ + for (var index = 0; index < 3; index++) + eclassExam(id: 2000 + index, title: '测试${2000 + index}'), + ], + ), + ); + + expect(scan.error, isNull); + expect( + scan.examRequests.map((uri) => uri.queryParameters['page']).toList(), + ['1', '2'], + ); + expect(scan.entries, hasLength(103)); + }); + + test('A failing exam list does not remove the activity deadlines', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + activities: (_) => [ + { + 'id': 31, + 'type': 'homework', + 'title': '第一次作业', + 'end_time': '2026-11-04T15:59:00Z', + 'visible_end_at': null, + }, + ], + examStatus: (_, _) => HttpStatus.internalServerError, + ), + ); + + expect(scan.error, isNull); + expect(scan.entries.map((entry) => entry.id).toList(), ['eclass-31']); + }); + + test('A failing activity list does not remove the exam deadlines', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + activities: (_) => [ + { + 'id': 31, + 'type': 'homework', + 'title': '第一次作业', + 'end_time': '2026-11-04T15:59:00Z', + 'visible_end_at': null, + }, + ], + exams: (_, _) => [eclassExam(id: 41, title: '第一次测试')], + activityStatus: (_) => HttpStatus.serviceUnavailable, + ), + ); + + expect(scan.error, isNull); + expect(scan.entries.map((entry) => entry.id).toList(), ['eclass-exam-41']); + }); + + test('An expired session on the exam list aborts the whole scan', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final scan = await scanEclass( + server, + eclassFixture( + courses: const [ + {'id': 7, 'name': '数字电子技术基础'}, + {'id': 8, 'name': '电路分析基础'}, + ], + activities: (_) => [ + { + 'id': 31, + 'type': 'homework', + 'title': '第一次作业', + 'end_time': '2026-11-04T15:59:00Z', + 'visible_end_at': null, + }, + ], + examStatus: (courseId, _) => courseId == 7 ? 401 : HttpStatus.ok, + ), + ); + + expect(scan.error, isA()); + expect(scan.entries, isEmpty); + }); + + test('A closed session reports cancellation instead of an empty scan', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + }); + + await HttpOverrides.runWithHttpOverrides(() async { + final session = SchoolSession( + network: SchoolNetwork.campus, + storage: MemorySchoolStorage(), + )..close(); + await expectLater( + fetchEclassDeadlines(session), + throwsA(isA()), + ); + }, LocalHttpOverrides(server.port)); + }); + test( 'Lexue activity pages supply deadlines missing from calendar export', () async { @@ -518,3 +912,192 @@ void main() { expect(agenda.deletedDeadlines.single.id, 'assignment'); }); } + +/// One eclass scan plus the traffic it produced. +class EclassScan { + EclassScan({ + required this.entries, + required this.error, + required this.examRequests, + required this.unexpected, + }); + + final List entries; + final Object? error; + final List examRequests; + final List unexpected; + + AgendaEntry entryFor(String id) => entries.singleWhere( + (entry) => entry.id == id, + ); +} + +/// Runs [fetchEclassDeadlines] against [server]. [handle] answers a request +/// and reports whether it recognised it; unknown requests are recorded. +Future scanEclass( + HttpServer server, + bool Function(HttpRequest request) handle, +) async { + final examRequests = []; + final unexpected = []; + server.listen((request) async { + try { + if (request.uri.path.endsWith('/exam-list')) { + examRequests.add(request.uri); + } + if (!handle(request)) { + unexpected.add('${request.uri.path}?${request.uri.query}'); + request.response.statusCode = HttpStatus.notFound; + } + } finally { + await request.response.close(); + } + }); + + SchoolSession? session; + var entries = const []; + Object? error; + try { + entries = await HttpOverrides.runWithHttpOverrides(() { + final fresh = SchoolSession( + network: SchoolNetwork.campus, + storage: MemorySchoolStorage(), + ); + session = fresh; + return fetchEclassDeadlines(fresh); + }, LocalHttpOverrides(server.port)); + } catch (failure) { + error = failure; + } + session?.close(); + return EclassScan( + entries: entries, + error: error, + examRequests: examRequests, + unexpected: unexpected, + ); +} + +List> _noActivities(int courseId) => const []; + +List> _noExams(int courseId, int page) => const []; + +int _requestOk(int courseId, int page) => HttpStatus.ok; + +int _activityRequestOk(int courseId) => HttpStatus.ok; + +/// Answers the endpoints one eclass scan touches, so each test only states the +/// payloads that matter to it. +bool Function(HttpRequest request) eclassFixture({ + List> courses = const [ + {'id': 7, 'name': '数字电子技术基础'}, + ], + List> Function(int courseId) activities = _noActivities, + List> Function(int courseId, int page) exams = _noExams, + int examPageCount = 1, + bool reportPageCount = true, + int Function(int courseId, int page) examStatus = _requestOk, + int Function(int courseId) activityStatus = _activityRequestOk, + Set submittedActivities = const {}, +}) { + final activityPath = RegExp( + r'^/zy-eclass\.bit\.edu\.cn/api/courses/(\d+)/activities$', + ); + final examPath = RegExp( + r'^/zy-eclass\.bit\.edu\.cn/api/courses/(\d+)/exam-list$', + ); + final statusPath = RegExp( + r'^/zy-eclass\.bit\.edu\.cn/api/course/(\d+)/homework/submission-status$', + ); + return (request) { + final path = request.uri.path; + if (path == '/zy-eclass.bit.edu.cn/api/my-courses') { + respondJson(request, {'courses': courses, 'pages': 1}); + return true; + } + final activity = activityPath.firstMatch(path); + if (activity != null) { + final courseId = int.parse(activity[1]!); + final status = activityStatus(courseId); + if (status != HttpStatus.ok) { + request.response.statusCode = status; + request.response.write('activities unavailable'); + return true; + } + respondJson(request, {'activities': activities(courseId)}); + return true; + } + final exam = examPath.firstMatch(path); + if (exam != null) { + final courseId = int.parse(exam[1]!); + final page = int.parse(request.uri.queryParameters['page'] ?? '1'); + final status = examStatus(courseId, page); + if (status != HttpStatus.ok) { + request.response.statusCode = status; + request.response.write('exam list unavailable'); + return true; + } + final rows = exams(courseId, page); + respondJson(request, { + 'exams': rows, + 'page': page, + 'page_size': 100, + 'start': 0, + 'end': rows.length, + if (reportPageCount) 'pages': examPageCount, + 'total': rows.length, + }); + return true; + } + final homework = statusPath.firstMatch(path); + if (homework != null) { + final courseId = int.parse(homework[1]!); + respondJson(request, { + 'course_id': courseId, + 'homework_activities': [ + for (final task in activities(courseId)) + if (task['type'] == 'homework') + { + 'id': task['id'], + 'status_code': submittedActivities.contains(task['id']) + ? 'submitted' + : 'absent', + }, + ], + }); + return true; + } + return false; + }; +} + +void respondJson(HttpRequest request, Object body) { + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode(body)); +} + +/// One row of `exam-list`, shaped like the live response. +Map eclassExam({ + required int id, + required String title, + Object? endTime = '2026-11-08T14:30:00Z', + Object? startTime = '2026-11-01T00:00:00Z', + int submissions = 0, + int makeupSubmissions = 0, + int submitTimes = 0, + bool closed = false, +}) => { + 'id': id, + 'title': title, + 'type': 'exam', + 'start_time': startTime, + 'end_time': endTime, + 'is_closed': closed, + 'is_in_progress': !closed, + 'is_started': true, + 'published': true, + 'submission_count': submissions, + 'makeup_exam_submission_count': makeupSubmissions, + 'submit_times': submitTimes, + 'referrer_type': 'module', +}; diff --git a/test/wencui_map_screen_test.dart b/test/wencui_map_screen_test.dart index d89ebc7..cac727b 100644 --- a/test/wencui_map_screen_test.dart +++ b/test/wencui_map_screen_test.dart @@ -151,13 +151,15 @@ void main() { testWidgets('Restroom selection and search use the surveyed types', ( tester, ) async { - final semantics = tester.ensureSemantics(); - addTearDown(semantics.dispose); + // testWidgets owns the semantics handle and disposes it before its final + // leak checks; a separate handle released in addTearDown is too late. await _open(tester, room: 'F202'); await tester.tap(find.widgetWithText(AppSelectionButton, '卫生间')); await tester.pumpAndSettle(); - expect(find.bySemanticsLabel('男卫生间,2层'), findsWidgets); - expect(find.bySemanticsLabel('女卫生间,2层'), findsWidgets); + // CustomPainterSemantics creates nodes without their own widget elements. + // Search the semantics tree, not the widget tree, for these map markers. + expect(find.semantics.byLabel('男卫生间,2层').evaluate(), isNotEmpty); + expect(find.semantics.byLabel('女卫生间,2层').evaluate(), isNotEmpty); final atlas = (await tester.runAsync(WencuiAtlas.load))!; for (final (gender, label, icon) in [ (WencuiRestroomGender.male, '男卫生间', Icons.man_rounded), @@ -181,7 +183,7 @@ void main() { expect((result.leading! as Icon).icon, Icons.woman_rounded); } expect(tester.takeException(), isNull); - }); + }, semanticsEnabled: true); testWidgets('First-floor restrooms show men and women together', ( tester,