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
|
- **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
|
successfully" *because they were killed*. Never read a success code from a process you
|
||||||
terminated — re-run it cleanly.
|
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`).
|
/// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`).
|
||||||
/// Order matters: the segment iteration order determines nothing statistically, but
|
/// Order matters: the segment iteration order determines nothing statistically, but
|
||||||
/// keeping it identical makes the two implementations diffable.
|
/// keeping it identical makes the two implementations diffable.
|
||||||
LinkedHashMap<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
||||||
final grouped = LinkedHashMap<int, List<TrackPoint>>();
|
// 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) {
|
for (final p in points) {
|
||||||
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1213,7 +1213,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ dev_dependencies:
|
|||||||
drift_dev: ^2.34.5
|
drift_dev: ^2.34.5
|
||||||
build_runner: ^2.16.0
|
build_runner: ^2.16.0
|
||||||
mocktail: ^1.0.5
|
mocktail: ^1.0.5
|
||||||
|
xml: ^6.6.1
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# 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.Geo
|
||||||
import com.rippr.geo.LatLon
|
import com.rippr.geo.LatLon
|
||||||
import com.rippr.stats.RideStatistics
|
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
|
// 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.
|
// 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) =
|
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,
|
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
|
||||||
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
|
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
|
||||||
@@ -55,5 +66,19 @@ fun main() {
|
|||||||
|
|
||||||
val hist = RideStatistics.speedHistogram(run, 10)
|
val hist = RideStatistics.speedHistogram(run, 10)
|
||||||
out("hist_buckets", hist.size)
|
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)
|
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).
|
// Dart side of the cross-language parity harness (T07).
|
||||||
// Must stay fixture-for-fixture identical to tool/parity/main.kt.
|
// Must stay fixture-for-fixture identical to tool/parity/main.kt.
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:rippr/src/domain/models.dart';
|
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/geo/geo.dart';
|
||||||
import 'package:rippr/src/stats/ride_statistics.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 p(int seg, int ts, double lat, double lon, double sp, double alt, int id) =>
|
||||||
TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts,
|
TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts,
|
||||||
latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt);
|
latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt);
|
||||||
@@ -61,4 +74,29 @@ void main() {
|
|||||||
final hist = speedHistogram(run, bucketKmh: 10);
|
final hist = speedHistogram(run, bucketKmh: 10);
|
||||||
out('hist_buckets', hist.length);
|
out('hist_buckets', hist.length);
|
||||||
out('hist_first_millis', hist.first.millis);
|
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.
|
# 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/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/"
|
cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/"
|
||||||
|
|
||||||
echo "== compiling Kotlin oracle =="
|
echo "== compiling Kotlin oracle =="
|
||||||
( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt stubs.kt telemetry_stub.kt main.kt \
|
( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt RideExport.kt stubs.kt \
|
||||||
-include-runtime -d parity.jar 2>&1 | grep -v '^warning:' || true )
|
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="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||||
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"
|
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ data class TrackPoint(
|
|||||||
data class Segment(
|
data class Segment(
|
||||||
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
|
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
|
||||||
) { val isOpen: Boolean get() = endedAt == 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