dart format

This commit is contained in:
2026-05-08 20:12:40 +02:00
parent 9e139b5704
commit 3b8da1d3d6
295 changed files with 6404 additions and 4161 deletions
+25 -12
View File
@@ -22,18 +22,27 @@ void main() {
});
test('SocketException maps to NetworkException message', () {
expect(errorToUserMessage(const SocketException('boom')),
const NetworkException().userMessage);
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(
'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);
expect(
errorToUserMessage(http.ClientException('failed')),
const NetworkException().userMessage,
);
});
test('HandshakeException maps to a TLS-specific message', () {
@@ -43,8 +52,10 @@ void main() {
});
test('FormatException maps to ParseException message', () {
expect(errorToUserMessage(const FormatException('bad json')),
const ParseException().userMessage);
expect(
errorToUserMessage(const FormatException('bad json')),
const ParseException().userMessage,
);
});
test('ApiError surfaces only the first line of its message', () {
@@ -58,8 +69,10 @@ void main() {
});
test('unknown error type falls back', () {
expect(errorToUserMessage(StateError('weird')),
contains('Etwas ist schiefgelaufen'));
expect(
errorToUserMessage(StateError('weird')),
contains('Etwas ist schiefgelaufen'),
);
});
test('custom fallback overrides the default', () {
@@ -2,8 +2,10 @@ 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);
RichObjectString _r(
String name, {
RichObjectStringObjectType type = RichObjectStringObjectType.user,
}) => RichObjectString(type, 'id-$name', name, null, null);
void main() {
group('RichObjectStringProcessor.parseToString', () {
@@ -40,20 +42,18 @@ void main() {
test('replaces every occurrence of the same placeholder', () {
expect(
RichObjectStringProcessor.parseToString(
'{actor} {actor} {actor}',
{'actor': _r('A')},
),
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')},
),
RichObjectStringProcessor.parseToString('{actor} sah {file}', {
'actor': _r('Elias'),
}),
'Elias sah {file}',
);
});
@@ -67,8 +67,9 @@ void main() {
test('messages without placeholders are returned verbatim', () {
expect(
RichObjectStringProcessor.parseToString('reine Textnachricht',
{'actor': _r('A')}),
RichObjectStringProcessor.parseToString('reine Textnachricht', {
'actor': _r('A'),
}),
'reine Textnachricht',
);
});
+26 -13
View File
@@ -7,13 +7,12 @@ import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state
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),
);
}) => 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', () {
@@ -47,7 +46,13 @@ void main() {
group('LessonResolver.resolveRoom', () {
test('returns the matching room when the id is found', () {
final room = GetRoomsResponseObject(3, 'A1', 'Aula 1', true, 'Hauptgebäude');
final room = GetRoomsResponseObject(
3,
'A1',
'Aula 1',
true,
'Hauptgebäude',
);
final state = _state(rooms: {room});
final result = LessonResolver.resolveRoom(state, 3);
@@ -66,10 +71,14 @@ void main() {
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));
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', () {
@@ -88,7 +97,11 @@ void main() {
test('formatLine renders name + (longname) + · extra in that order', () {
expect(
LessonFormatter.formatLine('Mathe', longname: 'Mathematik', extra: 'Hauptgebäude'),
LessonFormatter.formatLine(
'Mathe',
longname: 'Mathematik',
extra: 'Hauptgebäude',
),
'Mathe (Mathematik) · Hauptgebäude',
);
});
+116 -41
View File
@@ -5,24 +5,34 @@ 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++);
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: 99));
expect(calls, 0);
async.elapse(const Duration(milliseconds: 1));
expect(calls, 1);
});
});
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++);
'tag',
const Duration(milliseconds: 100),
() => calls++,
);
schedule();
async.elapse(const Duration(milliseconds: 80));
@@ -41,8 +51,16 @@ void main() {
fakeAsync((async) {
var aCalls = 0;
var bCalls = 0;
Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++);
Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++);
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);
@@ -52,28 +70,67 @@ void main() {
});
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');
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');
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');
});
});
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++);
Debouncer.throttle(
'a',
const Duration(milliseconds: 100),
() => aCalls++,
);
Debouncer.throttle(
'b',
const Duration(milliseconds: 100),
() => bCalls++,
);
expect(aCalls, 1);
expect(bCalls, 1);
@@ -86,7 +143,11 @@ void main() {
test('cancels a pending debounce so the action never runs', () {
fakeAsync((async) {
var calls = 0;
Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++);
Debouncer.debounce(
'tag',
const Duration(milliseconds: 100),
() => calls++,
);
Debouncer.cancel('tag');
async.elapse(const Duration(milliseconds: 200));
@@ -94,19 +155,33 @@ void main() {
});
});
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);
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');
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));
});
});
async.elapse(const Duration(milliseconds: 100));
});
},
);
});
}
+37 -15
View File
@@ -8,14 +8,13 @@ CacheableFile _file({
bool isDirectory = false,
int? size,
DateTime? modifiedAt,
}) =>
CacheableFile(
path: '/$name',
isDirectory: isDirectory,
name: name,
size: size,
modifiedAt: modifiedAt,
);
}) => CacheableFile(
path: '/$name',
isDirectory: isDirectory,
name: name,
size: size,
modifiedAt: modifiedAt,
);
void main() {
group('SortOptions.options', () {
@@ -55,8 +54,14 @@ void main() {
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));
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', () {
@@ -67,8 +72,16 @@ void main() {
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));
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.
@@ -81,7 +94,10 @@ void main() {
test('foldersToTop=false intermixes folders and files', () {
final response = ListFilesResponse({fileA, fileB, folderA, folderB});
final sorted = response.sortBy(sortOption: SortOption.name, foldersToTop: false);
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);
@@ -94,9 +110,15 @@ void main() {
test('reversed flips the order within each section', () {
final response = ListFilesResponse({fileA, fileB});
final asc = response.sortBy(sortOption: SortOption.name, foldersToTop: false);
final asc = response.sortBy(
sortOption: SortOption.name,
foldersToTop: false,
);
final desc = response.sortBy(
sortOption: SortOption.name, foldersToTop: false, reversed: true);
sortOption: SortOption.name,
foldersToTop: false,
reversed: true,
);
expect(desc, asc.reversed.toList());
});
@@ -7,15 +7,14 @@ MarianumDate _event({
required DateTime start,
required DateTime end,
bool isAllDay = false,
}) =>
MarianumDate(
uid: 't',
title: 't',
description: null,
start: start,
end: end,
isAllDay: isAllDay,
);
}) => MarianumDate(
uid: 't',
title: 't',
description: null,
start: start,
end: end,
isAllDay: isAllDay,
);
void main() {
setUpAll(() async {
@@ -66,23 +65,32 @@ void main() {
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 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(
'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);
@@ -103,7 +111,10 @@ void main() {
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');
expect(
EventFormatter.longRange(e),
'08.05.2026 09:00 09.05.2026 11:00',
);
});
});
}
+169 -93
View File
@@ -17,18 +17,18 @@ Appointment _appt({
bool isAllDay = false,
Object? id,
String? rrule,
}) =>
Appointment(
id: id,
startTime: start,
endTime: end,
subject: subject,
color: Colors.blue,
isAllDay: isAllDay,
recurrenceRule: rrule,
);
}) => Appointment(
id: id,
startTime: start,
endTime: end,
subject: subject,
color: Colors.blue,
isAllDay: isAllDay,
recurrenceRule: rrule,
);
GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject(
GetTimetableResponseObject _lesson({String? code}) =>
GetTimetableResponseObject(
id: 0,
date: 0,
startTime: 0,
@@ -41,21 +41,25 @@ GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject
);
CustomTimetableEvent _customEvent() => CustomTimetableEvent(
id: 'x',
title: '',
description: '',
startDate: DateTime(2026),
endDate: DateTime(2026),
color: null,
rrule: '',
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
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);
final a = _appt(
start: _at(2026, 5, 8, 9),
end: _at(2026, 5, 8, 10),
isAllDay: true,
);
expect(isAllDayLike(a), isTrue);
});
@@ -69,11 +73,20 @@ void main() {
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)');
});
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', () {
@@ -85,7 +98,11 @@ void main() {
});
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);
final a = _appt(
start: _at(2026, 5, 8, 9),
end: _at(2026, 5, 8, 10),
isAllDay: true,
);
expect(isOutsideSchoolHours(a), isTrue);
});
@@ -120,7 +137,9 @@ void main() {
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));
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);
@@ -131,9 +150,10 @@ void main() {
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);
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));
@@ -141,9 +161,13 @@ void main() {
test('events outside the visible week are dropped', () {
final lastWeek = _appt(
start: _at(2026, 4, 27, 9), end: _at(2026, 4, 27, 10));
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));
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);
@@ -151,7 +175,9 @@ void main() {
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));
start: _at(2026, 5, 9, 9),
end: _at(2026, 5, 9, 10),
);
final result = partitionAppointmentsForWeek([saturday], monday);
expect(result.inside.expand((e) => e), isEmpty);
});
@@ -160,20 +186,25 @@ void main() {
// 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');
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],
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');
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));
});
@@ -181,16 +212,21 @@ void main() {
group('PeriodLayout', () {
final p1 = const LessonPeriod(
name: '1', start: TimeOfDay(hour: 8, minute: 0), end: TimeOfDay(hour: 9, minute: 0));
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);
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));
name: '2',
start: TimeOfDay(hour: 9, minute: 15),
end: TimeOfDay(hour: 10, minute: 15),
);
final layout = PeriodLayout(
periods: [p1, brk, p2],
@@ -249,7 +285,11 @@ void main() {
expect(result, hasLength(2));
for (final cell in result) {
expect(cell.lane, 0);
expect(cell.laneCount, 1, reason: 'separate clusters → laneCount=1 each');
expect(
cell.laneCount,
1,
reason: 'separate clusters → laneCount=1 each',
);
}
});
@@ -262,85 +302,121 @@ void main() {
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);
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);
});
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),
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),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final result = assignLanes([regular, custom], maxLanes: 2)
.whereType<LaidOutAppointment>()
.toList();
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);
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),
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),
start: _at(2026, 5, 8, 8),
end: _at(2026, 5, 8, 10),
);
final result = assignLanes([regular, cancelled], maxLanes: 2)
.whereType<LaidOutAppointment>()
.toList();
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));
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');
});
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>()));
expect(
() => assignLanes(const [], maxLanes: 1),
throwsA(isA<AssertionError>()),
);
});
});
}
+26 -16
View File
@@ -14,27 +14,33 @@ void main() {
seenBusyInsideCallback = controller.busy;
});
expect(seenBusyInsideCallback, isTrue,
reason: 'busy must be true while the callback is running');
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);
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',
);
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'));
});
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();
@@ -51,8 +57,12 @@ void main() {
expect(controller.busy, isTrue);
final reentrant = await controller.run(() async {});
expect(reentrant, isFalse,
reason: 'second run while busy must be rejected without invoking callback');
expect(
reentrant,
isFalse,
reason:
'second run while busy must be rejected without invoking callback',
);
firstCanFinish.complete();
expect(await firstFuture, isTrue);