Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221. The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
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';
|
||||
@@ -66,13 +71,24 @@ void main() {
|
||||
///
|
||||
/// 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(
|
||||
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),
|
||||
);
|
||||
@@ -130,7 +146,7 @@ void main() {
|
||||
screenTest('recording swaps to PAUSE and STOP, with no DISCARD',
|
||||
(tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||
|
||||
expect(find.byKey(const Key('pause')), findsOneWidget);
|
||||
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||
@@ -143,7 +159,7 @@ void main() {
|
||||
screenTest('paused offers RESUME, STOP and DISCARD', (tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||
|
||||
expect(find.byKey(const Key('resume')), findsOneWidget);
|
||||
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||
@@ -154,7 +170,7 @@ void main() {
|
||||
screenTest('discard asks before destroying anything', (tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||
|
||||
await tester.tap(find.byKey(const Key('discard')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -186,6 +202,166 @@ void main() {
|
||||
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', () {
|
||||
@@ -282,6 +458,27 @@ void main() {
|
||||
|
||||
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', () {
|
||||
@@ -340,5 +537,121 @@ void main() {
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user