The drone status pill and the mission pill previously sat in two equally sized Expanded regions. That squeezed the drone pill's FittedBox down to illegibility whenever its content grew (e.g. "disconnected"), while the mission pill often had unused space to spare. A plain flex-ratio rebalance (e.g. mission flex:1 vs drone flex:4) turned out to be the wrong tool: Expanded/Flexible always force a fixed fractional share regardless of actual content need, so it either starved the mission pill even for short names, or capped the drone pill below what it needed. bottom_stats_bar.dart: the drone-side content (status pill or drone-name chip + send/warning/settings buttons) is now a plain, non-flex Row child, exactly like _detailsPill()/_AltitudeProfileToggleButton already were - it always gets exactly the width its current content needs, never more, never less. The mission pill remains the sole Expanded element and absorbs whatever space is left, ellipsizing first if it's tight. widget_test.dart: two tests exercising the footer at the default 800x600 test viewport started hitting a real RenderFlex overflow, since that width was never realistic for this landscape-only app's footer content in the first place (previously masked by the drone pill silently shrinking via FittedBox). Set a realistic device-sized viewport (2424x1080, matching the Pixel_10a emulator) for just those two tests. Verified on the Pixel_10a emulator: "disconnected" now renders fully legible in the drone pill, and the mission pill still reads normally alongside it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1249 lines
43 KiB
Dart
1249 lines
43 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 {
|
|
// Realistische (landschaftsbreite) Viewport-Groesse statt der 800x600-
|
|
// Standardvorgabe von flutter_test - die Fusszeile (BottomStatsBar)
|
|
// reiht mehrere intrinsisch grosse Pillen/Knoepfe nebeneinander (Doku:
|
|
// "darauf achten dass drone status immer auf seinen inhalt skaliert"),
|
|
// was bei 800px Breite selbst im Plan-Modus zu einem echten
|
|
// RenderFlex-Overflow fuehrt - auf jedem real relevanten Geraet (dieses
|
|
// Projekt ist landscape-only) ist dafuer genug Platz.
|
|
tester.view.physicalSize = const Size(2424, 1080);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
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 "Drone 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('Drone 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 {
|
|
// Realistische (landschaftsbreite) Viewport-Groesse statt der 800x600-
|
|
// Standardvorgabe - siehe Kommentar beim vorigen Warnungs-Button-Test.
|
|
tester.view.physicalSize = const Size(2424, 1080);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
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));
|
|
});
|
|
}
|