Files
rippr/test/widget_test.dart
uhryniuk 37fba4784a V3-02 + V3-03: settings screen, and metric/imperial units
Built together because V3-02's settings screen needed something real for
V3-03's units to control -- executed in reverse of the ticket numbering, but
both tickets are independently complete.

V3-03: UnitSystem (metric/imperial) lives in domain/models.dart alongside
Activity, defaulting from Platform.localeName on first launch. Storage stays
SI everywhere -- conversion happens only in ui/format.dart, at the last
possible moment, which is now documented as the file's central invariant.
Threaded through all three screens plus the speed histogram's bucket labels,
which relabel for display without changing how speedHistogram itself bins.
Export was deliberately left untouched: gpx()/geoJson() take no UnitSystem
parameter at all, a stronger guarantee than validating one would be.

V3-02: SettingsScreen reachable from the record screen. Map render toggle
(nothing previously exposed mapEnabledProvider to the user, despite it
existing since T15 -- there was nothing to "move off trip detail" as drafted),
unit selector, upload endpoint with http(s) validation, a copyable device id,
and a licences page via Flutter's built-in showLicensePage. Skipped
package_info_plus (static version string instead) and a privacy-policy link
(none published yet) as disproportionate to an S-sized ticket.

Neither ticket needed the ConfigNotifier the implementation notes proposed:
unitSystemProvider reuses the exact StateProvider-seeded-from-Config pattern
mapEnabledProvider already established, since Config mutates its own backing
SharedPreferences in place and re-assigning the same instance would never
notify a watcher anyway.

A real locale-dependent flake was caught, not just anticipated: a test
asserting a fresh Config defaults to metric failed, because this machine's own
locale resolves to a region in the imperial set. Fixed by seeding an explicit
value before asserting, and config_test.dart's locale test was written from
the start to only prove the fallback path doesn't throw, not to assert which
value it returns.

221 tests (211 -> 221): 13 format_test, 9 config_test, 9 settings_screen_test,
1 confirming the record screen's settings button is genuinely wired. Analyze
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 14:23:43 -05:00

444 lines
17 KiB
Dart

import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.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/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,
}) => 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),
],
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()));
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()));
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()));
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');
});
});
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);
}
});
});
}