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>
326 lines
11 KiB
Dart
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);
|
|
}
|
|
});
|
|
}
|