Files
samplez/rippr-flutter-src/test/ride_statistics_test.dart
uhryniuk 4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00

357 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/activity_profile.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);
});
});
group('activity profile changes real output (V3-01)', () {
test('a slow walk reads as stationary under the motorcycle floor, but not under '
"walking's own", () {
// 20 fixes a second apart, each ~0.7 km/h -- real walking pace, comfortably under
// the motorcycle noise floor (1.5) but above the walking one (0.3). This is the
// whole point of activity profiles: without one, a recorded walk would show
// 00:00:00 moving time despite every fix showing genuine movement.
final points = List.generate(
20,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0000065, speed: 0.7),
);
final asMotorcycle =
computeSummary(points, profile: ActivityProfile.motorcycle);
final asWalking = computeSummary(points, profile: ActivityProfile.walking);
expect(asMotorcycle.movingMillis, 0,
reason: 'the motorcycle floor must reject real walking speed as noise');
expect(asWalking.movingMillis, greaterThan(0),
reason: "the walking profile must recognise its own pace as movement");
});
test('elevation window size changes what counts as a real climb', () {
// A short, shallow rise across only a handful of samples. A wider averaging
// window (running/walking) smooths it away as noise; the narrower motorcycle
// window banks more of it as real.
final points = List.generate(
10,
(i) => point(ts: i * 1000, alt: 1000.0 + i * 0.5),
);
final motorcycle =
computeSummary(points, profile: ActivityProfile.motorcycle).elevationGainM;
final running =
computeSummary(points, profile: ActivityProfile.running).elevationGainM;
expect(running, lessThanOrEqualTo(motorcycle),
reason: "running's wider smoothing window must not report MORE gain than "
"motorcycle's narrower one on the same climb");
});
test('defaults to the motorcycle profile when none is given', () {
final points = List.generate(
5,
(i) => point(ts: i * 1000, speed: 40.0),
);
expect(
computeSummary(points),
computeSummary(points, profile: ActivityProfile.motorcycle),
);
});
});
}