Adds ambientPositionProvider (never calls LocationSource.stop(), only the idempotent start()) and wires it into ShellScaffold whenever no trip is active, so the persistent background map centers and follows the device's live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2 until a recording starts. RideMap gains an ambientPosition param that the existing chase-camera/pan-cancel/location-marker logic falls back to whenever there are no recorded points, with recorded points always taking priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README, FEEDBACK.md) that this worktree's branch point predated. Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/ marker coverage in test/ride_map_test.dart, plus shell-wiring and provider-level (never-calls-stop, mapEnabledProvider-off) coverage in test/widget_test.dart. flutter analyze remains clean.
967 lines
38 KiB
Dart
967 lines
38 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/app_shell.dart';
|
|
import 'package:rippr/src/ui/components/glass_panel.dart';
|
|
import 'package:rippr/src/ui/components/ride_map.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('tapping START actually starts a trip (UI-05)', (tester) async {
|
|
await tester.pumpWidget(host(const RecordScreen(), map: false));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(await repo.activeTrip(), isNull);
|
|
await tester.tap(find.byKey(const Key('start')));
|
|
await tester.pump();
|
|
|
|
expect(await repo.activeTrip(), isNotNull,
|
|
reason: 'the new icon-only control bar must still drive the same engine '
|
|
'call the old text button did');
|
|
});
|
|
|
|
screenTest('tapping PAUSE and then STOP actually pauses and completes the trip '
|
|
'(UI-05)', (tester) async {
|
|
await repo.startTrip(1000);
|
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
|
|
|
await tester.tap(find.byKey(const Key('pause')));
|
|
await tester.pump();
|
|
final paused = await repo.activeTrip();
|
|
expect(paused?.state, TripState.paused);
|
|
|
|
await tester.tap(find.byKey(const Key('stop')));
|
|
await tester.pump();
|
|
expect(await repo.activeTrip(), isNull,
|
|
reason: 'stopping completes the trip -- it is no longer the active one');
|
|
});
|
|
|
|
screenTest('HUD widgets render over the map, not replacing it (UI-05)',
|
|
(tester) async {
|
|
await repo.startTrip(1000);
|
|
await pumpLive(
|
|
tester,
|
|
host(
|
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
|
),
|
|
);
|
|
|
|
expect(find.byKey(const Key('shell-background-map')), findsOneWidget,
|
|
reason: 'the map is still there, underneath the HUD');
|
|
expect(find.text('SPEED'), findsOneWidget);
|
|
expect(find.text('DISTANCE'), findsOneWidget);
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
// UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
|
|
// buttons on RecordScreen -- they're destinations on the shell's persistent bottom
|
|
// nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
|
|
|
|
screenTest('the background map appears only while the map toggle is on '
|
|
'(V3-04, moved to the shell by UI-01)', (tester) async {
|
|
// Toggle off: the shell must not construct the map at all -- not just hidden.
|
|
await repo.startTrip(1000);
|
|
await pumpLive(
|
|
tester,
|
|
host(
|
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
|
map: false,
|
|
),
|
|
);
|
|
expect(find.byKey(const Key('shell-background-map')), findsNothing);
|
|
expect(find.byType(FlutterMap), findsNothing);
|
|
|
|
// Toggle on: the map is drawn.
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await pumpLive(
|
|
tester,
|
|
host(
|
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
|
),
|
|
);
|
|
expect(find.byKey(const Key('shell-background-map')), findsOneWidget);
|
|
});
|
|
|
|
screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
|
|
'shell by UI-01)', (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(
|
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: 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();
|
|
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();
|
|
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');
|
|
});
|
|
|
|
screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own '
|
|
'translucent surface, not just different from the dark ground (UI-05)',
|
|
(tester) async {
|
|
// UI-05's own named risk: mounted mode was built and tested against an opaque
|
|
// Card, not a blurred, translucent GlassPanel -- a light theme through blurred
|
|
// content behaves differently than the dark-on-dark case V3-05 originally
|
|
// guarded against. This asserts the real contrast ratio, not just "differs from
|
|
// the wrong ground" the way the test above does.
|
|
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')),
|
|
);
|
|
final panel = tester.widget<GlassPanel>(find.byType(GlassPanel).first);
|
|
final mountedColors = ripprMountedTheme().colorScheme;
|
|
// GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it
|
|
// is a solid, near-opaque fill (not a transparency composited over unknown
|
|
// content), the panel's own surface colour is what the text is actually read
|
|
// against in practice.
|
|
expect(panel.child, isNotNull);
|
|
expect(
|
|
contrastRatio(speed.style!.color!, mountedColors.surface),
|
|
greaterThanOrEqualTo(4.5),
|
|
reason: 'AA normal text against GlassPanel\'s mounted-theme surface',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('shell nav bar', () {
|
|
// UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on
|
|
// RecordScreen -- they're destinations on the shell's persistent bottom nav bar.
|
|
// This replaces the old "a settings/routes entry point exists and is wired"
|
|
// per-screen tests.
|
|
screenTest('all four destinations are present and switching tabs calls back '
|
|
'with the tapped index', (tester) async {
|
|
var lastIndex = -1;
|
|
await tester.pumpWidget(host(
|
|
ShellScaffold(
|
|
currentIndex: 0,
|
|
onDestinationSelected: (i) => lastIndex = i,
|
|
child: const RecordScreen(),
|
|
),
|
|
map: false,
|
|
));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget);
|
|
expect(find.byKey(const Key('nav-map')), findsOneWidget);
|
|
expect(find.byKey(const Key('nav-rides')), findsOneWidget);
|
|
expect(find.byKey(const Key('nav-plan')), findsOneWidget);
|
|
expect(find.byKey(const Key('nav-settings')), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const Key('nav-settings')));
|
|
expect(lastIndex, 3);
|
|
|
|
await tester.tap(find.byKey(const Key('nav-rides')));
|
|
expect(lastIndex, 1);
|
|
|
|
await tester.tap(find.byKey(const Key('nav-plan')));
|
|
expect(lastIndex, 2);
|
|
});
|
|
});
|
|
|
|
group('ambient position (FB-01)', () {
|
|
screenTest('with no active trip, an ambient GPS fix drives the shared '
|
|
'background map', (tester) async {
|
|
await tester.pumpWidget(host(
|
|
ShellScaffold(
|
|
currentIndex: 0,
|
|
onDestinationSelected: (_) {},
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
));
|
|
// A frame to build the tree, then time for `ambientPositionProvider`'s
|
|
// `await source.start()` to resolve and subscribe to `source.fixes`.
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
// No fix yet: still today's neutral fallback, no marker.
|
|
expect(find.byKey(const Key('location-marker')), findsNothing);
|
|
|
|
source.emitAt(timestamp: 1000, latitude: 51.2, longitude: -114.2);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
expect(find.byKey(const Key('location-marker')), findsOneWidget,
|
|
reason: 'an ambient fix drives the pulsing marker even with no trip '
|
|
'ever recorded');
|
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
|
final center = map.mapController!.camera.center;
|
|
expect(center.latitude, closeTo(51.2, 1e-9));
|
|
expect(center.longitude, closeTo(-114.2, 1e-9));
|
|
|
|
// A second fix keeps the map following, exactly like the recorded-path chase.
|
|
source.emitAt(timestamp: 2000, latitude: 51.21, longitude: -114.21);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
final moved = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
|
final movedCenter = moved.mapController!.camera.center;
|
|
expect(movedCenter.latitude, closeTo(51.21, 1e-9));
|
|
expect(movedCenter.longitude, closeTo(-114.21, 1e-9));
|
|
});
|
|
|
|
screenTest('once a trip starts recording, the shell stops watching ambient '
|
|
'GPS -- no duplicate location consumer during a ride', (tester) async {
|
|
// Idle first: the ambient provider engages and calls start() at least once
|
|
// (activeTripProvider's own first, async emission of `null` briefly reads
|
|
// as "idle" too, which is expected -- same shape as `points`/`segments`
|
|
// falling back to empty lists until their streams first emit).
|
|
await tester.pumpWidget(host(
|
|
ShellScaffold(
|
|
currentIndex: 0,
|
|
onDestinationSelected: (_) {},
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
source.emitAt(timestamp: 1000, latitude: 51.0, longitude: -114.0);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
expect(find.byKey(const Key('location-marker')), findsOneWidget,
|
|
reason: 'sanity check: ambient mode is actually engaged before the '
|
|
'trip starts');
|
|
final callsWhileIdle = source.startCalls;
|
|
expect(callsWhileIdle, greaterThanOrEqualTo(1));
|
|
|
|
// Now a trip starts recording. Nothing in this test ever taps Start, so the
|
|
// only thing that could call `LocationSource.start()` again is
|
|
// `ambientPositionProvider` -- and it must not, because `ShellScaffold`
|
|
// stops watching it entirely the moment `activeTripProvider` is non-null.
|
|
await repo.startTrip(2000);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
expect(source.startCalls, callsWhileIdle,
|
|
reason: 'once a trip is active, ambientPositionProvider must no '
|
|
'longer be watched at all');
|
|
});
|
|
});
|
|
|
|
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. UI-07: trip cards are GlassPanel-based, not Card --
|
|
// RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which
|
|
// other chrome on this screen also uses).
|
|
expect(find.byType(RideMap), findsOneWidget);
|
|
});
|
|
|
|
screenTest('search narrows the visible list to matching rides (UI-07)',
|
|
(tester) async {
|
|
await seedCompletedTrip(
|
|
startedAt: 1000, endedAt: 5000, name: 'Coast loop');
|
|
await seedCompletedTrip(
|
|
startedAt: 100000, endedAt: 200000, name: 'Mountain climb');
|
|
|
|
await tester.pumpWidget(host(const TripsScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Coast loop'), findsOneWidget);
|
|
expect(find.text('Mountain climb'), findsOneWidget);
|
|
|
|
await tester.enterText(find.byKey(const Key('ride-search')), 'coast');
|
|
await tester.pump();
|
|
|
|
expect(find.text('Coast loop'), findsOneWidget);
|
|
expect(find.text('Mountain climb'), findsNothing);
|
|
});
|
|
|
|
screenTest('filtering by activity narrows the visible list (UI-07)',
|
|
(tester) async {
|
|
await repo.startTrip(1000, activity: Activity.bicycle).then(
|
|
(h) async {
|
|
await repo.appendPoints([
|
|
TrackPoint(
|
|
tripId: h.tripId,
|
|
segmentId: h.segmentId,
|
|
timestamp: 1000,
|
|
latitude: 51.0,
|
|
longitude: -114.0,
|
|
speedKmh: 20,
|
|
altitudeM: 1000,
|
|
),
|
|
]);
|
|
await repo.renameTrip(h.tripId, 'Bike ride');
|
|
await repo.completeTrip(5000);
|
|
await repo.recomputeAggregates(h.tripId);
|
|
});
|
|
await repo.startTrip(100000, activity: Activity.motorcycle).then(
|
|
(h) async {
|
|
await repo.appendPoints([
|
|
TrackPoint(
|
|
tripId: h.tripId,
|
|
segmentId: h.segmentId,
|
|
timestamp: 100000,
|
|
latitude: 51.0,
|
|
longitude: -114.0,
|
|
speedKmh: 60,
|
|
altitudeM: 1000,
|
|
),
|
|
]);
|
|
await repo.renameTrip(h.tripId, 'Moto ride');
|
|
await repo.completeTrip(200000);
|
|
await repo.recomputeAggregates(h.tripId);
|
|
});
|
|
|
|
await tester.pumpWidget(host(const TripsScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Bike ride'), findsOneWidget);
|
|
expect(find.text('Moto ride'), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const Key('activity-filter')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const Key('activity-filter-bicycle')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Bike ride'), findsOneWidget);
|
|
expect(find.text('Moto ride'), findsNothing);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
|
|
group('ambientPositionProvider (FB-01)', () {
|
|
// Provider-level, not widget-level: exercises the provider directly against a
|
|
// `ProviderContainer` so its lifecycle (including disposal) can be driven
|
|
// precisely, per the ticket's own suggested test shape.
|
|
test('never calls LocationSource.stop() -- the underlying source is shared '
|
|
'with an active recording and stop() is not reference-counted', () async {
|
|
final fakeSource = FakeLocationSource();
|
|
final container = ProviderContainer(
|
|
overrides: [locationSourceProvider.overrideWithValue(fakeSource)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final sub = container.listen(ambientPositionProvider, (_, _) {});
|
|
// Let the provider's `await source.start()` resolve and subscribe.
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(fakeSource.startCalls, 1);
|
|
|
|
sub.close();
|
|
container.dispose();
|
|
|
|
expect(fakeSource.stopCalls, 0,
|
|
reason: 'ambientPositionProvider must only ever call start(), never '
|
|
'stop(), on the shared LocationSource');
|
|
await fakeSource.dispose();
|
|
});
|
|
|
|
test('turning mapEnabledProvider off never starts location and yields null',
|
|
() async {
|
|
final fakeSource = FakeLocationSource();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
locationSourceProvider.overrideWithValue(fakeSource),
|
|
mapEnabledProvider.overrideWith((ref) => false),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final values = <AsyncValue<LocationFix?>>[];
|
|
final sub = container.listen(
|
|
ambientPositionProvider,
|
|
(_, next) => values.add(next),
|
|
fireImmediately: true,
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(fakeSource.startCalls, 0,
|
|
reason: 'no tile or location request should fire while the map is '
|
|
'disabled, matching the existing map-toggle guarantee');
|
|
expect(values.last.valueOrNull, isNull);
|
|
|
|
sub.close();
|
|
await fakeSource.dispose();
|
|
});
|
|
});
|
|
}
|