Files
Holzleitner-Lieferservice-App/test/app_message_test.dart
Dennis Nemec bc95fe9577 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>
2026-10-30 20:28:24 +01:00

326 lines
11 KiB
Dart

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);
}
});
}