diff --git a/app/lib/ui/providers/active_drone_profile_provider.dart b/app/lib/ui/providers/active_drone_profile_provider.dart index aaf30a3..b3f440b 100644 --- a/app/lib/ui/providers/active_drone_profile_provider.dart +++ b/app/lib/ui/providers/active_drone_profile_provider.dart @@ -36,6 +36,11 @@ class ActiveDroneProfileNotifier extends Notifier { } } + /// Loggt den Wechsel NICHT selbst (Doku: "Wechsel der Mission oder des + /// Drohnenprofils ... nur im fly modus loggen") - dieser Provider kennt + /// den App-Modus (AppModeCubit, flutter_bloc) nicht. FlyScreen beobachtet + /// stattdessen selbst per ref.listen(activeDroneProfileProvider, ...) und + /// loggt dort, existiert also nur, waehrend der Fly-Modus aktiv ist. Future select(DroneProfile profile) async { state = profile; await ref.read(appSettingsRepositoryProvider).setActiveDroneProfileId(profile.id); diff --git a/app/lib/ui/providers/mission_meta_provider.dart b/app/lib/ui/providers/mission_meta_provider.dart index 3fb5004..bc63e6e 100644 --- a/app/lib/ui/providers/mission_meta_provider.dart +++ b/app/lib/ui/providers/mission_meta_provider.dart @@ -15,18 +15,31 @@ import 'terrain_provider.dart'; /// currentMissionProvider (das nur die Wegpunkte haelt) um Name/Ort und die /// Datenbank-id, sobald die Mission mindestens einmal gespeichert wurde. class MissionMeta { - const MissionMeta({this.id, this.name = 'New mission', this.placeName}); + const MissionMeta({this.id, this.name = 'New mission', this.placeName, this.switchSeq = 0}); /// Null, solange die Mission noch nie (auto-)gespeichert wurde. final int? id; final String name; final String? placeName; - MissionMeta copyWith({int? id, String? name, String? placeName}) { + /// Zaehlt nur bei einem echten Missionswechsel hoch (startNew/ + /// startNewFromPlace/loadMission), NICHT bei der id-Vergabe durch den + /// ersten Autosave einer neuen Mission (siehe _flushNow()) oder bei + /// restoreLastSession() (App-Start, kein Nutzerwechsel). FlyScreen + /// vergleicht darauf, um einen echten Missionswechsel waehrend des + /// Fly-Modus zu erkennen - sowohl fuer das System-Messages-Log (Doku: + /// "Wechsel der Mission ... nur im fly modus loggen") als auch fuer den + /// automatischen Missions-Upload (Doku: "automatisch die Wegpunktliste + /// übertragen, wenn die Mission gewechselt wird im fly modus"). Ein + /// simpler id-Vergleich waere durch die Autosave-id-Vergabe falsch positiv. + final int switchSeq; + + MissionMeta copyWith({int? id, String? name, String? placeName, int? switchSeq}) { return MissionMeta( id: id ?? this.id, name: name ?? this.name, placeName: placeName ?? this.placeName, + switchSeq: switchSeq ?? this.switchSeq, ); } } @@ -80,7 +93,7 @@ class CurrentMissionMetaNotifier extends Notifier { _suppressAutosave = true; ref.read(currentMissionProvider.notifier).replaceAll(const []); _suppressAutosave = false; - state = newState; + state = newState.copyWith(switchSeq: state.switchSeq + 1); ref.read(terrainProvider.notifier).restore(null); await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null); } @@ -93,7 +106,8 @@ class CurrentMissionMetaNotifier extends Notifier { _suppressAutosave = true; ref.read(currentMissionProvider.notifier).replaceAll(loaded.waypoints); _suppressAutosave = false; - state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName); + state = MissionMeta(id: id, name: loaded.name, placeName: loaded.placeName) + .copyWith(switchSeq: state.switchSeq + 1); ref.read(terrainProvider.notifier).restore(loaded.terrainProfile); _fitMapToWaypoints(loaded.waypoints); await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(id); diff --git a/app/lib/ui/providers/system_message_log_provider.dart b/app/lib/ui/providers/system_message_log_provider.dart index 9765993..237c59f 100644 --- a/app/lib/ui/providers/system_message_log_provider.dart +++ b/app/lib/ui/providers/system_message_log_provider.dart @@ -6,96 +6,26 @@ import '../../transport/flight_controller_link.dart'; import 'connection_settings_provider.dart'; import 'telemetry_provider.dart'; -/// Fuehrt das System-Messages-Log fuer den gleichnamigen Tab im -/// Drohnen-Status-Menue (Doku: "aendere das warnings Menü in System -/// Messages ... Verbindung hergestellt/verloren, Verbindungstyp geaendert, -/// Wegpunkte uebertragen") - beobachtet [telemetryProvider] und -/// [connectionSettingsProvider] dauerhaft (bewusst KEIN autoDispose, siehe -/// telemetry_provider.dart-Doku zur analogen Begruendung bei -/// flightControllerLinkProvider), damit ein Ereignis auch dann protokolliert -/// wird, wenn die System-Messages-Ansicht selbst gerade nicht geoeffnet ist. -/// Beginnt zu beobachten, sobald irgendein Widget diesen Provider erstmals -/// liest (siehe FlyScreen) - vor diesem Zeitpunkt liegende Ereignisse -/// erscheinen nicht im Log, das ist fuer ein Live-Log erwartbar. +/// Haelt die Liste der System Messages plus das manuelle [log()] (Doku: +/// "aendere das warnings Menü in System Messages"). Bewusst schlank, ohne +/// jede Telemetrie-/Verbindungsbeobachtung: mission_meta_provider.dart und +/// active_drone_profile_provider.dart rufen log() auch aus dem Plan-Modus +/// heraus auf (Missions-/Drohnenprofilwechsel) - wuerde diese Klasse selbst +/// telemetryProvider beobachten, risse schon ein simpler Missionswechsel im +/// Plan-Modus ungewollt den kompletten Telemetrie-/Transport-Stack (inkl. +/// echtem MSP/UDP-Transport) mit hoch. Die automatische Erkennung +/// (Connected/Lost/Verbindungstyp-Wechsel) sitzt deshalb separat in +/// [systemMessageAutoLogProvider], die ausschliesslich der Fly-Modus +/// beobachtet. class SystemMessageLogNotifier extends Notifier> { - TelemetryFrame? _previousFrame; - bool _hadData = false; - @override - List build() { - ref.listen>(telemetryProvider, (previous, next) { - next.when( - data: (frame) { - // Flankengetriggert wie "Connection lost" unten, nur umgekehrt - - // anders als dort ist hier kein Sonderfall fuer die allererste - // Verbindung noetig: der Uebergang "noch keine Daten" -> "erste - // Daten" ist immer ein echtes "Connected", auch beim allerersten - // Mal. - if (!_hadData) { - _log('Connected', SystemMessageSeverity.info); - } - final events = detectSystemMessages( - previous: _previousFrame, - current: frame, - now: DateTime.now(), - ); - _previousFrame = frame; - _hadData = true; - if (events.isNotEmpty) { - state = [...events.reversed, ...state]; - } - }, - error: (error, stackTrace) { - // Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde - // schon das (normale) Ausbleiben der allerersten Verbindung als - // "Connection lost" auftauchen. - if (_hadData) { - _log('Connection lost', SystemMessageSeverity.critical); - } - _previousFrame = null; - _hadData = false; - }, - loading: () {}, - ); - }); + List build() => []; - // Verbindungsart-Wechsel (Doku: "Verbindungstyp geaendert") - nur ab dem - // zweiten geladenen Wert protokollieren, sonst wuerde bereits das - // erstmalige Laden der Einstellungen aus der DB als "Wechsel" erscheinen. - ConnectionType? previousType; - var sawFirstLoad = false; - ref.listen(connectionSettingsProvider, (previous, next) { - final type = next.value?.connectionType; - if (type == null) return; - if (!sawFirstLoad) { - sawFirstLoad = true; - previousType = type; - return; - } - if (type != previousType) { - _log('Connection type changed to ${_connectionTypeLabel(type)}', - SystemMessageSeverity.info); - previousType = type; - } - }); - - return []; - } - - String _connectionTypeLabel(ConnectionType type) => switch (type) { - ConnectionType.wifi => 'WiFi mLRS UDP', - ConnectionType.mock => 'Mock (Test)', - }; - - /// Fuegt einen Eintrag manuell hinzu (Doku: "Wegpunkte uebertragen") - fuer - /// Ereignisse, die nicht aus der Telemetrie/den Einstellungen ableitbar - /// sind, sondern vom Aufloeser einer konkreten Aktion (z.B. FlyScreen nach - /// einem Missions-Upload) gemeldet werden. + /// Fuegt einen Eintrag manuell hinzu (Doku: "Wegpunkte uebertragen", + /// "Wechsel der Mission oder des Drohnenprofils") - fuer Ereignisse, die + /// vom Aufloeser einer konkreten Aktion gemeldet werden, statt aus + /// Telemetrie/Einstellungen automatisch erkannt zu werden. void log(String message, {SystemMessageSeverity severity = SystemMessageSeverity.info}) { - _log(message, severity); - } - - void _log(String message, SystemMessageSeverity severity) { state = [ SystemMessage(timestamp: DateTime.now(), message: message, severity: severity), ...state, @@ -108,3 +38,79 @@ class SystemMessageLogNotifier extends Notifier> { final systemMessageLogProvider = NotifierProvider>( SystemMessageLogNotifier.new); + +/// Automatische Erkennung von Connected/Connection-lost/Batterie-/Failsafe-/ +/// Verbindungstyp-Aenderungen (Doku: "wenn die Verbindung hergestellt +/// wird/verloren geht, der verbindungstyp geaendert wird") - getrennt von +/// [systemMessageLogProvider] selbst (siehe dessen Doku), damit Plan-Modus- +/// Code, der nur log() fuer eine manuelle Meldung braucht, nicht versehentlich +/// telemetryProvider mit hochzieht. autoDispose: nur beobachtet, waehrend +/// irgendwer (FlyScreen) diesen Provider liest - verlaesst der Nutzer den +/// Fly-Modus, wird die Beobachtung wieder abgebaut wie bei telemetryProvider +/// selbst. +final systemMessageAutoLogProvider = Provider.autoDispose((ref) { + TelemetryFrame? previousFrame; + var hadData = false; + + ref.listen>(telemetryProvider, (previous, next) { + next.when( + data: (frame) { + final log = ref.read(systemMessageLogProvider.notifier); + // Flankengetriggert wie "Connection lost" unten, nur umgekehrt - + // anders als dort ist hier kein Sonderfall fuer die allererste + // Verbindung noetig: der Uebergang "noch keine Daten" -> "erste + // Daten" ist immer ein echtes "Connected", auch beim allerersten Mal. + if (!hadData) { + log.log('Connected'); + } + final events = detectSystemMessages( + previous: previousFrame, + current: frame, + now: DateTime.now(), + ); + previousFrame = frame; + hadData = true; + for (final event in events) { + log.log(event.message, severity: event.severity); + } + }, + error: (error, stackTrace) { + // Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde + // schon das (normale) Ausbleiben der allerersten Verbindung als + // "Connection lost" auftauchen. + if (hadData) { + ref.read(systemMessageLogProvider.notifier) + .log('Connection lost', severity: SystemMessageSeverity.critical); + } + previousFrame = null; + hadData = false; + }, + loading: () {}, + ); + }); + + // Verbindungsart-Wechsel (Doku: "Verbindungstyp geaendert") - nur ab dem + // zweiten geladenen Wert protokollieren, sonst wuerde bereits das + // erstmalige Laden der Einstellungen aus der DB als "Wechsel" erscheinen. + ConnectionType? previousType; + var sawFirstLoad = false; + ref.listen(connectionSettingsProvider, (previous, next) { + final type = next.value?.connectionType; + if (type == null) return; + if (!sawFirstLoad) { + sawFirstLoad = true; + previousType = type; + return; + } + if (type != previousType) { + ref.read(systemMessageLogProvider.notifier) + .log('Connection type changed to ${_connectionTypeLabel(type)}'); + previousType = type; + } + }); +}); + +String _connectionTypeLabel(ConnectionType type) => switch (type) { + ConnectionType.wifi => 'WiFi mLRS UDP', + ConnectionType.mock => 'Mock (Test)', + }; diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 190b6ca..f2f73a6 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../../../app_mode/app_mode_cubit.dart'; +import '../../../domain/mission/drone_profile.dart'; import '../../../domain/mission/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; @@ -21,6 +22,7 @@ import '../../providers/active_drone_profile_provider.dart'; import '../../providers/current_mission_provider.dart'; import '../../providers/follow_drone_provider.dart'; import '../../providers/map_controller_provider.dart'; +import '../../providers/mission_meta_provider.dart'; import '../../providers/system_message_log_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/terrain_provider.dart'; @@ -137,9 +139,15 @@ class _FlyScreenState extends ConsumerState { /// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen /// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag /// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte - /// übertragen werden"). + /// übertragen werden") inklusive Missionsname. Wird sowohl vom Sende- + /// Knopf als auch automatisch bei einem Missionswechsel aufgerufen (siehe + /// den ref.listen(currentMissionMetaProvider, ...) unten) - der + /// _sending-Schutz verhindert dabei ein doppeltes Senden, falls beides + /// ueberlappt. Future _onSendTap() async { + if (_sending) return; final waypoints = ref.read(currentMissionProvider); + final missionName = ref.read(currentMissionMetaProvider).name; final messenger = ScaffoldMessenger.of(context); if (waypoints.isEmpty) { messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send'))); @@ -155,10 +163,11 @@ class _FlyScreenState extends ConsumerState { ); await service.syncAndUpload(FlatWaypointList(waypoints)); messenger.showSnackBar(const SnackBar(content: Text('Mission sent'))); - log.log('Mission sent (${waypoints.length} waypoints)'); + log.log('Mission sent: "$missionName" (${waypoints.length} waypoints)'); } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Send failed: $e'))); - log.log('Mission upload failed: $e', severity: SystemMessageSeverity.critical); + log.log('Mission upload failed: "$missionName": $e', + severity: SystemMessageSeverity.critical); } finally { if (mounted) setState(() => _sending = false); } @@ -170,16 +179,15 @@ class _FlyScreenState extends ConsumerState { final droneProfile = ref.watch(activeDroneProfileProvider); final telemetry = ref.watch(telemetryProvider).value; final followDrone = ref.watch(followDroneProvider); - // Beobachtet das System-Messages-Log dauerhaft ab dem ersten Eintritt in - // den Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp", - // erweitert um Verbindungs-/Uebertragungsereignisse) - haelt - // system_message_log_provider.dart's Notifier am Leben, solange dieser - // Screen existiert, siehe dessen Klassendoku. Der Rueckgabewert selbst - // wird hier nicht mehr gebraucht (der Warnungs-Knopf, der ihn frueher - // fuer forceShowWarningsButton auswertete, ist im Fly-Modus durch den - // Sende-Knopf ersetzt, siehe onSendTap oben) - System Messages bleiben - // ueber die Drohnen-Status-Pille erreichbar. - ref.watch(systemMessageLogProvider); + // Beobachtet die automatische Ereigniserkennung (Connected/Lost/ + // Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen + // ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer + // die Dauer des Fly-Modus - siehe systemMessageAutoLogProvider-Doku, + // warum das bewusst NICHT systemMessageLogProvider selbst ist (der + // bliebe sonst auch im Plan-Modus dauerhaft an telemetryProvider + // gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der + // Beobachtungs-Seiteneffekt. + ref.watch(systemMessageAutoLogProvider); // Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls) // aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position @@ -193,6 +201,35 @@ class _FlyScreenState extends ConsumerState { } }); + // Ein Missionswechsel waehrend des Fly-Modus (z.B. ueber die Mission- + // Pille in der Fusszeile, die auch dort erreichbar bleibt) wird geloggt + // und laedt die neue Route automatisch auf den Flightcontroller (Doku: + // "Wechsel der Mission ... nur im fly modus loggen", "automatisch die + // Wegpunktliste übertragen, wenn die Mission gewechselt wird im fly + // modus") - beides sitzt bewusst hier statt in + // mission_meta_provider.dart, das den App-Modus (AppModeCubit, + // flutter_bloc) nicht kennt; dieser ref.listen existiert dagegen nur, + // waehrend FlyScreen gemountet ist. switchSeq statt eines simplen + // id-Vergleichs, weil der erste Autosave einer neuen Mission ebenfalls + // deren id setzt, ohne dass die Mission dabei "gewechselt" wurde (siehe + // MissionMeta-Doku). + ref.listen(currentMissionMetaProvider, (previous, next) { + if (previous != null && next.switchSeq != previous.switchSeq) { + ref.read(systemMessageLogProvider.notifier).log('Mission changed to "${next.name}"'); + _onSendTap(); + } + }); + + // Drohnenprofilwechsel waehrend des Fly-Modus (Doku: "Wechsel ... des + // Drohnenprofils ... nur im fly modus loggen") - anders als beim + // Missionswechsel bewusst OHNE automatischen Neuversand (Doku sieht das + // explizit nur fuer den Missionswechsel vor). + ref.listen(activeDroneProfileProvider, (previous, next) { + if (previous != null && previous.id != next.id) { + ref.read(systemMessageLogProvider.notifier).log('Drone profile changed to "${next.name}"'); + } + }); + final routeGeometry = buildRouteGeometry( waypoints, minTurnRadius: droneProfile.minTurnRadius, diff --git a/app/test/ui/providers/mission_meta_provider_test.dart b/app/test/ui/providers/mission_meta_provider_test.dart new file mode 100644 index 0000000..f5bc86c --- /dev/null +++ b/app/test/ui/providers/mission_meta_provider_test.dart @@ -0,0 +1,63 @@ +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/services/database/app_database.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/mission_meta_provider.dart'; + +ProviderContainer _container() => ProviderContainer(overrides: [ + appDatabaseProvider + .overrideWithValue(AppDatabase.forTesting(NativeDatabase.memory())), + ]); + +/// MissionMeta.switchSeq ist das Signal, mit dem FlyScreen einen echten +/// Missionswechsel erkennt (System-Messages-Log + automatischer Upload, +/// beides bewusst nur im Fly-Modus - siehe FlyScreen-Doku). Das Loggen +/// selbst passiert dort, nicht hier: dieser Provider kennt den App-Modus +/// nicht und darf deshalb ausserhalb des Fly-Modus (z.B. beim Planen) nicht +/// unbedingt in den System-Messages-Log schreiben oder gar telemetryProvider +/// mit hochziehen. +void main() { + test('startNewFromPlace() erhoeht switchSeq', () async { + final container = _container(); + addTearDown(container.dispose); + + await container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Rotterdam'); + + expect(container.read(currentMissionMetaProvider).switchSeq, 1); + expect(container.read(currentMissionMetaProvider).name, 'Rotterdam'); + }); + + // loadMission() ruft zusaetzlich _fitMapToWaypoints() auf, das eine + // tatsaechlich gerenderte FlutterMap braucht (MapController.fitCamera) - + // in einem reinen Notifier-Test ohne Widget-Baum nicht moeglich. Wird + // stattdessen vom widget_test.dart-Test "Missionswechsel im Fly-Modus + // sendet die neue Route automatisch an den Flightcontroller" abgedeckt, + // der loadMission() innerhalb einer echten AppShell/MissionMap aufruft. + + test( + 'der erste Autosave einer neuen Mission erhoeht switchSeq NICHT ' + '(id-Vergabe durch Autosave ist kein Wechsel)', + () async { + final container = _container(); + addTearDown(container.dispose); + await container.read(currentMissionMetaProvider.notifier).startNewFromPlace('Berlin'); + final switchSeqAfterStart = container.read(currentMissionMetaProvider).switchSeq; + + container.read(currentMissionProvider.notifier).addWaypoint( + const Waypoint(lat: 1, lon: 1, altitudeM: 60, speedMs: 15, catchRadiusM: 60), + ); + // Autosave debounced ueber 800ms (Doku 7.5) - echt abwarten statt zu + // mocken, damit tatsaechlich der reale _flushNow()-Pfad getestet wird. + await Future.delayed(const Duration(milliseconds: 900)); + + final meta = container.read(currentMissionMetaProvider); + expect(meta.id, isNotNull, reason: 'Autosave sollte trotzdem eine id vergeben haben'); + expect(meta.switchSeq, switchSeqAfterStart); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); +} diff --git a/app/test/ui/providers/system_message_log_provider_test.dart b/app/test/ui/providers/system_message_log_provider_test.dart index 6a664d0..e932cbb 100644 --- a/app/test/ui/providers/system_message_log_provider_test.dart +++ b/app/test/ui/providers/system_message_log_provider_test.dart @@ -64,7 +64,7 @@ void main() { .overrideWith((ref) async => _settings(ConnectionType.mock)), ]); addTearDown(container.dispose); - container.listen(systemMessageLogProvider, (previous, next) {}); + container.listen(systemMessageAutoLogProvider, (previous, next) {}); controller.add(_frame()); await Future.delayed(Duration.zero); @@ -90,7 +90,7 @@ void main() { ]); addTearDown(container.dispose); - container.listen(systemMessageLogProvider, (previous, next) {}); + container.listen(systemMessageAutoLogProvider, (previous, next) {}); await Future.delayed(Duration.zero); expect(container.read(systemMessageLogProvider), isEmpty); @@ -105,7 +105,7 @@ void main() { ]); addTearDown(container.dispose); - container.listen(systemMessageLogProvider, (previous, next) {}); + container.listen(systemMessageAutoLogProvider, (previous, next) {}); await container.read(connectionSettingsProvider.future); expect(container.read(systemMessageLogProvider), isEmpty); diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 0eba47d..fc3cff3 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -19,11 +19,14 @@ 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'; @@ -671,6 +674,121 @@ void main() { 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.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((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 {