import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/header_wind_pill.dart'; import 'package:dmc_app/ui/widgets/map_search_controls.dart'; import 'package:dmc_app/ui/providers/map_controller_provider.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; import 'package:dmc_app/ui/widgets/wind_marker_pill.dart'; import 'package:latlong2/latlong.dart'; Widget _wrap(Widget child) => ProviderScope( child: BlocProvider( create: (_) => AppModeCubit(), child: MaterialApp(home: child), ), ); Future _pumpWithContainer(WidgetTester tester) async { final container = ProviderContainer(); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); return container; } void main() { testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand', (tester) async { await tester.pumpWidget(_wrap(const AppShell())); // Nur ein Frame pumpen statt pumpAndSettle: TileLayer laedt echte // Netzwerk-Kacheln, die in der Testumgebung nie ankommen. await tester.pump(); expect(find.byType(FlutterMap), findsOneWidget); expect(find.text('Plan'), findsOneWidget); expect(find.text('Fly'), findsOneWidget); }); testWidgets( 'Fly-Button ohne erfuellten Ready-to-Fly-Gate zeigt Hinweis statt Absturz', (tester) async { await tester.pumpWidget(_wrap(const AppShell())); await tester.pump(); await tester.tap(find.text('Fly')); await tester.pump(); expect( find.text('Mission muss zuerst hochgeladen und verifiziert werden.'), findsOneWidget, ); // Weiterhin im Plan-Modus, da das Gate nicht erfuellt war. expect(find.byType(FlutterMap), findsOneWidget); }); testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); expect(container.read(currentMissionProvider), isEmpty); await tester.tap(find.byType(ReticleButton)); await tester.pump(); final waypoints = container.read(currentMissionProvider); expect(waypoints, hasLength(1)); expect(waypoints.single.lat, closeTo(52.3676, 0.0001)); expect(waypoints.single.lon, closeTo(4.9041, 0.0001)); }); testWidgets( 'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); // 1. Tap: idle -> snapped (legt Wegpunkt an). await tester.tap(find.byType(ReticleButton)); await tester.pump(); expect(find.byType(HaloMenu), findsNothing); // 2. Tap: snapped -> editing. await tester.tap(find.byType(ReticleButton)); await tester.pump(); expect(find.byType(HaloMenu), findsOneWidget); }); testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); await tester.tap(find.byType(ReticleButton)); // idle -> snapped await tester.pump(); await tester.tap(find.byType(ReticleButton)); // snapped -> editing await tester.pump(); // Die Wedge-Bounding-Box-Mitte liegt ausserhalb der Keilform (Donut- // Segment) - stattdessen exakt auf das (nicht hit-testbare) Icon tippen, // das per Konstruktion innerhalb des Keils sitzt. await tester.tapAt(tester.getCenter(find.byIcon(Icons.remove))); await tester.pump(); expect(container.read(currentMissionProvider), isEmpty); expect(find.byType(HaloMenu), findsNothing); }); testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); await tester.tap(find.byType(ReticleButton)); // idle -> snapped await tester.pump(); await tester.tap(find.byType(ReticleButton)); // snapped -> editing await tester.pump(); await tester.tapAt(tester.getCenter(find.byIcon(Icons.loop))); await tester.pump(); final waypoints = container.read(currentMissionProvider); expect(waypoints.single.action, WaypointAction.loiter); expect(find.byType(HaloMenu), findsNothing); }); testWidgets('Bottom-Stats-Bar oeffnet die leere Wegpunktliste', (tester) async { await _pumpWithContainer(tester); await tester.tap(find.text('Details:')); // Die Liste wird als eigene Route mit Fade-Transition gepusht: erst ein // Frame ohne Dauer, damit der synchron ausgeloeste Navigator.push() // registriert wird, danach die Animationszeit abwarten. await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsOneWidget); expect(find.text('No waypoints yet'), findsOneWidget); }); testWidgets('Zeile antippen zentriert die Karte und schliesst die Liste', (tester) async { await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsOneWidget); await tester.tap(find.text('60 m').first); // Navigator.pop() spielt ebenfalls eine (Rueck-)Transition ab. await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(WaypointListPanel), findsNothing); }); testWidgets('Papierkorb-Icon in der Liste entfernt den Wegpunkt', (tester) async { final container = await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(container.read(currentMissionProvider), hasLength(1)); await tester.tap(find.byIcon(Icons.delete_outline)); await tester.pump(); expect(container.read(currentMissionProvider), isEmpty); expect(find.text('No waypoints yet'), findsOneWidget); }); testWidgets( 'Wertechip in der Liste aendert die Hoehe des Wegpunkts per Drag', (tester) async { final container = await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); // Icons.edit erscheint auch auf dem ReticleButton im Modus "snapped" - // gezielt das Edit-Icon der Listenzeile ansprechen. await tester.tap(find.descendant( of: find.byType(WaypointListPanel), matching: find.byIcon(Icons.edit), )); await tester.pump(); expect(find.byType(WaypointChip), findsNWidgets(3)); await tester.drag(find.byType(WaypointChip).first, const Offset(0, -140)); // Der Chip-Flash-Effekt setzt per Timer nach 120ms zurueck - abwarten, // damit kein Timer nach Testende/Dispose noch aussteht. await tester.pump(const Duration(milliseconds: 200)); final waypoints = container.read(currentMissionProvider); expect(waypoints.single.altitudeM, greaterThan(60)); }); testWidgets( 'Speed-Chip in der Liste aendert die Geschwindigkeit nur in Schritten von 1', (tester) async { final container = await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.descendant( of: find.byType(WaypointListPanel), matching: find.byIcon(Icons.edit), )); await tester.pump(); // Speed-Chip ist der zweite der drei Chips (Alt/Speed/Catch). Ein Drag // von nur 3 gemeinten Schritten (42px, abzueglich Touch-Slop real // weniger) darf bei Schrittgroesse 1 hoechstens um wenige m/s aendern - // mit der alten Schrittgroesse 10 waere schon ein einziger erkannter // Schritt (+10) ausserhalb dieser Spanne. final startSpeed = container.read(currentMissionProvider).single.speedMs; await tester.drag(find.byType(WaypointChip).at(1), const Offset(0, -42)); await tester.pump(const Duration(milliseconds: 200)); final speed = container.read(currentMissionProvider).single.speedMs; expect(speed, greaterThan(startSpeed)); expect(speed, lessThan(startSpeed + 4)); }); testWidgets( 'Altitude-Tab aendert die Hoehe per Drag auf dem Chart-Punkt', (tester) async { final container = await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.text('Altitude')); await tester.pump(); expect(find.byType(FullValueChart), findsOneWidget); final chartCenter = tester.getCenter(find.byType(FullValueChart)); await tester.dragFrom(chartCenter, const Offset(0, -100)); await tester.pump(); final waypoints = container.read(currentMissionProvider); expect(waypoints.single.altitudeM, greaterThan(60)); }); testWidgets( 'Speed-Tab aendert die Geschwindigkeit per Drag auf dem Chart-Punkt', (tester) async { final container = await _pumpWithContainer(tester); await tester.tap(find.byType(ReticleButton)); await tester.pump(); await tester.tap(find.text('Details:')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.text('Speed')); await tester.pump(); expect(find.byType(FullValueChart), findsOneWidget); final chartCenter = tester.getCenter(find.byType(FullValueChart)); await tester.dragFrom(chartCenter, const Offset(0, -50)); await tester.pump(); final waypoints = container.read(currentMissionProvider); expect(waypoints.single.speedMs, greaterThan(15)); }); testWidgets('MapSearchControls zeigt Suchfeld und Home-/Fit-Buttons', (tester) async { await _pumpWithContainer(tester); expect(find.byType(MapSearchControls), findsOneWidget); expect(find.text('Search location'), findsOneWidget); expect(find.byIcon(Icons.home), findsOneWidget); expect(find.byIcon(Icons.crop_free), findsOneWidget); }); testWidgets('Home-Button ohne Wegpunkte zeigt Hinweis statt Absturz', (tester) async { await _pumpWithContainer(tester); await tester.tap(find.byIcon(Icons.home)); await tester.pump(); expect(find.text('No waypoints yet'), findsOneWidget); }); testWidgets('Fit-Button ohne Wegpunkte zeigt Hinweis statt Absturz', (tester) async { await _pumpWithContainer(tester); await tester.tap(find.byIcon(Icons.crop_free)); await tester.pump(); expect(find.text('No waypoints to fit'), findsOneWidget); }); testWidgets('Leere Suche loest keinen Netzwerkaufruf/Fehler aus', (tester) async { await _pumpWithContainer(tester); await tester.enterText(find.byType(TextField), ''); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pump(); expect(find.text('Search failed'), findsNothing); expect(find.text('Location not found'), findsNothing); }); testWidgets( 'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten', (tester) async { final container = ProviderContainer(); final appModeCubit = AppModeCubit(); addTearDown(() { container.dispose(); appModeCubit.close(); }); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider.value( value: appModeCubit, child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); const movedTo = LatLng(48.2082, 16.3738); container.read(mapControllerProvider).move(movedTo, 14); await tester.pump(); // Ready-to-Fly-Gate technisch erfuellen, um in den Fly-Modus zu gelangen // (Doku 2.2/4.5) - hier nur, um die Kamera-Persistenz des geteilten // mapControllerProvider ueber den Moduswechsel zu pruefen. appModeCubit ..missionUploadConfirmed() ..missionVerifiedConfirmed() ..toFly(); await tester.pump(); appModeCubit.toPlan(); await tester.pump(); final camera = container.read(mapControllerProvider).camera; expect(camera.center.latitude, closeTo(movedTo.latitude, 0.0001)); expect(camera.center.longitude, closeTo(movedTo.longitude, 0.0001)); expect(camera.zoom, 14); }); testWidgets( 'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); final notifier = container.read(currentMissionProvider.notifier); for (var i = 0; i < 30; i++) { notifier.addWaypoint(Waypoint( lat: 48.2 + i * 0.001, lon: 16.3, altitudeM: 60, speedMs: 15, catchRadiusM: 60, )); } await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp( home: WaypointListPanel( activeIndex: 29, onClose: () {}, onCenterOnWaypoint: (_) {}, onDelete: (_) {}, onAltitudeChanged: (_, _) {}, onSpeedChanged: (_, _) {}, onCatchRadiusChanged: (_, _) {}, onActionChanged: (_, _) {}, ), ), ), ); await tester.pump(); // Auto-Scroll laeuft als post-frame-Callback mit 300ms Animation. await tester.pump(const Duration(milliseconds: 350)); final listView = tester.widget(find.byType(ListView)); expect(listView.controller!.offset, greaterThan(0)); }); testWidgets( 'Wind-Marker an Wegpunkten erscheinen erst nach Antippen der Kopfleisten-Pille', (tester) async { final container = ProviderContainer(); addTearDown(container.dispose); // Am Kartenzentrum platziert (siehe MissionMap._initialCenter), damit // der Marker nicht ausserhalb des sichtbaren Kartenausschnitts liegt - // flutter_map's MarkerLayer rendert (anders als CircleLayer) nur // Marker innerhalb der aktuellen Viewport-Bounds. container.read(currentMissionProvider.notifier).addWaypoint(const Waypoint( lat: 52.3676, lon: 4.9041, altitudeM: 60, speedMs: 15, catchRadiusM: 60, windSpeedMs: 5, windDirFromDeg: 300, windElevationM: 2, )); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); expect(find.byType(WindMarkerPill), findsNothing); await tester.tap(find.byType(HeaderWindPill)); await tester.pump(); expect(find.byType(WindMarkerPill), findsOneWidget); }); }