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 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}) => 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 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( 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(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); }); }); }