1052 lines
35 KiB
Dart
1052 lines
35 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/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() {
|
|
// 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(
|
|
'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));
|
|
});
|
|
}
|