Port Telemetry, Format, RideStatistics; add cross-language parity harness

T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models
(Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can
map to them in T08 rather than the domain depending on the database.

T04 — ride_statistics.dart including ElevationAccumulator, ported structurally
faithfully: moving average, reversal hysteresis, gainIncludingPending, and the
finish() reconciliation against lastRaw.

T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures
through the real Kotlin files and the Dart port, then diffs. Result: every value
byte-identical, including noisy_gain=38.959594555022136 to the last digit. The
sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double
with artefacts Dart's binary64 does not reproduce. Documented, not papered over.

That harness settled a real question. The ported elevation test failed at 50.9m
against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's
and Dart's Random(42) are different streams. On a shared LCG fixture both produce
39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed
luck rather than on a property of the algorithm. The Dart test now uses the shared
LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour
(25 seeds spanned 24.7-46.7m).

52 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:59:35 -05:00
parent f0f9ed8c34
commit 4af4e3411a
14 changed files with 1368 additions and 1 deletions

204
lib/src/domain/models.dart Normal file
View File

@@ -0,0 +1,204 @@
/// Core domain models, free of any persistence or platform dependency.
///
/// Ported from the Room entities in `com.rippr.data`. The Room annotations are
/// deliberately **not** carried over: Drift owns the table definitions in T08 and maps
/// to these types, so the domain layer never depends on the database package. That is
/// the same separation the Kotlin app achieved by keeping logic free of Android imports.
///
/// ## One deliberate divergence: Float becomes double
///
/// Kotlin stores `speedKmh`, `accuracyM` and `bearingDeg` as 32-bit `Float`. Dart has no
/// float32 — every `double` is IEEE-754 binary64. These are therefore widened.
///
/// This is the right call (a float32 shim would be pure friction for sub-millimetre
/// precision on a GPS-derived value), but it means **speed-derived values cannot be
/// compared bit-for-bit across the two implementations**. Kotlin's `Float.toDouble()`
/// produces artefacts like `12.300000190734863`; Dart produces `12.3`. The T07 parity
/// harness must use a tolerance for these, and only these.
library;
/// Where a ride is in its lifecycle.
///
/// [Trip.endedAt] alone distinguishes active from finished, but cannot tell recording
/// from paused — and the recorder needs that distinction to decide what to do when the
/// OS restarts it mid-ride. Hence both.
enum TripState { recording, paused, completed }
/// One ride, from pressing Start to pressing Stop.
///
/// The aggregate fields are denormalised on purpose. They are accumulated as points
/// arrive and recomputed authoritatively when the trip completes, so the trips list can
/// render hundreds of rides without touching the point table.
class Trip {
const Trip({
this.id = 0,
required this.startedAt,
this.endedAt,
this.name,
this.state = TripState.recording,
this.distanceM = 0.0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
this.elevationGainM = 0.0,
this.pointCount = 0,
});
final int id;
final int startedAt;
/// Null while the ride is still active.
final int? endedAt;
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
final String? name;
final TripState state;
final double distanceM;
final int movingMillis;
final double maxSpeedKmh;
final double elevationGainM;
final int pointCount;
bool get isActive => endedAt == null;
int get elapsedMillis => endedAt == null ? 0 : endedAt! - startedAt;
Trip copyWith({
int? id,
int? startedAt,
int? endedAt,
String? name,
TripState? state,
double? distanceM,
int? movingMillis,
double? maxSpeedKmh,
double? elevationGainM,
int? pointCount,
}) =>
Trip(
id: id ?? this.id,
startedAt: startedAt ?? this.startedAt,
endedAt: endedAt ?? this.endedAt,
name: name ?? this.name,
state: state ?? this.state,
distanceM: distanceM ?? this.distanceM,
movingMillis: movingMillis ?? this.movingMillis,
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
elevationGainM: elevationGainM ?? this.elevationGainM,
pointCount: pointCount ?? this.pointCount,
);
}
/// One pause-free stretch of recording within a [Trip].
///
/// This layer is what makes pause correct rather than cosmetic. Without it, a rider who
/// pauses at a gas station and resumes across town gets a polyline drawn straight
/// through terrain they never travelled, and a distance total that includes it. Points
/// are grouped by segment for rendering, distance accumulation, and GPX `<trkseg>`
/// output, so every consumer naturally leaves a gap where the rider stopped.
class Segment {
const Segment({
this.id = 0,
required this.tripId,
required this.startedAt,
this.endedAt,
});
final int id;
final int tripId;
final int startedAt;
/// Null while this segment is still being recorded into.
final int? endedAt;
bool get isOpen => endedAt == null;
}
/// A single GPS fix.
///
/// Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes
/// from the platform location fix, which is GPS-derived and can jump, whereas the
/// autoincrement id is genuinely monotonic in write order.
class TrackPoint {
const TrackPoint({
this.id = 0,
required this.tripId,
required this.segmentId,
required this.timestamp,
required this.latitude,
required this.longitude,
required this.speedKmh,
required this.altitudeM,
this.accuracyM = 0.0,
this.bearingDeg = 0.0,
this.synced = false,
});
final int id;
final int tripId;
final int segmentId;
final int timestamp;
final double latitude;
final double longitude;
final double speedKmh;
final double altitudeM;
final double accuracyM;
final double bearingDeg;
/// Set once the point has been accepted by the remote endpoint.
final bool synced;
TrackPoint copyWith({int? id, int? tripId, int? segmentId, bool? synced}) =>
TrackPoint(
id: id ?? this.id,
tripId: tripId ?? this.tripId,
segmentId: segmentId ?? this.segmentId,
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedKmh: speedKmh,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
synced: synced ?? this.synced,
);
}
/// Cheap SQL-computed stats for the live recording screen.
///
/// Deliberately limited to what plain aggregate functions can express. Distance and
/// elevation gain are absent because they need consecutive-row differences — they are
/// accumulated in Dart and stored on the [Trip] row instead.
///
/// The SQLite-3.18 window-function limitation that forced this in the Kotlin app no
/// longer strictly applies (Drift bundles a modern SQLite), but the split is kept: the
/// accumulate-as-you-go design is what lets a mid-ride crash leave usable totals.
class RideStats {
const RideStats({
required this.pointCount,
required this.maxSpeedKmh,
required this.avgSpeedKmh,
required this.firstTimestamp,
required this.lastTimestamp,
required this.pendingUpload,
});
static const empty = RideStats(
pointCount: 0,
maxSpeedKmh: 0.0,
avgSpeedKmh: 0.0,
firstTimestamp: 0,
lastTimestamp: 0,
pendingUpload: 0,
);
final int pointCount;
final double maxSpeedKmh;
final double avgSpeedKmh;
final int firstTimestamp;
final int lastTimestamp;
final int pendingUpload;
int get durationMillis =>
pointCount == 0 ? 0 : lastTimestamp - firstTimestamp;
}

View File

@@ -0,0 +1,385 @@
/// Batch statistics over a stored ride.
///
/// Ported from `com.rippr.stats.RideStatistics`.
///
/// This is the authoritative computation. The recorder accumulates the same values live
/// as points arrive, but re-runs this on trip completion so a mid-ride process kill
/// cannot leave permanently skewed totals. Both paths must agree, which is why they
/// share the constants below rather than duplicating magic numbers.
library;
import 'dart:collection';
import 'dart:math' as math;
import '../domain/models.dart';
import '../geo/geo.dart';
import '../telemetry/telemetry.dart';
class RideSummary {
const RideSummary({
this.distanceM = 0.0,
this.elapsedMillis = 0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
this.avgMovingSpeedKmh = 0.0,
this.elevationGainM = 0.0,
this.elevationLossM = 0.0,
this.pointCount = 0,
});
static const empty = RideSummary();
final double distanceM;
/// Wall clock, first fix to last.
final int elapsedMillis;
/// Time spent above the speed noise floor.
final int movingMillis;
final double maxSpeedKmh;
/// Distance ÷ moving time — not the mean of the speed samples.
final double avgMovingSpeedKmh;
final double elevationGainM;
final double elevationLossM;
final int pointCount;
int get stoppedMillis => math.max(0, elapsedMillis - movingMillis);
// Kotlin got this free from `data class`. Needed so tests can compare against
// RideSummary.empty by value rather than by identity.
@override
bool operator ==(Object other) =>
other is RideSummary &&
other.distanceM == distanceM &&
other.elapsedMillis == elapsedMillis &&
other.movingMillis == movingMillis &&
other.maxSpeedKmh == maxSpeedKmh &&
other.avgMovingSpeedKmh == avgMovingSpeedKmh &&
other.elevationGainM == elevationGainM &&
other.elevationLossM == elevationLossM &&
other.pointCount == pointCount;
@override
int get hashCode => Object.hash(distanceM, elapsedMillis, movingMillis,
maxSpeedKmh, avgMovingSpeedKmh, elevationGainM, elevationLossM, pointCount);
@override
String toString() => 'RideSummary(distance: $distanceM m, elapsed: $elapsedMillis ms, '
'moving: $movingMillis ms, max: $maxSpeedKmh km/h, gain: $elevationGainM m, '
'loss: $elevationLossM m, points: $pointCount)';
}
class SpeedBucket {
const SpeedBucket(this.fromKmh, this.toKmh, this.millis);
final int fromKmh;
final int toKmh;
final int millis;
String get label => '$fromKmh–$toKmh';
}
class ElevationSample {
const ElevationSample(this.distanceM, this.altitudeM);
final double distanceM;
final double altitudeM;
}
/// A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a
/// two-minute tunnel counts as two minutes of moving time at the last known speed.
const int maxSampleGapMillis = 10000;
/// Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive delta
/// turns a flat ride into thousands of metres of climbing — the classic bug in this
/// calculation. A climb only counts once it exceeds this much in one direction.
const double elevationHysteresisM = 3.0;
/// ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain.
const int _smoothingWindow = 15;
/// Group points by segment id, preserving first-seen order.
///
/// 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>>();
for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}
return grouped;
}
RideSummary computeSummary(
List<TrackPoint> points, {
List<Segment> segments = const [],
}) {
if (points.isEmpty) return RideSummary.empty;
var distanceM = 0.0;
var movingMillis = 0;
var maxSpeedKmh = 0.0;
final elevation = ElevationAccumulator();
// Grouping by segment is what keeps a pause from inventing distance: points either
// side of a gas-station stop can be kilometres apart.
for (final segmentPoints in _groupBySegment(points).values) {
TrackPoint? previous;
for (final point in segmentPoints) {
maxSpeedKmh = math.max(maxSpeedKmh, point.speedKmh);
elevation.add(point.altitudeM);
if (previous != null) {
distanceM += haversineMeters(
previous.latitude,
previous.longitude,
point.latitude,
point.longitude,
);
final dt = point.timestamp - previous.timestamp;
if (dt >= 1 &&
dt <= maxSampleGapMillis &&
point.speedKmh >= speedNoiseFloorKmh) {
movingMillis += dt;
}
}
previous = point;
}
}
elevation.finish();
final elapsedMillis = _elapsedFor(points, segments);
// Guard the divide: a ride that never moved would otherwise produce NaN, which the UI
// happily renders as the literal text "NaN".
final avgMovingSpeedKmh = movingMillis > 0
? distanceM / 1000.0 / (movingMillis / 3600000.0)
: 0.0;
return RideSummary(
distanceM: distanceM,
elapsedMillis: elapsedMillis,
movingMillis: movingMillis,
maxSpeedKmh: maxSpeedKmh,
avgMovingSpeedKmh: avgMovingSpeedKmh,
elevationGainM: elevation.gain,
elevationLossM: elevation.loss,
pointCount: points.length,
);
}
/// Prefers segment boundaries over point timestamps: they capture the time between a
/// segment's last fix and the pause itself, which point timestamps cannot see.
int _elapsedFor(List<TrackPoint> points, List<Segment> segments) {
final closed = <int>[
for (final s in segments)
if (s.endedAt != null) s.endedAt! - s.startedAt,
];
if (closed.isNotEmpty && closed.length == segments.length) {
return closed.fold(0, (a, b) => a + b);
}
if (points.isEmpty) return 0;
var lo = points.first.timestamp;
var hi = points.first.timestamp;
for (final p in points) {
lo = math.min(lo, p.timestamp);
hi = math.max(hi, p.timestamp);
}
return math.max(0, hi - lo);
}
/// Time spent in each speed band. Buckets are keyed on the *interval* between fixes, so
/// the result is a time distribution rather than a sample count — a bike that sits idle
/// at 2 Hz would otherwise dominate purely by producing more samples.
List<SpeedBucket> speedHistogram(List<TrackPoint> points, {int bucketKmh = 10}) {
if (points.length < 2 || bucketKmh <= 0) return const [];
final millisByBucket = <int, int>{};
for (final segmentPoints in _groupBySegment(points).values) {
for (var i = 1; i < segmentPoints.length; i++) {
final dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp;
if (dt < 1 || dt > maxSampleGapMillis) continue;
final bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt();
millisByBucket[bucket] = (millisByBucket[bucket] ?? 0) + dt;
}
}
// Kotlin used a sortedMapOf; Dart maps are insertion-ordered, so sort explicitly.
final keys = millisByBucket.keys.toList()..sort();
return [
for (final bucket in keys)
SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh,
millisByBucket[bucket]!),
];
}
/// Altitude against distance travelled, downsampled for charting.
///
/// Sampled by distance along the path rather than by index, so a long stop does not
/// flatten the interesting part of the profile into a few pixels.
List<ElevationSample> elevationProfile(
List<TrackPoint> points, {
int maxSamples = 200,
}) {
if (points.isEmpty) return const [];
if (points.length == 1) {
return [ElevationSample(0.0, points[0].altitudeM)];
}
final full = <ElevationSample>[];
var cumulative = 0.0;
TrackPoint? previous;
var previousSegment = points.first.segmentId;
for (final point in points) {
if (previous != null) {
// Distance only accrues within a segment, matching computeSummary().
if (point.segmentId == previousSegment) {
cumulative += haversineMeters(
previous.latitude,
previous.longitude,
point.latitude,
point.longitude,
);
}
}
full.add(ElevationSample(cumulative, point.altitudeM));
previous = point;
previousSegment = point.segmentId;
}
if (full.length <= maxSamples) return full;
final step = full.length / maxSamples;
final lastIndex = full.length - 1;
return [
for (var i = 0; i < maxSamples; i++)
full[math.min((i * step).round(), lastIndex)],
full.last,
];
}
/// Elevation gain/loss accumulator that survives GPS altitude noise.
///
/// Two mechanisms, because one is not enough:
///
/// 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely
/// stationary. Averaging over [windowSize] samples cuts the noise by roughly
/// sqrt(windowSize), bringing it under the threshold below.
/// 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down
/// by more than [thresholdM] from its peak. Simply summing every delta that exceeds
/// a threshold does *not* work — noise crosses any small threshold constantly, and a
/// parked bike accumulates well over a kilometre of imaginary climbing. That was
/// measured, not assumed: the naive version reported 1498 m over a parked bike.
///
/// Streaming rather than batch so the recorder can accumulate live and the batch
/// computation can reuse the identical code path.
class ElevationAccumulator {
ElevationAccumulator({
this.windowSize = _smoothingWindow,
this.thresholdM = elevationHysteresisM,
});
final int windowSize;
final double thresholdM;
final _window = Queue<double>();
double _windowSum = 0.0;
double _lastRaw = 0.0;
double? _lastCommitted;
double _extreme = 0.0;
int _direction = 0; // 0 unknown, +1 climbing, -1 descending
double _gain = 0.0;
double _loss = 0.0;
double get gain => _gain;
double get loss => _loss;
void add(double altitudeM) {
if (!altitudeM.isFinite) return;
_lastRaw = altitudeM;
_window.addLast(altitudeM);
_windowSum += altitudeM;
if (_window.length > windowSize) _windowSum -= _window.removeFirst();
final smoothed = _windowSum / _window.length;
final committed = _lastCommitted;
if (committed == null) {
_lastCommitted = smoothed;
_extreme = smoothed;
return;
}
switch (_direction) {
case 0:
if (smoothed > committed + thresholdM) {
_direction = 1;
_extreme = smoothed;
} else if (smoothed < committed - thresholdM) {
_direction = -1;
_extreme = smoothed;
}
case 1:
if (smoothed > _extreme) {
_extreme = smoothed;
} else if (smoothed < _extreme - thresholdM) {
_gain += _extreme - committed;
_lastCommitted = _extreme;
_direction = -1;
_extreme = smoothed;
}
default:
if (smoothed < _extreme) {
_extreme = smoothed;
} else if (smoothed > _extreme + thresholdM) {
_loss += committed - _extreme;
_lastCommitted = _extreme;
_direction = 1;
_extreme = smoothed;
}
}
}
/// Gain including the run still in progress, without mutating state.
///
/// Safe to poll while recording continues — [finish] would end the run, which is wrong
/// mid-ride, but reading only [gain] would report zero for a climb that has not yet
/// turned back down.
double gainIncludingPending() {
final committed = _lastCommitted;
if (committed == null) return _gain;
final tip = _direction == 1 ? math.max(_extreme, _lastRaw) : _extreme;
return _direction == 1 && tip > committed ? _gain + (tip - committed) : _gain;
}
/// Banks the run still in progress. Must be called once the last point is added, or a
/// steady climb to the summit with no descent afterwards reports zero gain.
void finish() {
final committed = _lastCommitted;
if (committed == null) return;
// The moving average lags the true altitude by about half a window, so the final
// smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before
// this). Reconcile against the last raw reading to recover it.
if (_direction == 1) {
_extreme = math.max(_extreme, _lastRaw);
} else if (_direction == -1) {
_extreme = math.min(_extreme, _lastRaw);
}
if (_direction == 1 && _extreme > committed) {
_gain += _extreme - committed;
} else if (_direction == -1 && _extreme < committed) {
_loss += committed - _extreme;
}
_lastCommitted = _extreme;
_direction = 0;
}
}

View File

@@ -0,0 +1,80 @@
/// Ephemeral, in-memory state published straight from the location callback.
///
/// Ported from `com.rippr.LiveTelemetry` and `com.rippr.UploadStatus`. Both were Kotlin
/// `object` singletons over `StateFlow`; here they are small value-holding broadcast
/// streams so the pure layer stays free of Flutter and Riverpod. The UI layer wraps
/// these in providers rather than the other way round.
///
/// Everything in this file is deliberately **not** persisted. Recording state lives in
/// the database because it must survive process death; these must not, because a stale
/// value from a previous process would be actively misleading.
library;
import 'dart:async';
/// A stream that also remembers its current value, so a late subscriber is not blind
/// until the next emission.
class _ValueStream<T> {
_ValueStream(this._value);
final _controller = StreamController<T>.broadcast();
T _value;
T get value => _value;
Stream<T> get stream => _controller.stream;
void set(T next) {
_value = next;
if (!_controller.isClosed) _controller.add(next);
}
Future<void> dispose() => _controller.close();
}
/// The most recent fix, published straight from the location callback.
///
/// The Trip row is only written every ~2 s and carries *max* speed, not current, so the
/// recording screen cannot show a live speedo from it.
///
/// This existing precisely because v2.0 shipped max speed as the headline figure and it
/// read as a frozen screen on a real ride — see `docs/TESTING.md` in the native repo.
class LiveTelemetry {
LiveTelemetry._();
static final instance = LiveTelemetry._();
final _speedKmh = _ValueStream<double>(0.0);
final _accuracyM = _ValueStream<double>(0.0);
double get speedKmh => _speedKmh.value;
double get accuracyM => _accuracyM.value;
Stream<double> get speedStream => _speedKmh.stream;
Stream<double> get accuracyStream => _accuracyM.stream;
void update(double speedKmh, double accuracyM) {
_speedKmh.set(speedKmh);
_accuracyM.set(accuracyM);
}
void clear() {
_speedKmh.set(0.0);
_accuracyM.set(0.0);
}
}
/// Last-seen upload failure, surfaced on the recording screen.
class UploadStatus {
UploadStatus._();
static final instance = UploadStatus._();
final _lastError = _ValueStream<String?>(null);
String? get lastError => _lastError.value;
Stream<String?> get stream => _lastError.stream;
void setError(String? message) => _lastError.set(message);
}

View File

@@ -0,0 +1,63 @@
/// Pure conversion and serialization logic, free of Flutter and platform types.
///
/// Ported from `com.rippr.Telemetry`. The Kotlin original also held process-wide
/// recording state; that was already deleted in v2 (state lives in the database, because
/// it must survive process death) and is not resurrected here.
library;
import 'dart:convert';
import '../domain/models.dart';
const double msToKmhFactor = 3.6;
double msToKmh(double metersPerSecond) => metersPerSecond * msToKmhFactor;
/// A parked bike still emits jittering fixes. Anything under this is reported as zero so
/// "max speed" is not set by GPS noise while the phone sits in a pocket.
const double speedNoiseFloorKmh = 1.5;
double sanitizeSpeedKmh(double raw) {
if (!raw.isFinite || raw < speedNoiseFloorKmh) return 0.0;
return raw;
}
/// Drop fixes too imprecise to be worth storing. 0 means "accuracy unknown".
bool isUsableFix(double accuracyMeters, {double maxAccuracyMeters = 50.0}) =>
accuracyMeters <= 0.0 || accuracyMeters <= maxAccuracyMeters;
String formatDuration(int millis) {
if (millis <= 0) return '00:00:00';
final totalSeconds = millis ~/ 1000;
final h = (totalSeconds ~/ 3600).toString().padLeft(2, '0');
final m = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (totalSeconds % 60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
/// Encode a batch of points for the upload endpoint.
///
/// The trip and segment ids are written **per point, not per batch**: the unsynced-point
/// query draws by id and can straddle a segment or, after a discard-and-restart, a trip
/// boundary. Hoisting them to batch level would silently mislabel points.
String encodeBatch(String deviceId, List<TrackPoint> points) {
final array = points
.map((p) => <String, Object?>{
'id': p.id,
'trip_id': p.tripId,
'segment_id': p.segmentId,
'ts': p.timestamp,
'lat': p.latitude,
'lon': p.longitude,
'speed_kmh': p.speedKmh,
'alt_m': p.altitudeM,
'acc_m': p.accuracyM,
'bearing': p.bearingDeg,
})
.toList(growable: false);
return jsonEncode(<String, Object?>{
'device_id': deviceId,
'points': array,
});
}

35
lib/src/ui/format.dart Normal file
View File

@@ -0,0 +1,35 @@
/// Display formatting shared across screens.
///
/// Ported from `com.rippr.ui.Format`.
///
/// Timestamps come from the platform location fix, which is UTC epoch millis, so
/// everything here converts to the device zone. Formatting in UTC would show a 21:00
/// ride as tomorrow.
library;
import 'package:intl/intl.dart';
import '../domain/models.dart';
// Built per call rather than cached in a top-level final. The Kotlin original captured
// Locale.getDefault() once at class-init; doing the same here would freeze the format
// for the process lifetime and ignore a locale change.
DateFormat get _dayTime => DateFormat("EEE d MMM '·' HH:mm");
DateFormat get _fileStamp => DateFormat('yyyy-MM-dd-HHmm');
String formatDateTime(int epochMillis) =>
_dayTime.format(DateTime.fromMillisecondsSinceEpoch(epochMillis));
String formatFileTimestamp(int epochMillis) =>
_fileStamp.format(DateTime.fromMillisecondsSinceEpoch(epochMillis));
/// A trip's own name, or a date-derived label when it has none.
String tripLabel(Trip trip) => trip.name ?? formatDateTime(trip.startedAt);
String formatDistance(double meters) => meters < 1000
? '${meters.toInt()} m'
: '${(meters / 1000).toStringAsFixed(1)} km';
String formatSpeed(double kmh) => '${kmh.toStringAsFixed(1)} km/h';
String formatElevation(double meters) => '${meters.toInt()} m';

View File

@@ -472,7 +472,7 @@ packages:
source: sdk
version: "0.0.0"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"

View File

@@ -48,6 +48,7 @@ dependencies:
shared_preferences: ^2.5.5
http: ^1.6.0
synchronized: ^3.4.1+1
intl: ^0.20.3
dev_dependencies:
integration_test:

View File

@@ -0,0 +1,302 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Ported from `com.rippr.stats.RideStatisticsTest`.
///
/// ## One case cannot be a literal port: the noise fixture
///
/// The Kotlin original seeds `kotlin.random.Random(42)`. Dart's `Random(42)` is a
/// different generator and produces a different sequence, so this suite cannot assert
/// the same *number* — only the same *bound*. That is fine here, because the assertion
/// was always a regression guard rather than an accuracy claim: a naive implementation
/// reported 1498 m over a parked bike, and anything in that neighbourhood must fail.
///
/// T07's cross-language parity harness must therefore drive elevation from a shared,
/// language-independent fixture rather than from either language's RNG.
/// A deterministic linear congruential generator, implemented identically in Kotlin and
/// Dart so both languages can be driven by the *same* noise sequence. Neither language's
/// built-in `Random` can do this — that is the whole reason this exists.
///
/// Dart ints are 64-bit two's complement on the VM and multiplication wraps, matching
/// Kotlin's `Long`. The Kotlin twin lives in `tool/parity/main.kt`.
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 lon = -114.0,
double speed = 50.0,
double alt = 1000.0,
}) =>
TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: ts,
latitude: lat,
longitude: lon,
speedKmh: speed,
altitudeM: alt,
);
/// A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each.
List<TrackPoint> straightRun(int n,
{int segmentId = 1, int startTs = 0, double speed = 40.0}) =>
List.generate(
n,
(i) => point(
segmentId: segmentId,
ts: startTs + i * 1000,
lat: 51.0 + i * 0.0001,
speed: speed),
);
setUp(() => nextId = 1);
group('distance', () {
test('distance matches the summed haversine hops', () {
final summary = computeSummary(straightRun(11));
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
expect(summary.distanceM, closeTo(111.2, 3.0));
});
test('distance never spans a pause', () {
// Two segments 100 km apart — a rider who trailered between them.
final first = straightRun(5, segmentId: 1, startTs: 0);
final second = List.generate(
5,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final summary = computeSummary([...first, ...second]);
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
expect(summary.distanceM, lessThan(200.0),
reason: 'gap leaked into distance: ${summary.distanceM} m');
});
test('single point has zero distance and no NaN', () {
final summary = computeSummary([point(ts: 0)]);
expect(summary.distanceM, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.pointCount, 1);
});
test('empty input returns the zero summary', () {
expect(computeSummary(const []), RideSummary.empty);
});
});
group('elevation — the regression that matters most', () {
test('stationary noisy altitude yields near-zero elevation gain', () {
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
//
// A naive "sum every delta over the threshold" implementation reported **1498 m**
// of climbing on this shape of input. Smoothing plus reversal hysteresis is what
// brings it down. This bound is a regression guard against that class of failure,
// not an accuracy claim.
//
// ## Why a hand-rolled LCG instead of Random(42)
//
// The Kotlin original seeded `kotlin.random.Random(42)`, which produces a
// different stream from Dart's `Random(42)` — so the two suites could never be
// compared, only vaguely trusted. Driving both from the identical LCG in
// `tool/parity/` proved the port is exact: **38.959594555022136 m in both
// languages, to the last digit.**
//
// That also exposed something about the native app: on a shared fixture this
// algorithm yields ~39 m, which would **fail Kotlin's own 35 m bound**. The
// Kotlin test passes on seed luck, not on a property of the algorithm. A sweep of
// 25 Dart seeds ranged 24.7–46.7 m (median 36). The bound below is therefore set
// from measured behaviour with headroom, rather than inherited from a lucky draw.
final rng = _Lcg(42);
final points = List.generate(
600,
(i) => point(
ts: i * 1000, speed: 0.0, alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(38.959594555022136, 1e-9),
reason: 'must stay bit-identical to the Kotlin implementation');
expect(summary.elevationGainM, lessThan(60.0),
reason:
'phantom climbing: ${summary.elevationGainM} m over a parked bike');
});
test('a genuine climb is recorded', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(100.0, 5.0));
expect(summary.elevationLossM, closeTo(0.0, 5.0));
});
test('a descent counts as loss, not gain', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1100.0 - i),
);
final summary = computeSummary(points);
expect(summary.elevationLossM, closeTo(100.0, 5.0));
expect(summary.elevationGainM, closeTo(0.0, 5.0));
});
test('an out-and-back records both gain and loss', () {
final up = List.generate(51, (i) => point(ts: i * 1000, alt: 1000.0 + i));
final down =
List.generate(51, (i) => point(ts: 51000 + i * 1000, alt: 1050.0 - i));
final summary = computeSummary([...up, ...down]);
expect(summary.elevationGainM, closeTo(50.0, 5.0));
expect(summary.elevationLossM, closeTo(50.0, 5.0));
});
});
group('moving vs elapsed time', () {
test('moving time excludes time below the speed noise floor', () {
final moving = List.generate(60, (i) => point(ts: i * 1000, speed: 40.0));
final stopped =
List.generate(60, (i) => point(ts: (60 + i) * 1000, speed: 0.0));
final summary = computeSummary([...moving, ...stopped]);
expect(summary.movingMillis.toDouble(), closeTo(59000.0, 2000.0),
reason: '~59 s of movement');
expect(summary.elapsedMillis, 119000);
expect(summary.stoppedMillis, greaterThan(55000));
});
test('a long gap between fixes does not inject phantom moving time', () {
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
// 120 s of movement at the last known speed.
final points = [
point(ts: 0, speed: 90.0),
point(ts: 120000, lat: 51.001, speed: 90.0),
];
expect(computeSummary(points).movingMillis, 0);
});
test('elapsed time prefers closed segment spans when available', () {
final points = straightRun(5, startTs: 1000);
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 10000)];
expect(computeSummary(points, segments: segments).elapsedMillis, 10000);
});
test('elapsed falls back to point timestamps when a segment is still open', () {
final points = straightRun(5, startTs: 1000); // 1000..5000
const segments = [Segment(id: 1, tripId: 1, startedAt: 0)];
expect(computeSummary(points, segments: segments).elapsedMillis, 4000);
});
});
group('average speed', () {
test('average speed uses distance over moving time, not the sample mean', () {
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
// => ~40 km/h.
final points = List.generate(
100,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, speed: 40.0),
);
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(40.0, 2.0));
});
test('average speed is zero rather than NaN when nothing moved', () {
final points = List.generate(10, (i) => point(ts: i * 1000, speed: 0.0));
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh.isNaN, isFalse,
reason: 'NaN would render literally on screen');
});
test('max speed is the highest sample', () {
final points = [
point(ts: 0, speed: 40.0),
point(ts: 1000, speed: 118.4),
point(ts: 2000, speed: 60.0),
];
expect(computeSummary(points).maxSpeedKmh, closeTo(118.4, 0.001));
});
});
group('histogram', () {
test('histogram measures time in band, not sample count', () {
final points = [
point(ts: 0, speed: 5.0),
point(ts: 1000, speed: 15.0), // 1 s in 10-20
point(ts: 2000, speed: 15.0), // 1 s in 10-20
point(ts: 3000, speed: 95.0), // 1 s in 90-100
];
final buckets = speedHistogram(points, bucketKmh: 10);
expect(buckets.firstWhere((b) => b.fromKmh == 10).millis, 2000);
expect(buckets.firstWhere((b) => b.fromKmh == 90).millis, 1000);
});
test('histogram is empty for degenerate input', () {
expect(speedHistogram(const []), isEmpty);
expect(speedHistogram([point(ts: 0)]), isEmpty);
});
});
group('elevation profile', () {
test('profile plots altitude against cumulative distance', () {
final points = List.generate(
5,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i * 10),
);
final profile = elevationProfile(points);
expect(profile.length, 5);
expect(profile.first.distanceM, closeTo(0.0, 1e-9));
expect(profile.last.altitudeM, closeTo(1040.0, 1e-9));
expect(profile.last.distanceM, greaterThan(profile.first.distanceM),
reason: 'distance must increase');
});
test('profile does not accrue distance across a pause', () {
final first = List.generate(
3, (i) => point(segmentId: 1, ts: i * 1000, lat: 51.0 + i * 0.0001));
final second = List.generate(
3,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final profile = elevationProfile([...first, ...second]);
expect(profile.last.distanceM, lessThan(200.0),
reason:
'the 111 km gap leaked into the profile: ${profile.last.distanceM}');
});
test('profile downsamples a long ride', () {
final points = List.generate(
21600,
(i) => point(ts: i * 500, lat: 51.0 + i * 0.00001, alt: 1000.0),
);
final profile = elevationProfile(points, maxSamples: 200);
expect(profile.length, lessThanOrEqualTo(201),
reason: 'expected ~200 samples, got ${profile.length}');
expect(profile.last.altitudeM, closeTo(points.last.altitudeM, 1e-9));
});
test('profile handles empty and single-point input', () {
expect(elevationProfile(const []), isEmpty);
expect(elevationProfile([point(ts: 0)]).length, 1);
});
});
}

111
test/telemetry_test.dart Normal file
View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/telemetry/telemetry.dart';
/// Ported from `com.rippr.TelemetryTest`.
///
/// Tolerances are carried over as-is. Note the speed assertions: Kotlin's `Float`
/// widened to `Double` yields 88.5 only within 1e-4, which is exactly why the original
/// used that tolerance. Dart's uniform binary64 is exact here, but the tolerance is kept
/// so the two suites stay comparable line for line.
void main() {
test('converts meters per second to kmh', () {
expect(msToKmh(10), closeTo(36, 0.001));
expect(msToKmh(0), closeTo(0, 0.001));
});
test('gps jitter below the noise floor reports as zero', () {
expect(sanitizeSpeedKmh(0.9), closeTo(0, 0.001));
expect(sanitizeSpeedKmh(double.nan), closeTo(0, 0.001));
expect(sanitizeSpeedKmh(-5), closeTo(0, 0.001));
});
test('real speeds pass through untouched', () {
expect(sanitizeSpeedKmh(97.3), closeTo(97.3, 0.001));
});
test('rejects fixes worse than the accuracy budget', () {
expect(isUsableFix(12), isTrue);
expect(isUsableFix(0), isTrue,
reason: 'unknown accuracy must not be discarded');
expect(isUsableFix(120), isFalse);
});
test('formats duration as hh mm ss', () {
expect(formatDuration(0), '00:00:00');
expect(formatDuration(-1), '00:00:00');
expect(formatDuration(65000), '00:01:05');
expect(formatDuration(7384000), '02:03:04');
});
test('encodes a batch as the documented payload shape', () {
final points = [
const TrackPoint(
id: 7,
tripId: 3,
segmentId: 5,
timestamp: 1700000000000,
latitude: 51.0447,
longitude: -114.0719,
speedKmh: 88.5,
altitudeM: 1045.0,
accuracyM: 4.2,
bearingDeg: 271.5,
),
];
final json =
jsonDecode(encodeBatch('device-abc', points)) as Map<String, dynamic>;
expect(json['device_id'], 'device-abc');
final first = (json['points'] as List).first as Map<String, dynamic>;
expect(first['id'], 7);
expect(first['trip_id'], 3);
expect(first['segment_id'], 5);
expect(first['ts'], 1700000000000);
expect(first['lat'] as double, closeTo(51.0447, 1e-6));
expect(first['lon'] as double, closeTo(-114.0719, 1e-6));
expect(first['speed_kmh'] as double, closeTo(88.5, 1e-4));
});
test('a batch spanning two segments labels each point individually', () {
// A batch is drawn by id and can cross a boundary, so identity must travel with
// the point rather than the batch.
final points = [
const TrackPoint(
id: 1,
tripId: 3,
segmentId: 5,
timestamp: 1,
latitude: 51.0,
longitude: -114.0,
speedKmh: 40,
altitudeM: 1000.0),
const TrackPoint(
id: 2,
tripId: 3,
segmentId: 6,
timestamp: 2,
latitude: 51.1,
longitude: -114.0,
speedKmh: 40,
altitudeM: 1000.0),
];
final array =
(jsonDecode(encodeBatch('d', points)) as Map<String, dynamic>)['points']
as List;
expect((array[0] as Map)['segment_id'], 5);
expect((array[1] as Map)['segment_id'], 6);
expect((array[0] as Map)['trip_id'], 3);
});
test('encodes an empty batch without failing', () {
final json =
jsonDecode(encodeBatch('d', const [])) as Map<String, dynamic>;
expect((json['points'] as List).length, 0);
});
}

59
tool/parity/main.kt Normal file
View File

@@ -0,0 +1,59 @@
import com.rippr.data.TrackPoint
import com.rippr.data.Segment
import com.rippr.geo.Geo
import com.rippr.geo.LatLon
import com.rippr.stats.RideStatistics
// 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.
class Lcg(private var s: Long) {
fun nextDouble(): Double {
s = s * 6364136223846793005L + 1442695040888963407L
val bits = (s ushr 11) and ((1L shl 53) - 1L)
return bits.toDouble() / (1L shl 53).toDouble()
}
}
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)
fun main() {
fun out(k: String, v: Any) = println("$k=$v")
out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938))
out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719))
out("perp_beyond_end", Geo.perpendicularDistanceMeters(
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0)))
val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) }
val simplified = Geo.simplify(ride, 5.0)
out("simplify_count", simplified.size)
out("simplify_last_lat", simplified.last().lat)
out("path_length_ride", Geo.pathLengthMeters(ride))
// The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise.
val rng = Lcg(42L)
val noisy = (0 until 600).map {
p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong())
}
out("noisy_gain", RideStatistics.compute(noisy).elevationGainM)
out("noisy_loss", RideStatistics.compute(noisy).elevationLossM)
val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) }
out("climb_gain", RideStatistics.compute(climb).elevationGainM)
val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) }
val s = RideStatistics.compute(run)
out("run_distance", s.distanceM)
out("run_moving_millis", s.movingMillis)
out("run_avg_speed", s.avgMovingSpeedKmh)
val prof = RideStatistics.elevationProfile(run, 200)
out("profile_size", prof.size)
out("profile_last_distance", prof.last().distanceM)
val hist = RideStatistics.speedHistogram(run, 10)
out("hist_buckets", hist.size)
out("hist_first_millis", hist.first().millis)
}

64
tool/parity/probe.dart Normal file
View File

@@ -0,0 +1,64 @@
// 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/geo/geo.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Same LCG as the Kotlin oracle. Dart ints are 64-bit two's complement on the VM and
/// multiplication wraps, matching Kotlin's Long.
class Lcg {
Lcg(this._s);
int _s;
double nextDouble() {
_s = _s * 6364136223846793005 + 1442695040888963407;
final bits = (_s >>> 11) & ((1 << 53) - 1);
return bits / (1 << 53);
}
}
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);
void main() {
void out(String k, Object v) => print('$k=$v');
out('haversine_calgary_edmonton', haversineMeters(51.0447, -114.0719, 53.5461, -113.4938));
out('haversine_short_hop', haversineMeters(51.0447, -114.0719, 51.04480, -114.0719));
out('perp_beyond_end', perpendicularDistanceMeters(
const LatLon(51.003, -114.0), const LatLon(51.000, -114.0), const LatLon(51.002, -114.0)));
final ride = List.generate(21600,
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001));
final simplified = simplify(ride, 5.0);
out('simplify_count', simplified.length);
out('simplify_last_lat', simplified.last.lat);
out('path_length_ride', pathLengthMeters(ride));
final rng = Lcg(42);
final noisy = List.generate(600,
(i) => p(1, i * 1000, 51.0, -114.0, 0.0, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), i + 1));
out('noisy_gain', computeSummary(noisy).elevationGainM);
out('noisy_loss', computeSummary(noisy).elevationLossM);
final climb = List.generate(101,
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 50.0, 1000.0 + i, i + 1));
out('climb_gain', computeSummary(climb).elevationGainM);
final run = List.generate(100,
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 40.0, 1000.0, i + 1));
final s = computeSummary(run);
out('run_distance', s.distanceM);
out('run_moving_millis', s.movingMillis);
out('run_avg_speed', s.avgMovingSpeedKmh);
final prof = elevationProfile(run, maxSamples: 200);
out('profile_size', prof.length);
out('profile_last_distance', prof.last.distanceM);
final hist = speedHistogram(run, bucketKmh: 10);
out('hist_buckets', hist.length);
out('hist_first_millis', hist.first.millis);
}

51
tool/parity/run.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Cross-language parity harness (T07).
#
# Drives the SAME fixtures through the native Kotlin implementation and the Dart port,
# and diffs the output. This is the port's strongest correctness guarantee: it proved the
# elevation accumulator is bit-identical across both languages.
#
# Requires: kotlinc (brew install kotlin) and a JDK 17-21.
#
# NOTE: never redirect the build to /dev/null. A v2 sweep once returned four identical
# results because a quoting bug corrupted the source while the compile error hid behind
# a redirect. If every value looks suspiciously equal, suspect the harness first.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
NATIVE="${RIPPR_NATIVE:-$HOME/dojo/rippr}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [ ! -d "$NATIVE" ]; then
echo "Native repo not found at $NATIVE. Set RIPPR_NATIVE." >&2
exit 1
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/stats/RideStatistics.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 )
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"
echo "== running Dart side =="
( cd "$HERE/../.." && dart run tool/parity/probe.dart ) | grep '=' | sort > "$WORK/dart.txt"
echo "== diff =="
if diff -u "$WORK/kotlin.txt" "$WORK/dart.txt"; then
echo "PARITY OK - byte-identical on every key"
else
echo
echo "Differences above. Expected and accepted: run_avg_speed and any other"
echo "speed-derived value, where Kotlin's 32-bit Float widens to double with"
echo "artefacts Dart's uniform binary64 does not reproduce. Anything else is a bug."
exit 1
fi

10
tool/parity/stubs.kt Normal file
View File

@@ -0,0 +1,10 @@
package com.rippr.data
data class TrackPoint(
val id: Long = 0, val tripId: Long, val segmentId: Long, val timestamp: Long,
val latitude: Double, val longitude: Double, val speedKmh: Float,
val altitudeM: Double, val accuracyM: Float = 0f, val bearingDeg: Float = 0f,
val synced: Boolean = false,
)
data class Segment(
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
) { val isOpen: Boolean get() = endedAt == null }

View File

@@ -0,0 +1,2 @@
package com.rippr
object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }