Route plans are their own entity (RoutePlan/Waypoint, schema v3), never joined into ride totals or the rides list. RoutePlanRepository recomputes straight-line distance after every waypoint mutation. RoutePlannerScreen supports tap-to-add, tap-to-delete, and hand-rolled drag-to-move via MapCamera's screen/latlng conversions. Fixed a real bug found by testing: FlutterMap's initialCenter/initialZoom are read once at construction, so building the map before the waypoints stream's first emission froze the camera on null-island. Also documents a genuine ten-minute test hang traced to Stream.first on a Drift watch() query inside testWidgets, which needs a pump-driven Timer that flutter_test's fake zone never advances on its own.
598 lines
23 KiB
Dart
598 lines
23 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/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/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 Ready and only START', (tester) async {
|
|
await tester.pumpWidget(host(const RecordScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Ready'), findsOneWidget);
|
|
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 tester.pumpWidget(host(const RecordScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
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('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 tester.pumpWidget(host(const RecordScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
final speed = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const Key('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));
|
|
});
|
|
|
|
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
|
|
var opened = false;
|
|
await tester.pumpWidget(
|
|
host(RecordScreen(onOpenSettings: () => opened = true)),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const Key('open-settings')), findsOneWidget);
|
|
await tester.tap(find.byKey(const Key('open-settings')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(opened, isTrue,
|
|
reason: 'the button must actually invoke the callback that navigates');
|
|
});
|
|
|
|
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
|
|
var opened = false;
|
|
await tester.pumpWidget(
|
|
host(RecordScreen(onOpenRoutes: () => opened = true)),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const Key('open-routes')), findsOneWidget);
|
|
await tester.tap(find.byKey(const Key('open-routes')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(opened, isTrue);
|
|
});
|
|
|
|
screenTest('the live map appears only while recording and the toggle is on '
|
|
'(V3-04)', (tester) async {
|
|
// Idle, toggle on: no trip to draw, so no map at all.
|
|
await tester.pumpWidget(host(const RecordScreen()));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(const Key('live-map')), findsNothing);
|
|
|
|
// Recording, toggle off: RecordingEngine has produced a trip, but the map must
|
|
// not be constructed at all -- not just hidden.
|
|
await repo.startTrip(1000);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
|
expect(find.byKey(const Key('live-map')), findsNothing);
|
|
expect(find.byType(FlutterMap), findsNothing);
|
|
|
|
// Recording, toggle on: the map is drawn.
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await pumpLive(tester, host(const RecordScreen()));
|
|
expect(find.byKey(const Key('live-map')), findsOneWidget);
|
|
});
|
|
|
|
screenTest('backgrounding the app drops the tile layer (V3-04)',
|
|
(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(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();
|
|
|
|
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();
|
|
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 tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
final speed = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const Key('speed')),
|
|
matching: find.text('0'),
|
|
),
|
|
);
|
|
expect(speed.style?.fontSize, 64 * mountedTextScale);
|
|
expect(speed.style?.color, isNot(ripprBackground),
|
|
reason: 'still legible, just against a different (lighter) ground');
|
|
|
|
final start = tester.getSize(find.byKey(const Key('start')));
|
|
expect(start.height, 96,
|
|
reason: 'V3-05: 72dp is not enough at speed, with gloves');
|
|
});
|
|
});
|
|
|
|
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.
|
|
expect(find.byType(Card), findsOneWidget);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
}
|