Files
dmc/app/test/widget_test.dart
T
Constantin LeueandClaude Sonnet 5 7a26002a63 Auto-scroll waypoint list to active row; fix speed chip step size
Wegpunktliste: der aktive Wegpunkt (per Halo-Menue "Edit" ausgewaehlt)
war bei laengeren Missionen ausserhalb des sichtbaren Bereichs, da die
Liste beim Oeffnen immer oben startete. WaypointListPanel bekommt jetzt
einen ScrollController mit fester Zeilenhoehe (itemExtent, damit die
Zielposition ohne Layout-Messung berechnet werden kann) und scrollt per
post-frame-Callback zur aktiven Zeile - sowohl beim ersten Oeffnen als
auch beim Zurueckwechseln vom Altitude-/Speed-Tab auf List.

WaypointChip (der Swipe-Wertechip in den Listenzeilen) hatte eine fest
einprogrammierte Schrittgroesse von 10 fuer alle Felder (Alt/Speed/
Catch) - fuer Speed bei einer Spanne von nur 13-25 m/s viel zu grob,
ein einzelner Drag-Schritt sprang schon fast durch den gesamten
gueltigen Bereich. Schrittgroesse ist jetzt ein expliziter Parameter;
Speed nutzt DefaultDroneProfile.speedStep (1), Altitude weiterhin
DefaultDroneProfile.altStep (10), Catch-Radius weiterhin 10 (keine
Beschwerde hierzu, Wert unveraendert). Der Alt-/Speed-Drehrad und die
Vollbild-Charts nutzten diese Profile bereits korrekt - der Row-Chip
war die einzige Stelle mit hartcodiertem Wert.

Verifiziert: flutter analyze (0 issues), flutter test (24/24, zwei neue
Tests fuer Auto-Scroll und Speed-Schrittgroesse), manuell auf
Pixel_10a-Emulator mit 7 Wegpunkten - Liste oeffnet direkt bei
Wegpunkt 7, Speed-Chip-Drag aendert 15 m/s in kleinen Schritten (18)
statt in Zehnerspruengen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 10:33:25 +02:00

474 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
import 'package:dmc_app/ui/app_shell.dart';
import 'package:dmc_app/ui/providers/current_mission_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/map_search_controls.dart';
import 'package:dmc_app/ui/providers/map_controller_provider.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:latlong2/latlong.dart';
Widget _wrap(Widget child) => ProviderScope(
child: BlocProvider(
create: (_) => AppModeCubit(),
child: MaterialApp(home: child),
),
);
Future<ProviderContainer> _pumpWithContainer(WidgetTester tester) async {
final container = ProviderContainer();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: BlocProvider(
create: (_) => AppModeCubit(),
child: const MaterialApp(home: AppShell()),
),
),
);
await tester.pump();
return container;
}
void main() {
testWidgets('AppShell zeigt die Karte und den Plan/Fly-Umschalter im Startzustand',
(tester) async {
await tester.pumpWidget(_wrap(const AppShell()));
// Nur ein Frame pumpen statt pumpAndSettle: TileLayer laedt echte
// Netzwerk-Kacheln, die in der Testumgebung nie ankommen.
await tester.pump();
expect(find.byType(FlutterMap), findsOneWidget);
expect(find.text('Plan'), findsOneWidget);
expect(find.text('Fly'), findsOneWidget);
});
testWidgets(
'Fly-Button ohne erfuellten Ready-to-Fly-Gate zeigt Hinweis statt Absturz',
(tester) async {
await tester.pumpWidget(_wrap(const AppShell()));
await tester.pump();
await tester.tap(find.text('Fly'));
await tester.pump();
expect(
find.text('Mission muss zuerst hochgeladen und verifiziert werden.'),
findsOneWidget,
);
// Weiterhin im Plan-Modus, da das Gate nicht erfuellt war.
expect(find.byType(FlutterMap), findsOneWidget);
});
testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu',
(tester) async {
final container = ProviderContainer();
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));
});
testWidgets(
'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue',
(tester) async {
final container = ProviderContainer();
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);
});
testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue',
(tester) async {
final container = ProviderContainer();
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);
});
testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue',
(tester) async {
final container = ProviderContainer();
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);
});
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);
});
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);
});
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));
});
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));
});
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));
});
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));
});
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(
'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten',
(tester) async {
final container = ProviderContainer();
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();
// Ready-to-Fly-Gate technisch erfuellen, um in den Fly-Modus zu gelangen
// (Doku 2.2/4.5) - hier nur, um die Kamera-Persistenz des geteilten
// mapControllerProvider ueber den Moduswechsel zu pruefen.
appModeCubit
..missionUploadConfirmed()
..missionVerifiedConfirmed()
..toFly();
await tester.pump();
appModeCubit.toPlan();
await tester.pump();
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 = ProviderContainer();
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));
});
}