Files
dmc/app/test/widget_test.dart
T
Constantin LeueandClaude Sonnet 5 310d92f187 Show mission map and footer in Fly mode, add area/drone zoom buttons
Fly mode previously showed just a black placeholder. It now renders the
same MissionMap (route + waypoint markers) and footer (warnings banner,
altitude profile, BottomStatsBar) as the Plan screen, minus the
reticle/wheels since flying observes the route rather than replanning it.
Waypoints can still be edited via the details list for spontaneous
in-flight changes.

Live drone position comes from a new telemetryProvider/
flightControllerLinkProvider pair (autoDispose), the first place
FlightControllerLink/TelemetryFrame get wired into the UI - currently
backed by MockFlightControllerLink until a real MSP transport exists
(Doku 4.19). A drone marker renders on the map once a telemetry frame
arrives.

The Fly-mode header keeps the fit-to-area button but replaces "zoom to
home" with "zoom to drone" (FlyMapControls) - the first waypoint isn't a
meaningful reference point anymore once airborne.

Extracted computeMissionStats and MissionFooterBar out of PlanScreen so
Plan/Fly share the exact same stats/footer logic instead of duplicating
it, and extracted NavIconButton out of MapSearchControls so both Plan's
and Fly's map controls use the same button widget.

Widget tests that now mount FlyScreen switch from the ProviderScope-based
_wrap() helper to a manual ProviderContainer with an explicit
FlightControllerLink.disconnect() call before test end - the mock's
Timer.periodic doesn't get cancelled by container disposal alone, and
flutter_test's pending-timer check runs before addTearDown callbacks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 07:45:36 +02:00

1038 lines
35 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/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<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())],
);
/// 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<void> _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<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 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<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();
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<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');
});
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));
});
}