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 Mon–Fri 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().toList(); final overflow = result.whereType().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().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().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().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()), ); }); }); }