Files
Client/test/view/timetable/calendar_logic_test.dart
T
2026-05-08 20:12:40 +02:00

423 lines
14 KiB
Dart
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.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart';
import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart';
import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart';
import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
DateTime _at(int year, int month, int day, [int hour = 0, int minute = 0]) =>
DateTime(year, month, day, hour, minute);
Appointment _appt({
required DateTime start,
required DateTime end,
String subject = 'Test',
bool isAllDay = false,
Object? id,
String? rrule,
}) => Appointment(
id: id,
startTime: start,
endTime: end,
subject: subject,
color: Colors.blue,
isAllDay: isAllDay,
recurrenceRule: rrule,
);
GetTimetableResponseObject _lesson({String? code}) =>
GetTimetableResponseObject(
id: 0,
date: 0,
startTime: 0,
endTime: 0,
kl: const [],
te: const [],
su: const [],
ro: const [],
code: code,
);
CustomTimetableEvent _customEvent() => CustomTimetableEvent(
id: 'x',
title: '',
description: '',
startDate: DateTime(2026),
endDate: DateTime(2026),
color: null,
rrule: '',
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
void main() {
group('isAllDayLike', () {
test('explicit isAllDay flag wins', () {
final a = _appt(
start: _at(2026, 5, 8, 9),
end: _at(2026, 5, 8, 10),
isAllDay: true,
);
expect(isAllDayLike(a), isTrue);
});
test('events under 8 hours are not all-day-like', () {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 15, 59));
expect(isAllDayLike(a), isFalse);
});
test('events of exactly 8 hours count as all-day-like', () {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 16));
expect(isAllDayLike(a), isTrue);
});
test(
'Duration.inHours truncation does not let a 9h 30min event escape',
() {
final a = _appt(
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 17, 30),
);
expect(
isAllDayLike(a),
isTrue,
reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)',
);
},
);
});
group('isOutsideSchoolHours', () {
// School hours run 7:30 → 17:15 (kCalendarStartHour = 7.5, kCalendarEndHour = 17.25).
test('lessons fully inside the grid are inside', () {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9));
expect(isOutsideSchoolHours(a), isFalse);
});
test('all-day-like events are always outside', () {
final a = _appt(
start: _at(2026, 5, 8, 9),
end: _at(2026, 5, 8, 10),
isAllDay: true,
);
expect(isOutsideSchoolHours(a), isTrue);
});
test('events ending at or before grid start are outside', () {
final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 7, 30));
expect(isOutsideSchoolHours(a), isTrue);
});
test('events starting at or after grid end are outside', () {
final a = _appt(start: _at(2026, 5, 8, 17, 15), end: _at(2026, 5, 8, 18));
expect(isOutsideSchoolHours(a), isTrue);
});
test('events that engulf the entire grid are outside', () {
final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 18));
expect(isOutsideSchoolHours(a), isTrue);
});
test('events that cross only the start boundary are inside', () {
final a = _appt(start: _at(2026, 5, 8, 7), end: _at(2026, 5, 8, 8));
expect(isOutsideSchoolHours(a), isFalse);
});
test('events that cross only the end boundary are inside', () {
final a = _appt(start: _at(2026, 5, 8, 17), end: _at(2026, 5, 8, 18));
expect(isOutsideSchoolHours(a), isFalse);
});
});
group('partitionAppointmentsForWeek', () {
final monday = _at(2026, 5, 4); // a Monday
test('single non-recurring lesson lands in the right day bucket', () {
final wednesday9 = _appt(
start: _at(2026, 5, 6, 9),
end: _at(2026, 5, 6, 10),
);
final result = partitionAppointmentsForWeek([wednesday9], monday);
expect(result.inside[0], isEmpty);
expect(result.inside[1], isEmpty);
expect(result.inside[2], hasLength(1));
expect(result.inside[3], isEmpty);
expect(result.outside.expand((e) => e), isEmpty);
});
test('all-day events go to the outside bucket on their day', () {
final tuesdayAllDay = _appt(
start: _at(2026, 5, 5),
end: _at(2026, 5, 6),
isAllDay: true,
);
final result = partitionAppointmentsForWeek([tuesdayAllDay], monday);
expect(result.inside.expand((e) => e), isEmpty);
expect(result.outside[1], hasLength(1));
});
test('events outside the visible week are dropped', () {
final lastWeek = _appt(
start: _at(2026, 4, 27, 9),
end: _at(2026, 4, 27, 10),
);
final nextWeek = _appt(
start: _at(2026, 5, 11, 9),
end: _at(2026, 5, 11, 10),
);
final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday);
expect(result.inside.expand((e) => e), isEmpty);
expect(result.outside.expand((e) => e), isEmpty);
});
test('weekend events (Sat/Sun) are dropped, only MonFri counted', () {
final saturday = _appt(
start: _at(2026, 5, 9, 9),
end: _at(2026, 5, 9, 10),
);
final result = partitionAppointmentsForWeek([saturday], monday);
expect(result.inside.expand((e) => e), isEmpty);
});
test('weekly RRULE expands to one occurrence per matching week', () {
// Anchor on the Monday before our visible week, repeating weekly.
// The visible week's Monday should produce one occurrence.
final anchor = _appt(
start: _at(2026, 4, 27, 9),
end: _at(2026, 4, 27, 10),
rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO',
);
final result = partitionAppointmentsForWeek([anchor], monday);
expect(
result.inside[0],
hasLength(1),
reason: 'Monday of the visible week should get one expansion',
);
expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9));
});
test('malformed RRULE falls back to placing the anchor', () {
final broken = _appt(
start: _at(2026, 5, 6, 9),
end: _at(2026, 5, 6, 10),
rrule: 'this is not a valid rrule',
);
final result = partitionAppointmentsForWeek([broken], monday);
expect(result.inside[2], hasLength(1));
});
});
group('PeriodLayout', () {
final p1 = const LessonPeriod(
name: '1',
start: TimeOfDay(hour: 8, minute: 0),
end: TimeOfDay(hour: 9, minute: 0),
);
final brk = const LessonPeriod(
name: 'Pause',
start: TimeOfDay(hour: 9, minute: 0),
end: TimeOfDay(hour: 9, minute: 15),
isBreak: true,
);
final p2 = const LessonPeriod(
name: '2',
start: TimeOfDay(hour: 9, minute: 15),
end: TimeOfDay(hour: 10, minute: 15),
);
final layout = PeriodLayout(
periods: [p1, brk, p2],
lessonHeight: 60, // 60px per lesson
breakHeight: 20,
);
test('totalHeight sums lessons and breaks', () {
expect(layout.totalHeight, 60 + 20 + 60);
});
test('topOf returns cumulative height of preceding periods', () {
expect(layout.topOf(p1), 0);
expect(layout.topOf(brk), 60);
expect(layout.topOf(p2), 80);
});
test('heightOf returns the period-type-specific height', () {
expect(layout.heightOf(p1), 60);
expect(layout.heightOf(brk), 20);
});
test('yOfDateTime maps proportionally inside a period', () {
// 8:30 = halfway through the 1st lesson → y = 30
expect(layout.yOfDateTime(_at(2026, 5, 8, 8, 30)), 30);
});
test('yOfDateTime clips to 0 before the first period', () {
expect(layout.yOfDateTime(_at(2026, 5, 8, 6)), 0);
});
test('yOfDateTime clips to totalHeight after the last period', () {
expect(layout.yOfDateTime(_at(2026, 5, 8, 18)), layout.totalHeight);
});
test('periodAtY returns the lesson under the cursor', () {
expect(layout.periodAtY(0), p1);
expect(layout.periodAtY(59), p1);
});
test('periodAtY skips a break to the next non-break lesson', () {
// y=70 falls in the break range; periodAtY should jump to p2.
expect(layout.periodAtY(70), p2);
});
test('periodAtY returns null past the last period', () {
expect(layout.periodAtY(layout.totalHeight + 10), isNull);
});
});
group('assignLanes', () {
test('non-overlapping appointments stay on lane 0', () {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9));
final b = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));
final result = assignLanes([a, b], maxLanes: 2);
expect(result, hasLength(2));
for (final cell in result) {
expect(cell.lane, 0);
expect(
cell.laneCount,
1,
reason: 'separate clusters → laneCount=1 each',
);
}
});
test('two overlapping appointments split into 2 lanes', () {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10));
final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11));
final result = assignLanes([a, b], maxLanes: 2);
expect(result, hasLength(2));
expect(result.map((c) => c.lane).toSet(), {0, 1});
expect(result.every((c) => c.laneCount == 2), isTrue);
});
test(
'three overlapping appointments with maxLanes=2 collapse the third into overflow',
() {
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11));
final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11));
final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));
final result = assignLanes([a, b, c], maxLanes: 2);
final visible = result.whereType<LaidOutAppointment>().toList();
final overflow = result.whereType<LaidOutOverflow>().toList();
expect(
visible,
hasLength(1),
reason: 'maxLanes-1 = 1 visible appointment',
);
expect(overflow, hasLength(1));
expect(overflow.first.appointments, hasLength(2));
expect(overflow.first.lane, 1);
expect(overflow.first.laneCount, 2);
},
);
test('CustomAppointment beats a regular lesson on lane priority', () {
final custom = _appt(
id: CustomAppointment(_customEvent()),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final regular = _appt(
id: WebuntisAppointment(_lesson()),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final result = assignLanes([
regular,
custom,
], maxLanes: 2).whereType<LaidOutAppointment>().toList();
// Same startTime → priority decides: custom (0) goes left of regular (2).
final customCell = result.firstWhere(
(c) => c.appointment.id is CustomAppointment,
);
final regularCell = result.firstWhere(
(c) => c.appointment.id is WebuntisAppointment,
);
expect(customCell.lane, lessThan(regularCell.lane));
});
test('cancelled lesson lands left of a non-cancelled one on tie', () {
final cancelled = _appt(
id: WebuntisAppointment(_lesson(code: 'cancelled')),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final regular = _appt(
id: WebuntisAppointment(_lesson()),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final result = assignLanes([
regular,
cancelled,
], maxLanes: 2).whereType<LaidOutAppointment>().toList();
String? codeOf(LaidOutAppointment c) {
final id = c.appointment.id;
return id is WebuntisAppointment ? id.lesson.code : null;
}
final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled');
final regularCell = result.firstWhere((c) => codeOf(c) == null);
expect(cancelledCell.lane, lessThan(regularCell.lane));
});
test(
'overflow time-range spans earliest start to latest end of collapsed appointments',
() {
// 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3.
final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12));
final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10));
final c = _appt(
start: _at(2026, 5, 8, 9, 30),
end: _at(2026, 5, 8, 14),
);
final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11));
final overflow = assignLanes([
a,
b,
c,
d,
], maxLanes: 2).whereType<LaidOutOverflow>().single;
expect(overflow.appointments, hasLength(3));
expect(
overflow.startTime,
_at(2026, 5, 8, 9),
reason: 'earliest non-visible start time',
);
expect(
overflow.endTime,
_at(2026, 5, 8, 14),
reason: 'latest non-visible end time',
);
},
);
test('empty input returns an empty list', () {
expect(assignLanes(const [], maxLanes: 2), isEmpty);
});
test('asserts maxLanes >= 2', () {
expect(
() => assignLanes(const [], maxLanes: 1),
throwsA(isA<AssertionError>()),
);
});
});
}