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

This commit is contained in:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
+105
View File
@@ -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',
);
});
});
}
+104
View File
@@ -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(''), '?');
});
});
}