diff --git a/docs/port/PROGRESS.md b/docs/port/PROGRESS.md index 7253857..1cfe9a0 100644 --- a/docs/port/PROGRESS.md +++ b/docs/port/PROGRESS.md @@ -157,3 +157,123 @@ Two lessons, both echoing v2's harness troubles: - **A killed background job still reports exit code 0.** Both jobs "completed successfully" *because they were killed*. Never read a success code from a process you terminated — re-run it cleanly. + +--- + +## T03 — Telemetry, formatting, ephemeral state · **complete** + +`telemetry.dart` (msToKmh, sanitizeSpeedKmh, isUsableFix, formatDuration, encodeBatch), +`ui/format.dart`, `telemetry/live_telemetry.dart`. **8 tests.** + +Also added `domain/models.dart` — `Trip`, `Segment`, `TrackPoint`, `TripState`, +`RideStats` as plain Dart with **no persistence dependency**. Drift will map *to* these +in T08 rather than the domain depending on the database. This is the Dart equivalent of +the discipline that made the Kotlin logic testable on the JVM. + +**Kotlin `Float` becomes Dart `double`.** Dart has no float32. Widening is the right +call — a shim would be friction for sub-millimetre precision on GPS-derived values — but +it means speed-derived values cannot be compared bit-for-bit. See T07 for the measured +consequence. + +**`Format` builds its `DateFormat` per call**, unlike the Kotlin original which captured +`Locale.getDefault()` once at class-init. Doing the same in Dart would freeze the format +for the process lifetime and ignore a locale change. + +--- + +## T04 — Ride statistics · **complete** + +`stats/ride_statistics.dart` including `ElevationAccumulator`. **21 tests.** + +Ported structurally faithfully — moving average, reversal hysteresis, +`gainIncludingPending()`, and the `finish()` reconciliation against `lastRaw`. Nothing +was "improved" during translation, per the plan. + +### The failure that proved the port correct + +The ported elevation test failed: **50.86 m against Kotlin's 35 m bound.** That looks +exactly like a porting bug in the hardest code in the project. + +It was not. The two languages' `Random(42)` are different streams. Rather than tune the +bound blind — which `docs/v3/BACKLOG.md` explicitly warns against — the question was +settled by building the T07 harness early and driving **both implementations from one +shared LCG**: + +``` +noisy_gain = 38.959594555022136 ← Kotlin +noisy_gain = 38.959594555022136 ← Dart +``` + +Bit-identical. The port is exact. + +**This also found something about the native app.** On the shared fixture the algorithm +yields ~39 m, which would **fail Kotlin's own 35 m bound**. The native test passes on +seed luck, not on a property of the algorithm. A sweep of 25 Dart seeds spanned +24.7–46.7 m (median 36). The Dart test now uses the shared LCG, asserts bit-equality +with Kotlin, and sets its bound from measured behaviour with headroom. + +> Worth carrying back to the native repo if it is ever revived: that guard is weaker +> than it looks. + +--- + +## T05 — Live accumulator · **complete** + +`recording/ride_accumulator.dart`. **12 tests, green on the first run.** + +The cross-batch anchor is intact — `distance across many small batches matches one big +batch` is the guard, and its absence under-reports distance by a few percent invisibly. + +--- + +## T06 — Export writers · **complete** + +`export/ride_export.dart`. **15 tests, green on the first run.** Parsed with a real XML +parser and `jsonDecode`, not substring matching, exactly as the Kotlin suite did. + +--- + +## T07 — Cross-language parity harness · **complete** + +`tool/parity/` — `run.sh`, `main.kt` (Kotlin oracle), `probe.dart`. Run it with +`JAVA_HOME` set to a 17–21 JDK; needs `kotlinc` (`brew install kotlin`, ~95 MB). + +It copies `Geo.kt`, `RideStatistics.kt` and `RideExport.kt` **verbatim** from the native +repo and compiles them against minimal stand-ins for the Room-annotated holders and one +`Telemetry` constant. The files under test are never reimplemented. + +### Result + +Every key byte-identical across 20 fixtures, including: + +| | | +|---|---| +| `noisy_gain` | `38.959594555022136` — the elevation accumulator, to the last digit | +| `simplify_count` | 218 of 21,600 points, identical Douglas–Peucker decisions | +| `gpx_len` / `gpx_fnv` | GPX output byte-identical, including the escaped hostile name | +| `geojson_len` / `geojson_fnv` | GeoJSON byte-identical | + +**The single accepted difference** is `run_avg_speed`: `40.030228` (Kotlin `Float`) vs +`40.03022888407912` (Dart `double`) — precisely the divergence predicted in T03. The +harness prints this explanation on failure so a future reader is not left guessing. + +### Two traps hit while building it + +**`String.hashCode` is not comparable across languages.** The first version compared +Java's and Dart's hashes of the GPX output — which would have "failed" forever for no +reason. Replaced with an FNV-1a implemented identically in both. + +**A missing jar silently passed as success.** When `RideExport.kt` was not yet copied, +compilation failed, `java -jar` errored, and the pipeline continued. `run.sh` now checks +for the jar and exits non-zero. This is the same class of bug as the v2 sweep that +returned four identical results because a compile error hid behind `/dev/null` — the +comment in `run.sh` says so explicitly. + +--- + +## Phase 1 complete + +**79 tests passing, `flutter analyze` clean.** ~890 lines of Kotlin logic ported, with +its ~965 lines of tests, and proven equivalent rather than assumed equivalent. + +Next: **T08 (Drift schema)**, the first task that touches persistence. diff --git a/lib/src/export/ride_export.dart b/lib/src/export/ride_export.dart new file mode 100644 index 0000000..a6326ff --- /dev/null +++ b/lib/src/export/ride_export.dart @@ -0,0 +1,183 @@ +/// Serialises a ride to formats other tools understand. +/// +/// Ported from `com.rippr.export.RideExport`. Pure string generation with no platform +/// dependency, so it is fully unit-testable. +/// +/// **Never decimate here.** Simplification exists only in the map render path; an export +/// must carry every raw point. The tests assert exact counts to catch any leak. +library; + +import '../domain/models.dart'; + +/// ISO 8601 in UTC. Fix timestamps are already UTC epoch millis, so no zone maths. +String _time(int epochMillis) { + final d = DateTime.fromMillisecondsSinceEpoch(epochMillis, isUtc: true); + String two(int v) => v.toString().padLeft(2, '0'); + return '${d.year.toString().padLeft(4, '0')}-${two(d.month)}-${two(d.day)}' + 'T${two(d.hour)}:${two(d.minute)}:${two(d.second)}Z'; +} + +String _coord(double value) => value.toStringAsFixed(7); + +String _num(double value) => value.toStringAsFixed(1); + +/// XML-escapes text destined for an attribute or element body. +/// +/// A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure is +/// invisible until an import rejects the file. +String escapeXml(String text) { + final b = StringBuffer(); + for (final c in text.split('')) { + switch (c) { + case '&': + b.write('&'); + case '<': + b.write('<'); + case '>': + b.write('>'); + case '"': + b.write('"'); + case "'": + b.write('''); + default: + b.write(c); + } + } + return b.toString(); +} + +/// Segment order first, then any points whose segment is missing, so nothing is dropped. +List _orderedSegmentIds( + List segments, Map> bySegment) { + final ids = [ + for (final s in segments) + if (bySegment.containsKey(s.id)) s.id, + ]; + for (final id in bySegment.keys) { + if (!segments.any((s) => s.id == id)) ids.add(id); + } + return ids; +} + +Map> _groupBySegment(List points) { + final grouped = >{}; + for (final p in points) { + (grouped[p.segmentId] ??= []).add(p); + } + return grouped; +} + +/// GPX 1.1. +/// +/// One `` per [Segment] — this is exactly how GPX represents a recording gap, so +/// pauses survive the round-trip into Strava, Garmin, or Google Earth instead of becoming +/// a straight line across town. +/// +/// Speed goes in ``; it is not part of core GPX 1.1, and consumers that do +/// not understand the extension ignore it, which is the correct degradation. +String gpx( + Trip trip, + List segments, + List points, + String name, +) { + final bySegment = _groupBySegment(points); + final ordered = _orderedSegmentIds(segments, bySegment); + + final b = StringBuffer(); + b.write('\n'); + b.write( + '\n'); + b.write(' \n'); + b.write(' ${escapeXml(name)}\n'); + b.write(' \n'); + b.write(' \n'); + b.write(' \n'); + b.write(' ${escapeXml(name)}\n'); + + for (final segmentId in ordered) { + b.write(' \n'); + for (final p in bySegment[segmentId]!) { + b.write( + ' \n'); + b.write(' ${_num(p.altitudeM)}\n'); + b.write(' \n'); + b.write(' \n'); + // GPX speed is metres per second. + b.write(' ${_num(p.speedKmh / 3.6)}\n'); + b.write(' \n'); + b.write(' \n'); + } + b.write(' \n'); + } + + b.write(' \n'); + b.write('\n'); + return b.toString(); +} + +/// GeoJSON `FeatureCollection`, one `LineString` per segment. +/// +/// Coordinates are `[lon, lat, ele]` — **longitude first**, the opposite order to GPX and +/// the most common mistake in GeoJSON output. +String geoJson( + Trip trip, + List segments, + List points, + String name, +) { + final bySegment = _groupBySegment(points); + final ordered = _orderedSegmentIds(segments, bySegment); + + final features = ordered.map((segmentId) { + final coords = bySegment[segmentId]! + .map((p) => + '[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]') + .join(', '); + return ''' { + "type": "Feature", + "properties": { "segment_id": $segmentId }, + "geometry": { "type": "LineString", "coordinates": [$coords] } + }'''; + }).join(',\n'); + + return '''{ + "type": "FeatureCollection", + "properties": { + "name": ${_jsonString(name)}, + "started_at": ${trip.startedAt}, + "distance_m": ${_num(trip.distanceM)}, + "max_speed_kmh": ${_num(trip.maxSpeedKmh)} + }, + "features": [ +$features + ] +} +'''; +} + +String _jsonString(String text) { + final b = StringBuffer('"'); + for (final c in text.split('')) { + switch (c) { + case '"': + b.write('\\"'); + case r'\': + b.write(r'\\'); + case '\n': + b.write(r'\n'); + case '\r': + b.write(r'\r'); + case '\t': + b.write(r'\t'); + default: + if (c.codeUnitAt(0) < 0x20) { + b.write('\\u${c.codeUnitAt(0).toRadixString(16).padLeft(4, '0')}'); + } else { + b.write(c); + } + } + } + b.write('"'); + return b.toString(); +} diff --git a/lib/src/recording/ride_accumulator.dart b/lib/src/recording/ride_accumulator.dart new file mode 100644 index 0000000..7969b7f --- /dev/null +++ b/lib/src/recording/ride_accumulator.dart @@ -0,0 +1,114 @@ +/// Running totals for the ride in progress. +/// +/// Ported from `com.rippr.Accumulator`. +/// +/// Distance and elevation both need consecutive-row differences, so they are folded in +/// here as points are written and persisted onto the `Trip` row once per flush. That +/// lets the recording screen show live numbers without rescanning the point table. +/// +/// The values this produces are an *estimate*. They are overwritten by +/// [computeSummary] when the trip completes, which is also why this reuses +/// [ElevationAccumulator] rather than reimplementing hysteresis: a naive version +/// measured 1498 m of phantom climbing on a parked bike. +/// +/// Not thread-safe — the writer loop owns it, under the recorder's write lock. +library; + +import 'dart:math' as math; + +import '../domain/models.dart'; +import '../geo/geo.dart'; +import '../stats/ride_statistics.dart'; +import '../telemetry/telemetry.dart'; + +class Accumulator { + /// The last point of the *previous* batch. + /// + /// Without this, distance restarts at every flush boundary and silently under-reports + /// by roughly one inter-point hop per batch — a few percent, which nobody notices + /// until they compare against an odometer. + TrackPoint? _lastPoint; + + ElevationAccumulator _elevation = ElevationAccumulator(); + double _restoredElevationM = 0.0; + + double _distanceM = 0.0; + int _movingMillis = 0; + double _maxSpeedKmh = 0.0; + int _pointCount = 0; + + double get distanceM => _distanceM; + int get movingMillis => _movingMillis; + double get maxSpeedKmh => _maxSpeedKmh; + int get pointCount => _pointCount; + + void fold(List points) { + for (final point in points) { + _pointCount++; + _maxSpeedKmh = math.max(_maxSpeedKmh, point.speedKmh); + _elevation.add(point.altitudeM); + + final prev = _lastPoint; + // Distance and moving time only accrue *within* a segment. Points either side of + // a pause can be kilometres apart and that gap was never ridden. + if (prev != null && prev.segmentId == point.segmentId) { + _distanceM += haversineMeters( + prev.latitude, + prev.longitude, + point.latitude, + point.longitude, + ); + final dt = point.timestamp - prev.timestamp; + if (dt >= 1 && + dt <= maxSampleGapMillis && + point.speedKmh >= speedNoiseFloorKmh) { + _movingMillis += dt; + } + } + _lastPoint = point; + } + } + + /// Elevation gain including the run still in progress. + /// + /// Reads a snapshot rather than mutating, so it is safe to call on every flush while + /// recording continues. + double elevationGain() => + _restoredElevationM + _elevation.gainIncludingPending(); + + /// Seeds from a trip already in progress after the process is restarted. + void restore({ + required double distanceM, + required int movingMillis, + required double maxSpeedKmh, + required double elevationGainM, + required int pointCount, + TrackPoint? lastPoint, + }) { + _distanceM = distanceM; + _movingMillis = movingMillis; + _maxSpeedKmh = maxSpeedKmh; + _pointCount = pointCount; + _lastPoint = lastPoint; + // Elevation cannot be resumed mid-run from a scalar total, so the accumulator + // restarts and only gain from here on is added to the persisted figure. The + // authoritative recomputation at trip completion corrects any drift. + _elevation = ElevationAccumulator(); + _restoredElevationM = elevationGainM; + } + + void reset() { + _lastPoint = null; + _elevation = ElevationAccumulator(); + _distanceM = 0.0; + _movingMillis = 0; + _maxSpeedKmh = 0.0; + _pointCount = 0; + _restoredElevationM = 0.0; + } + + /// Clears the cross-segment anchor so a resumed ride does not span the pause. + void onSegmentChanged() { + _lastPoint = null; + } +} diff --git a/lib/src/stats/ride_statistics.dart b/lib/src/stats/ride_statistics.dart index 6c6fda5..c95d313 100644 --- a/lib/src/stats/ride_statistics.dart +++ b/lib/src/stats/ride_statistics.dart @@ -106,8 +106,10 @@ const int _smoothingWindow = 15; /// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`). /// Order matters: the segment iteration order determines nothing statistically, but /// keeping it identical makes the two implementations diffable. -LinkedHashMap> _groupBySegment(List points) { - final grouped = LinkedHashMap>(); +Map> _groupBySegment(List points) { + // A Dart map literal is already a LinkedHashMap, so insertion order is preserved -- + // matching Kotlin's groupBy. + final grouped = >{}; for (final p in points) { (grouped[p.segmentId] ??= []).add(p); } diff --git a/pubspec.lock b/pubspec.lock index d9942a8..275a4d5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1213,7 +1213,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct dev" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index c81f36c..7544c64 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dev_dependencies: drift_dev: ^2.34.5 build_runner: ^2.16.0 mocktail: ^1.0.5 + xml: ^6.6.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/ride_accumulator_test.dart b/test/ride_accumulator_test.dart new file mode 100644 index 0000000..b9e402f --- /dev/null +++ b/test/ride_accumulator_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/recording/ride_accumulator.dart'; +import 'package:rippr/src/stats/ride_statistics.dart'; + +/// Ported from `com.rippr.AccumulatorTest`. +/// +/// As in `ride_statistics_test.dart`, the one RNG-driven case uses the shared LCG rather +/// than either language's `Random`, so the fixture is identical across implementations. +class _Lcg { + _Lcg(this._s); + int _s; + double nextDouble() { + _s = _s * 6364136223846793005 + 1442695040888963407; + final bits = (_s >>> 11) & ((1 << 53) - 1); + return bits / (1 << 53); + } +} + +void main() { + var nextId = 1; + + TrackPoint point({ + int segmentId = 1, + required int ts, + double lat = 51.0, + double speed = 40.0, + double alt = 1000.0, + }) => + TrackPoint( + id: nextId++, + tripId: 1, + segmentId: segmentId, + timestamp: ts, + latitude: lat, + longitude: -114.0, + speedKmh: speed, + altitudeM: alt, + ); + + List run(int n, + {int segmentId = 1, int startTs = 0, double startLat = 51.0}) => + List.generate( + n, + (i) => point( + segmentId: segmentId, + ts: startTs + i * 1000, + lat: startLat + i * 0.0001), + ); + + setUp(() => nextId = 1); + + group('the cross-batch anchor — the subtle bug this class exists to avoid', () { + test('distance across many small batches matches one big batch', () { + final points = run(100); + + final batched = Accumulator(); + for (var i = 0; i < points.length; i += 7) { + batched.fold(points.sublist(i, (i + 7).clamp(0, points.length))); + } + final single = Accumulator()..fold(points); + + expect(batched.distanceM, closeTo(single.distanceM, 1e-6), + reason: + 'losing the anchor between flushes silently under-reports distance'); + }); + + test('accumulated distance agrees with the batch statistics', () { + final points = run(100); + + final acc = Accumulator(); + for (var i = 0; i < points.length; i += 25) { + acc.fold(points.sublist(i, (i + 25).clamp(0, points.length))); + } + final summary = computeSummary(points); + + expect(acc.distanceM, closeTo(summary.distanceM, 0.5)); + expect(acc.movingMillis, summary.movingMillis); + expect(acc.maxSpeedKmh, closeTo(summary.maxSpeedKmh, 1e-6)); + expect(acc.pointCount, summary.pointCount); + }); + }); + + group('segment boundaries', () { + test('distance does not span a segment boundary', () { + // Second segment starts a full degree of latitude away — a trailered gap. + final acc = Accumulator(); + acc.fold(run(5, segmentId: 1, startLat: 51.0)); + acc.fold(run(5, segmentId: 2, startTs: 600000, startLat: 52.0)); + + expect(acc.distanceM, lessThan(200.0), + reason: 'the ~111 km gap leaked into distance: ${acc.distanceM} m'); + }); + + test('onSegmentChanged clears the anchor even within one batch stream', () { + final acc = Accumulator(); + acc.fold(run(5, segmentId: 1, startLat: 51.0)); + acc.onSegmentChanged(); + acc.fold(run(5, segmentId: 1, startTs: 600000, startLat: 52.0)); + + expect(acc.distanceM, lessThan(200.0), + reason: 'anchor was not cleared: ${acc.distanceM} m'); + }); + }); + + group('moving time', () { + test('moving time ignores samples below the speed noise floor', () { + final acc = Accumulator(); + acc.fold(List.generate(30, (i) => point(ts: i * 1000, speed: 40.0))); + acc.fold( + List.generate(30, (i) => point(ts: (30 + i) * 1000, speed: 0.0))); + + expect(acc.movingMillis, 29000); + }); + + test('a long gap between fixes does not inject phantom moving time', () { + final acc = Accumulator(); + acc.fold([ + point(ts: 0, speed: 90.0), + point(ts: 120000, lat: 51.001, speed: 90.0), + ]); + expect(acc.movingMillis, 0); + }); + }); + + group('elevation', () { + test('elevation gain is visible before the climb ends', () { + // A steady ascent that never turns back down. Reading the committed total alone + // would report zero, so the live figure has to include the pending run. + final acc = Accumulator(); + acc.fold(List.generate(101, (i) => point(ts: i * 1000, alt: 1000.0 + i))); + expect(acc.elevationGain(), closeTo(100.0, 6.0)); + }); + + test('a parked bike does not accumulate phantom climbing', () { + final rng = _Lcg(7); + final acc = Accumulator(); + acc.fold(List.generate( + 600, + (i) => point( + ts: i * 1000, + speed: 0.0, + alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)), + )); + expect(acc.elevationGain(), lessThan(60.0), + reason: 'phantom climbing: ${acc.elevationGain()} m'); + }); + }); + + group('restore', () { + test('restore continues from persisted totals rather than starting over', () { + final acc = Accumulator(); + acc.restore( + distanceM: 5000.0, + movingMillis: 300000, + maxSpeedKmh: 95.0, + elevationGainM: 120.0, + pointCount: 600, + lastPoint: null, + ); + acc.fold(run(11)); + + expect(acc.distanceM, greaterThan(5000.0), reason: 'distance went backwards'); + expect(acc.distanceM, closeTo(5111.2, 5.0)); + expect(acc.pointCount, 611); + expect(acc.maxSpeedKmh, closeTo(95.0, 1e-6)); + expect(acc.elevationGain(), greaterThanOrEqualTo(120.0), + reason: 'persisted elevation was lost'); + }); + + test('restore keeps a higher persisted max speed', () { + final acc = Accumulator(); + acc.restore( + distanceM: 0.0, + movingMillis: 0, + maxSpeedKmh: 150.0, + elevationGainM: 0.0, + pointCount: 0, + lastPoint: null, + ); + acc.fold(run(5)); + expect(acc.maxSpeedKmh, closeTo(150.0, 1e-6)); + }); + }); + + test('reset clears everything', () { + final acc = Accumulator(); + acc.fold(run(20)); + acc.reset(); + + expect(acc.distanceM, closeTo(0.0, 1e-9)); + expect(acc.movingMillis, 0); + expect(acc.maxSpeedKmh, closeTo(0.0, 1e-9)); + expect(acc.pointCount, 0); + expect(acc.elevationGain(), closeTo(0.0, 1e-9)); + }); + + test('folding an empty batch is a no-op', () { + final acc = Accumulator(); + acc.fold(const []); + expect(acc.pointCount, 0); + expect(acc.distanceM, closeTo(0.0, 1e-9)); + }); +} diff --git a/test/ride_export_test.dart b/test/ride_export_test.dart new file mode 100644 index 0000000..061f616 --- /dev/null +++ b/test/ride_export_test.dart @@ -0,0 +1,207 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/export/ride_export.dart'; +import 'package:xml/xml.dart'; + +/// Ported from `com.rippr.export.RideExportTest`. +/// +/// Like the Kotlin original, this parses the output with a **real** parser rather than +/// matching substrings. Structural validity is the whole point: a malformed export fails +/// invisibly until some other tool rejects the file. +void main() { + var nextId = 1; + + const trip = Trip( + id: 1, + startedAt: 1700000000000, + endedAt: 1700000600000, + state: TripState.completed, + distanceM: 12345.6, + maxSpeedKmh: 118.4, + ); + + TrackPoint point(int segmentId, int i) => TrackPoint( + id: nextId++, + tripId: 1, + segmentId: segmentId, + timestamp: 1700000000000 + i * 1000, + latitude: 51.0447 + i * 0.0001, + longitude: -114.0719, + speedKmh: 72.0, + altitudeM: 1045.0, + ); + + ({List segments, List points, int total}) ride( + {required int segmentCount, required int perSegment}) { + final segments = List.generate( + segmentCount, + (i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1), + ); + final points = [ + for (final s in segments) + for (var i = 0; i < perSegment; i++) point(s.id, i), + ]; + return ( + segments: segments, + points: points, + total: segmentCount * perSegment + ); + } + + setUp(() => nextId = 1); + + group('gpx', () { + test('gpx is well-formed and carries the expected structure', () { + final r = ride(segmentCount: 2, perSegment: 5); + + final doc = + XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop')); + + expect(doc.rootElement.name.local, 'gpx'); + expect(doc.rootElement.getAttribute('version'), '1.1'); + expect(doc.findAllElements('trk').length, 1); + }); + + test('one trkseg per segment, so pauses survive the round trip', () { + final r = ride(segmentCount: 3, perSegment: 4); + + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride')); + + expect(doc.findAllElements('trkseg').length, 3); + }); + + test('every raw point is exported with no decimation', () { + final r = ride(segmentCount: 2, perSegment: 250); + + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride')); + + expect(doc.findAllElements('trkpt').length, r.total, + reason: 'decimation must never reach an export'); + }); + + test('timestamps are ISO 8601 in UTC', () { + final r = ride(segmentCount: 1, perSegment: 1); + + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride')); + final time = doc.findAllElements('time').elementAt(1).innerText; + + expect(time, '2023-11-14T22:13:20Z'); + expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix'); + }); + + test('coordinates and elevation land on the right attributes', () { + final r = ride(segmentCount: 1, perSegment: 1); + + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride')); + final trkpt = doc.findAllElements('trkpt').first; + + expect(trkpt.getAttribute('lat'), '51.0447000'); + expect(trkpt.getAttribute('lon'), '-114.0719000'); + expect(doc.findAllElements('ele').first.innerText, '1045.0'); + }); + + test('speed is exported in metres per second', () { + final r = ride(segmentCount: 1, perSegment: 1); + + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride')); + + // 72 km/h is exactly 20 m/s. + expect(doc.findAllElements('speed').first.innerText, '20.0'); + }); + + test('a hostile trip name still produces valid xml', () { + final r = ride(segmentCount: 1, perSegment: 2); + const nasty = 'Sam & Dave\'s "fast" '; + + // Would throw if the name were interpolated unescaped. + final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty)); + + expect(doc.findAllElements('name').first.innerText, nasty); + }); + + test('escaping covers every xml metacharacter', () { + expect(escapeXml('&<>"\''), '&<>"''); + }); + + test('an empty ride still produces a valid document', () { + final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty')); + + expect(doc.rootElement.name.local, 'gpx'); + expect(doc.findAllElements('trkpt').length, 0); + expect(doc.findAllElements('trkseg').length, 0); + }); + + test('points whose segment is missing are still exported', () { + final orphan = [point(99, 0)]; + + final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride')); + + expect(doc.findAllElements('trkpt').length, 1, + reason: 'no point may be silently dropped'); + }); + }); + + group('geojson', () { + test('geojson coordinates are longitude first', () { + final r = ride(segmentCount: 1, perSegment: 1); + + final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride')) + as Map; + final coords = ((((json['features'] as List).first + as Map)['geometry'] + as Map)['coordinates'] as List).first as List; + + expect(coords[0] as double, closeTo(-114.0719, 1e-6), + reason: 'longitude must come first'); + expect(coords[1] as double, closeTo(51.0447, 1e-6)); + expect(coords[2] as double, closeTo(1045.0, 1e-6)); + }); + + test('geojson has one LineString feature per segment', () { + final r = ride(segmentCount: 3, perSegment: 4); + + final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride')) + as Map; + final features = json['features'] as List; + + expect(features.length, 3); + expect( + ((features.first as Map)['geometry'] + as Map)['type'], + 'LineString'); + }); + + test('geojson exports every raw point', () { + final r = ride(segmentCount: 2, perSegment: 120); + + final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride')) + as Map; + var exported = 0; + for (final f in json['features'] as List) { + exported += (((f as Map)['geometry'] + as Map)['coordinates'] as List).length; + } + + expect(exported, r.total); + }); + + test('a hostile name is escaped in json too', () { + final r = ride(segmentCount: 1, perSegment: 1); + + final json = jsonDecode( + geoJson(trip, r.segments, r.points, r'He said "go" \ fast')) + as Map; + + expect((json['properties'] as Map)['name'], + r'He said "go" \ fast'); + }); + + test('geojson of an empty ride parses', () { + final json = jsonDecode(geoJson(trip, const [], const [], 'Empty')) + as Map; + expect((json['features'] as List).length, 0); + }); + }); +} diff --git a/tool/parity/main.kt b/tool/parity/main.kt index 6b4868e..bfd6eed 100644 --- a/tool/parity/main.kt +++ b/tool/parity/main.kt @@ -3,6 +3,9 @@ import com.rippr.data.Segment import com.rippr.geo.Geo import com.rippr.geo.LatLon import com.rippr.stats.RideStatistics +import com.rippr.data.Trip +import com.rippr.data.TripState +import com.rippr.export.RideExport // A deterministic LCG implemented identically in Kotlin and Dart, so both languages // see the SAME noise sequence. Neither language's built-in Random can do this. @@ -14,6 +17,14 @@ class Lcg(private var s: Long) { } } +// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used +// to compare text across languages -- this can. +fun fnv1a(s: String): Long { + var h = 2166136261L + for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL } + return h +} + fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) = TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts, latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt) @@ -55,5 +66,19 @@ fun main() { val hist = RideStatistics.speedHistogram(run, 10) out("hist_buckets", hist.size) + + // --- export byte output ------------------------------------------------- + val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L, + state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f) + val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1), + Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3)) + val epts = esegs.flatMap { sg -> (0 until 5).map { + p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } } + val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's ") + val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's ") + out("gpx_len", g.length) + out("gpx_fnv", fnv1a(g)) + out("geojson_len", j.length) + out("geojson_fnv", fnv1a(j)) out("hist_first_millis", hist.first().millis) } diff --git a/tool/parity/probe.dart b/tool/parity/probe.dart index 8eee076..9f83d05 100644 --- a/tool/parity/probe.dart +++ b/tool/parity/probe.dart @@ -1,8 +1,10 @@ +// ignore_for_file: avoid_print -- this is a command-line harness; stdout is the output. // Dart side of the cross-language parity harness (T07). // Must stay fixture-for-fixture identical to tool/parity/main.kt. import 'dart:math' as math; import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/export/ride_export.dart'; import 'package:rippr/src/geo/geo.dart'; import 'package:rippr/src/stats/ride_statistics.dart'; @@ -18,6 +20,17 @@ class Lcg { } } +/// FNV-1a 32-bit. Dart's String.hashCode differs from Java's, so it cannot be used to +/// compare text across languages -- this can. +int fnv1a(String s) { + var h = 2166136261; + for (final c in s.codeUnits) { + h ^= c; + h = (h * 16777619) & 0xFFFFFFFF; + } + return h; +} + TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) => TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts, latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt); @@ -61,4 +74,29 @@ void main() { final hist = speedHistogram(run, bucketKmh: 10); out('hist_buckets', hist.length); out('hist_first_millis', hist.first.millis); + + // --- export byte output ------------------------------------------------- + const etrip = Trip( + id: 1, + startedAt: 1700000000000, + endedAt: 1700000600000, + state: TripState.completed, + distanceM: 12345.6, + maxSpeedKmh: 118.4); + const esegs = [ + Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1), + Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3), + ]; + final epts = [ + for (final sg in esegs) + for (var i = 0; i < 5; i++) + p(sg.id, 1700000000000 + i * 1000, 51.0447 + i * 0.0001, -114.0719, 72.0, + 1045.0, i + 1), + ]; + final g = gpx(etrip, esegs, epts, "Sam & Dave's "); + final j = geoJson(etrip, esegs, epts, "Sam & Dave's "); + out('gpx_len', g.length); + out('gpx_fnv', fnv1a(g)); + out('geojson_len', j.length); + out('geojson_fnv', fnv1a(j)); } diff --git a/tool/parity/run.sh b/tool/parity/run.sh index d5b0cdb..b744f23 100755 --- a/tool/parity/run.sh +++ b/tool/parity/run.sh @@ -25,13 +25,22 @@ fi # The files actually under test are copied verbatim from the native repo — never # reimplemented. Only the Room-annotated holders and one Telemetry constant are stubbed, # so the algorithm files compile without androidx or org.json on the classpath. -cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/" +cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/" cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/" +cp "$NATIVE/app/src/main/java/com/rippr/export/RideExport.kt" "$WORK/" cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/" echo "== compiling Kotlin oracle ==" -( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt stubs.kt telemetry_stub.kt main.kt \ - -include-runtime -d parity.jar 2>&1 | grep -v '^warning:' || true ) +( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt RideExport.kt stubs.kt \ + telemetry_stub.kt main.kt -include-runtime -d parity.jar 2>&1 \ + | grep -v '^warning:' || true ) + +# A missing jar means compilation failed. Never let that pass as a green run -- a v2 +# sweep hid exactly this behind a redirect and reported four identical results. +if [ ! -f "$WORK/parity.jar" ]; then + echo "Kotlin oracle failed to compile (see errors above)." >&2 + exit 1 +fi JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java" "$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt" diff --git a/tool/parity/stubs.kt b/tool/parity/stubs.kt index bda930c..8e66a39 100644 --- a/tool/parity/stubs.kt +++ b/tool/parity/stubs.kt @@ -8,3 +8,12 @@ data class TrackPoint( data class Segment( val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null, ) { val isOpen: Boolean get() = endedAt == null } + +enum class TripState { RECORDING, PAUSED, COMPLETED } +data class Trip( + val id: Long = 0, val startedAt: Long, val endedAt: Long? = null, + val name: String? = null, val state: TripState = TripState.RECORDING, + val distanceM: Double = 0.0, val movingMillis: Long = 0, + val maxSpeedKmh: Float = 0f, val elevationGainM: Double = 0.0, + val pointCount: Int = 0, +)