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).
357 lines
14 KiB
Dart
357 lines
14 KiB
Dart
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),
|
||
);
|
||
});
|
||
});
|
||
}
|