fix: Fehlerbehandlung beim Laden und Meldungen überarbeiten
Reklamationen der Fahrer: Meldungen verdeckten Buttons, und nach dem Abschluss stand „Lieferung nicht in der Tour gefunden" ohne Ausweg da. Meldungen: - Erfolgs-SnackBars entfernt, bei Erfolg bleibt der Spinner - einheitliche, schwebende Meldungen statt Stau über den Buttons - Server-Rohtexte durch verständliche Texte ersetzt (api_failure_text) Laden: - fehlt eine geöffnete Lieferung, wird die Tour einmal neu geholt; die Meldung kommt nur nach erfolgreichem Abruf ohne die Lieferung - Endlos-Spinner auf Lieferdetail, Filial-Abholung und Beladen ersetzt durch Kopfzeile, Fehlergrund und „Erneut versuchen" - Tour wird nach einem Tageswechsel nachgeladen, offene Seiten bleiben dabei unberührt - Abschluss und Zuweisung überschreiben kein zwischenzeitliches Neuladen, alte Antworten drehen einen Abschluss nicht mehr zurück - leere Server-Antworten gelten als Fehler statt als „keine Daten" Anmeldung: - Token-Erneuerung wartet höchstens 12 s, der native Aufruf läuft weiter - App-Start ohne Netz zeigt einen Hinweis mit „Erneut versuchen" - Fehlerseite der Fahrzeugauswahl bietet „Abmelden" Außerdem Podfile.lock an die Plugins angeglichen. Tests für die neuen Fehlerpfade ergänzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
91
test/api_failure_text_test.dart
Normal file
91
test/api_failure_text_test.dart
Normal file
@ -0,0 +1,91 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:hl_lieferservice/data/network/api_failure_text.dart';
|
||||
|
||||
DioException _fehler(int? status, [String? serverText]) {
|
||||
final options = RequestOptions(path: '/x');
|
||||
return DioException(
|
||||
requestOptions: options,
|
||||
response: status == null
|
||||
? null
|
||||
: Response(
|
||||
requestOptions: options,
|
||||
statusCode: status,
|
||||
data: serverText == null ? null : {'message': serverText},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const bekannt = {'existiert bereits': 'Dieses Kennzeichen ist bereits vergeben'};
|
||||
|
||||
test('bekannter Server-Text wird übersetzt, ohne Rohtext', () {
|
||||
final text = describeApiFailure(
|
||||
_fehler(400, "validation: kennzeichen 'test' existiert bereits"),
|
||||
operation: 'Anlegen eines Fahrzeugs',
|
||||
byServerText: bekannt,
|
||||
);
|
||||
expect(text, 'Dieses Kennzeichen ist bereits vergeben');
|
||||
});
|
||||
|
||||
test('unbekannter Server-Text erreicht den Fahrer nicht', () {
|
||||
final text = describeApiFailure(
|
||||
_fehler(400, 'validation: numericValue 12 above max 10'),
|
||||
operation: 'Service setzen',
|
||||
byServerText: bekannt,
|
||||
);
|
||||
expect(text, 'Service setzen fehlgeschlagen. Bitte erneut versuchen');
|
||||
});
|
||||
|
||||
test('Serverfehler nennt keinen HTTP-Code', () {
|
||||
final text = describeApiFailure(_fehler(500), operation: 'Notiz anlegen');
|
||||
expect(text, 'Notiz anlegen fehlgeschlagen. Bitte erneut versuchen');
|
||||
});
|
||||
|
||||
test('Statuscodes der Fachseite und Sitzungsende', () {
|
||||
expect(
|
||||
describeApiFailure(
|
||||
_fehler(404),
|
||||
operation: 'x',
|
||||
byStatus: const {404: 'Fahrzeug nicht gefunden'},
|
||||
),
|
||||
'Fahrzeug nicht gefunden',
|
||||
);
|
||||
expect(describeApiFailure(_fehler(401), operation: 'x'), 'Sitzung abgelaufen');
|
||||
});
|
||||
|
||||
test('ohne Antwort: Hinweis auf die Verbindung', () {
|
||||
expect(
|
||||
describeApiFailure(_fehler(null), operation: 'x'),
|
||||
'Server nicht erreichbar. Bitte Verbindung prüfen',
|
||||
);
|
||||
});
|
||||
|
||||
test('Timeouts und Abbrüche werden unterschieden', () {
|
||||
DioException ohneAntwort(DioExceptionType type) => DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
type: type,
|
||||
);
|
||||
|
||||
for (final type in const [
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
]) {
|
||||
expect(
|
||||
describeApiFailure(ohneAntwort(type), operation: 'x'),
|
||||
'Der Server antwortet nicht. Bitte Verbindung prüfen',
|
||||
);
|
||||
}
|
||||
for (final type in const [
|
||||
DioExceptionType.connectionError,
|
||||
DioExceptionType.unknown,
|
||||
]) {
|
||||
expect(
|
||||
describeApiFailure(ohneAntwort(type), operation: 'x'),
|
||||
'Server nicht erreichbar. Bitte Verbindung prüfen',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
325
test/app_message_test.dart
Normal file
325
test/app_message_test.dart
Normal file
@ -0,0 +1,325 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/address.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/car.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/customer.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/service/phase_service.dart';
|
||||
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
import 'package:hl_lieferservice/widget/app_message.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart';
|
||||
|
||||
const _fehler = 'Das hat nicht geklappt';
|
||||
|
||||
const _address = Address(
|
||||
street: 'Konstantinplatz',
|
||||
houseNumber: '3',
|
||||
postalCode: '41849',
|
||||
city: 'Wassenberg',
|
||||
country: 'DE',
|
||||
);
|
||||
|
||||
Delivery _delivery(String id, int sortOrder) => Delivery(
|
||||
id: id,
|
||||
tourId: 't1',
|
||||
customerId: 'c1',
|
||||
contactPersonIds: const [],
|
||||
deliveryAddressSnapshot: _address,
|
||||
erpBelegartId: 24,
|
||||
erpBelegnummer: 'V-$id',
|
||||
state: DeliveryState.active,
|
||||
sortOrder: sortOrder,
|
||||
prepaidAmount: 20,
|
||||
paymentMethodId: 'm-cash',
|
||||
items: [
|
||||
DeliveryItem(
|
||||
id: 'i-$id',
|
||||
deliveryId: id,
|
||||
articleId: 'a1',
|
||||
warehouseId: 'w1',
|
||||
belegzeilenNr: 1,
|
||||
requiredQuantity: 2,
|
||||
unitPrice: 50,
|
||||
scanProgress: ScanProgress(
|
||||
status: ScanStatus.done,
|
||||
scannedQuantity: 2,
|
||||
lastUpdatedAt: DateTime(2026),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
TourDetails _details() => TourDetails(
|
||||
tour: Tour(
|
||||
id: 't1',
|
||||
accountId: 414,
|
||||
date: DateTime(2026, 9, 25),
|
||||
syncedAt: DateTime(2026),
|
||||
),
|
||||
deliveries: [_delivery('d1', 1), _delivery('d2', 2)],
|
||||
customers: const {
|
||||
'c1': Customer(
|
||||
id: 'c1',
|
||||
name: 'Cremer',
|
||||
erpCustomerId: 7843068,
|
||||
address: _address,
|
||||
),
|
||||
},
|
||||
contacts: const {},
|
||||
articles: const {},
|
||||
warehouses: const {},
|
||||
notesByDeliveryId: const {},
|
||||
creditsByDeliveryId: const {},
|
||||
paymentsByDeliveryId: const {},
|
||||
services: const [],
|
||||
serviceValuesByDeliveryId: const {},
|
||||
contactSourcesByDeliveryId: const {},
|
||||
contactChannelsBySourceId: const {},
|
||||
);
|
||||
|
||||
class _Tours implements TourRepository {
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) async => _details();
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> setDeliveryOrder({
|
||||
required String tourId,
|
||||
required List<String> orderedDeliveryIds,
|
||||
}) async => throw const TourRepositoryException(_fehler);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Cars implements CarsRepository {
|
||||
@override
|
||||
Future<List<Car>> listMine({bool includeInactive = false}) async => const [];
|
||||
|
||||
@override
|
||||
Future<Car> create({required String plate}) async =>
|
||||
throw const CarsRepositoryException(_fehler);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Methods implements PaymentMethodsRepository {
|
||||
@override
|
||||
Future<List<PaymentMethod>> list({bool includeInactive = false}) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _NoCache implements AttachmentCache {
|
||||
@override
|
||||
Future<void> retainOnly(Set<String> ids) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// Baut [home] wie in der App: Theme, Enforcer und alle benötigten Blocs.
|
||||
Future<OperationBloc> _pump(
|
||||
WidgetTester tester,
|
||||
Widget Function(BuildContext) home,
|
||||
) async {
|
||||
// Höhe eines iPhones — daran hängt die Überdeckung. Breiter als das Gerät,
|
||||
// weil die Testschrift deutlich breiter läuft als die echte.
|
||||
tester.view.physicalSize = const Size(2400, 2556);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final opBloc = OperationBloc();
|
||||
final tourBloc = TourBloc(
|
||||
tourRepository: _Tours(),
|
||||
opBloc: opBloc,
|
||||
attachmentCache: _NoCache(),
|
||||
)..add(const LoadTour());
|
||||
final carsBloc = CarsBloc(repository: _Cars(), opBloc: opBloc)
|
||||
..add(const CarLoad());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
RepositoryProvider.value(value: const FeatureFlags({})),
|
||||
BlocProvider.value(value: opBloc),
|
||||
BlocProvider.value(value: tourBloc),
|
||||
BlocProvider.value(value: carsBloc),
|
||||
BlocProvider(create: (_) => TourDateCubit()),
|
||||
BlocProvider(create: (_) => PhaseBloc(phaseService: PhaseService())),
|
||||
BlocProvider(
|
||||
create: (_) => CarSelectBloc(
|
||||
repository: CarSelectionRepository(environmentId: 'test'),
|
||||
carsRepository: _Cars(),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => PaymentMethodsCubit(repository: _Methods()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData(snackBarTheme: appSnackBarTheme),
|
||||
builder: (context, child) => OperationViewEnforcer(child: child!),
|
||||
home: Builder(builder: home),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return opBloc;
|
||||
}
|
||||
|
||||
/// Lässt die 4-s-Anzeige und das Nachlaufen des OperationBloc verstreichen.
|
||||
Future<void> _meldungAblaufenLassen(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void _erwarteFrei(WidgetTester tester, Finder bedienelement) {
|
||||
expect(bedienelement, findsOneWidget);
|
||||
final meldung = tester.getRect(find.byType(SnackBar));
|
||||
final rect = tester.getRect(bedienelement);
|
||||
expect(
|
||||
meldung.overlaps(rect),
|
||||
isFalse,
|
||||
reason: 'Meldung $meldung verdeckt Bedienelement $rect',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _zeigeFehler(WidgetTester tester, OperationBloc opBloc) async {
|
||||
opBloc.add(FailOperation(message: _fehler));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_fehler), findsOneWidget);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Meldungen verdecken keine Bedienelemente', () {
|
||||
testWidgets('Fahrzeugverwaltung: Plus-Button', (tester) async {
|
||||
final opBloc = await _pump(tester, (_) => const CarManagementPage());
|
||||
|
||||
await _zeigeFehler(tester, opBloc);
|
||||
|
||||
_erwarteFrei(tester, find.byType(FloatingActionButton));
|
||||
await _meldungAblaufenLassen(tester);
|
||||
});
|
||||
|
||||
testWidgets('Lieferdetail: Zurück, Weiter und die Step-Aktionen', (
|
||||
tester,
|
||||
) async {
|
||||
final opBloc = await _pump(
|
||||
tester,
|
||||
(_) => const DeliveryDetail(deliveryId: 'd1'),
|
||||
);
|
||||
|
||||
await _zeigeFehler(tester, opBloc);
|
||||
_erwarteFrei(tester, find.text('Zurück'));
|
||||
_erwarteFrei(tester, find.text('Weiter'));
|
||||
await _meldungAblaufenLassen(tester);
|
||||
|
||||
// Step „Notizen": „Hinzufügen" schwebt über der Leiste.
|
||||
await tester.tap(find.text('Weiter'));
|
||||
await tester.pumpAndSettle();
|
||||
await _zeigeFehler(tester, opBloc);
|
||||
_erwarteFrei(tester, find.text('Hinzufügen'));
|
||||
_erwarteFrei(tester, find.text('Weiter'));
|
||||
await _meldungAblaufenLassen(tester);
|
||||
|
||||
// Über Artikel und Services zum Step „Zahlung".
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.tap(find.text('Weiter'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await _zeigeFehler(tester, opBloc);
|
||||
_erwarteFrei(tester, find.text('Zahlung abwickeln'));
|
||||
await _meldungAblaufenLassen(tester);
|
||||
});
|
||||
});
|
||||
|
||||
group('Meldungen stauen sich nicht', () {
|
||||
testWidgets('eine neue Meldung ersetzt die alte, auch mehrere schnell', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, (_) => const Scaffold());
|
||||
final context = tester.element(find.byType(Scaffold));
|
||||
|
||||
context.showAppMessage('eins');
|
||||
await tester.pump();
|
||||
context.showAppMessage('zwei');
|
||||
context.showAppMessage('drei');
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('drei'), findsOneWidget);
|
||||
expect(find.text('eins'), findsNothing);
|
||||
expect(find.text('zwei'), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('auch eine Meldung mit Aktion verschwindet von selbst', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, (_) => const Scaffold());
|
||||
|
||||
tester
|
||||
.element(find.byType(Scaffold))
|
||||
.showAppMessage(
|
||||
_fehler,
|
||||
action: SnackBarAction(label: 'Erneut', onPressed: () {}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_fehler), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Reihenfolge-Fehler erscheint genau einmal, auch beim zweiten Mal',
|
||||
(tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
(_) => const DeliverySortPage(selectedCarId: 'car-1'),
|
||||
);
|
||||
|
||||
for (var versuch = 0; versuch < 2; versuch++) {
|
||||
await tester.tap(find.text('Reihenfolge bestätigen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_fehler), findsOneWidget);
|
||||
|
||||
// Eine zweite Meldung säße in der Warteschlange und käme jetzt.
|
||||
await _meldungAblaufenLassen(tester);
|
||||
expect(find.text(_fehler), findsNothing);
|
||||
}
|
||||
});
|
||||
}
|
||||
642
test/load_failure_test.dart
Normal file
642
test/load_failure_test.dart
Normal file
@ -0,0 +1,642 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
|
||||
|
||||
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
||||
import 'package:hl_lieferservice/data/network/auth_session_event.dart';
|
||||
import 'package:hl_lieferservice/data/network/auth_token_provider.dart';
|
||||
import 'package:hl_lieferservice/data/network/backend_config.dart';
|
||||
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
|
||||
import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart';
|
||||
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
|
||||
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/address.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
|
||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
|
||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
|
||||
const _gone =
|
||||
'Diese Lieferung ist in der geladenen Tour nicht enthalten. '
|
||||
'Bitte das Tour-Datum prüfen.';
|
||||
const _offline = 'Server nicht erreichbar. Bitte Verbindung prüfen';
|
||||
|
||||
Delivery _delivery(String id, {DeliveryState state = DeliveryState.active}) =>
|
||||
Delivery(
|
||||
id: id,
|
||||
tourId: 't1',
|
||||
customerId: 'c1',
|
||||
contactPersonIds: const [],
|
||||
deliveryAddressSnapshot: const Address(
|
||||
street: 'Konstantinplatz',
|
||||
houseNumber: '3',
|
||||
postalCode: '41849',
|
||||
city: 'Wassenberg',
|
||||
country: 'DE',
|
||||
),
|
||||
erpBelegartId: 24,
|
||||
erpBelegnummer: 'V-$id',
|
||||
state: state,
|
||||
sortOrder: 1,
|
||||
prepaidAmount: 0,
|
||||
paymentMethodId: 'm-cash',
|
||||
items: const [],
|
||||
);
|
||||
|
||||
TourDetails _tour(List<Delivery> deliveries) => TourDetails(
|
||||
tour: Tour(
|
||||
id: 't1',
|
||||
accountId: 414,
|
||||
date: DateTime(2026, 10, 30),
|
||||
syncedAt: DateTime(2026, 10, 30),
|
||||
),
|
||||
deliveries: deliveries,
|
||||
customers: const {},
|
||||
contacts: const {},
|
||||
articles: const {},
|
||||
warehouses: const {},
|
||||
notesByDeliveryId: const {},
|
||||
creditsByDeliveryId: const {},
|
||||
paymentsByDeliveryId: const {},
|
||||
services: const [],
|
||||
serviceValuesByDeliveryId: const {},
|
||||
contactSourcesByDeliveryId: const {},
|
||||
contactChannelsBySourceId: const {},
|
||||
);
|
||||
|
||||
/// Antwortet der Reihe nach mit den vorbereiteten Ergebnissen. Ein
|
||||
/// [Completer] bleibt offen, bis der Test ihn abschließt.
|
||||
class _ScriptedTours implements TourRepository {
|
||||
_ScriptedTours(this.answers);
|
||||
|
||||
final List<Object?> answers;
|
||||
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) {
|
||||
final next = answers.removeAt(0);
|
||||
if (next is Completer<TourDetails?>) return next.future;
|
||||
if (next is Exception) return Future.error(next);
|
||||
return Future.value(next as TourDetails?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Delivery> cancelDelivery({
|
||||
required String deliveryId,
|
||||
required String reason,
|
||||
}) async => _delivery(deliveryId, state: DeliveryState.canceled);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _NoCache implements AttachmentCache {
|
||||
@override
|
||||
Future<void> retainOnly(Set<String> ids) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
TourBloc _bloc(List<Object?> answers) => TourBloc(
|
||||
tourRepository: _ScriptedTours(answers),
|
||||
opBloc: OperationBloc(),
|
||||
attachmentCache: _NoCache(),
|
||||
);
|
||||
|
||||
const _networkDown = TourRepositoryException(_offline);
|
||||
|
||||
/// Lässt die Bloc-Handler laufen, ohne auf ein festes Ereignis zu warten.
|
||||
Future<void> _settle() => Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
|
||||
void main() {
|
||||
group('TourBloc bei Verbindungsfehlern', () {
|
||||
test('Neuladen scheitert: die alte Tour bleibt mit Fehlertext stehen',
|
||||
() async {
|
||||
final bloc = _bloc([
|
||||
_tour([_delivery('d1')]),
|
||||
_networkDown,
|
||||
]);
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
bloc.add(const RefreshTour());
|
||||
await _settle();
|
||||
|
||||
final state = bloc.state as TourLoaded;
|
||||
expect(state.details.deliveries.single.id, 'd1');
|
||||
expect(state.refreshError, _offline);
|
||||
expect(state.isRefreshing, isFalse);
|
||||
});
|
||||
|
||||
test('erstes Laden scheitert: Fehlerzustand mit dem Grund', () async {
|
||||
final bloc = _bloc([_networkDown]);
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
|
||||
expect((bloc.state as TourLoadFailed).message, _offline);
|
||||
});
|
||||
|
||||
test('alte Antwort eines Neuladens dreht einen Abbruch nicht zurück',
|
||||
() async {
|
||||
final slow = Completer<TourDetails?>();
|
||||
final bloc = _bloc([
|
||||
_tour([_delivery('d1')]),
|
||||
slow,
|
||||
]);
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
bloc.add(const RefreshTour());
|
||||
await _settle();
|
||||
bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt'));
|
||||
await _settle();
|
||||
|
||||
// Die Antwort kam vom Server, bevor er den Abbruch kannte.
|
||||
slow.complete(_tour([_delivery('d1')]));
|
||||
await _settle();
|
||||
|
||||
final state = bloc.state as TourLoaded;
|
||||
expect(state.details.deliveries.single.state, DeliveryState.canceled);
|
||||
expect(state.isRefreshing, isFalse);
|
||||
});
|
||||
|
||||
test('Abbruch überschreibt kein zwischenzeitlich fertiges Neuladen',
|
||||
() async {
|
||||
final repo = _SlowCancelTours(
|
||||
_tour([_delivery('d1')]),
|
||||
_tour([_delivery('d1'), _delivery('neu')]),
|
||||
);
|
||||
final bloc = TourBloc(
|
||||
tourRepository: repo,
|
||||
opBloc: OperationBloc(),
|
||||
attachmentCache: _NoCache(),
|
||||
);
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt'));
|
||||
await _settle();
|
||||
bloc.add(const RefreshTour());
|
||||
await _settle();
|
||||
repo.cancel.complete(_delivery('d1', state: DeliveryState.canceled));
|
||||
await _settle();
|
||||
|
||||
final deliveries = (bloc.state as TourLoaded).details.deliveries;
|
||||
expect(deliveries.map((d) => d.id), ['d1', 'neu']);
|
||||
expect(deliveries.first.state, DeliveryState.canceled);
|
||||
});
|
||||
});
|
||||
|
||||
group('Unterseiten einer Lieferung', () {
|
||||
/// Öffnet [page] über einer Startseite, wie aus der Übersicht heraus.
|
||||
Future<void> open(
|
||||
WidgetTester tester,
|
||||
TourBloc bloc,
|
||||
Widget page,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
BlocProvider.value(
|
||||
value: bloc,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => page),
|
||||
),
|
||||
child: const Text('Übersicht'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Übersicht'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// `FailOperation` hält seinen Zustand fünf Sekunden; danach darf im Test
|
||||
/// kein Timer mehr offen sein.
|
||||
Future<void> finish(WidgetTester tester) =>
|
||||
tester.pump(const Duration(seconds: 6));
|
||||
|
||||
for (final entry in <String, Widget>{
|
||||
'Lieferdetail': const DeliveryDetail(deliveryId: 'd1'),
|
||||
'Filial-Abholung': const FilialePickupScanPage(deliveryId: 'd1'),
|
||||
}.entries) {
|
||||
testWidgets(
|
||||
'${entry.key}: Fehlschlag zeigt Grund, Neuladen und Zurück, '
|
||||
'und fehlt die Lieferung auch im zweiten Abruf, geht es zur Übersicht',
|
||||
(tester) async {
|
||||
final bloc = _bloc([
|
||||
_networkDown,
|
||||
_tour(const []),
|
||||
_tour(const []),
|
||||
]);
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
});
|
||||
await open(tester, bloc, entry.value);
|
||||
|
||||
expect(find.text(_offline), findsOneWidget);
|
||||
expect(find.byType(BackButton), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Erneut versuchen'));
|
||||
await tester.runAsync(_settle);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Übersicht'), findsOneWidget);
|
||||
expect(find.text(_gone), findsOneWidget);
|
||||
expect(find.textContaining('nicht in der Tour'), findsNothing);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
await finish(tester);
|
||||
});
|
||||
|
||||
testWidgets('${entry.key}: Server meldet keine Tour, zurück zur Übersicht',
|
||||
(tester) async {
|
||||
final bloc = _bloc([
|
||||
_networkDown,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
});
|
||||
await open(tester, bloc, entry.value);
|
||||
|
||||
await tester.tap(find.text('Erneut versuchen'));
|
||||
await tester.runAsync(_settle);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Übersicht'), findsOneWidget);
|
||||
expect(find.text(_gone), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
await finish(tester);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('fehlende Lieferung: erst neu laden, dann melden', () {
|
||||
late _CountingTours tours;
|
||||
late TourBloc bloc;
|
||||
|
||||
Future<void> pumpGuarded(WidgetTester tester) async {
|
||||
tours = _CountingTours();
|
||||
bloc = TourBloc(
|
||||
tourRepository: tours,
|
||||
opBloc: OperationBloc(),
|
||||
attachmentCache: _NoCache(),
|
||||
);
|
||||
tours.answers.add(_tour([_delivery('d1')]));
|
||||
await tester.runAsync(() async {
|
||||
bloc.add(const LoadTour());
|
||||
await _settle();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
BlocProvider.value(
|
||||
value: bloc,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const _GuardedPage()),
|
||||
),
|
||||
child: const Text('Übersicht'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Übersicht'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
||||
}
|
||||
|
||||
/// Ein Neuladen liefert die vorbereiteten Antworten der Reihe nach.
|
||||
Future<void> reload(WidgetTester tester) async {
|
||||
bloc.add(const RefreshTour());
|
||||
await tester.runAsync(_settle);
|
||||
await tester.pump();
|
||||
await tester.runAsync(_settle);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('zweiter Abruf enthält sie: keine Meldung, Seite bleibt',
|
||||
(tester) async {
|
||||
await pumpGuarded(tester);
|
||||
tours.answers
|
||||
..add(_tour(const []))
|
||||
..add(_tour([_delivery('d1')]));
|
||||
|
||||
await reload(tester);
|
||||
|
||||
expect(tours.loads, 3, reason: 'genau ein zusätzlicher Abruf');
|
||||
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
||||
expect(find.text(_gone), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('zweiter Abruf enthält sie nicht: erst dann die Meldung',
|
||||
(tester) async {
|
||||
await pumpGuarded(tester);
|
||||
tours.answers
|
||||
..add(_tour(const []))
|
||||
..add(_tour(const []));
|
||||
|
||||
await reload(tester);
|
||||
|
||||
expect(tours.loads, 3, reason: 'kein dritter Versuch');
|
||||
expect(find.text(_gone), findsOneWidget);
|
||||
expect(find.text('Übersicht'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('zweiter Abruf scheitert: keine Meldung, Fehler und Wiederholung',
|
||||
(tester) async {
|
||||
await pumpGuarded(tester);
|
||||
tours.answers
|
||||
..add(_tour(const []))
|
||||
..add(_networkDown);
|
||||
|
||||
await reload(tester);
|
||||
|
||||
expect(find.text(_gone), findsNothing);
|
||||
expect(find.text(_offline), findsOneWidget);
|
||||
expect(find.text('Erneut versuchen'), findsOneWidget);
|
||||
|
||||
// Netz ist wieder da und die Lieferung gehört zur Tour.
|
||||
tours.answers.add(_tour([_delivery('d1')]));
|
||||
await tester.tap(find.text('Erneut versuchen'));
|
||||
await tester.runAsync(_settle);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
||||
expect(find.text(_gone), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('später erneut verschwunden: wieder ein zusätzlicher Abruf',
|
||||
(tester) async {
|
||||
await pumpGuarded(tester);
|
||||
tours.answers
|
||||
..add(_tour(const []))
|
||||
..add(_tour([_delivery('d1')]));
|
||||
await reload(tester);
|
||||
|
||||
tours.answers
|
||||
..add(_tour(const []))
|
||||
..add(_tour([_delivery('d1')]));
|
||||
await reload(tester);
|
||||
|
||||
expect(tours.loads, 5);
|
||||
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('leere Server-Antworten sind Fehler', () {
|
||||
HolzleitnerApi emptyApi() {
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) => handler.resolve(
|
||||
Response<Object>(requestOptions: options, statusCode: 200),
|
||||
),
|
||||
),
|
||||
);
|
||||
return HolzleitnerApi(dio: dio, interceptors: const []);
|
||||
}
|
||||
|
||||
test('Tour', () {
|
||||
expect(
|
||||
TourRepositoryImpl(emptyApi()).getMyTourSummary(),
|
||||
throwsA(isA<TourRepositoryException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Fahrzeuge', () {
|
||||
expect(
|
||||
CarsRepositoryImpl(emptyApi()).listMine(),
|
||||
throwsA(isA<CarsRepositoryException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Zahlungsmethoden', () {
|
||||
expect(
|
||||
PaymentMethodsRepositoryImpl(emptyApi()).list(),
|
||||
throwsA(isA<PaymentMethodsRepositoryException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Token-Erneuerung', () {
|
||||
KeycloakOidcTokenProvider provider(_HangingAppAuth appAuth) =>
|
||||
KeycloakOidcTokenProvider(
|
||||
config: const BackendConfig(
|
||||
apiBaseUrl: 'http://localhost',
|
||||
keycloakIssuerUrl: 'http://localhost/realms/x',
|
||||
keycloakClientId: 'app',
|
||||
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
||||
),
|
||||
issuerUrl: 'http://localhost/realms/x',
|
||||
redirectUrl: 'holzleitner://oauth2redirect',
|
||||
appAuth: appAuth,
|
||||
storage: _StoredRefreshToken(),
|
||||
refreshTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
test('hängender Refresh hält den Request nicht auf', () async {
|
||||
final auth = provider(_HangingAppAuth());
|
||||
expect(
|
||||
await auth.restoreSession(),
|
||||
SessionRestoreResult.temporarilyUnavailable,
|
||||
);
|
||||
await expectLater(
|
||||
auth.currentAccessToken(),
|
||||
throwsA(isA<AuthTemporarilyUnavailableException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('später Abschluss wird übernommen, ohne zweiten Refresh', () async {
|
||||
final appAuth = _HangingAppAuth();
|
||||
final auth = provider(appAuth);
|
||||
await auth.restoreSession();
|
||||
expect(appAuth.calls, 1);
|
||||
|
||||
appAuth.finish(
|
||||
TokenResponse(
|
||||
'access-neu',
|
||||
'refresh-neu',
|
||||
DateTime.now().add(const Duration(hours: 1)),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await _settle();
|
||||
|
||||
expect(await auth.currentAccessToken(), 'access-neu');
|
||||
expect(appAuth.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Sitzung beim App-Start', () {
|
||||
AuthBloc authBloc(_ScriptedRestore provider) =>
|
||||
AuthBloc(tokenProvider: provider, operationBloc: OperationBloc());
|
||||
|
||||
test('ohne Verbindung: Login-Seite mit Hinweis, Session bleibt', () async {
|
||||
final bloc = authBloc(
|
||||
_ScriptedRestore([SessionRestoreResult.temporarilyUnavailable]),
|
||||
);
|
||||
bloc.add(const RestoreSessionRequested());
|
||||
await _settle();
|
||||
|
||||
expect((bloc.state as Unauthenticated).restoreUnavailable, isTrue);
|
||||
});
|
||||
|
||||
test('ohne Session: normale Login-Seite ohne Hinweis', () async {
|
||||
final bloc = authBloc(_ScriptedRestore([SessionRestoreResult.noSession]));
|
||||
bloc.add(const RestoreSessionRequested());
|
||||
await _settle();
|
||||
|
||||
expect((bloc.state as Unauthenticated).restoreUnavailable, isFalse);
|
||||
});
|
||||
|
||||
test('erneuter Versuch zeigt wieder den Start-Bildschirm', () async {
|
||||
final bloc = authBloc(
|
||||
_ScriptedRestore([
|
||||
SessionRestoreResult.temporarilyUnavailable,
|
||||
SessionRestoreResult.restored,
|
||||
]),
|
||||
);
|
||||
bloc.add(const RestoreSessionRequested());
|
||||
await _settle();
|
||||
bloc.add(const RestoreSessionRequested());
|
||||
await _settle();
|
||||
|
||||
expect(bloc.state, isA<AuthBootstrapping>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _CountingTours extends _ScriptedTours {
|
||||
_CountingTours() : super([]);
|
||||
|
||||
int loads = 0;
|
||||
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) {
|
||||
loads++;
|
||||
return super.getMyTourDetails(date: date);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleinstmögliche Seite mit dem Guard. Die echten Seiten brauchen für eine
|
||||
/// vorhandene Lieferung den ganzen Bloc-Baum.
|
||||
class _GuardedPage extends StatefulWidget {
|
||||
const _GuardedPage();
|
||||
|
||||
@override
|
||||
State<_GuardedPage> createState() => _GuardedPageState();
|
||||
}
|
||||
|
||||
class _GuardedPageState extends State<_GuardedPage> with DeliveryPresenceGuard {
|
||||
@override
|
||||
String get guardedDeliveryId => 'd1';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<TourBloc, TourState>(
|
||||
listener: deliveryStillPresent,
|
||||
builder: (context, state) {
|
||||
final present = state is TourLoaded &&
|
||||
state.details.deliveries.any((d) => d.id == guardedDeliveryId);
|
||||
if (!present) {
|
||||
return TourNotLoadedScaffold(title: 'Lieferung', state: state);
|
||||
}
|
||||
return const Scaffold(body: Text('Lieferung sichtbar'));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlowCancelTours extends _ScriptedTours {
|
||||
_SlowCancelTours(TourDetails first, TourDetails second)
|
||||
: super([first, second]);
|
||||
|
||||
final cancel = Completer<Delivery>();
|
||||
|
||||
@override
|
||||
Future<Delivery> cancelDelivery({
|
||||
required String deliveryId,
|
||||
required String reason,
|
||||
}) => cancel.future;
|
||||
}
|
||||
|
||||
class _HangingAppAuth implements FlutterAppAuth {
|
||||
final _result = Completer<TokenResponse>();
|
||||
int calls = 0;
|
||||
|
||||
void finish(TokenResponse response) => _result.complete(response);
|
||||
|
||||
@override
|
||||
Future<TokenResponse> token(TokenRequest request) {
|
||||
calls++;
|
||||
return _result.future;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _StoredRefreshToken implements FlutterSecureStorage {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
if (invocation.memberName == #read) return Future<String?>.value('rt');
|
||||
if (invocation.memberName == #write) return Future<void>.value();
|
||||
return super.noSuchMethod(invocation);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScriptedRestore implements KeycloakOidcTokenProvider {
|
||||
_ScriptedRestore(this.results);
|
||||
|
||||
final List<SessionRestoreResult> results;
|
||||
|
||||
@override
|
||||
Stream<AuthSessionEvent> get events => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<SessionRestoreResult> restoreSession() async => results.removeAt(0);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
214
test/tour_day_change_test.dart
Normal file
214
test/tour_day_change_test.dart
Normal file
@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_day_watcher.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
|
||||
final _day1 = DateTime(2026, 10, 30, 22);
|
||||
final _day2 = DateTime(2026, 10, 31, 7);
|
||||
|
||||
TourDetails _tourAm(DateTime date) => TourDetails(
|
||||
tour: Tour(id: 't-$date', accountId: 414, date: date, syncedAt: date),
|
||||
deliveries: const [],
|
||||
customers: const {},
|
||||
contacts: const {},
|
||||
articles: const {},
|
||||
warehouses: const {},
|
||||
notesByDeliveryId: const {},
|
||||
creditsByDeliveryId: const {},
|
||||
paymentsByDeliveryId: const {},
|
||||
services: const [],
|
||||
serviceValuesByDeliveryId: const {},
|
||||
contactSourcesByDeliveryId: const {},
|
||||
contactChannelsBySourceId: const {},
|
||||
);
|
||||
|
||||
/// Liefert die Tour zum aktuellen Testtag und zählt die Anfragen.
|
||||
class _Tours implements TourRepository {
|
||||
_Tours(this.now);
|
||||
|
||||
final DateTime Function() now;
|
||||
int loads = 0;
|
||||
Object? failure;
|
||||
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) async {
|
||||
loads++;
|
||||
final error = failure;
|
||||
if (error != null) throw error;
|
||||
return _tourAm(date ?? now());
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _NoCache implements AttachmentCache {
|
||||
@override
|
||||
Future<void> retainOnly(Set<String> ids) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late _Tours tours;
|
||||
late TourBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
now = _day1;
|
||||
tours = _Tours(() => now);
|
||||
bloc = TourBloc(
|
||||
tourRepository: tours,
|
||||
opBloc: OperationBloc(),
|
||||
attachmentCache: _NoCache(),
|
||||
clock: () => now,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
Future<void> settle() =>
|
||||
Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
|
||||
DateTime loadedDate() => (bloc.state as TourLoaded).details.tour.date;
|
||||
|
||||
group('Bloc', () {
|
||||
test('neuer Tag: lädt die Tour des neuen Tages', () async {
|
||||
bloc.add(const LoadTour());
|
||||
await settle();
|
||||
now = _day2;
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(loadedDate(), _day2);
|
||||
expect(tours.loads, 2);
|
||||
});
|
||||
|
||||
test('derselbe Tag: kein Request', () async {
|
||||
bloc.add(const LoadTour());
|
||||
await settle();
|
||||
now = _day1.add(const Duration(hours: 1));
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(tours.loads, 1);
|
||||
});
|
||||
|
||||
test('im Kalender gewählte Tour bleibt', () async {
|
||||
final gewaehlt = DateTime(2026, 10, 20);
|
||||
bloc.add(LoadTour.forDate(gewaehlt));
|
||||
await settle();
|
||||
now = _day2;
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(loadedDate(), gewaehlt);
|
||||
expect(tours.loads, 1);
|
||||
});
|
||||
|
||||
test('kein Netz: alte Tour bleibt, nächster Versuch lädt nach', () async {
|
||||
bloc.add(const LoadTour());
|
||||
await settle();
|
||||
now = _day2;
|
||||
tours.failure = const TourRepositoryException('Server nicht erreichbar');
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(loadedDate(), _day1);
|
||||
expect((bloc.state as TourLoaded).refreshError, isNotNull);
|
||||
|
||||
tours.failure = null;
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(loadedDate(), _day2);
|
||||
});
|
||||
|
||||
test('vor dem ersten Laden passiert nichts', () async {
|
||||
bloc.add(const RefreshTourIfDayChanged());
|
||||
await settle();
|
||||
|
||||
expect(tours.loads, 0);
|
||||
expect(bloc.state, isA<TourInitial>());
|
||||
});
|
||||
});
|
||||
|
||||
group('TourDayWatcher', () {
|
||||
Future<void> pumpWatcher(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
bloc.add(const LoadTour());
|
||||
await settle();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
BlocProvider.value(
|
||||
value: bloc,
|
||||
child: MaterialApp(
|
||||
home: TourDayWatcher(
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Lieferung')),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('öffnen'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> resume(WidgetTester tester) async {
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.runAsync(settle);
|
||||
}
|
||||
|
||||
testWidgets('Rückkehr in die App am neuen Tag lädt nach', (tester) async {
|
||||
await pumpWatcher(tester);
|
||||
// Der Test startet die App im Zustand „nicht aktiv", wie ein Wechsel.
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
now = _day2;
|
||||
|
||||
await resume(tester);
|
||||
|
||||
expect(tours.loads, 2);
|
||||
expect(loadedDate(), _day2);
|
||||
});
|
||||
|
||||
testWidgets('offene Seite: erst beim Schließen wird nachgeladen',
|
||||
(tester) async {
|
||||
await pumpWatcher(tester);
|
||||
await tester.tap(find.text('öffnen'));
|
||||
await tester.pumpAndSettle();
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
now = _day2;
|
||||
|
||||
await resume(tester);
|
||||
expect(tours.loads, 1, reason: 'Eingaben der offenen Seite schützen');
|
||||
expect(loadedDate(), _day1);
|
||||
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.runAsync(settle);
|
||||
|
||||
expect(tours.loads, 2);
|
||||
expect(loadedDate(), _day2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user