Files
dmc/app/test/widget_test.dart
T
Constantin LeueandClaude Sonnet 5 b68c16d8e6 Log mission name on send, log mission/drone-profile changes, auto-send on mission switch
Include the mission name in the "Mission sent"/"Mission upload failed"
System Messages (was just the waypoint count before).

Add MissionMeta.switchSeq, bumped only by an actual mission switch
(startNew/startNewFromPlace/loadMission) - not by the id a brand-new
mission gets from its first autosave, and not by restoreLastSession() on
app start. FlyScreen compares it to detect a genuine switch and reacts
two ways: logs "Mission changed to ..." and automatically re-uploads the
new route to the flight controller, reusing the same send path as the
manual send button (same _sending guard, same success/failure snackbar
and log entry).

Mission-change and drone-profile-change logging intentionally live in
FlyScreen's ref.listen callbacks, not in mission_meta_provider.dart /
active_drone_profile_provider.dart themselves - those providers have no
notion of the app mode (Plan vs Fly, tracked separately by AppModeCubit),
and logging there would record a change regardless of mode. Since
FlyScreen only exists while Fly mode is active, scoping the listeners
there means switching missions or drone profiles from Plan mode produces
no System Messages entries, and only a mission switch (not a drone
profile switch) triggers the automatic re-upload, matching what was
asked for.

Also splits systemMessageLogProvider (the plain message list + log(), no
telemetry dependency) from the new systemMessageAutoLogProvider (the
Connected/lost/battery/failsafe/connection-type auto-detection, which
does watch telemetryProvider) - discovered while wiring the mission-change
logging that logging a plain message from Plan-mode code was forcing the
entire telemetry/transport stack to spin up as a side effect, which broke
an unrelated Plan-mode test (UdpTransport threw on a double-disconnect
during teardown). Keeping the two concerns apart means calling log() for
a one-off message never has that side effect.

Verified end to end on the Pixel_10a emulator: switching to an empty
mission while in Fly mode correctly showed "No waypoints to send" and
logged "Mission changed to ..." automatically, without touching the send
button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:04:35 +02:00

1230 lines
42 KiB
Dart

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/screens/fly/fly_screen.dart';
import 'package:dmc_app/ui/providers/active_drone_profile_provider.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/system_message_log_provider.dart';
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart';
import 'package:dmc_app/ui/widgets/drone_follow_icon.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/paper_plane_icon.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());
// flightControllerLinkProvider waehlt inzwischen je nach Verbindungsart
// (Settings-Default: WLAN) zwischen echtem MSP-ueber-UDP-Link und dem Mock -
// in Tests immer explizit den Mock erzwingen, sonst wuerde der Fly-Modus
// versuchen, einen echten Platform-Channel (WifiNetworkSpecifier) zu
// verwenden, der in der Testumgebung nie eine Antwort liefert und den Test
// haengen liesse.
ProviderContainer _testContainer() => ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(_testDatabase()),
flightControllerLinkProvider.overrideWith((ref) {
final link = MockFlightControllerLink();
ref.onDispose(link.disconnect);
return link;
}),
],
);
/// 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()),
flightControllerLinkProvider.overrideWith((ref) {
final link = MockFlightControllerLink();
ref.onDispose(link.disconnect);
return link;
}),
],
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() {
// 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<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(
'Missionswechsel im Fly-Modus sendet die neue Route automatisch an '
'den Flightcontroller', (tester) async {
final container = _testContainer();
final appModeCubit = AppModeCubit();
addTearDown(() {
container.dispose();
appModeCubit.close();
});
final missionBId = await container.read(missionRepositoryProvider).upsert(
name: 'Mission B',
waypoints: const [
Waypoint(lat: 10, lon: 10, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: BlocProvider<AppModeCubit>.value(
value: appModeCubit,
child: const MaterialApp(home: AppShell()),
),
),
);
await tester.pump();
appModeCubit.toFly();
// Zwei Frames noetig, bis AppShells context.select(AppModeCubit) auf den
// Moduswechsel reagiert und FlyScreen tatsaechlich mountet (ein einzelner
// pump() reicht hier nicht, anders als beim Antippen des Fly-Buttons in
// anderen Tests, wo der Tap selbst schon einen Zwischenschritt einbringt).
await tester.pump();
await tester.pump();
expect(find.byType(FlyScreen), findsOneWidget);
await container.read(currentMissionMetaProvider.notifier).loadMission(missionBId);
// syncAndUpload() durchlaeuft mehrere awaits (connect/upload/verify) auf
// dem Mock, alle ohne echte Timer - ein paar Frames reichen zum
// Durchlaufen, bewusst kein pumpAndSettle() (siehe andere Fly-Modus-
// Tests: der periodische Mock-Telemetrie-Timer wuerde das nie "settlen"
// lassen).
await tester.pump();
await tester.pump();
await tester.pump();
expect(appModeCubit.state.missionUploaded, isTrue);
expect(appModeCubit.state.missionVerified, isTrue);
expect(find.textContaining('Mission sent'), findsOneWidget);
final log = container.read(systemMessageLogProvider);
expect(log.map((e) => e.message), contains('Mission changed to "Mission B"'));
expect(
log.map((e) => e.message),
contains(predicate<String>((m) => m.startsWith('Mission sent: "Mission B"'))),
);
await _stopTelemetry(container);
});
testWidgets(
'Missionswechsel im Plan-Modus wird NICHT im System-Messages-Log erfasst '
'(nur im Fly-Modus, Doku: "nur im fly modus loggen")', (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 container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Wien');
await tester.pump();
expect(container.read(systemMessageLogProvider), isEmpty);
});
testWidgets(
'Drohnenprofilwechsel im Fly-Modus wird im System-Messages-Log erfasst',
(tester) async {
final container = _testContainer();
addTearDown(container.dispose);
final otherProfile = DroneProfile.t1Ranger().copyWith(id: 99, name: 'Racer');
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.pump();
expect(find.byType(FlyScreen), findsOneWidget);
await container.read(activeDroneProfileProvider.notifier).select(otherProfile);
await tester.pump();
final log = container.read(systemMessageLogProvider);
expect(log.map((e) => e.message), contains('Drone profile changed to "Racer"'));
await _stopTelemetry(container);
});
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.byType(DroneFollowIcon));
await tester.pump();
expect(find.text('No telemetry yet'), findsOneWidget);
await tester.pump(const Duration(seconds: 1, milliseconds: 100));
await tester.tap(find.byType(DroneFollowIcon));
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(
'Drohnen-Status-Pille oeffnet "Drone Status & System Messages", '
'System-Messages-Tab zeigt "Connected" nach der ersten Telemetrie',
(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();
// MockFlightControllerLink tickt erst nach 1s (siehe oben).
await tester.pump(const Duration(seconds: 1, milliseconds: 100));
// Zwei PaperPlaneIcon-Instanzen im Fly-Fusszeile (Drohnen-Status-Pille
// UND der pinke Sende-Knopf, siehe bottom_stats_bar.dart) - die Pille
// steht im Widget-Baum zuerst.
await tester.tap(find.byType(PaperPlaneIcon).first);
await tester.pumpAndSettle();
expect(find.text('Drone Status & System Messages'), findsOneWidget);
await tester.tap(find.text('System Messages'));
await tester.pumpAndSettle();
expect(find.text('Connected'), findsOneWidget);
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));
});
}