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:
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:marianum_mobile/api/api_error.dart';
|
||||
import 'package:marianum_mobile/api/errors/auth_exception.dart';
|
||||
import 'package:marianum_mobile/api/errors/error_mapper.dart';
|
||||
import 'package:marianum_mobile/api/errors/network_exception.dart';
|
||||
import 'package:marianum_mobile/api/errors/parse_exception.dart';
|
||||
|
||||
void main() {
|
||||
group('errorToUserMessage', () {
|
||||
test('null falls back to the default message', () {
|
||||
expect(errorToUserMessage(null), contains('Etwas ist schiefgelaufen'));
|
||||
});
|
||||
|
||||
test('AppException returns its own userMessage', () {
|
||||
final exception = AuthException.unauthorized();
|
||||
expect(errorToUserMessage(exception), exception.userMessage);
|
||||
});
|
||||
|
||||
test('SocketException maps to NetworkException message', () {
|
||||
expect(errorToUserMessage(const SocketException('boom')),
|
||||
const NetworkException().userMessage);
|
||||
});
|
||||
|
||||
test('TimeoutException maps to the timeout-specific NetworkException message', () {
|
||||
expect(errorToUserMessage(TimeoutException('slow')),
|
||||
NetworkException.timeout().userMessage);
|
||||
});
|
||||
|
||||
test('http.ClientException maps to NetworkException message', () {
|
||||
expect(errorToUserMessage(http.ClientException('failed')),
|
||||
const NetworkException().userMessage);
|
||||
});
|
||||
|
||||
test('HandshakeException maps to a TLS-specific message', () {
|
||||
expect(errorToUserMessage(const HandshakeException('bad cert')),
|
||||
'Sichere Verbindung konnte nicht hergestellt werden.');
|
||||
});
|
||||
|
||||
test('FormatException maps to ParseException message', () {
|
||||
expect(errorToUserMessage(const FormatException('bad json')),
|
||||
const ParseException().userMessage);
|
||||
});
|
||||
|
||||
test('ApiError surfaces only the first line of its message', () {
|
||||
final err = ApiError('Boom\nGET https://example.com/foo');
|
||||
expect(errorToUserMessage(err), 'Boom');
|
||||
});
|
||||
|
||||
test('ApiError with empty message falls back to default', () {
|
||||
final err = ApiError('');
|
||||
expect(errorToUserMessage(err), contains('Etwas ist schiefgelaufen'));
|
||||
});
|
||||
|
||||
test('unknown error type falls back', () {
|
||||
expect(errorToUserMessage(StateError('weird')),
|
||||
contains('Etwas ist schiefgelaufen'));
|
||||
});
|
||||
|
||||
test('custom fallback overrides the default', () {
|
||||
expect(errorToUserMessage(null, fallback: 'meins'), 'meins');
|
||||
});
|
||||
});
|
||||
|
||||
group('errorToTechnicalDetails', () {
|
||||
test('null returns null', () {
|
||||
expect(errorToTechnicalDetails(null), isNull);
|
||||
});
|
||||
|
||||
test('AppException uses its technicalDetails when set', () {
|
||||
final ex = AuthException.unauthorized(technicalDetails: 'http 401, foo');
|
||||
expect(errorToTechnicalDetails(ex), 'http 401, foo');
|
||||
});
|
||||
|
||||
test('AppException without details falls back to toString()', () {
|
||||
final ex = AuthException.unauthorized();
|
||||
expect(errorToTechnicalDetails(ex), ex.toString());
|
||||
});
|
||||
|
||||
test('arbitrary object stringifies', () {
|
||||
expect(errorToTechnicalDetails(StateError('x')), contains('x'));
|
||||
});
|
||||
});
|
||||
|
||||
group('errorAllowsRetry', () {
|
||||
test('null allows retry by default', () {
|
||||
expect(errorAllowsRetry(null), isTrue);
|
||||
});
|
||||
|
||||
test('AuthException disallows retry (allowRetry=false)', () {
|
||||
expect(errorAllowsRetry(AuthException.unauthorized()), isFalse);
|
||||
});
|
||||
|
||||
test('NetworkException allows retry (allowRetry=true)', () {
|
||||
expect(errorAllowsRetry(const NetworkException()), isTrue);
|
||||
});
|
||||
|
||||
test('non-AppException allows retry by default', () {
|
||||
expect(errorAllowsRetry(StateError('x')), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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/chat/rich_object_string_processor.dart';
|
||||
|
||||
RichObjectString _r(String name, {RichObjectStringObjectType type = RichObjectStringObjectType.user}) =>
|
||||
RichObjectString(type, 'id-$name', name, null, null);
|
||||
|
||||
void main() {
|
||||
group('RichObjectStringProcessor.parseToString', () {
|
||||
test('null data returns the message unchanged', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString('Hallo {actor}', null),
|
||||
'Hallo {actor}',
|
||||
);
|
||||
});
|
||||
|
||||
test('substitutes a single placeholder by .name', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString(
|
||||
'{actor} hat eine Datei geteilt',
|
||||
{'actor': _r('Elias')},
|
||||
),
|
||||
'Elias hat eine Datei geteilt',
|
||||
);
|
||||
});
|
||||
|
||||
test('substitutes multiple placeholders independently', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString(
|
||||
'{actor} hat {file} mit {target} geteilt',
|
||||
{
|
||||
'actor': _r('Elias'),
|
||||
'file': _r('foo.pdf', type: RichObjectStringObjectType.file),
|
||||
'target': _r('Klasse 11a', type: RichObjectStringObjectType.group),
|
||||
},
|
||||
),
|
||||
'Elias hat foo.pdf mit Klasse 11a geteilt',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces every occurrence of the same placeholder', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString(
|
||||
'{actor} {actor} {actor}',
|
||||
{'actor': _r('A')},
|
||||
),
|
||||
'A A A',
|
||||
);
|
||||
});
|
||||
|
||||
test('placeholders with no matching key remain unchanged', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString(
|
||||
'{actor} sah {file}',
|
||||
{'actor': _r('Elias')},
|
||||
),
|
||||
'Elias sah {file}',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty data map returns the message unchanged', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString('Hallo {actor}', const {}),
|
||||
'Hallo {actor}',
|
||||
);
|
||||
});
|
||||
|
||||
test('messages without placeholders are returned verbatim', () {
|
||||
expect(
|
||||
RichObjectStringProcessor.parseToString('reine Textnachricht',
|
||||
{'actor': _r('A')}),
|
||||
'reine Textnachricht',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/webuntis/queries/get_rooms/get_rooms_response.dart';
|
||||
import 'package:marianum_mobile/api/webuntis/queries/get_subjects/get_subjects_response.dart';
|
||||
import 'package:marianum_mobile/api/webuntis/services/lesson_resolver.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
|
||||
TimetableState _state({
|
||||
Set<GetSubjectsResponseObject> subjects = const {},
|
||||
Set<GetRoomsResponseObject> rooms = const {},
|
||||
}) =>
|
||||
TimetableState(
|
||||
subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects),
|
||||
rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms),
|
||||
startDate: DateTime(2026, 1, 1),
|
||||
endDate: DateTime(2026, 12, 31),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LessonResolver.resolveSubject', () {
|
||||
test('returns the matching subject when the id is found', () {
|
||||
final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true);
|
||||
final state = _state(subjects: {math});
|
||||
|
||||
final result = LessonResolver.resolveSubject(state, 7);
|
||||
expect(result.id, 7);
|
||||
expect(result.name, 'M');
|
||||
expect(result.longName, 'Mathe');
|
||||
});
|
||||
|
||||
test('returns the placeholder fallback when id is null', () {
|
||||
final state = _state(subjects: const {});
|
||||
final result = LessonResolver.resolveSubject(state, null);
|
||||
expect(result.id, 0);
|
||||
expect(result.name, '?');
|
||||
expect(result.longName, 'Unbekannt');
|
||||
});
|
||||
|
||||
test('returns the placeholder fallback when id is unknown', () {
|
||||
final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true);
|
||||
final state = _state(subjects: {math});
|
||||
|
||||
final result = LessonResolver.resolveSubject(state, 999);
|
||||
expect(result.id, 0);
|
||||
expect(result.longName, 'Unbekannt');
|
||||
});
|
||||
});
|
||||
|
||||
group('LessonResolver.resolveRoom', () {
|
||||
test('returns the matching room when the id is found', () {
|
||||
final room = GetRoomsResponseObject(3, 'A1', 'Aula 1', true, 'Hauptgebäude');
|
||||
final state = _state(rooms: {room});
|
||||
|
||||
final result = LessonResolver.resolveRoom(state, 3);
|
||||
expect(result.id, 3);
|
||||
expect(result.name, 'A1');
|
||||
expect(result.building, 'Hauptgebäude');
|
||||
});
|
||||
|
||||
test('returns the placeholder fallback when id is unknown', () {
|
||||
final state = _state(rooms: const {});
|
||||
final result = LessonResolver.resolveRoom(state, 42);
|
||||
expect(result.id, 0);
|
||||
expect(result.name, '?');
|
||||
});
|
||||
});
|
||||
|
||||
group('LessonFormatter', () {
|
||||
test('iconForCode picks the right icon per status', () {
|
||||
expect(LessonFormatter.iconForCode('cancelled').codePoint,
|
||||
isNot(LessonFormatter.iconForCode('irregular').codePoint));
|
||||
expect(LessonFormatter.iconForCode(null).codePoint,
|
||||
isNot(LessonFormatter.iconForCode('cancelled').codePoint));
|
||||
});
|
||||
|
||||
test('statusLabel maps known codes to German labels', () {
|
||||
expect(LessonFormatter.statusLabel(null), 'Regulär');
|
||||
expect(LessonFormatter.statusLabel(''), 'Regulär');
|
||||
expect(LessonFormatter.statusLabel('cancelled'), 'Entfällt');
|
||||
expect(LessonFormatter.statusLabel('irregular'), 'Geändert');
|
||||
expect(LessonFormatter.statusLabel('something-else'), 'something-else');
|
||||
});
|
||||
|
||||
test('codePrefix prepends a label for known codes', () {
|
||||
expect(LessonFormatter.codePrefix('cancelled'), 'Entfällt: ');
|
||||
expect(LessonFormatter.codePrefix('irregular'), 'Änderung: ');
|
||||
expect(LessonFormatter.codePrefix(null), '');
|
||||
});
|
||||
|
||||
test('formatLine renders name + (longname) + · extra in that order', () {
|
||||
expect(
|
||||
LessonFormatter.formatLine('Mathe', longname: 'Mathematik', extra: 'Hauptgebäude'),
|
||||
'Mathe (Mathematik) · Hauptgebäude',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatLine omits longname when it equals name', () {
|
||||
expect(LessonFormatter.formatLine('Mathe', longname: 'Mathe'), 'Mathe');
|
||||
});
|
||||
|
||||
test('formatLine substitutes ? when name is empty', () {
|
||||
expect(LessonFormatter.formatLine(''), '?');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:marianum_mobile/extensions/date_time.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
// Jiffy needs locale data once before any formatting calls.
|
||||
await Jiffy.setLocale('de');
|
||||
});
|
||||
|
||||
group('IsSameDay', () {
|
||||
test('isSameDay matches by year/month/day, ignoring time', () {
|
||||
final a = DateTime(2026, 5, 8, 9, 30);
|
||||
final b = DateTime(2026, 5, 8, 22, 0);
|
||||
expect(a.isSameDay(b), isTrue);
|
||||
});
|
||||
|
||||
test('isSameDay differs across midnight', () {
|
||||
final a = DateTime(2026, 5, 8, 23, 59);
|
||||
final b = DateTime(2026, 5, 9, 0, 0);
|
||||
expect(a.isSameDay(b), isFalse);
|
||||
});
|
||||
|
||||
test('isSameOrAfter is inclusive', () {
|
||||
final a = DateTime(2026, 5, 8, 12);
|
||||
final b = DateTime(2026, 5, 8, 12);
|
||||
expect(a.isSameOrAfter(b), isTrue);
|
||||
expect(a.add(const Duration(seconds: 1)).isSameOrAfter(b), isTrue);
|
||||
expect(a.subtract(const Duration(seconds: 1)).isSameOrAfter(b), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('DateTimeFormatting', () {
|
||||
final dt = DateTime(2026, 5, 8, 9, 7);
|
||||
|
||||
test('formatHm pads hours and minutes to two digits', () {
|
||||
expect(dt.formatHm(), '09:07');
|
||||
});
|
||||
|
||||
test('formatDate uses dd.MM.yyyy', () {
|
||||
expect(dt.formatDate(), '08.05.2026');
|
||||
});
|
||||
|
||||
test('formatDateTime combines date and time', () {
|
||||
expect(dt.formatDateTime(), '08.05.2026 09:07');
|
||||
});
|
||||
|
||||
test('formatDateShort drops the year', () {
|
||||
expect(dt.formatDateShort(), '08.05.');
|
||||
});
|
||||
|
||||
test('formatDateShortHm combines short date and time', () {
|
||||
expect(dt.formatDateShortHm(), '08.05. 09:07');
|
||||
});
|
||||
|
||||
test('timeRangeTo joins start and end with a hyphen', () {
|
||||
final end = dt.add(const Duration(minutes: 45));
|
||||
expect(dt.timeRangeTo(end), '09:07 - 09:52');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/utils/debouncer.dart';
|
||||
|
||||
void main() {
|
||||
// Each test is wrapped in fakeAsync so timers fire deterministically.
|
||||
group('Debouncer.debounce', () {
|
||||
test('runs the action once after the delay elapses without further calls', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 99));
|
||||
expect(calls, 0);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('subsequent calls within the delay reset the timer (coalesce)', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
void schedule() => Debouncer.debounce(
|
||||
'tag', const Duration(milliseconds: 100), () => calls++);
|
||||
|
||||
schedule();
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
schedule(); // resets
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
schedule(); // resets
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
expect(calls, 0, reason: 'each schedule() resets the timer');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('different tags are independent', () {
|
||||
fakeAsync((async) {
|
||||
var aCalls = 0;
|
||||
var bCalls = 0;
|
||||
Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++);
|
||||
Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
expect(aCalls, 1);
|
||||
expect(bCalls, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Debouncer.throttle', () {
|
||||
test('first call runs immediately, subsequent calls within window are dropped', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1, reason: 'throttle fires the first call synchronously');
|
||||
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1, reason: 'subsequent calls within the gate are ignored');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 2, reason: 'after the window elapses, throttle fires again');
|
||||
});
|
||||
});
|
||||
|
||||
test('different tags throttle independently', () {
|
||||
fakeAsync((async) {
|
||||
var aCalls = 0;
|
||||
var bCalls = 0;
|
||||
Debouncer.throttle('a', const Duration(milliseconds: 100), () => aCalls++);
|
||||
Debouncer.throttle('b', const Duration(milliseconds: 100), () => bCalls++);
|
||||
expect(aCalls, 1);
|
||||
expect(bCalls, 1);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Debouncer.cancel', () {
|
||||
test('cancels a pending debounce so the action never runs', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
Debouncer.cancel('tag');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 200));
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('cancels an active throttle gate so the next call fires immediately', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1);
|
||||
|
||||
Debouncer.cancel('tag');
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 2,
|
||||
reason: 'cancel removed the gate so the next throttle fires again');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import 'package:marianum_mobile/utils/file_clipboard.dart';
|
||||
|
||||
CacheableFile _file(String name) =>
|
||||
CacheableFile(path: '/$name', isDirectory: false, name: name);
|
||||
|
||||
void main() {
|
||||
// FileClipboard is a singleton — clear between tests so state doesn't leak.
|
||||
setUp(FileClipboard.instance.clear);
|
||||
|
||||
group('FileClipboard.cut', () {
|
||||
test('switches to cut state and notifies listeners', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.cut([_file('a.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.cut);
|
||||
expect(cb.files.map((f) => f.name), ['a.txt']);
|
||||
expect(cb.isEmpty, isFalse);
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('empty input is a no-op', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.cut(const []);
|
||||
|
||||
expect(cb.operation, isNull);
|
||||
expect(cb.isEmpty, isTrue);
|
||||
expect(notifyCount, 0, reason: 'no state change → no notifyListeners');
|
||||
});
|
||||
|
||||
test('files getter returns an unmodifiable view', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.cut([_file('a.txt')]);
|
||||
expect(() => cb.files.add(_file('b.txt')), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
group('FileClipboard.copy', () {
|
||||
test('switches to copy state and notifies listeners', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.copy([_file('a.txt'), _file('b.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.copy);
|
||||
expect(cb.files, hasLength(2));
|
||||
});
|
||||
|
||||
test('overwrites a previous cut state', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.cut([_file('cut.txt')]);
|
||||
cb.copy([_file('copy.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.copy);
|
||||
expect(cb.files.single.name, 'copy.txt');
|
||||
});
|
||||
});
|
||||
|
||||
group('FileClipboard.clear', () {
|
||||
test('resets state and notifies', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.copy([_file('a.txt')]);
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.clear();
|
||||
|
||||
expect(cb.operation, isNull);
|
||||
expect(cb.isEmpty, isTrue);
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('clearing an already-empty clipboard is a no-op', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.clear();
|
||||
cb.clear();
|
||||
|
||||
expect(notifyCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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:00–10: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:00–09.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 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<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>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/async_action_button.dart';
|
||||
|
||||
void main() {
|
||||
group('AsyncActionController.run', () {
|
||||
test('toggles busy true while running and false after success', () async {
|
||||
final controller = AsyncActionController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
var seenBusyInsideCallback = false;
|
||||
final ok = await controller.run(() async {
|
||||
seenBusyInsideCallback = controller.busy;
|
||||
});
|
||||
|
||||
expect(seenBusyInsideCallback, isTrue,
|
||||
reason: 'busy must be true while the callback is running');
|
||||
expect(ok, isTrue);
|
||||
expect(controller.busy, isFalse);
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('captures mapped error message on failure and returns false', () async {
|
||||
final controller = AsyncActionController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final ok = await controller.run(
|
||||
() async => throw Exception('boom'),
|
||||
errorBuilder: (e) => 'custom: $e',
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(controller.busy, isFalse);
|
||||
expect(controller.error, contains('custom:'));
|
||||
expect(controller.error, contains('boom'));
|
||||
});
|
||||
|
||||
test('rejects re-entry while busy', () async {
|
||||
final controller = AsyncActionController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final firstStarted = Completer<void>();
|
||||
final firstCanFinish = Completer<void>();
|
||||
final firstFuture = controller.run(() async {
|
||||
firstStarted.complete();
|
||||
await firstCanFinish.future;
|
||||
});
|
||||
|
||||
await firstStarted.future;
|
||||
expect(controller.busy, isTrue);
|
||||
|
||||
final reentrant = await controller.run(() async {});
|
||||
expect(reentrant, isFalse,
|
||||
reason: 'second run while busy must be rejected without invoking callback');
|
||||
|
||||
firstCanFinish.complete();
|
||||
expect(await firstFuture, isTrue);
|
||||
expect(controller.busy, isFalse);
|
||||
});
|
||||
|
||||
test('clearError resets error and notifies listeners', () async {
|
||||
final controller = AsyncActionController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
var notifyCount = 0;
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
await controller.run(() async => throw Exception('x'));
|
||||
expect(controller.error, isNotNull);
|
||||
final beforeClear = notifyCount;
|
||||
|
||||
controller.clearError();
|
||||
expect(controller.error, isNull);
|
||||
expect(notifyCount, beforeClear + 1);
|
||||
|
||||
// No-op when already cleared.
|
||||
controller.clearError();
|
||||
expect(notifyCount, beforeClear + 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user