Files
rippr/integration_test/ride_simulation_test.dart
Dylan 131dca3241 Verify the iPhone build locally, and scope what still needs a phone
Added integration_test/ride_simulation_test.dart, which drives a whole recording
against a simulator that is genuinely moving, then reads the stored ride back
through the provider graph -- the app is uninstalled when a run ends, so anything
worth knowing has to be reported from inside the test.

Attacked an assumption and it survived. simctl location start --speed=15
interpolates between waypoints over time, unlike Android's teleporting geo fix,
so it looked like speed might finally be testable locally. A probe captured 8
fixes, every one 0.0 m/s. The recorded ride shows the consequence plainly: 245.5m
of distance with zero moving time, because every sample sits under the 1.5 km/h
noise floor.

What that leaves verified locally: the app builds and launches, the real UI
renders, Drift opens against iOS storage, plugins register, CoreLocation
delivers fixes, distance accumulates from real movement, trips persist and list,
the detail screen and its map render, and navigation works. Also confirmed
visually that the permission dialog shows the specific usage string written for
Guideline 5.1.1.

What still needs a phone: speed, moving time, speed colouring, elevation against
correlated GPS error, battery, and above all whether iOS suspends a stationary
app mid-ride -- the item that decides whether geolocator is sufficient.

Named gap: no screenshot of the iOS trips or detail screens. Verified
functionally, but tapping the iOS simulator is not scriptable and a fresh install
re-prompts for location over the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:36:52 -05:00

90 lines
3.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rippr/main.dart' as app;
import 'package:rippr/src/app/providers.dart';
/// Drives a whole recording on a simulator that is genuinely *moving*.
///
/// Run `xcrun simctl location booted start --speed=15 <waypoints>` first. The iOS
/// simulator interpolates between waypoints over time, so the device genuinely moves —
/// but **CoreLocation still reports speed 0.0** throughout. Measured, not assumed: a
/// probe run captured 8 fixes, every one of them zero.
///
/// So position, distance, path shape and segment structure are all verifiable here.
/// Speed, moving time and speed colouring are not, on either platform.
///
/// The app is uninstalled when the run ends, taking its database with it, so anything
/// worth knowing is read back through the provider graph and printed from inside.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('record a ride end to end through the UI', (tester) async {
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.tap(find.byKey(const Key('start')));
await tester.pumpAndSettle(const Duration(seconds: 2));
// Ride for a while, letting the flush timer land batches.
for (var i = 0; i < 15; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(seconds: 1)));
await tester.pump();
}
await tester.tap(find.byKey(const Key('stop')));
await tester.pumpAndSettle(const Duration(seconds: 3));
// Read the stored ride back through the real provider graph. The app is
// uninstalled when the run ends, taking its database with it, so anything worth
// knowing has to be reported from inside the test.
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
listen: false,
);
final repo = container.read(tripRepositoryProvider);
final trips = await repo.watchCompletedTrips().first;
debugPrint('RIPPR-PROBE completedTrips=${trips.length}');
for (final t in trips) {
final points = await repo.pointsForTrip(t.id);
final segments = await repo.segmentsForTrip(t.id);
final lats = [for (final p in points) p.latitude];
debugPrint('RIPPR-PROBE trip=${t.id} points=${t.pointCount} '
'distanceM=${t.distanceM.toStringAsFixed(1)} '
'maxSpeedKmh=${t.maxSpeedKmh.toStringAsFixed(1)} '
'movingMs=${t.movingMillis} segments=${segments.length}');
if (lats.isNotEmpty) {
debugPrint('RIPPR-PROBE latSpan=${(lats.reduce((a, b) => a > b ? a : b) - lats.reduce((a, b) => a < b ? a : b)).toStringAsFixed(5)}');
}
}
// What CAN be proven locally: the ride persisted, moved, and is listed.
expect(trips, isNotEmpty, reason: 'the ride was not saved');
expect(trips.first.pointCount, greaterThan(0));
expect(trips.first.distanceM, greaterThan(0),
reason: 'the simulator moved, so distance must be non-zero');
// Now the list, then the detail screen with its map.
await tester.tap(find.text('Rides'));
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(find.byType(Card), findsWidgets, reason: 'the ride should be listed');
await tester.tap(find.byKey(Key('trip-${trips.first.id}')));
await tester.pumpAndSettle(const Duration(seconds: 4));
expect(find.text('DISTANCE'), findsOneWidget);
debugPrint('RIPPR-PROBE detail screen rendered');
// Hold here so an external `simctl io screenshot` can capture the detail screen and
// its map. Tapping the iOS simulator is not scriptable, so this is how a visual
// check gets taken at all.
for (var i = 0; i < 14; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(seconds: 1)));
await tester.pump();
}
});
}