refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import 'package:marianum_mobile/view/pages/files/data/sort_options.dart';
CacheableFile _file({
required String name,
bool isDirectory = false,
int? size,
DateTime? modifiedAt,
}) =>
CacheableFile(
path: '/$name',
isDirectory: isDirectory,
name: name,
size: size,
modifiedAt: modifiedAt,
);
void main() {
group('SortOptions.options', () {
test('name comparator is alphabetic', () {
final cmp = SortOptions.getOption(SortOption.name).compare;
expect(cmp(_file(name: 'a'), _file(name: 'b')), lessThan(0));
expect(cmp(_file(name: 'b'), _file(name: 'a')), greaterThan(0));
expect(cmp(_file(name: 'a'), _file(name: 'a')), 0);
});
test('date comparator is chronological by modifiedAt', () {
final cmp = SortOptions.getOption(SortOption.date).compare;
final older = _file(name: 'a', modifiedAt: DateTime(2026, 1, 1));
final newer = _file(name: 'b', modifiedAt: DateTime(2026, 5, 1));
expect(cmp(older, newer), lessThan(0));
expect(cmp(newer, older), greaterThan(0));
});
test('size comparator pushes directories to the end (positional 1 vs 0)', () {
final cmp = SortOptions.getOption(SortOption.size).compare;
final dir = _file(name: 'd', isDirectory: true);
final file = _file(name: 'f', size: 100);
// (dir, file) → returns 1 (dir.isDirectory true) → file sorts before dir.
expect(cmp(dir, file), 1);
expect(cmp(file, dir), 0);
});
test('size comparator handles null sizes', () {
final cmp = SortOptions.getOption(SortOption.size).compare;
final noSize = _file(name: 'a');
final withSize = _file(name: 'b', size: 100);
// a.size == null → returns 0
expect(cmp(noSize, withSize), 0);
// b.size == null → returns 1
expect(cmp(withSize, noSize), 1);
});
test('size comparator orders by file size when both known', () {
final cmp = SortOptions.getOption(SortOption.size).compare;
expect(cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), lessThan(0));
expect(cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), greaterThan(0));
});
test('options map contains all enum values exactly once', () {
expect(SortOptions.options.keys.toSet(), SortOption.values.toSet());
});
});
group('ListFilesResponse.sortBy', () {
final folderA = _file(name: 'A', isDirectory: true);
final folderB = _file(name: 'B', isDirectory: true);
final fileA = _file(name: 'aaa', size: 100, modifiedAt: DateTime(2026, 1, 1));
final fileB = _file(name: 'bbb', size: 50, modifiedAt: DateTime(2026, 5, 1));
// Note: sortBy uses a string-buffer sort + compareTo descending. The actual
// list ordering reflects what users see in the file list.
test('foldersToTop=true keeps folders before files regardless of name', () {
final response = ListFilesResponse({fileA, fileB, folderA, folderB});
final sorted = response.sortBy(sortOption: SortOption.name);
final folderCount = sorted.takeWhile((f) => f.isDirectory).length;
expect(folderCount, 2, reason: 'both folders should sit at the top');
});
test('foldersToTop=false intermixes folders and files', () {
final response = ListFilesResponse({fileA, fileB, folderA, folderB});
final sorted = response.sortBy(sortOption: SortOption.name, foldersToTop: false);
final folderPositions = <int>[];
for (var i = 0; i < sorted.length; i++) {
if (sorted[i].isDirectory) folderPositions.add(i);
}
// Without foldersToTop, folders aren't guaranteed to be at the front:
// assert at least one folder is somewhere other than the very top of
// a folders-first ordering.
expect(folderPositions, isNot([0, 1]));
});
test('reversed flips the order within each section', () {
final response = ListFilesResponse({fileA, fileB});
final asc = response.sortBy(sortOption: SortOption.name, foldersToTop: false);
final desc = response.sortBy(
sortOption: SortOption.name, foldersToTop: false, reversed: true);
expect(desc, asc.reversed.toList());
});
test('empty input yields an empty list', () {
final response = ListFilesResponse({});
expect(response.sortBy(), isEmpty);
});
});
}
@@ -0,0 +1,109 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiffy/jiffy.dart';
import 'package:marianum_mobile/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import 'package:marianum_mobile/view/pages/marianum_dates/data/event_formatter.dart';
MarianumDate _event({
required DateTime start,
required DateTime end,
bool isAllDay = false,
}) =>
MarianumDate(
uid: 't',
title: 't',
description: null,
start: start,
end: end,
isAllDay: isAllDay,
);
void main() {
setUpAll(() async {
await Jiffy.setLocale('de');
});
group('EventFormatter.trailingLabel', () {
test('all-day events show "Ganztägig"', () {
final e = _event(
start: DateTime(2026, 5, 8),
end: DateTime(2026, 5, 9),
isAllDay: true,
);
expect(EventFormatter.trailingLabel(e), 'Ganztägig');
});
test('zero-length same-day event shows a single time', () {
final at = DateTime(2026, 5, 8, 9, 30);
final e = _event(start: at, end: at);
expect(EventFormatter.trailingLabel(e), '09:30');
});
test('same-day event shows time range', () {
final e = _event(
start: DateTime(2026, 5, 8, 9),
end: DateTime(2026, 5, 8, 10, 30),
);
expect(EventFormatter.trailingLabel(e), '09:0010:30');
});
test('multi-day event shows date+time on both sides', () {
final e = _event(
start: DateTime(2026, 5, 8, 9),
end: DateTime(2026, 5, 9, 11),
);
expect(EventFormatter.trailingLabel(e), '08.05. 09:0009.05. 11:00');
});
});
group('EventFormatter.longRange', () {
test('all-day single-day collapses inclusive end to start date', () {
// ICS-style all-day: end is exclusive (next midnight). Display drops it.
final e = _event(
start: DateTime(2026, 5, 8),
end: DateTime(2026, 5, 9),
isAllDay: true,
);
expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig');
});
test('all-day multi-day shows inclusive end (one day before exclusive end)', () {
final e = _event(
start: DateTime(2026, 5, 8),
end: DateTime(2026, 5, 11), // exclusive → display "until 10.05."
isAllDay: true,
);
expect(EventFormatter.longRange(e), '08.05.2026 10.05.2026 · Ganztägig');
});
test('all-day event whose end equals start (degenerate) renders as single day', () {
final e = _event(
start: DateTime(2026, 5, 8),
end: DateTime(2026, 5, 8),
isAllDay: true,
);
expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig');
});
test('zero-length same-day timed event shows single time', () {
final at = DateTime(2026, 5, 8, 9, 30);
final e = _event(start: at, end: at);
expect(EventFormatter.longRange(e), '08.05.2026 · 09:30');
});
test('same-day timed event shows date · range', () {
final e = _event(
start: DateTime(2026, 5, 8, 9),
end: DateTime(2026, 5, 8, 10, 30),
);
expect(EventFormatter.longRange(e), '08.05.2026 · 09:00 10:30');
});
test('multi-day timed event shows full datetimes on both sides', () {
final e = _event(
start: DateTime(2026, 5, 8, 9),
end: DateTime(2026, 5, 9, 11),
);
expect(EventFormatter.longRange(e), '08.05.2026 09:00 09.05.2026 11:00');
});
});
}
@@ -0,0 +1,346 @@
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>()));
});
});
}