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:
302
test/ride_statistics_test.dart
Normal file
302
test/ride_statistics_test.dart
Normal 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
111
test/telemetry_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user