Files
dmc/app/test/widget_test.dart
T
Constantin LeueandClaude Sonnet 5 dc8e6f9d6e Add location search, home/fit map buttons, and slim OSM attribution
- TopNavBar: Nominatim geocoding search field plus Home (center on first
  waypoint) and Fit (zoom to mission bounds) buttons (HTML prototype:
  #searchWrap/#homeBtn/#fitBtn). Both show a SnackBar instead of doing
  nothing when there are no waypoints yet.
- Renders as its own bar just below TopModeBar rather than fused into the
  same pill: TopModeBar lives in AppShell, a layer above PlanScreen, which
  owns the MapController these actions actually need. Fusing them would
  need lifting the MapController to shared state - reasonable follow-up
  if pixel-fidelity with the prototype's single bar matters later, but
  not necessary for the functionality itself.
- Replaced SimpleAttributionWidget with a compact custom attribution: OSM's
  tile usage policy requires visible attribution to stay, so instead of
  removing it (as first asked) I shrank it and dropped the "flutter_map |"
  prefix per the user's follow-up choice, so it reads cleanly against the
  now-opaque bottom bar instead of looking like a second banner.

Added 5 tests (nav bar renders, home/fit SnackBars with no waypoints,
empty search is a no-op). All 26 tests and flutter analyze pass. Verified
on the Pixel_10a emulator: searched "Berlin" and confirmed the map flew
there, dropped a waypoint, panned far away, and confirmed Home re-centers
and snaps onto it exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:06:16 +02:00

356 lines
12 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/reticle_button.dart';
import 'package:dmc_app/ui/widgets/top_nav_bar.dart';
import 'package:dmc_app/ui/widgets/waypoint_chip.dart';
import 'package:dmc_app/ui/widgets/waypoint_list_panel.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(
'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('TopNavBar zeigt Suchfeld und Home-/Fit-Buttons',
(tester) async {
await _pumpWithContainer(tester);
expect(find.byType(TopNavBar), 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);
});
}