Files
dmc/app/test/widget_test.dart
T
Constantin LeueandClaude Sonnet 5 b51c6c16d5 Add mission/drone profile sharing and importing, fix map not fitting to loaded mission
Sharing/Import (Doku 3.5/3.6), analog shareMission()/shareDrone() und den
importMissionInput/importDroneInput-Handlern im HTML-Demonstrator:

- services/sharing/mission_share_codec.dart, drone_share_codec.dart: reine
  Funktionen zum Bauen/Parsen des Export-JSON (appVersion/exportedAt-Umschlag),
  inkl. Erkennung einer abweichenden Hauptversion beim Import - vollstaendig
  unit-getestet ohne Plugin-Abhaengigkeit.
- services/database/waypoint_json_codec.dart: Waypoint-JSON-(De-)Serialisierung
  aus mission_repository.dart herausgezogen, damit Persistenz und Sharing
  exakt dasselbe Dateiformat verwenden statt es zu duplizieren.
- services/sharing/sharing_service.dart: duenne I/O-Schicht - schreibt eine
  temporaere Datei und oeffnet das native Share-Sheet (share_plus), bzw.
  liest eine vom Nutzer per Systemdialog ausgewaehlte JSON-Datei
  (file_picker). Als Klasse mit Instanzmethoden gehalten, damit sie sich in
  Tests durch einen Fake ersetzen laesst.
- ui/widgets/missions_drones_panel.dart: Share-Icon je Zeile, Import-Button
  im Toolbar beider Tabs.

Paket-Versionen bewusst gewaehlt: file_picker 11.0.2/share_plus 11.x wurden
zunaechst wegen einer win32-Konflikt-Aufloesung genutzt, kompilierten auf
diesem Projekt (AGP 9.0.1) aber nicht - file_picker < 12.0.0-beta.1 prueft
nur AGP-Version >= 9 und ueberspringt dann das Anwenden des Kotlin-Android-
Plugins, in der Annahme, AGPs eingebauter Kotlin-Support wuerde das
uebernehmen, was hier zu einem fehlenden compileReleaseKotlin-Task und
"Symbol nicht gefunden" fuehrte. file_picker >=12.0.0-beta.1 respektiert
zusaetzlich die bereits vom Flutter-Template gesetzte Gradle-Property
android.builtInKotlin=false und wendet das Plugin dann korrekt an -
file_picker auf ">=12.0.0-beta.1 <13.0.0" (share_plus zurueck auf ^13.3.0,
beide dann konsistent auf win32 ^6.x) gesetzt, um dies zu nutzen.

Zusaetzlich beim manuellen Durchtesten auf dem Pixel_10a-Emulator einen
zweiten, davon unabhaengigen Bug gefunden und behoben: CurrentMissionMeta-
Notifier.loadMission()/restoreLastSession() aktualisierten zwar Wegpunkte
und Missionsname, bewegten aber nie die Kartenkamera - eine geladene
Mission wurde dadurch mit falscher Kartenausschnitt angezeigt (Name/
Wegpunkte einer Stadt, Karte noch an der zuletzt betrachteten Stelle).
Fix: _fitMapToWaypoints() zentriert nach dem Laden auf die Bounding-Box
der Mission, analog der bereits vorhandenen _onFitPressed()-Logik in
MapSearchControls.

Getestet: 13 neue Unit-Tests fuer die Sharing-Codecs, 4 neue Widget-Tests
mit einem Fake-SharingService (kein echter Platform-Channel-Zugriff in
Tests). Alle 97 Tests sowie flutter analyze bestehen. Manuell auf dem
Pixel_10a-Emulator verifiziert: natives Share-Sheet oeffnet sich mit der
korrekten JSON-Datei, Datei-Import ueber den Systemdialog legt eine neue
Mission bzw. ein neues Drohnenprofil an, Kartensprung beim Laden einer
Mission funktioniert jetzt korrekt fuer sowohl manuelles Laden als auch
den Autosave-Restore beim App-Start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 19:50:19 +02:00

792 lines
27 KiB
Dart

import 'dart:convert';
import 'package:drift/native.dart';
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: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/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<void> shareJsonFile({
required String json,
required String filename,
required String shareTitle,
}) async {
sharedJson = json;
sharedFilename = filename;
}
@override
Future<String?> 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())],
);
Widget _wrap(Widget child) => ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(_testDatabase())],
child: BlocProvider(
create: (_) => AppModeCubit(),
child: MaterialApp(home: child),
),
);
Future<ProviderContainer> _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() {
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 = _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<AppModeCubit>.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 = _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<ListView>(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');
});
}