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(); + } + }); +}