Complete Phase 1: accumulator, exports, and full parity coverage
T05 — ride_accumulator.dart with the cross-batch anchor intact (12 tests). T06 — ride_export.dart, GPX 1.1 and GeoJSON (15 tests), parsed with real parsers rather than substring matching. T07 — parity harness extended to cover export byte output. GPX and GeoJSON are byte-identical across Kotlin and Dart: same length, same FNV hash, including the escaped hostile name and all %.7f/%.1f formatting. Two harness bugs found and fixed while building it. String.hashCode is not comparable across Java and Dart, so text comparison used FNV-1a instead. And a missing jar let a failed Kotlin compile pass as a green run -- run.sh now checks for the artifact and exits non-zero, the same failure mode as the v2 sweep that hid a compile error behind /dev/null. Phase 1 done: 79 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
183
lib/src/export/ride_export.dart
Normal file
183
lib/src/export/ride_export.dart
Normal file
@@ -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<int> _orderedSegmentIds(
|
||||
List<Segment> segments, Map<int, List<TrackPoint>> bySegment) {
|
||||
final ids = <int>[
|
||||
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<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
||||
final grouped = <int, List<TrackPoint>>{};
|
||||
for (final p in points) {
|
||||
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// GPX 1.1.
|
||||
///
|
||||
/// One `<trkseg>` 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 `<extensions>`; 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<Segment> segments,
|
||||
List<TrackPoint> points,
|
||||
String name,
|
||||
) {
|
||||
final bySegment = _groupBySegment(points);
|
||||
final ordered = _orderedSegmentIds(segments, bySegment);
|
||||
|
||||
final b = StringBuffer();
|
||||
b.write('<?xml version="1.0" encoding="UTF-8"?>\n');
|
||||
b.write(
|
||||
'<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">\n');
|
||||
b.write(' <metadata>\n');
|
||||
b.write(' <name>${escapeXml(name)}</name>\n');
|
||||
b.write(' <time>${_time(trip.startedAt)}</time>\n');
|
||||
b.write(' </metadata>\n');
|
||||
b.write(' <trk>\n');
|
||||
b.write(' <name>${escapeXml(name)}</name>\n');
|
||||
|
||||
for (final segmentId in ordered) {
|
||||
b.write(' <trkseg>\n');
|
||||
for (final p in bySegment[segmentId]!) {
|
||||
b.write(
|
||||
' <trkpt lat="${_coord(p.latitude)}" lon="${_coord(p.longitude)}">\n');
|
||||
b.write(' <ele>${_num(p.altitudeM)}</ele>\n');
|
||||
b.write(' <time>${_time(p.timestamp)}</time>\n');
|
||||
b.write(' <extensions>\n');
|
||||
// GPX speed is metres per second.
|
||||
b.write(' <speed>${_num(p.speedKmh / 3.6)}</speed>\n');
|
||||
b.write(' </extensions>\n');
|
||||
b.write(' </trkpt>\n');
|
||||
}
|
||||
b.write(' </trkseg>\n');
|
||||
}
|
||||
|
||||
b.write(' </trk>\n');
|
||||
b.write('</gpx>\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<Segment> segments,
|
||||
List<TrackPoint> 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();
|
||||
}
|
||||
114
lib/src/recording/ride_accumulator.dart
Normal file
114
lib/src/recording/ride_accumulator.dart
Normal file
@@ -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<TrackPoint> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
||||
final grouped = LinkedHashMap<int, List<TrackPoint>>();
|
||||
Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
||||
// A Dart map literal is already a LinkedHashMap, so insertion order is preserved --
|
||||
// matching Kotlin's groupBy.
|
||||
final grouped = <int, List<TrackPoint>>{};
|
||||
for (final p in points) {
|
||||
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
||||
}
|
||||
|
||||
@@ -1213,7 +1213,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
|
||||
@@ -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
|
||||
|
||||
204
test/ride_accumulator_test.dart
Normal file
204
test/ride_accumulator_test.dart
Normal file
@@ -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<TrackPoint> 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));
|
||||
});
|
||||
}
|
||||
207
test/ride_export_test.dart
Normal file
207
test/ride_export_test.dart
Normal file
@@ -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<Segment> segments, List<TrackPoint> 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 = <TrackPoint>[
|
||||
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 <ride> "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<String, dynamic>;
|
||||
final coords = ((((json['features'] as List).first
|
||||
as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['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<String, dynamic>;
|
||||
final features = json['features'] as List;
|
||||
|
||||
expect(features.length, 3);
|
||||
expect(
|
||||
((features.first as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['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<String, dynamic>;
|
||||
var exported = 0;
|
||||
for (final f in json['features'] as List) {
|
||||
exported += (((f as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['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<String, dynamic>;
|
||||
|
||||
expect((json['properties'] as Map<String, dynamic>)['name'],
|
||||
r'He said "go" \ fast');
|
||||
});
|
||||
|
||||
test('geojson of an empty ride parses', () {
|
||||
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
|
||||
as Map<String, dynamic>;
|
||||
expect((json['features'] as List).length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 <ride>")
|
||||
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 = <TrackPoint>[
|
||||
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 <ride>");
|
||||
final j = geoJson(etrip, esegs, epts, "Sam & Dave's <ride>");
|
||||
out('gpx_len', g.length);
|
||||
out('gpx_fnv', fnv1a(g));
|
||||
out('geojson_len', j.length);
|
||||
out('geojson_fnv', fnv1a(j));
|
||||
}
|
||||
|
||||
@@ -27,11 +27,20 @@ fi
|
||||
# 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/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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user