Files
rippr/test/widget_test.dart
uhryniuk 9455574fbf Merge FB-02: hide idle Speed panel
# Conflicts:
#	docs/feedback/FB-02-hide-idle-speed-panel.md
2026-08-24 16:01:45 -05:00

981 lines
38 KiB
Dart

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<void> 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<void> 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<int> 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<Text>(
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<Text>(
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<Text>(
find.descendant(
of: find.byKey(const ValueKey(HudMetric.speed)),
matching: find.text('0'),
),
);
final panel = tester.widget<GlassPanel>(
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<FlutterMap>(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<FlutterMap>(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<TextButton>(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<TextButton>(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<TextButton>(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<Icon>(
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<void>.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 = <AsyncValue<LocationFix?>>[];
final sub = container.listen(
ambientPositionProvider,
(_, next) => values.add(next),
fireImmediately: true,
);
await Future<void>.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();
});
});
}