Files
rippr/test/widget_test.dart
uhryniuk 6f1bfc753a V3-01: activity type per ride
Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as
a column on Trip, and threads it through as real behaviour rather than a label:
ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy
gate, histogram bucket width, and elevation smoothing window/threshold per
activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator
and speedHistogram. The motorcycle profile reproduces the exact constants the
app shipped with before this existed, and a test asserts they never drift apart.

This is the port's first real migration: schemaVersion 1 -> 2,
m.addColumn(trips, trips.activity) with a motorcycle default so every existing
row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3,
not drift_dev's schema tooling) that a real database with real rides upgrades
and keeps every trip, segment and point.

No picker in front of Start: a new trip defaults to whichever activity was most
recently used, derived live from the trips table rather than duplicated into a
Config field. Editable afterwards on trip detail, which recomputes aggregates
under the new profile immediately -- a walking pace that reads as noise under a
motorcycle's floor reads as real movement once the activity is corrected, and
there's a test proving exactly that transition.

The widget-test pass for the activity-picker sheet caught a real overflow bug:
seven options overflowed a Column-based bottom sheet the same way the record
screen once did. Fixed with a scrollable ListView + isScrollControlled, same
shape as that earlier fix.

188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading
proofs in computeSummary, 5 repository, 2 widget. Analyze clean.

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

385 lines
15 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/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}) => 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),
],
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));
});
});
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);
});
});
}