import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/app/providers.dart'; import 'package:rippr/src/data/database.dart'; import 'package:rippr/src/data/trip_repository.dart'; import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/hud/hud_metric.dart'; import 'package:rippr/src/ui/activity_display.dart'; import 'package:rippr/src/recording/location_source.dart'; import 'package:rippr/src/recording/wakelock_controller.dart'; import 'package:rippr/src/ui/app_shell.dart'; import 'package:rippr/src/ui/components/glass_panel.dart'; import 'package:rippr/src/ui/components/ride_map.dart'; import 'package:rippr/src/ui/detail/trip_detail_screen.dart'; import 'package:rippr/src/ui/format.dart'; import 'package:rippr/src/ui/record/record_screen.dart'; import 'package:rippr/src/ui/theme.dart'; import 'package:rippr/src/ui/trips/trips_screen.dart'; /// **The first UI tests this project has ever had.** /// /// `docs/v3/BACKLOG.md` names "zero UI tests across six screens" as v2's largest coverage /// gap: everything was verified by manual screenshot, which is how a 64 sp speed figure /// once shipped rendering black-on-black. Flutter makes these cheap enough that carrying /// that debt into the port would be indefensible. void main() { late AppDatabase db; late TripRepository repo; late FakeLocationSource source; setUp(() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; db = AppDatabase(NativeDatabase.memory()); repo = TripRepository(db); source = FakeLocationSource(); }); tearDown(() async { await source.dispose(); await db.close(); }); /// Runs a widget test and then tears the tree down cleanly. /// /// `flutter_test` asserts that no `Timer` is pending once the tree is disposed. Two /// things here legitimately hold one: the elapsed clock on the record screen, and /// Drift's stream-query cache, which keeps a query alive briefly after its last /// listener leaves so re-subscribing is cheap. Removing the tree cancels the first; /// pumping past the keep-alive window clears the second. void screenTest(String description, Future Function(WidgetTester) body) { testWidgets(description, (tester) async { await body(tester); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(seconds: 2)); }); } /// Pumps a screen that has a live ride on it. /// /// `pumpAndSettle` cannot be used once a ride is active: the elapsed clock ticks every /// second forever, so "settled" never arrives. Two explicit frames are enough to build /// and then reflect the streamed trip. Future pumpLive(WidgetTester tester, Widget widget) async { await tester.pumpWidget(widget); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); } /// Wraps a screen with the real theme and an in-memory stack. /// /// The theme is deliberately included: the black-on-black bug was invisible to logic /// tests and only a rendered widget can catch its equivalent. Widget host( Widget child, { bool map = true, UnitSystem units = UnitSystem.metric, bool mountedMode = false, FakeWakelockController? wakelock, }) => ProviderScope( overrides: [ databaseProvider.overrideWithValue(db), locationSourceProvider.overrideWithValue(source), // The map is off by default in tests that are not about the map: it fetches // tiles, which a widget test cannot serve, and it changes scroll geometry. mapEnabledProvider.overrideWith((ref) => map), unitSystemProvider.overrideWith((ref) => units), mountedModeProvider.overrideWith((ref) => mountedMode), wakelockControllerProvider.overrideWithValue( wakelock ?? FakeWakelockController(), ), ], child: MaterialApp(theme: ripprTheme(), home: child), ); Future seedCompletedTrip({ required int startedAt, required int endedAt, String? name, int points = 5, double startLat = 51.0, }) async { final h = await repo.startTrip(startedAt); await repo.appendPoints([ for (var i = 0; i < points; i++) TrackPoint( tripId: h.tripId, segmentId: h.segmentId, timestamp: startedAt + i * 1000, latitude: startLat + i * 0.0001, longitude: -114.0, speedKmh: 40.0 + i, altitudeM: 1000.0 + i, ), ]); if (name != null) await repo.renameTrip(h.tripId, name); await repo.completeTrip(endedAt); await repo.recomputeAggregates(h.tripId); return h.tripId; } group('record screen', () { screenTest('idle shows only START', (tester) async { await tester.pumpWidget(host(const RecordScreen())); await tester.pumpAndSettle(); expect(find.byKey(const Key('start')), findsOneWidget); expect(find.byKey(const Key('pause')), findsNothing); expect(find.byKey(const Key('stop')), findsNothing); // A resting screen must not look like a ride going nowhere. expect(find.text('Distance'), findsNothing); }); screenTest('the headline is live speed, not max speed', (tester) async { // v2.0 shipped max speed as the headline and it read as a frozen, broken screen on // a real ride, because a max figure only moves when you beat it. This guards the // 2.0.1 fix. await repo.startTrip(1000); await pumpLive(tester, host(const RecordScreen(), map: false)); expect(find.text('SPEED'), findsOneWidget); expect(find.text('MAX SPEED'), findsNothing); }); screenTest('recording swaps to PAUSE and STOP, with no DISCARD', (tester) async { await repo.startTrip(1000); await pumpLive(tester, host(const RecordScreen(), map: false)); expect(find.byKey(const Key('pause')), findsOneWidget); expect(find.byKey(const Key('stop')), findsOneWidget); // Destructive actions must not sit next to Pause during a live ride — a gloved // mis-tap at speed would lose the whole recording. expect(find.byKey(const Key('discard')), findsNothing); expect(find.byKey(const Key('start')), findsNothing); }); screenTest('paused offers RESUME, STOP and DISCARD', (tester) async { await repo.startTrip(1000); await repo.pauseTrip(2000); await pumpLive(tester, host(const RecordScreen(), map: false)); expect(find.byKey(const Key('resume')), findsOneWidget); expect(find.byKey(const Key('stop')), findsOneWidget); expect(find.byKey(const Key('discard')), findsOneWidget); expect(find.text('Paused'), findsOneWidget); }); screenTest('discard asks before destroying anything', (tester) async { await repo.startTrip(1000); await repo.pauseTrip(2000); await pumpLive(tester, host(const RecordScreen(), map: false)); await tester.tap(find.byKey(const Key('discard'))); await tester.pumpAndSettle(); expect(find.text('Discard this ride?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(await repo.activeTrip(), isNotNull, reason: 'cancelling must not delete the ride'); }); screenTest('tapping START actually starts a trip (UI-05)', (tester) async { await tester.pumpWidget(host(const RecordScreen(), map: false)); await tester.pumpAndSettle(); expect(await repo.activeTrip(), isNull); await tester.tap(find.byKey(const Key('start'))); await tester.pump(); expect(await repo.activeTrip(), isNotNull, reason: 'the new icon-only control bar must still drive the same engine ' 'call the old text button did'); }); screenTest('tapping PAUSE and then STOP actually pauses and completes the trip ' '(UI-05)', (tester) async { await repo.startTrip(1000); await pumpLive(tester, host(const RecordScreen(), map: false)); await tester.tap(find.byKey(const Key('pause'))); await tester.pump(); final paused = await repo.activeTrip(); expect(paused?.state, TripState.paused); await tester.tap(find.byKey(const Key('stop'))); await tester.pump(); expect(await repo.activeTrip(), isNull, reason: 'stopping completes the trip -- it is no longer the active one'); }); screenTest('HUD widgets render over the map, not replacing it (UI-05)', (tester) async { await repo.startTrip(1000); await pumpLive( tester, host( ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()), ), ); expect(find.byKey(const Key('shell-background-map')), findsOneWidget, reason: 'the map is still there, underneath the HUD'); expect(find.text('SPEED'), findsOneWidget); expect(find.text('DISTANCE'), findsOneWidget); }); screenTest('text is legible against the dark ground', (tester) async { // The regression this exists for: removing Compose's Surface left LocalContentColor // black, and a 64sp figure rendered invisibly on a near-black background. No logic // test could catch it. This asserts the rendered colour differs from the ground. await repo.startTrip(1000); await pumpLive(tester, host(const RecordScreen(), map: false)); final speed = tester.widget( find.descendant( of: find.byKey(const ValueKey(HudMetric.speed)), matching: find.text('0'), ), ); final colour = speed.style?.color; expect(colour, isNotNull, reason: 'the headline must name its colour'); expect(colour, isNot(ripprBackground)); expect(colour, isNot(Colors.black)); }); // UI-01: Settings/Routes/Rides entry points are no longer per-screen callback // buttons on RecordScreen -- they're destinations on the shell's persistent bottom // nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage. screenTest('the background map appears only while the map toggle is on ' '(V3-04, moved to the shell by UI-01)', (tester) async { // Toggle off: the shell must not construct the map at all -- not just hidden. await repo.startTrip(1000); await pumpLive( tester, host( ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()), map: false, ), ); expect(find.byKey(const Key('shell-background-map')), findsNothing); expect(find.byType(FlutterMap), findsNothing); // Toggle on: the map is drawn. await tester.pumpWidget(const SizedBox.shrink()); await pumpLive( tester, host( ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()), ), ); expect(find.byKey(const Key('shell-background-map')), findsOneWidget); }); screenTest('backgrounding the app drops the tile layer (V3-04, moved to the ' 'shell by UI-01)', (tester) async { final h = await repo.startTrip(1000); await repo.appendPoints([ TrackPoint( tripId: h.tripId, segmentId: h.segmentId, timestamp: 1000, latitude: 51.0, longitude: -114.0, speedKmh: 20.0, altitudeM: 1000.0, ), ]); await pumpLive( tester, host( ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()), ), ); expect(find.byType(TileLayer), findsOneWidget, reason: 'foregrounded: tiles render normally'); // Simulates the platform lifecycle message a real backgrounding sends -- this is // the standard way to drive AppLifecycleState changes in a widget test. final message = const StringCodec().encodeMessage('AppLifecycleState.paused'); await tester.binding.defaultBinaryMessenger .handlePlatformMessage('flutter/lifecycle', message, (_) {}); await tester.pump(); await tester.pump(); expect(find.byType(TileLayer), findsNothing, reason: 'backgrounded: no tile layer means no tile request can fire'); expect(find.byType(PolylineLayer), findsOneWidget, reason: 'the drawn path itself is not removed, only tile fetching'); final resumed = const StringCodec().encodeMessage('AppLifecycleState.resumed'); await tester.binding.defaultBinaryMessenger .handlePlatformMessage('flutter/lifecycle', resumed, (_) {}); await tester.pump(); await tester.pump(); expect(find.byType(TileLayer), findsOneWidget, reason: 'foregrounding again must resume tiles'); }); screenTest('mounted mode requests the wake lock while recording and releases ' 'it on stop (V3-05)', (tester) async { final wakelock = FakeWakelockController(); await repo.startTrip(1000); await pumpLive( tester, host(const RecordScreen(), mountedMode: true, wakelock: wakelock), ); expect(wakelock.enabled, isTrue, reason: 'recording, mounted: the screen must not sleep'); await repo.completeTrip(2000); await tester.pump(); expect(wakelock.enabled, isFalse, reason: 'the lock must not survive the ride ending'); }); screenTest('un-mounted, the wake lock is never requested (V3-05)', (tester) async { final wakelock = FakeWakelockController(); await repo.startTrip(1000); await pumpLive( tester, host(const RecordScreen(), mountedMode: false, wakelock: wakelock), ); expect(wakelock.enableCalls, 0, reason: 'un-mounted behaviour must be exactly as before this ticket'); }); screenTest('leaving the record screen releases the lock even if the ride is ' 'still active (V3-05)', (tester) async { final wakelock = FakeWakelockController(); await repo.startTrip(1000); await pumpLive( tester, host(const RecordScreen(), mountedMode: true, wakelock: wakelock), ); expect(wakelock.enabled, isTrue); // Navigating away must not leak the lock -- this is the ticket's named risk: a // leak flattens the battery silently, after the screen that acquired it is gone. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(wakelock.enabled, isFalse); expect(wakelock.disableCalls, greaterThan(0)); }); screenTest('the mounted theme is high-contrast and text scales up (V3-05)', (tester) async { await repo.startTrip(1000); await pumpLive( tester, host(const RecordScreen(), map: false, mountedMode: true), ); final speed = tester.widget( find.descendant( of: find.byKey(const ValueKey(HudMetric.speed)), matching: find.text('0'), ), ); expect(speed.style?.fontSize, 18 * mountedTextScale); expect(speed.style?.color, isNot(ripprBackground), reason: 'still legible, just against a different (lighter) ground'); final pause = tester.getSize(find.byKey(const Key('pause'))); expect(pause.height, 96, reason: 'V3-05: 72dp is not enough at speed, with gloves'); }); screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own ' 'translucent surface, not just different from the dark ground (UI-05)', (tester) async { // UI-05's own named risk: mounted mode was built and tested against an opaque // Card, not a blurred, translucent GlassPanel -- a light theme through blurred // content behaves differently than the dark-on-dark case V3-05 originally // guarded against. This asserts the real contrast ratio, not just "differs from // the wrong ground" the way the test above does. await repo.startTrip(1000); await pumpLive( tester, host(const RecordScreen(), map: false, mountedMode: true), ); final speed = tester.widget( find.descendant( of: find.byKey(const ValueKey(HudMetric.speed)), matching: find.text('0'), ), ); final panel = tester.widget( find.descendant( of: find.byKey(const ValueKey(HudMetric.speed)), matching: find.byType(GlassPanel), ), ); final mountedColors = ripprMountedTheme().colorScheme; // GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it // is a solid, near-opaque fill (not a transparency composited over unknown // content), the panel's own surface colour is what the text is actually read // against in practice. expect(panel.child, isNotNull); expect( contrastRatio(speed.style!.color!, mountedColors.surface), greaterThanOrEqualTo(4.5), reason: 'AA normal text against GlassPanel\'s mounted-theme surface', ); }); }); group('shell nav bar', () { // UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on // RecordScreen -- they're destinations on the shell's persistent bottom nav bar. // This replaces the old "a settings/routes entry point exists and is wired" // per-screen tests. screenTest('all four destinations are present and switching tabs calls back ' 'with the tapped index', (tester) async { var lastIndex = -1; await tester.pumpWidget(host( ShellScaffold( currentIndex: 0, onDestinationSelected: (i) => lastIndex = i, child: const RecordScreen(), ), map: false, )); await tester.pumpAndSettle(); expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget); expect(find.byKey(const Key('nav-map')), findsOneWidget); expect(find.byKey(const Key('nav-rides')), findsOneWidget); expect(find.byKey(const Key('nav-plan')), findsOneWidget); expect(find.byKey(const Key('nav-settings')), findsOneWidget); await tester.tap(find.byKey(const Key('nav-settings'))); expect(lastIndex, 3); await tester.tap(find.byKey(const Key('nav-rides'))); expect(lastIndex, 1); await tester.tap(find.byKey(const Key('nav-plan'))); expect(lastIndex, 2); }); }); group('ambient position (FB-01)', () { screenTest('with no active trip, an ambient GPS fix drives the shared ' 'background map', (tester) async { await tester.pumpWidget(host( ShellScaffold( currentIndex: 0, onDestinationSelected: (_) {}, child: const SizedBox.shrink(), ), )); // A frame to build the tree, then time for `ambientPositionProvider`'s // `await source.start()` to resolve and subscribe to `source.fixes`. await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); // No fix yet: still today's neutral fallback, no marker. expect(find.byKey(const Key('location-marker')), findsNothing); source.emitAt(timestamp: 1000, latitude: 51.2, longitude: -114.2); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(const Key('location-marker')), findsOneWidget, reason: 'an ambient fix drives the pulsing marker even with no trip ' 'ever recorded'); final map = tester.widget(find.byType(FlutterMap)); final center = map.mapController!.camera.center; expect(center.latitude, closeTo(51.2, 1e-9)); expect(center.longitude, closeTo(-114.2, 1e-9)); // A second fix keeps the map following, exactly like the recorded-path chase. source.emitAt(timestamp: 2000, latitude: 51.21, longitude: -114.21); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); final moved = tester.widget(find.byType(FlutterMap)); final movedCenter = moved.mapController!.camera.center; expect(movedCenter.latitude, closeTo(51.21, 1e-9)); expect(movedCenter.longitude, closeTo(-114.21, 1e-9)); }); screenTest('once a trip starts recording, the shell stops watching ambient ' 'GPS -- no duplicate location consumer during a ride', (tester) async { // Idle first: the ambient provider engages and calls start() at least once // (activeTripProvider's own first, async emission of `null` briefly reads // as "idle" too, which is expected -- same shape as `points`/`segments` // falling back to empty lists until their streams first emit). await tester.pumpWidget(host( ShellScaffold( currentIndex: 0, onDestinationSelected: (_) {}, child: const SizedBox.shrink(), ), )); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); source.emitAt(timestamp: 1000, latitude: 51.0, longitude: -114.0); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(const Key('location-marker')), findsOneWidget, reason: 'sanity check: ambient mode is actually engaged before the ' 'trip starts'); final callsWhileIdle = source.startCalls; expect(callsWhileIdle, greaterThanOrEqualTo(1)); // Now a trip starts recording. Nothing in this test ever taps Start, so the // only thing that could call `LocationSource.start()` again is // `ambientPositionProvider` -- and it must not, because `ShellScaffold` // stops watching it entirely the moment `activeTripProvider` is non-null. await repo.startTrip(2000); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(source.startCalls, callsWhileIdle, reason: 'once a trip is active, ambientPositionProvider must no ' 'longer be watched at all'); }); }); group('trips list', () { screenTest('empty state explains what to do', (tester) async { await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); expect(find.textContaining('No rides yet'), findsOneWidget); }); screenTest('completed rides are listed newest first', (tester) async { await seedCompletedTrip( startedAt: 1000, endedAt: 5000, name: 'Older ride'); await seedCompletedTrip( startedAt: 100000, endedAt: 200000, name: 'Newer ride'); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); final newer = tester.getTopLeft(find.text('Newer ride')).dy; final older = tester.getTopLeft(find.text('Older ride')).dy; expect(newer, lessThan(older)); }); screenTest('an active ride does not appear in the list', (tester) async { await seedCompletedTrip( startedAt: 1000, endedAt: 5000, name: 'Finished'); await repo.startTrip(100000); await tester.pumpWidget(host(const TripsScreen())); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(const Key('trip-1')), findsOneWidget); expect(find.text('Finished'), findsOneWidget); // Only the completed one. UI-07: trip cards are GlassPanel-based, not Card -- // RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which // other chrome on this screen also uses). expect(find.byType(RideMap), findsOneWidget); }); screenTest('search narrows the visible list to matching rides (UI-07)', (tester) async { await seedCompletedTrip( startedAt: 1000, endedAt: 5000, name: 'Coast loop'); await seedCompletedTrip( startedAt: 100000, endedAt: 200000, name: 'Mountain climb'); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); expect(find.text('Coast loop'), findsOneWidget); expect(find.text('Mountain climb'), findsOneWidget); await tester.enterText(find.byKey(const Key('ride-search')), 'coast'); await tester.pump(); expect(find.text('Coast loop'), findsOneWidget); expect(find.text('Mountain climb'), findsNothing); }); screenTest('filtering by activity narrows the visible list (UI-07)', (tester) async { await repo.startTrip(1000, activity: Activity.bicycle).then( (h) async { await repo.appendPoints([ TrackPoint( tripId: h.tripId, segmentId: h.segmentId, timestamp: 1000, latitude: 51.0, longitude: -114.0, speedKmh: 20, altitudeM: 1000, ), ]); await repo.renameTrip(h.tripId, 'Bike ride'); await repo.completeTrip(5000); await repo.recomputeAggregates(h.tripId); }); await repo.startTrip(100000, activity: Activity.motorcycle).then( (h) async { await repo.appendPoints([ TrackPoint( tripId: h.tripId, segmentId: h.segmentId, timestamp: 100000, latitude: 51.0, longitude: -114.0, speedKmh: 60, altitudeM: 1000, ), ]); await repo.renameTrip(h.tripId, 'Moto ride'); await repo.completeTrip(200000); await repo.recomputeAggregates(h.tripId); }); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); expect(find.text('Bike ride'), findsOneWidget); expect(find.text('Moto ride'), findsOneWidget); await tester.tap(find.byKey(const Key('activity-filter'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); await tester.tap(find.byKey(const Key('activity-filter-bicycle'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); expect(find.text('Bike ride'), findsOneWidget); expect(find.text('Moto ride'), findsNothing); }); screenTest('Merge enables at exactly two selections', (tester) async { final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); final b = await seedCompletedTrip(startedAt: 10000, endedAt: 15000); final c = await seedCompletedTrip(startedAt: 20000, endedAt: 25000); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); // One selected: merge is present but disabled. await tester.longPress(find.byKey(Key('trip-$a'))); await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('merge'))).onPressed, isNull, reason: 'merging one ride is meaningless', ); // Two: enabled. await tester.tap(find.byKey(Key('trip-$b'))); await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('merge'))).onPressed, isNotNull, ); // Three: ambiguous, so disabled again. await tester.tap(find.byKey(Key('trip-$c'))); await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('merge'))).onPressed, isNull, reason: 'merge combines exactly two rides', ); }); screenTest('deleting asks first and then removes the ride', (tester) async { final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); await tester.longPress(find.byKey(Key('trip-$a'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('delete'))); await tester.pumpAndSettle(); expect(find.textContaining('Delete 1 ride'), findsOneWidget); // "Delete" appears both in the toolbar and in the dialog; target the dialog's. await tester.tap(find.descendant( of: find.byType(AlertDialog), matching: find.text('Delete'), )); await tester.pumpAndSettle(); expect(await repo.tripById(a), isNull); }); screenTest('a tile shows its own activity icon (V3-01)', (tester) async { final motorcycleTrip = await seedCompletedTrip(startedAt: 1000, endedAt: 5000, name: 'Ride'); final bikeTrip = await seedCompletedTrip(startedAt: 10000, endedAt: 15000, name: 'Bike'); await repo.setActivity(bikeTrip, Activity.bicycle); await tester.pumpWidget(host(const TripsScreen())); await tester.pumpAndSettle(); Icon iconFor(int tripId) => tester.widget( find.descendant( of: find.byKey(Key('trip-$tripId')), matching: find.byType(Icon), ), ); expect(iconFor(motorcycleTrip).icon, activityIcon(Activity.motorcycle)); expect(iconFor(bikeTrip).icon, activityIcon(Activity.bicycle)); }); }); group('trip detail', () { screenTest('shows the authoritative summary', (tester) async { final id = await seedCompletedTrip( startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11); await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); expect(find.text('Sunday blast'), findsOneWidget); expect(find.text('DISTANCE'), findsOneWidget); expect(find.text('Max speed'), findsOneWidget); expect(find.text('Segments'), findsOneWidget); }); screenTest('a missing ride says so instead of rendering blank', (tester) async { await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false)); await tester.pumpAndSettle(); expect(find.textContaining('no longer exists'), findsOneWidget); }); screenTest('charts degrade gracefully below two points', (tester) async { final id = await seedCompletedTrip( startedAt: 1000, endedAt: 2000, points: 1); await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); // Both charts need intervals; one point has none. An unexplained blank box would // read as a bug. // // The detail body is a ListView, so the second chart is not built until it is // scrolled into view -- asserting without scrolling would silently check only one. expect(find.textContaining('Not enough points'), findsOneWidget); await tester.drag(find.byType(ListView), const Offset(0, -600)); await tester.pumpAndSettle(); expect(find.textContaining('Not enough points'), findsWidgets); expect(find.text('Elevation profile'.toUpperCase()), findsOneWidget); }); screenTest('renaming updates the title', (tester) async { final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rename'))); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('name-field')), 'Coast run'); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(find.text('Coast run'), findsOneWidget); expect((await repo.tripById(id))!.name, 'Coast run'); }); screenTest('editing the activity updates the row and recomputes aggregates', (tester) async { final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); expect(find.text(activityLabel(Activity.motorcycle)), findsOneWidget); await tester.tap(find.byKey(const Key('activity-row'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(Key('activity-${Activity.walking.name}'))); await tester.pumpAndSettle(); expect(find.text(activityLabel(Activity.walking)), findsOneWidget); expect((await repo.tripById(id))!.activity, Activity.walking); }); screenTest('switching units changes the rendered distance label (V3-03)', (tester) async { final id = await seedCompletedTrip( startedAt: 1000, endedAt: 5000, points: 11); final trip = (await repo.tripById(id))!; await tester.pumpWidget( host(TripDetailScreen(tripId: id), map: false), ); await tester.pumpAndSettle(); final (metricValue, metricUnit) = formatDistanceParts(trip.distanceM); expect(find.text(metricValue), findsWidgets); // BigStat renders its unit as " $unit", with a leading space, next to the figure. expect(find.text(' $metricUnit'), findsOneWidget); // A full unmount first: Riverpod's ProviderScope does not reliably re-seed a // StateProvider's initial value on an override change alone if the same // container survives the rebuild, so pumping a second host() directly on top of // the first would silently keep reading the metric container. await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget( host(TripDetailScreen(tripId: id), map: false, units: UnitSystem.imperial), ); await tester.pumpAndSettle(); final (imperialValue, imperialUnit) = formatDistanceParts( trip.distanceM, unit: UnitSystem.imperial, ); expect(find.text(' $imperialUnit'), findsOneWidget); expect(imperialUnit, isNot(metricUnit)); // A short 11-point ride at ~11 m hops is short enough that the two rounded values // could coincidentally match as text; the unit label changing is what this test is // really proving, but assert the value differs too when it is safe to. if (metricValue != imperialValue) { expect(find.text(imperialValue), findsWidgets); } }); screenTest('a single-segment ride explains why it cannot be split (V3-10)', (tester) async { final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('split'))); await tester.pumpAndSettle(); expect(find.textContaining('nothing to split'), findsOneWidget); expect(await repo.segmentsForTrip(id), hasLength(1), reason: 'a rejected split must not touch anything'); }); screenTest('splitting a multi-segment ride creates a second ride (V3-10)', (tester) async { final h = await repo.startTrip(1000); await repo.appendPoints([ TrackPoint( tripId: h.tripId, segmentId: h.segmentId, timestamp: 1000, latitude: 51.0, longitude: -114.0, speedKmh: 40.0, altitudeM: 1000.0, ), ]); await repo.pauseTrip(2000); final h2 = (await repo.resumeTrip(3000))!; await repo.appendPoints([ TrackPoint( tripId: h2.tripId, segmentId: h2.segmentId, timestamp: 3000, latitude: 52.0, longitude: -114.0, speedKmh: 40.0, altitudeM: 1000.0, ), ]); await repo.completeTrip(4000); final segments = await repo.segmentsForTrip(h.tripId); expect(segments, hasLength(2)); await tester.pumpWidget(host(TripDetailScreen(tripId: h.tripId), map: false)); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('split'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(Key('split-at-${segments[1].id}'))); await tester.pumpAndSettle(); await tester.tap(find.text('Split')); await tester.pumpAndSettle(); expect(await db.countTrips(), 2); expect(await repo.segmentsForTrip(h.tripId), hasLength(1)); }); }); group('ambientPositionProvider (FB-01)', () { // Provider-level, not widget-level: exercises the provider directly against a // `ProviderContainer` so its lifecycle (including disposal) can be driven // precisely, per the ticket's own suggested test shape. test('never calls LocationSource.stop() -- the underlying source is shared ' 'with an active recording and stop() is not reference-counted', () async { final fakeSource = FakeLocationSource(); final container = ProviderContainer( overrides: [locationSourceProvider.overrideWithValue(fakeSource)], ); addTearDown(container.dispose); final sub = container.listen(ambientPositionProvider, (_, _) {}); // Let the provider's `await source.start()` resolve and subscribe. await Future.delayed(Duration.zero); expect(fakeSource.startCalls, 1); sub.close(); container.dispose(); expect(fakeSource.stopCalls, 0, reason: 'ambientPositionProvider must only ever call start(), never ' 'stop(), on the shared LocationSource'); await fakeSource.dispose(); }); test('turning mapEnabledProvider off never starts location and yields null', () async { final fakeSource = FakeLocationSource(); final container = ProviderContainer( overrides: [ locationSourceProvider.overrideWithValue(fakeSource), mapEnabledProvider.overrideWith((ref) => false), ], ); addTearDown(container.dispose); final values = >[]; final sub = container.listen( ambientPositionProvider, (_, next) => values.add(next), fireImmediately: true, ); await Future.delayed(Duration.zero); expect(fakeSource.startCalls, 0, reason: 'no tile or location request should fire while the map is ' 'disabled, matching the existing map-toggle guarantee'); expect(values.last.valueOrNull, isNull); sub.close(); await fakeSource.dispose(); }); }); }