Removes the idle GlassPanel/BigStat Speed-Status-Last ride block from
RecordScreen; the live map plus START control is now the entire idle
screen. HudEditOverlay is gated on !ui.isIdle. Migrates the five
affected widget tests to seed a recording trip and assert against the
HUD's own ValueKey(HudMetric.speed) widget instead of the old idle
Key('speed').
Also fixes a pre-existing gap this migration exposed: _HudMetricValue
had no mounted-mode awareness at all (no text scaling, and its
per-metric accent colours fall just under AA contrast against the
mounted theme's GlassPanel surface). Adds scale and mountedMode
params, reusing BigStat's own mechanisms (scale multiplier, explicit
onSurface ink) to fix both.
847 lines
32 KiB
Dart
847 lines
32 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/hud/hud_metric.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 only START', (tester) async {
|
|
await tester.pumpWidget(host(const RecordScreen()));
|
|
await tester.pumpAndSettle();
|
|
|
|
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 repo.startTrip(1000);
|
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
|
|
|
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 repo.startTrip(1000);
|
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
|
|
|
final speed = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey(HudMetric.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 repo.startTrip(1000);
|
|
await pumpLive(
|
|
tester,
|
|
host(const RecordScreen(), map: false, mountedMode: true),
|
|
);
|
|
|
|
final speed = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
|
matching: find.text('0'),
|
|
),
|
|
);
|
|
expect(speed.style?.fontSize, 18 * mountedTextScale);
|
|
expect(speed.style?.color, isNot(ripprBackground),
|
|
reason: 'still legible, just against a different (lighter) ground');
|
|
|
|
final pause = tester.getSize(find.byKey(const Key('pause')));
|
|
expect(pause.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 repo.startTrip(1000);
|
|
await pumpLive(
|
|
tester,
|
|
host(const RecordScreen(), map: false, mountedMode: true),
|
|
);
|
|
|
|
final speed = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
|
matching: find.text('0'),
|
|
),
|
|
);
|
|
final panel = tester.widget<GlassPanel>(
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
|
matching: find.byType(GlassPanel),
|
|
),
|
|
);
|
|
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('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));
|
|
});
|
|
});
|
|
}
|