merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/cache_store.dart';
void main() {
group('CacheStore.parse', () {
test('splits the timestamp header from the payload', () {
final entry = CacheStore.parse('1700000000000\n{"a":"b\\nc"}');
expect(entry!.lastUpdate, 1700000000000);
expect(entry.json, '{"a":"b\\nc"}');
});
test('keeps newlines inside the payload', () {
expect(CacheStore.parse('1\nline1\nline2')!.json, 'line1\nline2');
});
test('rejects files without a valid header', () {
expect(CacheStore.parse('{"a":1}'), isNull);
expect(CacheStore.parse('abc\n{}'), isNull);
});
});
group('CacheStore.directoryName', () {
test('keeps the original directory for the pre-multi-account account', () {
expect(CacheStore.directoryName(''), 'request_cache');
});
test('separates accounts and the signed-out state', () {
final names = {
CacheStore.directoryName(''),
CacheStore.directoryName('a1b2'),
CacheStore.directoryName('c3d4'),
CacheStore.directoryName(null),
};
expect(names, hasLength(4));
});
});
}
+9
View File
@@ -86,6 +86,15 @@ void main() {
final d = DateTime(2026, 5, 18, 13, 45);
expect(d.subtractDays(5), d.addDays(-5));
});
test('dateOnly drops the time of day', () {
expect(DateTime(2026, 9, 24, 13, 45).dateOnly, DateTime(2026, 9, 24));
});
test('mondayOfWeek returns Monday midnight for any weekday', () {
expect(DateTime(2026, 9, 21).mondayOfWeek, DateTime(2026, 9, 21));
expect(DateTime(2026, 9, 27, 23, 59).mondayOfWeek, DateTime(2026, 9, 21));
});
});
group('DateTimeFormatting', () {
+33
View File
@@ -1,5 +1,6 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'package:marianum_mobile/push/push_registration.dart';
void main() {
@@ -37,4 +38,36 @@ void main() {
expect(AuthorizationStatus.values, hasLength(5));
});
});
group('PushRegistration.canDisplay', () {
test('only authorized and provisional show notifications', () {
expect(
AuthorizationStatus.values.where(PushRegistration.canDisplay),
unorderedEquals([
AuthorizationStatus.authorized,
AuthorizationStatus.provisional,
]),
);
});
});
group('osPermissionWireValue', () {
test('maps every status', () {
expect(osPermissionWireValue(AuthorizationStatus.authorized), 'granted');
expect(
osPermissionWireValue(AuthorizationStatus.provisional),
'provisional',
);
expect(osPermissionWireValue(AuthorizationStatus.denied), 'denied');
expect(
osPermissionWireValue(AuthorizationStatus.deniedPermanently),
'denied',
);
expect(
osPermissionWireValue(AuthorizationStatus.notDetermined),
'notDetermined',
);
expect(osPermissionWireValue(null), isNull);
});
});
}
+13
View File
@@ -62,5 +62,18 @@ void main() {
test('empty shares compare equal', () {
expect(_share().contentEquals(_share()), isTrue);
});
test('relocated share still equals its raw delivery', () {
final raw = _share(filePaths: ['/cache/a.jpg']);
final claimed = PendingShare(
filePaths: ['/cache/share_intent/1/a.jpg'],
text: null,
receivedAt: DateTime(2026, 1, 1),
sourcePaths: raw.filePaths,
);
expect(claimed.contentEquals(raw), isTrue);
expect(raw.contentEquals(claimed), isTrue);
});
});
}
+43
View File
@@ -114,4 +114,47 @@ void main() {
expect(hidden, {Modules.talk, Modules.files});
});
});
group('applyTeacherDefaults', () {
test('hides the absence report once for teachers', () {
final settings = settingsWith(Modules.values);
expect(AppModule.applyTeacherDefaults(settings, isTeacher: true), isTrue);
expect(settings.hiddenModules, [Modules.absenceReport]);
expect(settings.teacherDefaultsApplied, isTrue);
});
test('keeps a module the teacher re-enabled afterwards', () {
final settings = settingsWith(Modules.values);
AppModule.applyTeacherDefaults(settings, isTeacher: true);
settings.hiddenModules.remove(Modules.absenceReport);
expect(
AppModule.applyTeacherDefaults(settings, isTeacher: true),
isFalse,
);
expect(settings.hiddenModules, isEmpty);
});
test('leaves non-teachers untouched', () {
final settings = settingsWith(Modules.values);
expect(
AppModule.applyTeacherDefaults(settings, isTeacher: false),
isFalse,
);
expect(settings.hiddenModules, isEmpty);
expect(settings.teacherDefaultsApplied, isFalse);
});
test('does not duplicate an already hidden module', () {
final settings = ModulesSettings(
moduleOrder: Modules.values,
hiddenModules: [Modules.absenceReport, Modules.files],
);
AppModule.applyTeacherDefaults(settings, isTeacher: true);
expect(settings.hiddenModules, [Modules.absenceReport, Modules.files]);
});
});
}
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart';
TimetableGetRoomsResponse _rooms(String name) => TimetableGetRoomsResponse(
result: [McRoom(id: 1, shortName: name, longName: name)],
);
void main() {
final base = TimetableState(
startDate: DateTime(2026, 9, 21),
endDate: DateTime(2026, 9, 25),
rooms: _rooms('A101'),
dataVersion: 3,
);
test('returns the same state when the content is unchanged', () {
expect(base.withReferenceData(rooms: _rooms('A101')), same(base));
});
test('bumps dataVersion and takes the new value when content changed', () {
final next = _rooms('B202');
final result = base.withReferenceData(rooms: next);
expect(result.rooms, same(next));
expect(result.dataVersion, 4);
});
test('keeps unchanged instances when only another field changed', () {
final result = base.withReferenceData(rooms: _rooms('A101'));
expect(result.rooms, same(base.rooms));
});
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'package:marianum_mobile/extensions/date_time.dart';
import 'package:marianum_mobile/state/app/modules/timetable/bloc/week_cache.dart';
TimetableGetWeekResponse _week(DateTime monday, {String room = 'A101'}) =>
TimetableGetWeekResponse(
from: monday,
until: monday.addDays(4),
entries: [
McTimetableEntry(
id: 1,
date: monday,
startTime: DateTime(1970, 1, 1, 8),
endTime: DateTime(1970, 1, 1, 9),
subjects: const ['ma'],
teachers: const [],
rooms: [room],
classNames: const [],
lessonType: 'LESSON',
status: 'REGULAR',
substitutionText: null,
lessonText: null,
infoText: null,
),
],
);
void main() {
final monday = DateTime(2026, 9, 21);
test('returns null when the stored week has identical content', () {
final cache = {monday.weekKey(): _week(monday)};
final result = mergeWeekIntoCache(
cache,
monday,
_week(monday),
viewedWeekStart: monday,
now: monday,
);
expect(result, isNull);
});
test('stores changed content', () {
final cache = {monday.weekKey(): _week(monday)};
final changed = _week(monday, room: 'B202');
final result = mergeWeekIntoCache(
cache,
monday,
changed,
viewedWeekStart: monday,
now: monday,
);
expect(result![monday.weekKey()], same(changed));
});
test('drops weeks far from both the viewed week and today', () {
final far = monday.addDays(7 * (kWeekCacheRadius + 3));
final nearToday = monday.addDays(-7 * kWeekCacheRadius);
final viewed = monday.addDays(7 * (kWeekCacheRadius * 3));
final cache = {
far.weekKey(): _week(far),
nearToday.weekKey(): _week(nearToday),
};
final result = mergeWeekIntoCache(
cache,
viewed,
_week(viewed),
viewedWeekStart: viewed,
now: monday.addDays(2),
);
expect(result!.keys, {nearToday.weekKey(), viewed.weekKey()});
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart';
import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart';
import 'package:marianum_mobile/api/marianumcloud/talk/room/notification_level.dart';
typedef Level = GetRoomResponseObjectParticipantNotificationLevel;
typedef RoomType = GetRoomResponseObjectConversationType;
GetRoomResponseObject _room(RoomType type, Level level) =>
GetRoomResponseObject(
1,
't',
type,
'n',
'd',
'',
3,
0,
0,
0,
0,
's',
false,
false,
0,
false,
false,
true,
0,
false,
level,
0,
false,
false,
0,
0,
GetChatResponseObject(
1,
't',
GetRoomResponseObjectMessageActorType.user,
'a',
'A',
0,
'',
GetRoomResponseObjectMessageType.comment,
true,
'r',
'm',
null,
null,
null,
null,
false,
),
null,
null,
null,
);
void main() {
test('Standard wird bei 1:1-Chats zu „Alle Nachrichten"', () {
final room = _room(RoomType.oneToOne, Level.defaultLevel);
expect(room.effectiveNotificationLevel, Level.alwaysNotify);
expect(room.isMuted, isFalse);
});
test('Standard wird bei Gruppen zu „Nur Erwähnungen"', () {
expect(
_room(RoomType.group, Level.defaultLevel).effectiveNotificationLevel,
Level.notifyOnMention,
);
});
test('explizit gesetzter Level bleibt erhalten', () {
final room = _room(RoomType.oneToOne, Level.neverNotify);
expect(room.effectiveNotificationLevel, Level.neverNotify);
expect(room.isMuted, isTrue);
});
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/directory_size.dart';
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('dir_size_test'));
tearDown(() => tmp.deleteSync(recursive: true));
test('sums files recursively', () async {
File('${tmp.path}/a').writeAsBytesSync(List.filled(10, 0));
Directory('${tmp.path}/sub').createSync();
File('${tmp.path}/sub/b').writeAsBytesSync(List.filled(5, 0));
expect(await directorySize(tmp), 15);
});
test('returns 0 for a missing directory', () async {
expect(await directorySize(Directory('${tmp.path}/missing')), 0);
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/recurrence_occurrences.dart';
void main() {
setUp(RecurrenceOccurrences.clear);
final anchor = DateTime.utc(2024, 9, 2, 8);
test('returns only occurrences inside the half-open range', () {
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=DAILY',
anchor,
DateTime.utc(2026, 9, 21),
DateTime.utc(2026, 9, 24),
);
expect(hits, [
DateTime.utc(2026, 9, 21, 8),
DateTime.utc(2026, 9, 22, 8),
DateTime.utc(2026, 9, 23, 8),
]);
});
test('answers earlier ranges after a later one was expanded', () {
RecurrenceOccurrences.between(
'RRULE:FREQ=WEEKLY',
anchor,
DateTime.utc(2026, 1, 1),
DateTime.utc(2026, 1, 31),
);
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=WEEKLY',
anchor,
DateTime.utc(2024, 9, 1),
DateTime.utc(2024, 9, 10),
);
expect(hits, [DateTime.utc(2024, 9, 2, 8), DateTime.utc(2024, 9, 9, 8)]);
});
test('stops at the end of a finite series', () {
final hits = RecurrenceOccurrences.between(
'RRULE:FREQ=DAILY;COUNT=2',
anchor,
DateTime.utc(2024, 1, 1),
DateTime.utc(2030, 1, 1),
);
expect(hits, hasLength(2));
});
}
+21 -30
View File
@@ -11,13 +11,10 @@ CacheableFile _file({
bool isDirectory = false,
}) => CacheableFile(path: path, isDirectory: isDirectory, name: name);
Map<String, dynamic> _doc(ListFilesResponse listing) => {
'json': jsonEncode(listing.toJson()),
'lastupdate': 0,
};
String _payload(ListFilesResponse listing) => jsonEncode(listing.toJson());
void main() {
group('searchLocalCaches', () {
group('local cache index', () {
final root = ListFilesResponse({
_file(path: 'Documents/', name: 'Documents', isDirectory: true),
_file(path: 'Photos/', name: 'Photos', isDirectory: true),
@@ -27,51 +24,45 @@ void main() {
_file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'),
_file(path: 'Documents/Notes.txt', name: 'Notes.txt'),
});
final docs = {
'/MarianumMobile/wd-folder-aaa': _doc(root),
'/MarianumMobile/wd-folder-bbb': _doc(documents),
'/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0},
};
final index = buildLocalCacheIndex([
_payload(root),
_payload(documents),
]);
test('matches by name case-insensitively across all caches', () async {
final hits = await searchLocalCaches('report', docs: docs);
test('matches by name case-insensitively across all caches', () {
final hits = searchLocalCacheIndex(index, 'report');
final paths = hits.map((f) => f.path).toSet();
expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'});
});
test('returns empty list for empty query', () async {
expect(await searchLocalCaches(' ', docs: docs), isEmpty);
test('returns empty list for empty query', () {
expect(searchLocalCacheIndex(index, ' '), isEmpty);
});
test('respects pathScope prefix', () async {
final hits = await searchLocalCaches(
test('respects pathScope prefix', () {
final hits = searchLocalCacheIndex(
index,
'report',
pathScope: ['Documents'],
docs: docs,
);
expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']);
});
test('ignores non-folder cache documents', () async {
final hits = await searchLocalCaches('anything', docs: docs);
// Only documents starting with `wd-folder-` are scanned. The unrelated
// `get-room-ccc` doc must not crash the helper.
expect(hits, isEmpty);
test('skips payloads that are not folder listings', () {
expect(buildLocalCacheIndex(['not json', '{"foo": 1}']), isEmpty);
});
test('deduplicates entries that appear in multiple cached folders',
() async {
test('deduplicates entries that appear in multiple cached folders', () {
final shared = _file(
path: 'Documents/Tax-Report.pdf',
name: 'Tax-Report.pdf',
);
final dedupRoot = ListFilesResponse({shared});
final dedupDocs = {
'/MarianumMobile/wd-folder-aaa': _doc(dedupRoot),
'/MarianumMobile/wd-folder-bbb': _doc(dedupRoot),
};
final hits = await searchLocalCaches('tax', docs: dedupDocs);
expect(hits, hasLength(1));
final dedupIndex = buildLocalCacheIndex([
_payload(dedupRoot),
_payload(dedupRoot),
]);
expect(searchLocalCacheIndex(dedupIndex, 'tax'), hasLength(1));
});
});
}
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'package:marianum_mobile/view/pages/timetable/data/lesson_type_label.dart';
McTimetableEntry _entry({
String lessonType = 'LESSON',
String? substitutionText,
String? lessonText,
}) => McTimetableEntry(
id: 1,
date: DateTime(2026, 5, 4),
startTime: DateTime(1970, 1, 1, 8),
endTime: DateTime(1970, 1, 1, 15),
subjects: const [],
teachers: const [],
rooms: const ['A101'],
classNames: const ['8a'],
lessonType: lessonType,
status: 'REGULAR',
substitutionText: substitutionText,
lessonText: lessonText,
infoText: null,
);
void main() {
group('LessonTypeLabel.forEntry', () {
test('prefers the substitution text', () {
expect(
LessonTypeLabel.forEntry(
_entry(substitutionText: 'Klassenfahrt 8a', lessonText: 'Anderes'),
),
'Klassenfahrt 8a',
);
});
test('falls back to the lesson text', () {
expect(
LessonTypeLabel.forEntry(_entry(lessonText: ' Wandertag\n8a ')),
'Wandertag 8a',
);
});
test('falls back to "Event" with room when no text is set', () {
expect(
LessonTypeLabel.forEntry(_entry(substitutionText: ' ')),
'A101 Event',
);
});
test('keeps known lesson types over free text', () {
expect(
LessonTypeLabel.forEntry(
_entry(lessonType: 'BREAK_SUPERVISION', lessonText: 'Hof'),
),
'A101 Aufsicht',
);
});
});
}
@@ -65,10 +65,10 @@ void main() {
);
});
test('falls back to the teacher when the entry has no class', () {
test('shows only the room when the entry has no class', () {
expect(
_location(showClassInsteadOfTeacher: true, classNames: const []),
'A101\nMüller',
'A101',
);
});
});
+273 -39
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
@@ -33,6 +34,8 @@ McTimetableEntry _lesson({
String? teacherName,
String? teacherOrgname,
String? substText,
String lessonType = 'LESSON',
List<String> classNames = const [],
}) => McTimetableEntry(
id: 1,
date: date,
@@ -50,8 +53,8 @@ McTimetableEntry _lesson({
]
: const [],
rooms: room != null ? [room] : const [],
classNames: const [],
lessonType: 'LESSON',
classNames: classNames,
lessonType: lessonType,
status: status,
substitutionText: substText,
lessonText: null,
@@ -117,8 +120,18 @@ void main() {
test('only includes lessons on the anchor day', () {
final lessons = [
_lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'),
_lesson(date: DateTime(2026, 5, 7), startHhmm: 800, endHhmm: 845, subjectName: 'EN'),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 800,
endHhmm: 845,
subjectName: 'MA',
),
_lesson(
date: DateTime(2026, 5, 7),
startHhmm: 800,
endHhmm: 845,
subjectName: 'EN',
),
];
final data = WidgetDataMapper.buildDayData(
now: now,
@@ -164,14 +177,11 @@ void main() {
rooms: null,
holidays: null,
);
expect(
data.lessons.map((l) => l.status).toList(),
[
WidgetLessonStatus.cancelled,
WidgetLessonStatus.irregular,
WidgetLessonStatus.teacherChanged,
],
);
expect(data.lessons.map((l) => l.status).toList(), [
WidgetLessonStatus.cancelled,
WidgetLessonStatus.irregular,
WidgetLessonStatus.teacherChanged,
]);
});
test('marks day as holiday when in holiday range', () {
@@ -224,10 +234,11 @@ void main() {
rooms: null,
holidays: null,
);
expect(
data.lessons.map((l) => l.subjectShort).toList(),
['MA', 'EN', 'BIO'],
);
expect(data.lessons.map((l) => l.subjectShort).toList(), [
'MA',
'EN',
'BIO',
]);
});
});
@@ -236,9 +247,24 @@ void main() {
test('long event bumps every regular lesson it covers', () {
final lessons = [
_lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'),
_lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'),
_lesson(date: DateTime(2026, 5, 6), startHhmm: 1000, endHhmm: 1045, subjectName: 'BIO'),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 800,
endHhmm: 845,
subjectName: 'MA',
),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 900,
endHhmm: 945,
subjectName: 'EN',
),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 1000,
endHhmm: 1045,
subjectName: 'BIO',
),
];
final events = GetCustomTimetableEventResponse([
_event(
@@ -264,8 +290,18 @@ void main() {
test('event + same-slot duplicate regular: kept lesson shows +2', () {
final lessons = [
_lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'),
_lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'MA'),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 900,
endHhmm: 945,
subjectName: 'EN',
),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 900,
endHhmm: 945,
subjectName: 'MA',
),
];
final events = GetCustomTimetableEventResponse([
_event(
@@ -315,7 +351,12 @@ void main() {
test('two events covering the same regular lesson bump it twice', () {
final lessons = [
_lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 900,
endHhmm: 945,
subjectName: 'EN',
),
];
final events = GetCustomTimetableEventResponse([
_event(
@@ -361,16 +402,198 @@ void main() {
});
});
group('teacher plan', () {
final now = DateTime(2026, 5, 6, 7);
final day = DateTime(2026, 5, 6);
WidgetLesson single(McTimetableEntry lesson) =>
WidgetDataMapper.buildDayData(
now: now,
lessons: [lesson],
subjects: null,
rooms: null,
holidays: null,
showClassInsteadOfTeacher: true,
).lessons.single;
test('shows the classes in place of the teacher', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 755,
endHhmm: 840,
subjectName: 'MA',
teacherName: 'MUE',
teacherOrgname: 'KUN',
classNames: const ['10a', '10b'],
),
);
expect(lesson.teacher, '10a, 10b');
expect(lesson.originalTeacher, isNull);
});
test('leaves the slot empty for entries without a class', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 920,
endHhmm: 935,
room: 'Hof',
teacherName: 'MUE',
lessonType: 'BREAK_SUPERVISION',
),
);
expect(lesson.teacher, isNull);
});
});
group('tile color', () {
final now = DateTime(2026, 5, 6, 7);
final day = DateTime(2026, 5, 6);
WidgetLesson single(
McTimetableEntry lesson, {
TimetableGetSubjectsResponse? subjects,
}) => WidgetDataMapper.buildDayData(
now: now,
lessons: [lesson],
subjects: subjects,
rooms: null,
holidays: null,
).lessons.single;
test('regular lesson without subject color uses the status red', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 755,
endHhmm: 840,
subjectName: 'MA',
teacherName: 'MUE',
),
);
expect(lesson.color, '#993333');
});
test('regular lesson takes the personal subject color', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 755,
endHhmm: 840,
subjectName: 'MA',
teacherName: 'MUE',
),
subjects: TimetableGetSubjectsResponse(
result: [
McSubject(
id: 1,
shortName: 'MA',
longName: 'Mathematik',
color: 'indigo',
),
],
),
);
expect(lesson.color, '#5C6BC0');
});
test('substitution keeps its signal color despite a subject color', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 755,
endHhmm: 840,
status: 'IRREGULAR',
subjectName: 'MA',
teacherName: 'MUE',
),
subjects: TimetableGetSubjectsResponse(
result: [
McSubject(
id: 1,
shortName: 'MA',
longName: 'Mathematik',
color: 'indigo',
),
],
),
);
expect(lesson.status, WidgetLessonStatus.irregular);
expect(lesson.color, '#8F19B3');
});
test('supervision is classified as duty with room in the title', () {
final lesson = single(
_lesson(
date: day,
startHhmm: 920,
endHhmm: 935,
room: 'Hof',
teacherName: 'MUE',
lessonType: 'BREAK_SUPERVISION',
),
);
expect(lesson.status, WidgetLessonStatus.duty);
expect(lesson.subjectShort, 'Hof Aufsicht');
expect(lesson.color, '#00796B');
});
test('custom event resolves legacy color names', () {
final data = WidgetDataMapper.buildDayData(
now: now,
lessons: const [],
subjects: null,
rooms: null,
holidays: null,
customEvents: GetCustomTimetableEventResponse([
_event(
id: 'e',
title: 'Probe',
start: DateTime(2026, 5, 6, 14),
end: DateTime(2026, 5, 6, 15),
),
]),
);
expect(data.lessons.single.color, '#FFA000');
});
});
group('buildWeekData', () {
final now = DateTime(2026, 5, 5, 10); // Tuesday
test('covers the 14-day window including next week', () {
final lessons = [
_lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'),
_lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'),
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
_lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'),
_lesson(
date: DateTime(2026, 5, 4),
startHhmm: 800,
endHhmm: 845,
subjectName: 'MO',
),
_lesson(
date: DateTime(2026, 5, 6),
startHhmm: 800,
endHhmm: 845,
subjectName: 'WE',
),
_lesson(
date: DateTime(2026, 5, 8),
startHhmm: 800,
endHhmm: 845,
subjectName: 'FR',
),
_lesson(
date: DateTime(2026, 5, 11),
startHhmm: 800,
endHhmm: 845,
subjectName: 'NEXT',
),
_lesson(
date: DateTime(2026, 5, 18),
startHhmm: 800,
endHhmm: 845,
subjectName: 'FAR',
),
];
final data = WidgetDataMapper.buildWeekData(
now: now,
@@ -380,10 +603,12 @@ void main() {
holidays: null,
);
expect(data.anchorDate, DateTime(2026, 5, 4));
expect(
data.lessons.map((l) => l.subjectShort).toList(),
['MO', 'WE', 'FR', 'NEXT'],
);
expect(data.lessons.map((l) => l.subjectShort).toList(), [
'MO',
'WE',
'FR',
'NEXT',
]);
});
test('window stays on the current week when the anchor rolls forward', () {
@@ -391,8 +616,18 @@ void main() {
// still starts at the current week's Monday so today stays available.
final fridayEvening = DateTime(2026, 5, 8, 18);
final lessons = [
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
_lesson(
date: DateTime(2026, 5, 8),
startHhmm: 800,
endHhmm: 845,
subjectName: 'FR',
),
_lesson(
date: DateTime(2026, 5, 11),
startHhmm: 800,
endHhmm: 845,
subjectName: 'NEXT',
),
];
final data = WidgetDataMapper.buildWeekData(
now: fridayEvening,
@@ -402,10 +637,7 @@ void main() {
holidays: null,
);
expect(data.anchorDate, DateTime(2026, 5, 11));
expect(
data.lessons.map((l) => l.subjectShort).toList(),
['FR', 'NEXT'],
);
expect(data.lessons.map((l) => l.subjectShort).toList(), ['FR', 'NEXT']);
});
test('carries per-day holiday info in days', () {
@@ -429,8 +661,10 @@ void main() {
expect(data.days, hasLength(WidgetDataMapper.weekWindowDays));
expect(data.days.first.date, DateTime(2026, 5, 4));
expect(data.days.last.date, DateTime(2026, 5, 17));
final holidayDays =
data.days.where((d) => d.isHoliday).map((d) => d.date).toList();
final holidayDays = data.days
.where((d) => d.isHoliday)
.map((d) => d.date)
.toList();
expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]);
expect(
data.days.firstWhere((d) => d.isHoliday).holidayName,