From 131dca32418090200b027da3cfc10835a172ab00 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 17 Aug 2026 09:36:52 -0500 Subject: [PATCH] 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 --- docs/port/IOS-VERIFICATION.md | 89 ++++++++++++++++++++++ docs/port/PROGRESS.md | 33 ++++++++ integration_test/ride_simulation_test.dart | 89 ++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 docs/port/IOS-VERIFICATION.md create mode 100644 integration_test/ride_simulation_test.dart diff --git a/docs/port/IOS-VERIFICATION.md b/docs/port/IOS-VERIFICATION.md new file mode 100644 index 0000000..6eca49b --- /dev/null +++ b/docs/port/IOS-VERIFICATION.md @@ -0,0 +1,89 @@ +# iPhone verification — what a Mac can prove, and what needs a phone + +Scoped and then executed. Everything in the first table has been **run**; everything in +the second cannot be done without hardware and a rider. + +Reproduce with: + +```bash +xcrun simctl location booted start --speed=15 --interval=1 \ + 51.0447,-114.0719 51.0530,-114.0719 51.0610,-114.0800 51.0700,-114.0900 & +( for i in $(seq 1 90); do xcrun simctl privacy booted grant location-always com.rippr.port; sleep 1; done ) & +flutter test integration_test/ride_simulation_test.dart -d +``` + +--- + +## The finding that matters most + +**The iOS simulator moves, but still reports zero speed.** + +`simctl location start --speed=15` genuinely interpolates between waypoints over time — +unlike Android's `adb emu geo fix`, which teleports. That looked like it might finally +make speed testable locally. It does not: + +``` +RIPPR-PROBE fixes=8 speeds=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] +``` + +Eight fixes, every one zero. Measured, not assumed — the assumption was worth attacking +and it survived. + +The consequence is visible in a real recorded ride: + +``` +trip=1 points=18 distanceM=245.5 maxSpeedKmh=0.0 movingMs=0 segments=1 +``` + +**245 metres travelled, zero moving time.** Distance comes from consecutive positions, so +it works. Moving time comes from `speedKmh >= 1.5`, so with speed pinned at zero every +sample falls under the noise floor. That is correct behaviour on bad input, and it is +exactly why `REAL-RIDE-CHECKLIST.md` cannot be skipped. + +--- + +## ✅ Verified locally, on a Mac + +| What | How | Evidence | +|---|---|---| +| App builds and launches on iOS | `flutter build ios --simulator`, `simctl launch` | Runs | +| The real UI renders | Screenshot of the record screen | Wordmark, SPEED headline, Ready state, 72dp button all correct | +| **The permission dialog and its usage string** | Screenshot | *"Rippr records the GPS track of your ride… Nothing is recorded until you press Start."* — the Guideline 5.1.1 item, confirmed rendering | +| Drift opens against real iOS storage | Integration test | `rippr_db.sqlite` + WAL created in the app container | +| Plugin registration resolves | Integration test | geolocator, path_provider, shared_preferences all load | +| CoreLocation delivers fixes | Integration test | 18 fixes in ~15 s | +| **Distance accumulates from real movement** | Integration test | 245.5 m, latSpan 0.00197° — consistent | +| A trip persists and is listed | Integration test | `completedTrips=1`, Card present | +| Segments are created | Integration test | `segments=1` | +| Trip detail renders, including the map | Integration test | `DISTANCE` found on screen | +| Navigation record → rides → detail | Integration test | All transitions land | +| Cold start with no ride | Integration test | Lands idle, no crash | +| App icon under the iOS mask | Rendered preview | Ring and orange accent survive the superellipse | + +## ⛔ Needs a real iPhone + +| What | Why a simulator cannot | +|---|---| +| **Max speed, average moving speed** | CoreLocation reports 0.0 — measured above | +| **Moving time** | Derived from speed; reads 00:00:00 locally even after 245 m | +| **Speed colouring on the map** | Every path renders in one colour | +| **Elevation gain** | Simulated altitude has no correlated GPS error; the ~30 m drift question is unanswerable here | +| **Background suspension when stationary (I3)** | The simulator does not model iOS's power-management suspension. **This decides whether `geolocator` is sufficient or the paid engine is needed.** | +| **Screen-lock continuation** | Real background lifecycle only | +| **Phone call / interruption handling** | No telephony | +| **Battery drain** | No meaningful power model | +| **The blue background-location indicator** | Real background mode only | +| **Real GPS accuracy and dropouts** | Simulated fixes are perfect; the accuracy gate never rejects anything | +| **App Store review** | Requires submission | + +## 🟡 Gap in local coverage, worth naming + +**No screenshot of the iOS trips list or detail screen.** They are verified +*functionally* — the integration test asserts the Card and the `DISTANCE` readout are +present — but not *visually*, because tapping the iOS simulator is not scriptable the way +`adb shell input tap` is, and every fresh install re-prompts for location, which sits over +the UI during the capture window. + +Android's equivalents were checked visually. On iOS this is worth a manual look next time +the app is built for a device: launch it, record a short ride by hand, and eyeball the +list, the detail stats and the map. diff --git a/docs/port/PROGRESS.md b/docs/port/PROGRESS.md index d0b0132..dff600a 100644 --- a/docs/port/PROGRESS.md +++ b/docs/port/PROGRESS.md @@ -797,3 +797,36 @@ tests the project has ever had. **What remains is not code.** It is a rider, two phones, and [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md). + + +--- + +## iPhone verification pass + +Scoped and executed: [IOS-VERIFICATION.md](IOS-VERIFICATION.md). + +**An assumption was attacked and survived.** `simctl location start --speed=15` +interpolates between waypoints over time — unlike Android's teleporting `geo fix` — which +looked like it might make speed testable locally at last. A probe captured 8 fixes, every +one reporting **0.0 m/s**. So "no simulator produces velocity" holds on iOS too, and the +real-ride checklist remains mandatory. + +A full ride was recorded on the simulator through the real UI: + +``` +trip=1 points=18 distanceM=245.5 maxSpeedKmh=0.0 movingMs=0 segments=1 +``` + +245 metres of travel with **zero moving time** — correct behaviour given speed is pinned +at zero, and a compact illustration of exactly what local testing cannot reach. + +Also confirmed visually on iOS: the record screen renders correctly, and the location +permission dialog displays the specific usage string written for Guideline 5.1.1. + +`integration_test/ride_simulation_test.dart` is kept — it is a reusable local +verification, not a throwaway. + +**Named gap:** no screenshot of the iOS trips list or detail screen. Both are verified +functionally, but tapping the iOS simulator is not scriptable the way `adb shell input +tap` is, and a fresh install re-prompts for location over the UI. Android's equivalents +were checked visually. diff --git a/integration_test/ride_simulation_test.dart b/integration_test/ride_simulation_test.dart new file mode 100644 index 0000000..6590cfb --- /dev/null +++ b/integration_test/ride_simulation_test.dart @@ -0,0 +1,89 @@ +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 ` 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.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.delayed(const Duration(seconds: 1))); + await tester.pump(); + } + }); +}