import 'dart:convert'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; import 'package:dmc_app/domain/mission/drone_profile.dart'; import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/services/database/app_database.dart' hide DroneProfile, Mission; import 'package:dmc_app/services/geocoding/nominatim_service.dart'; import 'package:dmc_app/services/sharing/drone_share_codec.dart'; import 'package:dmc_app/services/sharing/mission_share_codec.dart'; import 'package:dmc_app/services/sharing/sharing_service.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/providers/database_provider.dart'; import 'package:dmc_app/ui/providers/geocoding_provider.dart'; import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; import 'package:dmc_app/ui/providers/sharing_provider.dart'; import 'package:dmc_app/ui/providers/telemetry_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/missions_drones_panel.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'; /// Test-Doppelgaenger fuer [SharingService]: zeichnet auf, was geteilt /// wurde, statt echte Platform-Channels (share_plus/file_picker) /// anzusprechen, und liefert einen vorab festgelegten Datei-Inhalt fuer /// den Import zurueck. class _FakeSharingService extends SharingService { _FakeSharingService({this.pickResult}); final String? pickResult; String? sharedJson; String? sharedFilename; @override Future shareJsonFile({ required String json, required String filename, required String shareTitle, }) async { sharedJson = json; sharedFilename = filename; } @override Future pickJsonFileText() async => pickResult; } // AppShell/PlanScreen initialisieren beim Start die Drift-Datenbank (Doku // 4.15/7.5: Drohnenprofil-Bootstrap, Autosave-Restore) - in Widget-Tests // durch eine In-Memory-Instanz ersetzt, damit dafuer nicht das // path_provider-Plugin (echtes Dateisystem) benoetigt wird. AppDatabase _testDatabase() => AppDatabase.forTesting(NativeDatabase.memory()); ProviderContainer _testContainer() => ProviderContainer( overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], ); /// Der Fly-Modus startet ueber telemetryProvider einen Mock-Telemetrie- /// Timer (Timer.periodic, MockFlightControllerLink). Anders als der /// einmalige Autosave-Debounce reicht bei einem periodischen Timer kein /// blosses Vorspulen der Zeit - er muss explizit getrennt werden, bevor der /// Test endet, sonst meldet flutter_test einen noch ausstehenden Timer. Future _stopTelemetry(ProviderContainer container) => container.read(flightControllerLinkProvider).disconnect(); Widget _wrap(Widget child) => ProviderScope( overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())], child: BlocProvider( create: (_) => AppModeCubit(), child: MaterialApp(home: child), ), ); Future _pumpWithContainer(WidgetTester tester) async { final container = _testContainer(); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); return container; } void main() { // TopModeBar instanziiert beim Wechsel in den Fly-Modus wifiTransportProvider // (Fortschritts-Pille/Auto-Connect-Pruefung, siehe top_mode_bar.dart) - das // erzeugt standardmaessig einen echten MethodChannelMlrsNetworkController. // Ohne diesen Mock-Handler wirft schon der reine dispose()-Aufruf am // Testende MissingPluginException, weil kein natives Android-Plugin // existiert. TestWidgetsFlutterBinding.ensureInitialized(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( const MethodChannel('com.dmc.dmc_app/mlrs_network'), (call) async => null, ); 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 wechselt direkt in den Fly-Modus (Ready-to-Fly-Gate nicht mehr erzwungen)', (tester) async { // Manueller Container statt _wrap(): der Fly-Modus startet ueber // telemetryProvider einen Mock-Telemetrie-Timer (Timer.periodic), der // ohne explizites container.dispose() als "noch ausstehender Timer" // das Testende blockieren wuerde. final container = _testContainer(); 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.text('Fly')); await tester.pump(); // PlanScreen ist verschwunden, FlyScreen (mit eigener Karte) samt // Flugmodus-Pille (Standard: Waypoint) ist sichtbar. expect(find.byType(FlutterMap), findsOneWidget); expect(find.text('Mode: Waypoint'), findsOneWidget); await _stopTelemetry(container); }); testWidgets('Flugmodus-Dropdown wechselt den Modus und setzt ihn beim ' 'erneuten Eintritt in den Fly-Modus zurueck', (tester) async { final container = _testContainer(); 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.text('Fly')); await tester.pump(); expect(find.text('Mode: Waypoint'), findsOneWidget); await tester.tap(find.text('Mode: Waypoint')); await tester.pumpAndSettle(); await tester.tap(find.text('Return to Home')); await tester.pumpAndSettle(); expect(find.text('Mode: Return to Home'), findsOneWidget); // Zurueck nach Plan und wieder nach Fly - Doku 3.11: "Standard- // Rueckstellung auf Waypoint-Modus beim Eintritt in Fly-Modus". await tester.tap(find.text('Plan')); await tester.pump(); await tester.tap(find.text('Fly')); await tester.pump(); expect(find.text('Mode: Waypoint'), findsOneWidget); await _stopTelemetry(container); }); testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu', (tester) async { final container = _testContainer(); 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)); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); testWidgets( 'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue', (tester) async { final container = _testContainer(); 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); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue', (tester) async { final container = _testContainer(); 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); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue', (tester) async { final container = _testContainer(); 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); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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)); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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)); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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)); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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)); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); 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( 'Ortssuche legt eine neue, nach dem Ort benannte Mission an', (tester) async { final client = MockClient((request) async => http.Response( jsonEncode([ { 'lat': '51.9225', 'lon': '4.47917', 'display_name': 'Rotterdam, South Holland, Netherlands', 'address': {'city': 'Rotterdam'}, }, ]), 200, )); final container = ProviderContainer( overrides: [ appDatabaseProvider.overrideWithValue(_testDatabase()), nominatimServiceProvider .overrideWithValue(NominatimService(client: client)), ], ); addTearDown(container.dispose); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); // Bestehenden Wegpunkt anlegen, damit sich pruefen laesst, dass die // Suche die Mission wirklich neu beginnt statt nur die Karte zu // verschieben (HTML-Demonstrator: setMissionFromPlace()). await tester.tap(find.byType(ReticleButton)); await tester.pump(); expect(container.read(currentMissionProvider), hasLength(1)); await tester.enterText(find.byType(TextField), 'Rotterdam'); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pump(); await tester.pump(); expect(container.read(currentMissionMetaProvider).name, 'Rotterdam'); expect(container.read(currentMissionMetaProvider).placeName, 'Rotterdam'); expect(container.read(currentMissionProvider), isEmpty); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); testWidgets( 'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten', (tester) async { final container = _testContainer(); 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(); appModeCubit.toFly(); await tester.pump(); appModeCubit.toPlan(); await tester.pump(); await _stopTelemetry(container); 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 = _testContainer(); 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 = _testContainer(); 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); }); testWidgets( 'Import-Button im Missionen-Tab legt eine neue Mission aus der Datei an', (tester) async { final exportJson = buildMissionExportJson(MissionExportData( name: 'Imported route', placeName: 'Imported route', createdAt: DateTime.utc(2026, 1, 1), waypoints: const [ Waypoint(lat: 1, lon: 1, altitudeM: 60, speedMs: 15, catchRadiusM: 60), Waypoint(lat: 1, lon: 1.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60), ], )); final fakeSharing = _FakeSharingService(pickResult: exportJson); final container = ProviderContainer( overrides: [ appDatabaseProvider.overrideWithValue(_testDatabase()), sharingServiceProvider.overrideWithValue(fakeSharing), ], ); addTearDown(container.dispose); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.missions())), )); await tester.pump(); expect(find.text('No saved missions yet'), findsOneWidget); await tester.tap(find.text('Import')); await tester.pump(); await tester.pump(); expect(find.text('Imported route'), findsOneWidget); expect(find.text('Mission imported'), findsOneWidget); }); testWidgets('Share-Icon einer Mission uebergibt den vollstaendigen Export an den SharingService', (tester) async { final fakeSharing = _FakeSharingService(); final container = ProviderContainer( overrides: [ appDatabaseProvider.overrideWithValue(_testDatabase()), sharingServiceProvider.overrideWithValue(fakeSharing), ], ); addTearDown(container.dispose); await container.read(missionRepositoryProvider).upsert( name: 'Shared route', placeName: 'Shared route', waypoints: const [ Waypoint(lat: 2, lon: 2, altitudeM: 60, speedMs: 15, catchRadiusM: 60), Waypoint(lat: 2, lon: 2.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60), ], ); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.missions())), )); await tester.pump(); await tester.tap(find.byIcon(Icons.share)); await tester.pump(); await tester.pump(); expect(fakeSharing.sharedFilename, 'mission-Shared_route.json'); final parsed = parseMissionImportJson(fakeSharing.sharedJson!); expect(parsed.name, 'Shared route'); expect(parsed.waypoints, hasLength(2)); }); testWidgets( 'Import-Button im Drohnen-Tab legt ein neues Profil aus der Datei an', (tester) async { final exportJson = buildDroneExportJson(DroneProfile.t1Ranger().copyWith(name: 'Imported drone')); final fakeSharing = _FakeSharingService(pickResult: exportJson); final container = ProviderContainer( overrides: [ appDatabaseProvider.overrideWithValue(_testDatabase()), sharingServiceProvider.overrideWithValue(fakeSharing), ], ); addTearDown(container.dispose); await container.read(droneProfileRepositoryProvider).ensureSeeded(); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.drones())), )); await tester.pump(); await tester.tap(find.text('Import')); await tester.pump(); await tester.pump(); expect(find.text('Imported drone'), findsOneWidget); expect(find.text('Drone profile imported'), findsOneWidget); }); testWidgets( 'Share-Icon eines Drohnenprofils uebergibt den Export an den SharingService', (tester) async { final fakeSharing = _FakeSharingService(); final container = ProviderContainer( overrides: [ appDatabaseProvider.overrideWithValue(_testDatabase()), sharingServiceProvider.overrideWithValue(fakeSharing), ], ); addTearDown(container.dispose); await container.read(droneProfileRepositoryProvider).ensureSeeded(); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: const MaterialApp(home: Scaffold(body: MissionsDronesPanel.drones())), )); await tester.pump(); await tester.tap(find.byIcon(Icons.share)); await tester.pump(); await tester.pump(); expect(fakeSharing.sharedFilename, 'drone-T1_Ranger.json'); final parsed = parseDroneImportJson(fakeSharing.sharedJson!); expect(parsed.profile.name, 'T1 Ranger'); }); testWidgets( 'Warnungs-Button erscheint bei Reichweiten-Ueberschreitung und oeffnet die Warnungsliste', (tester) async { final container = _testContainer(); addTearDown(container.dispose); expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); // T1-Ranger-Standardprofil erlaubt max. 15000 m Reichweite - 0.2 Grad // Breitenunterschied (~22 km) ueberschreitet das deutlich. container.read(currentMissionProvider.notifier) ..addWaypoint(const Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)) ..addWaypoint(const Waypoint(lat: 0.2, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); expect(find.textContaining('exceeds max range'), findsOneWidget); await tester.tap(find.byIcon(Icons.warning_amber_rounded)); await tester.pumpAndSettle(); expect(find.text('Warnings'), findsOneWidget); expect(find.textContaining('exceeds max range'), findsWidgets); // Antippen der Warnung (Aktion "map") schliesst die Liste wieder, ohne // dass eine Ausnahme auftritt. await tester.tap(find.textContaining('Show on map')); await tester.pumpAndSettle(); expect(find.text('Warnings'), findsNothing); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); }); testWidgets( 'FlyScreen zeigt die Karte mit der aktuellen Mission und die Fusszeile', (tester) async { final container = _testContainer(); addTearDown(container.dispose); container.read(currentMissionProvider.notifier).addWaypoint(const Waypoint( lat: 52.3676, lon: 4.9041, altitudeM: 60, speedMs: 15, catchRadiusM: 60, )); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); await tester.tap(find.text('Fly')); await tester.pump(); expect(find.byType(FlutterMap), findsOneWidget); expect(find.text('1 WP'), findsOneWidget); // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein // Timer mehr aussteht (Doku 7.5). await tester.pump(const Duration(milliseconds: 900)); await _stopTelemetry(container); }); testWidgets( 'Drohnen-Button im Fly-Modus zentriert nach Eintreffen der Telemetrie auf die Drohne', (tester) async { final container = _testContainer(); 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.text('Fly')); await tester.pump(); // Noch keine Telemetrie eingetroffen (MockFlightControllerLink tickt // erst nach 1s) - Hinweis statt Kamerasprung. await tester.tap(find.byIcon(Icons.my_location)); await tester.pump(); expect(find.text('No telemetry yet'), findsOneWidget); await tester.pump(const Duration(seconds: 1, milliseconds: 100)); await tester.tap(find.byIcon(Icons.my_location)); await tester.pump(); final camera = container.read(mapControllerProvider).camera; expect(camera.center.latitude, closeTo(52.5, 0.0001)); expect(camera.center.longitude, closeTo(13.4, 0.0001)); await _stopTelemetry(container); }); testWidgets('Fit-Button im Fly-Modus ohne Wegpunkte zeigt Hinweis statt Absturz', (tester) async { final container = _testContainer(); 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.text('Fly')); await tester.pump(); await tester.tap(find.byIcon(Icons.crop_free)); await tester.pump(); expect(find.text('No waypoints to fit'), findsOneWidget); await _stopTelemetry(container); }); testWidgets( 'Antippen einer Speed-Warnung oeffnet die Wegpunktliste direkt auf dem Speed-Tab', (tester) async { final container = _testContainer(); addTearDown(container.dispose); // Starker Gegenwind auf dem zweiten Wegpunkt macht die Bodengeschwindigkeit // negativ - loest computeLegWindBad() aus. container.read(currentMissionProvider.notifier) ..addWaypoint(const Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60)) ..addWaypoint(const Waypoint( lat: 0.001, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60, windSpeedMs: 30, windDirFromDeg: 0, )); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: BlocProvider( create: (_) => AppModeCubit(), child: const MaterialApp(home: AppShell()), ), ), ); await tester.pump(); await tester.tap(find.byIcon(Icons.warning_amber_rounded)); await tester.pumpAndSettle(); await tester.tap(find.textContaining('Open speed view')); await tester.pumpAndSettle(); expect(find.text('Speed'), findsWidgets); expect(find.byType(FullValueChart), findsOneWidget); await tester.pump(const Duration(milliseconds: 900)); }); }