Add the Rippr Flutter port: source, history bundle, and installable APK
The port runs on Android and iOS and is feature-complete; the native Android app is superseded but kept, since it is still the only version that has recorded real rides. rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different from the native com.rippr so both install side by side. Recording the same ride on both at once is the strongest available check that the port is faithful. Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS has no APK equivalent and must be built and signed through Xcode with a free Apple ID, which gives a 7-day profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
248
rippr-flutter-src/test/database_test.dart
Normal file
248
rippr-flutter-src/test/database_test.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
|
||||
/// Ported from `com.rippr.data.SchemaTest`.
|
||||
///
|
||||
/// **These were instrumented tests requiring a device.** Against Drift on the Dart VM
|
||||
/// they are plain unit tests: faster, and runnable with no emulator booted. That is one
|
||||
/// of the concrete wins of the port.
|
||||
///
|
||||
/// Every test uses an in-memory database. The native suite once wiped a real device's
|
||||
/// rides by running against the production singleton in `setUp`, so this is not
|
||||
/// negotiable — see `docs/TESTING.md` in the native repo.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<(int tripId, int segmentId)> seedTrip({int startedAt = 1000}) async {
|
||||
final tripId =
|
||||
await db.insertTrip(Trip(startedAt: startedAt, state: TripState.recording));
|
||||
final segmentId = await db.insertSegment(tripId, startedAt);
|
||||
return (tripId, segmentId);
|
||||
}
|
||||
|
||||
TrackPoint point(int tripId, int segmentId, int ts, {double speed = 50.0}) =>
|
||||
TrackPoint(
|
||||
tripId: tripId,
|
||||
segmentId: segmentId,
|
||||
timestamp: ts,
|
||||
latitude: 51.0447,
|
||||
longitude: -114.0719,
|
||||
speedKmh: speed,
|
||||
altitudeM: 1045.0,
|
||||
);
|
||||
|
||||
group('cascade', () {
|
||||
test('deleting a trip cascades to segments and points', () async {
|
||||
final (tripId, segmentId) = await seedTrip();
|
||||
await db.insertPoints(
|
||||
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||
|
||||
expect(await db.countPointsForTrip(tripId), 5);
|
||||
expect(await db.countSegmentsForTrip(tripId), 1);
|
||||
|
||||
await db.deleteTrip(tripId);
|
||||
|
||||
expect(await db.countPointsForTrip(tripId), 0);
|
||||
expect(await db.countSegmentsForTrip(tripId), 0);
|
||||
expect(await db.getTrip(tripId), isNull);
|
||||
});
|
||||
|
||||
test('foreign keys are actually enforced', () async {
|
||||
// SQLite defaults foreign_keys to OFF. Room enabled it; Drift does not, so the
|
||||
// pragma in beforeOpen is load-bearing. Without it the CASCADE above is
|
||||
// decorative and every delete silently orphans its points.
|
||||
final result =
|
||||
await db.customSelect('PRAGMA foreign_keys').getSingle();
|
||||
expect(result.data.values.first, 1,
|
||||
reason: 'foreign_keys pragma is off — CASCADE would do nothing');
|
||||
});
|
||||
|
||||
test('a point cannot reference a trip that does not exist', () async {
|
||||
await expectLater(
|
||||
db.insertPoint(point(9999, 9999, 1000)),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('active trip', () {
|
||||
test('an open trip is the active one and a closed trip is not', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
|
||||
expect((await db.getActiveTrip())?.id, tripId);
|
||||
|
||||
await db.closeTrip(tripId, 5000);
|
||||
|
||||
expect(await db.getActiveTrip(), isNull);
|
||||
});
|
||||
|
||||
test('watchActiveTrip emits the open trip', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
expect((await db.watchActiveTrip().first)?.id, tripId);
|
||||
});
|
||||
|
||||
test('completed trips are ordered newest first', () async {
|
||||
final a = await db.insertTrip(
|
||||
const Trip(startedAt: 1000, state: TripState.recording));
|
||||
final b = await db.insertTrip(
|
||||
const Trip(startedAt: 3000, state: TripState.recording));
|
||||
final c = await db.insertTrip(
|
||||
const Trip(startedAt: 2000, state: TripState.recording));
|
||||
for (final id in [a, b, c]) {
|
||||
await db.closeTrip(id, 9000);
|
||||
}
|
||||
|
||||
final listed = await db.watchCompletedTrips().first;
|
||||
expect(listed.map((t) => t.startedAt).toList(), [3000, 2000, 1000]);
|
||||
});
|
||||
});
|
||||
|
||||
group('point ordering', () {
|
||||
test('points come back by segment then id, not by timestamp', () async {
|
||||
// Timestamps are GPS-derived and can jump; id is monotonic in write order. The
|
||||
// ordering contract is what keeps a ride walkable with pauses intact.
|
||||
final (tripId, seg1) = await seedTrip();
|
||||
final seg2 = await db.insertSegment(tripId, 2000);
|
||||
|
||||
await db.insertPoints([
|
||||
point(tripId, seg2, 500), // deliberately the earliest timestamp
|
||||
point(tripId, seg1, 9000),
|
||||
point(tripId, seg1, 1000),
|
||||
]);
|
||||
|
||||
final ordered = await db.pointsForTrip(tripId);
|
||||
expect(ordered.map((p) => p.segmentId).toList(), [seg1, seg1, seg2]);
|
||||
expect(ordered.first.timestamp, 9000,
|
||||
reason: 'must not be re-sorted by timestamp');
|
||||
});
|
||||
|
||||
test('lastInSegment returns the highest id, the recorder anchor', () async {
|
||||
final (tripId, segmentId) = await seedTrip();
|
||||
await db.insertPoints(
|
||||
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||
|
||||
final last = await db.lastInSegment(segmentId);
|
||||
expect(last!.timestamp, 1004);
|
||||
});
|
||||
|
||||
test('openSegment finds the one still being recorded into', () async {
|
||||
final (tripId, seg1) = await seedTrip();
|
||||
await db.closeSegment(seg1, 2000);
|
||||
final seg2 = await db.insertSegment(tripId, 3000);
|
||||
|
||||
expect((await db.openSegment(tripId))?.id, seg2);
|
||||
|
||||
await db.closeSegment(seg2, 4000);
|
||||
expect(await db.openSegment(tripId), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('stats guards', () {
|
||||
test('a trip with no points reports zeros rather than failing', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
|
||||
final stats = await db.watchTripStats(tripId).first;
|
||||
|
||||
expect(stats.pointCount, 0);
|
||||
expect(stats.maxSpeedKmh, 0.0);
|
||||
expect(stats.avgSpeedKmh, 0.0);
|
||||
expect(stats.firstTimestamp, 0);
|
||||
expect(stats.durationMillis, 0);
|
||||
});
|
||||
|
||||
test('stats aggregate over the trip', () async {
|
||||
final (tripId, segmentId) = await seedTrip();
|
||||
await db.insertPoints([
|
||||
point(tripId, segmentId, 1000, speed: 10),
|
||||
point(tripId, segmentId, 2000, speed: 50),
|
||||
point(tripId, segmentId, 3000, speed: 30),
|
||||
]);
|
||||
|
||||
final stats = await db.watchTripStats(tripId).first;
|
||||
|
||||
expect(stats.pointCount, 3);
|
||||
expect(stats.maxSpeedKmh, 50.0);
|
||||
expect(stats.avgSpeedKmh, closeTo(30.0, 1e-9));
|
||||
expect(stats.durationMillis, 2000);
|
||||
expect(stats.pendingUpload, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('upload backlog', () {
|
||||
test('unsynced points come back oldest first and mark cleanly', () async {
|
||||
final (tripId, segmentId) = await seedTrip();
|
||||
await db.insertPoints(
|
||||
List.generate(10, (i) => point(tripId, segmentId, 1000 + i)));
|
||||
|
||||
expect(await db.countUnsynced(), 10);
|
||||
|
||||
final batch = await db.unsyncedPoints(4);
|
||||
expect(batch.length, 4);
|
||||
expect(batch.first.timestamp, 1000);
|
||||
|
||||
await db.markSynced(batch.map((p) => p.id).toList());
|
||||
|
||||
expect(await db.countUnsynced(), 6);
|
||||
expect((await db.unsyncedPoints(1)).first.timestamp, 1004);
|
||||
});
|
||||
|
||||
test('marking an empty list is a no-op', () async {
|
||||
final (tripId, segmentId) = await seedTrip();
|
||||
await db.insertPoints([point(tripId, segmentId, 1000)]);
|
||||
await db.markSynced(const []);
|
||||
expect(await db.countUnsynced(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('aggregates', () {
|
||||
test('updateAggregates writes only the running totals', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
await db.renameTrip(tripId, 'Morning loop');
|
||||
|
||||
await db.updateAggregates(
|
||||
id: tripId,
|
||||
distanceM: 1234.5,
|
||||
movingMillis: 60000,
|
||||
maxSpeedKmh: 88.5,
|
||||
elevationGainM: 42.0,
|
||||
pointCount: 99,
|
||||
);
|
||||
|
||||
final trip = (await db.getTrip(tripId))!;
|
||||
expect(trip.distanceM, 1234.5);
|
||||
expect(trip.movingMillis, 60000);
|
||||
expect(trip.maxSpeedKmh, 88.5);
|
||||
expect(trip.elevationGainM, 42.0);
|
||||
expect(trip.pointCount, 99);
|
||||
expect(trip.name, 'Morning loop', reason: 'unrelated columns must survive');
|
||||
expect(trip.endedAt, isNull, reason: 'must not close the trip');
|
||||
});
|
||||
|
||||
test('state round-trips through the enum column', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
|
||||
await db.setTripState(tripId, TripState.paused);
|
||||
expect((await db.getTrip(tripId))!.state, TripState.paused);
|
||||
|
||||
await db.closeTrip(tripId, 5000);
|
||||
expect((await db.getTrip(tripId))!.state, TripState.completed);
|
||||
});
|
||||
|
||||
test('renaming to null clears the name rather than storing empty', () async {
|
||||
final (tripId, _) = await seedTrip();
|
||||
await db.renameTrip(tripId, 'Something');
|
||||
await db.renameTrip(tripId, null);
|
||||
expect((await db.getTrip(tripId))!.name, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
204
rippr-flutter-src/test/geo_test.dart
Normal file
204
rippr-flutter-src/test/geo_test.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/geo/geo.dart';
|
||||
|
||||
/// Ported from `com.rippr.geo.GeoTest` (native Android v2.0.1).
|
||||
///
|
||||
/// Every case and every tolerance is carried over unchanged. Where the Kotlin
|
||||
/// suite encoded a hard-won correction — notably the Calgary–Edmonton
|
||||
/// great-circle figure, which was verified by hand after the *test* turned out to
|
||||
/// be wrong rather than the code — that correction travels with it.
|
||||
void main() {
|
||||
const calgary = LatLon(51.0447, -114.0719);
|
||||
const edmonton = LatLon(53.5461, -113.4938);
|
||||
|
||||
List<LatLon> line(int n) =>
|
||||
List.generate(n, (i) => LatLon(51.0 + i * 0.001, -114.0));
|
||||
|
||||
group('haversine', () {
|
||||
test('matches a known long-distance reference', () {
|
||||
// Calgary to Edmonton, great-circle. Cross-checked by hand: 2.5014° of
|
||||
// latitude is ~278.1 km, and 0.5781° of longitude at ~52.3° is ~39.3 km,
|
||||
// giving sqrt(278.1² + 39.3²) ≈ 280.9 km. (Road distance is ~300 km — not
|
||||
// the same thing.)
|
||||
final d = haversineBetween(calgary, edmonton);
|
||||
expect(d, closeTo(280900.0, 280900.0 * 0.005));
|
||||
});
|
||||
|
||||
test('one degree of latitude is about 111 km anywhere', () {
|
||||
final atEquator = haversineMeters(0.0, 0.0, 1.0, 0.0);
|
||||
final atLatitude60 = haversineMeters(60.0, 0.0, 61.0, 0.0);
|
||||
expect(atEquator, closeTo(111195.0, 200.0));
|
||||
expect(atLatitude60, closeTo(atEquator, 200.0),
|
||||
reason: 'latitude spacing does not vary with longitude');
|
||||
});
|
||||
|
||||
test('a degree of longitude shrinks with latitude', () {
|
||||
final atEquator = haversineMeters(0.0, 0.0, 0.0, 1.0);
|
||||
final atLatitude60 = haversineMeters(60.0, 0.0, 60.0, 1.0);
|
||||
// cos(60°) = 0.5, so it should be about half.
|
||||
expect(atLatitude60, closeTo(atEquator / 2, 500.0));
|
||||
});
|
||||
|
||||
test('identical points are zero distance', () {
|
||||
expect(haversineBetween(calgary, calgary), closeTo(0.0, 1e-9));
|
||||
});
|
||||
|
||||
test('short hops between consecutive fixes stay accurate', () {
|
||||
// ~11 m north, the scale of a 2 Hz fix at road speed.
|
||||
final d = haversineMeters(51.0447, -114.0719, 51.04480, -114.0719);
|
||||
expect(d, closeTo(11.1, 0.5));
|
||||
});
|
||||
|
||||
test('distance is symmetric', () {
|
||||
expect(
|
||||
haversineBetween(calgary, edmonton),
|
||||
closeTo(haversineBetween(edmonton, calgary), 1e-6),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles an antimeridian crossing without exploding', () {
|
||||
// 0.02° apart in longitude, straddling +/-180.
|
||||
final d = haversineMeters(0.0, 179.99, 0.0, -179.99);
|
||||
expect(d, lessThan(3000), reason: 'expected a short hop, got $d m');
|
||||
});
|
||||
});
|
||||
|
||||
group('douglas-peucker', () {
|
||||
test('a straight line collapses to its endpoints', () {
|
||||
final simplified = simplify(line(50), 5.0);
|
||||
expect(simplified.length, 2);
|
||||
expect(simplified.first, line(50).first);
|
||||
expect(simplified.last, line(50).last);
|
||||
});
|
||||
|
||||
test('endpoints always survive', () {
|
||||
final zigzag = List.generate(
|
||||
30,
|
||||
(i) => LatLon(51.0 + i * 0.001, -114.0 + (i % 2 == 0 ? 0.0 : 0.002)),
|
||||
);
|
||||
final simplified = simplify(zigzag, 5.0);
|
||||
expect(simplified.first, zigzag.first);
|
||||
expect(simplified.last, zigzag.last);
|
||||
});
|
||||
|
||||
test('deviation above epsilon is preserved', () {
|
||||
// Middle point sits ~110 m off the line between its neighbours.
|
||||
const points = [
|
||||
LatLon(51.000, -114.0),
|
||||
LatLon(51.001, -113.999),
|
||||
LatLon(51.002, -114.0),
|
||||
];
|
||||
expect(simplify(points, 5.0).length, 3);
|
||||
});
|
||||
|
||||
test('deviation below epsilon is dropped', () {
|
||||
const points = [
|
||||
LatLon(51.000, -114.0),
|
||||
LatLon(51.001, -114.00001), // under a metre off the line
|
||||
LatLon(51.002, -114.0),
|
||||
];
|
||||
expect(simplify(points, 5.0).length, 2);
|
||||
});
|
||||
|
||||
test('epsilon of zero returns the input untouched', () {
|
||||
final input = line(20);
|
||||
expect(simplify(input, 0.0), input);
|
||||
});
|
||||
|
||||
test('inputs of two or fewer are returned as-is', () {
|
||||
expect(simplify(const <LatLon>[], 5.0).length, 0);
|
||||
expect(simplify(line(1), 5.0).length, 1);
|
||||
expect(simplify(line(2), 5.0).length, 2);
|
||||
});
|
||||
|
||||
test('handles a full ride without stack overflow and stays fast', () {
|
||||
// Three hours at 2 Hz, the real scale this has to survive.
|
||||
final ride = List.generate(
|
||||
21600,
|
||||
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001),
|
||||
);
|
||||
final started = DateTime.now();
|
||||
final simplified = simplify(ride, 5.0);
|
||||
final elapsedMs = DateTime.now().difference(started).inMilliseconds;
|
||||
|
||||
expect(simplified.length, lessThan(ride.length),
|
||||
reason: 'simplification should reduce the point count');
|
||||
expect(simplified.first, ride.first);
|
||||
expect(simplified.last, ride.last);
|
||||
expect(elapsedMs, lessThan(1000),
|
||||
reason: 'took ${elapsedMs}ms, expected well under a second');
|
||||
});
|
||||
});
|
||||
|
||||
group('perpendicular distance', () {
|
||||
test('a point on the segment has zero perpendicular distance', () {
|
||||
final d = perpendicularDistanceMeters(
|
||||
const LatLon(51.001, -114.0),
|
||||
const LatLon(51.000, -114.0),
|
||||
const LatLon(51.002, -114.0),
|
||||
);
|
||||
expect(d.abs(), lessThan(0.01), reason: 'expected ~0, got $d');
|
||||
});
|
||||
|
||||
test('a point beyond the end measures to the endpoint, not the infinite line',
|
||||
() {
|
||||
// Directly past the segment's end along the same bearing.
|
||||
final d = perpendicularDistanceMeters(
|
||||
const LatLon(51.003, -114.0),
|
||||
const LatLon(51.000, -114.0),
|
||||
const LatLon(51.002, -114.0),
|
||||
);
|
||||
expect(d, closeTo(111.0, 5.0),
|
||||
reason: 'should be the ~111 m to the endpoint');
|
||||
});
|
||||
|
||||
test('a degenerate segment falls back to point distance', () {
|
||||
const a = LatLon(51.0, -114.0);
|
||||
final d = perpendicularDistanceMeters(const LatLon(51.001, -114.0), a, a);
|
||||
expect(d, closeTo(111.0, 5.0));
|
||||
});
|
||||
});
|
||||
|
||||
group('bounds', () {
|
||||
test('bounds is null for an empty path', () {
|
||||
expect(bounds(const <LatLon>[]), isNull);
|
||||
});
|
||||
|
||||
test('bounds spans mixed-sign coordinates', () {
|
||||
final b = bounds(const [
|
||||
LatLon(-10.0, -20.0),
|
||||
LatLon(30.0, 40.0),
|
||||
LatLon(5.0, 0.0),
|
||||
])!;
|
||||
expect(b.minLat, closeTo(-10.0, 1e-9));
|
||||
expect(b.maxLat, closeTo(30.0, 1e-9));
|
||||
expect(b.minLon, closeTo(-20.0, 1e-9));
|
||||
expect(b.maxLon, closeTo(40.0, 1e-9));
|
||||
expect(b.centerLat, closeTo(10.0, 1e-9));
|
||||
});
|
||||
|
||||
test('a stationary ride reports degenerate bounds', () {
|
||||
final b = bounds(List.filled(10, calgary))!;
|
||||
expect(b.isDegenerate, isTrue,
|
||||
reason: 'map auto-fit must special-case this');
|
||||
});
|
||||
|
||||
test('a real path is not degenerate', () {
|
||||
expect(bounds(line(10))!.isDegenerate, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('path length', () {
|
||||
test('path length sums consecutive hops', () {
|
||||
final points = line(11); // ten hops of 0.001 degrees latitude
|
||||
expect(pathLengthMeters(points), closeTo(10 * 111.19, 20.0));
|
||||
});
|
||||
|
||||
test('path length of fewer than two points is zero', () {
|
||||
expect(pathLengthMeters(const <LatLon>[]), closeTo(0.0, 1e-9));
|
||||
expect(pathLengthMeters(const [calgary]), closeTo(0.0, 1e-9));
|
||||
});
|
||||
});
|
||||
}
|
||||
440
rippr-flutter-src/test/recording_engine_test.dart
Normal file
440
rippr-flutter-src/test/recording_engine_test.dart
Normal file
@@ -0,0 +1,440 @@
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/recording/location_source.dart';
|
||||
import 'package:rippr/src/recording/recording_engine.dart';
|
||||
|
||||
/// Covers `TrackingServiceLifecycleTest`'s ground, but on the Dart VM.
|
||||
///
|
||||
/// The Kotlin equivalent drove a real Android `Service` through all five actions on a
|
||||
/// device, polled the database with a 25 s timeout, and was flaky enough that the timeout
|
||||
/// was once raised to hide what turned out to be a real `stopSelf()` race. None of that
|
||||
/// applies here: the pipeline is plain Dart behind a fake location source, so these run
|
||||
/// deterministically in milliseconds.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late TripRepository repo;
|
||||
late FakeLocationSource source;
|
||||
late RecordingEngine engine;
|
||||
late int fakeNow;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
repo = TripRepository(db);
|
||||
source = FakeLocationSource();
|
||||
fakeNow = 1000;
|
||||
engine = RecordingEngine(
|
||||
repository: repo,
|
||||
locationSource: source,
|
||||
clock: () => fakeNow,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await engine.dispose();
|
||||
await source.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
/// Lets the broadcast stream deliver. Fixes reach the engine on a microtask, not
|
||||
/// synchronously, so every emission must be followed by this before asserting.
|
||||
Future<void> settle() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
/// Emits [n] fixes a second apart, moving ~11 m north each time.
|
||||
Future<void> ride(int n,
|
||||
{int startTs = 1000, double startLat = 51.0, double speedMps = 11.11}) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
source.emitAt(
|
||||
timestamp: startTs + i * 1000,
|
||||
latitude: startLat + i * 0.0001,
|
||||
speedMps: speedMps,
|
||||
);
|
||||
}
|
||||
await settle();
|
||||
}
|
||||
|
||||
group('the fix path', () {
|
||||
test('a started ride buffers fixes without writing immediately', () async {
|
||||
await engine.start();
|
||||
await ride(5);
|
||||
|
||||
expect(engine.pendingCount, 5,
|
||||
reason: 'the fix path must not touch the database');
|
||||
expect(await db.countPointsForTrip(engine.currentTripId), 0);
|
||||
});
|
||||
|
||||
test('fixes are ignored before start and after stop', () async {
|
||||
await ride(3);
|
||||
expect(engine.pendingCount, 0, reason: 'source is not running yet');
|
||||
|
||||
await engine.start();
|
||||
await engine.stop();
|
||||
await ride(3);
|
||||
expect(engine.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('fixes worse than the accuracy budget are dropped', () async {
|
||||
await engine.start();
|
||||
source.emitAt(timestamp: 1000, accuracyM: 500);
|
||||
await settle();
|
||||
expect(engine.pendingCount, 0);
|
||||
|
||||
source.emitAt(timestamp: 2000, accuracyM: 5);
|
||||
await settle();
|
||||
expect(engine.pendingCount, 1);
|
||||
});
|
||||
|
||||
test('speed is converted and the noise floor applied', () async {
|
||||
await engine.start();
|
||||
source.emitAt(timestamp: 1000, speedMps: 10.0); // 36 km/h
|
||||
source.emitAt(timestamp: 2000, speedMps: 0.1); // 0.36 km/h, under the floor
|
||||
await settle();
|
||||
await engine.stop();
|
||||
|
||||
final points = await db.allPoints();
|
||||
expect(points[0].speedKmh, closeTo(36.0, 1e-9));
|
||||
expect(points[1].speedKmh, 0.0,
|
||||
reason: 'jitter under the noise floor must read as zero');
|
||||
});
|
||||
|
||||
test('live telemetry updates at fix rate', () async {
|
||||
await engine.start();
|
||||
source.emitAt(timestamp: 1000, speedMps: 20.0);
|
||||
await settle();
|
||||
expect(engine.pendingCount, 1);
|
||||
// 20 m/s is 72 km/h.
|
||||
// LiveTelemetry is a singleton; the value is what the record screen reads.
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
group('persistence', () {
|
||||
test('stop drains everything buffered', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(7);
|
||||
|
||||
await engine.stop();
|
||||
|
||||
expect(await db.countPointsForTrip(tripId), 7);
|
||||
expect(engine.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('aggregates are persisted and then reconciled on stop', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(11); // ten hops of ~11.12 m
|
||||
|
||||
await engine.stop();
|
||||
|
||||
final trip = (await repo.tripById(tripId))!;
|
||||
expect(trip.pointCount, 11);
|
||||
expect(trip.distanceM, closeTo(111.2, 3.0));
|
||||
expect(trip.state, TripState.completed);
|
||||
expect(trip.endedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('a ride that captured nothing is discarded, not saved', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
|
||||
final result = await engine.stop();
|
||||
|
||||
expect(result, isNull);
|
||||
expect(await repo.tripById(tripId), isNull,
|
||||
reason: 'an empty trip is noise in the history list');
|
||||
expect(await db.countTrips(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('pause and segments', () {
|
||||
test('the full lifecycle produces one trip and two segments', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(3, startTs: 1000);
|
||||
|
||||
await engine.pause();
|
||||
await ride(3, startTs: 5000); // ignored: the source is stopped
|
||||
|
||||
fakeNow = 6000;
|
||||
await engine.start(); // resume
|
||||
await ride(3, startTs: 6000, startLat: 51.001);
|
||||
|
||||
await engine.stop();
|
||||
|
||||
expect(await db.countTrips(), 1);
|
||||
final segments = await repo.segmentsForTrip(tripId);
|
||||
expect(segments.length, 2);
|
||||
expect(segments.every((s) => !s.isOpen), isTrue);
|
||||
expect(await db.countPointsForTrip(tripId), 6,
|
||||
reason: 'fixes emitted while paused must not be recorded');
|
||||
});
|
||||
|
||||
test('a fix in flight at pause lands in the segment it belongs to', () async {
|
||||
// This is the guarantee that stamping ids at creation exists for. The fix is
|
||||
// buffered before the pause and written after it; it must carry the OLD segment id.
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
final firstSegment =
|
||||
(await repo.segmentsForTrip(tripId)).single.id;
|
||||
|
||||
await ride(2); // buffered, not yet written
|
||||
expect(engine.pendingCount, 2);
|
||||
|
||||
await engine.pause();
|
||||
|
||||
final points = await db.pointsForTrip(tripId);
|
||||
expect(points.length, 2);
|
||||
expect(points.every((p) => p.segmentId == firstSegment), isTrue,
|
||||
reason: 'a queued fix must not be re-homed into the next segment');
|
||||
});
|
||||
|
||||
test('distance does not span the pause', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(3, startLat: 51.0);
|
||||
|
||||
await engine.pause();
|
||||
fakeNow = 6000;
|
||||
await engine.start();
|
||||
// Resumed a full degree of latitude away — a trailered gap of ~111 km.
|
||||
await ride(3, startTs: 6000, startLat: 52.0);
|
||||
await engine.stop();
|
||||
|
||||
final trip = (await repo.tripById(tripId))!;
|
||||
expect(trip.distanceM, lessThan(200.0),
|
||||
reason: 'the pause gap leaked into distance: ${trip.distanceM} m');
|
||||
});
|
||||
|
||||
test('pause then stop completes the trip normally', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(3);
|
||||
await engine.pause();
|
||||
|
||||
final result = await engine.stop();
|
||||
|
||||
expect(result, tripId);
|
||||
expect((await repo.tripById(tripId))!.state, TripState.completed);
|
||||
});
|
||||
});
|
||||
|
||||
group('discard', () {
|
||||
test('discard deletes the trip and everything buffered', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(5);
|
||||
|
||||
await engine.discard();
|
||||
|
||||
expect(await repo.tripById(tripId), isNull);
|
||||
expect(await db.countTrips(), 0);
|
||||
expect(engine.pendingCount, 0);
|
||||
expect(engine.state, RecorderState.idle);
|
||||
});
|
||||
|
||||
test('discard leaves earlier completed rides alone', () async {
|
||||
await engine.start();
|
||||
final keep = engine.currentTripId;
|
||||
await ride(3);
|
||||
await engine.stop();
|
||||
|
||||
fakeNow = 10000;
|
||||
await engine.start();
|
||||
await ride(3, startTs: 10000);
|
||||
await engine.discard();
|
||||
|
||||
expect(await repo.tripById(keep), isNotNull);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('idempotency and state', () {
|
||||
test('start twice adopts rather than duplicating', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await engine.start();
|
||||
|
||||
expect(engine.currentTripId, tripId);
|
||||
expect(await db.countTrips(), 1);
|
||||
expect(await db.countSegmentsForTrip(tripId), 1);
|
||||
});
|
||||
|
||||
test('pause twice is safe', () async {
|
||||
await engine.start();
|
||||
await engine.pause();
|
||||
await engine.pause();
|
||||
expect(engine.state, RecorderState.paused);
|
||||
});
|
||||
|
||||
test('stop with nothing running is a safe no-op', () async {
|
||||
expect(await engine.stop(), isNull);
|
||||
expect(engine.state, RecorderState.idle);
|
||||
});
|
||||
|
||||
test('the source is started and stopped in step with the lifecycle', () async {
|
||||
await engine.start();
|
||||
expect(source.isRunning, isTrue);
|
||||
|
||||
await engine.pause();
|
||||
expect(source.isRunning, isFalse);
|
||||
|
||||
await engine.start();
|
||||
expect(source.isRunning, isTrue);
|
||||
|
||||
await engine.stop();
|
||||
expect(source.isRunning, isFalse);
|
||||
});
|
||||
|
||||
test('state transitions are observable', () async {
|
||||
final seen = <RecorderState>[];
|
||||
final sub = engine.stateStream.listen(seen.add);
|
||||
|
||||
await engine.start();
|
||||
await engine.pause();
|
||||
await engine.start();
|
||||
await engine.stop();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await sub.cancel();
|
||||
|
||||
expect(seen, [
|
||||
RecorderState.recording,
|
||||
RecorderState.paused,
|
||||
RecorderState.recording,
|
||||
RecorderState.idle,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a recording trip is resumed into a new segment', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(5);
|
||||
// Deliberately no stop(): dispose without completing simulates a process kill.
|
||||
await engine.dispose();
|
||||
|
||||
// A brand new engine over the same database, as after a process restart.
|
||||
final revived = RecordingEngine(
|
||||
repository: repo,
|
||||
locationSource: source,
|
||||
clock: () => fakeNow,
|
||||
);
|
||||
addTearDown(revived.dispose);
|
||||
|
||||
fakeNow = 20000;
|
||||
final state = await revived.restoreAfterProcessDeath();
|
||||
|
||||
expect(state, RecorderState.recording);
|
||||
expect(revived.currentTripId, tripId);
|
||||
final segments = await repo.segmentsForTrip(tripId);
|
||||
expect(segments.length, 2,
|
||||
reason: 'the dead time is a real gap and must render as one');
|
||||
});
|
||||
|
||||
test('the dead time is never measured as distance', () async {
|
||||
// The bug this guards is in the native app: after a crash the open segment is
|
||||
// adopted rather than closed, so computeSummary -- the authoritative pass at trip
|
||||
// completion -- measures straight across the gap. A rider who crashes in town and
|
||||
// relaunches downtown would have those kilometres added to their ride.
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
final crashedSegment = (await repo.segmentsForTrip(tripId)).single.id;
|
||||
|
||||
// Write points the way the flush loop would have, *without* going through pause or
|
||||
// stop — so the segment is left OPEN, which is exactly what a crash leaves behind.
|
||||
// Using pause() here would close the segment and quietly stop reproducing the bug.
|
||||
await repo.appendPoints([
|
||||
for (var i = 0; i < 3; i++)
|
||||
TrackPoint(
|
||||
tripId: tripId,
|
||||
segmentId: crashedSegment,
|
||||
timestamp: 1000 + i * 1000,
|
||||
latitude: 51.0 + i * 0.0001,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
await engine.dispose();
|
||||
|
||||
final revived = RecordingEngine(
|
||||
repository: repo,
|
||||
locationSource: source,
|
||||
clock: () => fakeNow,
|
||||
);
|
||||
addTearDown(revived.dispose);
|
||||
|
||||
fakeNow = 900000;
|
||||
await revived.restoreAfterProcessDeath();
|
||||
// Relaunched a full degree of latitude away — ~111 km from where it died.
|
||||
await ride(3, startTs: 900000, startLat: 52.0);
|
||||
await revived.stop();
|
||||
|
||||
final trip = (await repo.tripById(tripId))!;
|
||||
expect(trip.distanceM, lessThan(200.0),
|
||||
reason:
|
||||
'the dead time leaked into distance: ${trip.distanceM} m — the crash gap '
|
||||
'was measured as if it had been ridden');
|
||||
});
|
||||
|
||||
test('restored totals continue rather than restarting from zero', () async {
|
||||
await engine.start();
|
||||
final tripId = engine.currentTripId;
|
||||
await ride(11);
|
||||
// Force the buffered points and their aggregates to disk without completing.
|
||||
await engine.pause();
|
||||
|
||||
final before = (await repo.tripById(tripId))!;
|
||||
expect(before.distanceM, greaterThan(100.0));
|
||||
|
||||
await engine.dispose();
|
||||
|
||||
final revived = RecordingEngine(
|
||||
repository: repo,
|
||||
locationSource: source,
|
||||
clock: () => fakeNow,
|
||||
);
|
||||
addTearDown(revived.dispose);
|
||||
|
||||
fakeNow = 30000;
|
||||
await revived.start(); // resumes the paused trip
|
||||
await ride(3, startTs: 30000, startLat: 53.0);
|
||||
await revived.stop();
|
||||
|
||||
final after = (await repo.tripById(tripId))!;
|
||||
expect(after.pointCount, 14, reason: 'earlier points must be kept');
|
||||
expect(after.distanceM, greaterThanOrEqualTo(before.distanceM),
|
||||
reason: 'distance must not restart from zero');
|
||||
});
|
||||
|
||||
test('a paused trip is restored as paused, not resumed', () async {
|
||||
await engine.start();
|
||||
await ride(3);
|
||||
await engine.pause();
|
||||
await engine.dispose();
|
||||
|
||||
final revived = RecordingEngine(
|
||||
repository: repo,
|
||||
locationSource: source,
|
||||
clock: () => fakeNow,
|
||||
);
|
||||
addTearDown(revived.dispose);
|
||||
|
||||
final state = await revived.restoreAfterProcessDeath();
|
||||
|
||||
expect(state, RecorderState.paused);
|
||||
expect(source.isRunning, isFalse,
|
||||
reason: 'a paused ride must not silently start consuming GPS');
|
||||
});
|
||||
|
||||
test('no active trip restores to idle', () async {
|
||||
final state = await engine.restoreAfterProcessDeath();
|
||||
expect(state, RecorderState.idle);
|
||||
});
|
||||
});
|
||||
}
|
||||
204
rippr-flutter-src/test/ride_accumulator_test.dart
Normal file
204
rippr-flutter-src/test/ride_accumulator_test.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/recording/ride_accumulator.dart';
|
||||
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||
|
||||
/// Ported from `com.rippr.AccumulatorTest`.
|
||||
///
|
||||
/// As in `ride_statistics_test.dart`, the one RNG-driven case uses the shared LCG rather
|
||||
/// than either language's `Random`, so the fixture is identical across implementations.
|
||||
class _Lcg {
|
||||
_Lcg(this._s);
|
||||
int _s;
|
||||
double nextDouble() {
|
||||
_s = _s * 6364136223846793005 + 1442695040888963407;
|
||||
final bits = (_s >>> 11) & ((1 << 53) - 1);
|
||||
return bits / (1 << 53);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
var nextId = 1;
|
||||
|
||||
TrackPoint point({
|
||||
int segmentId = 1,
|
||||
required int ts,
|
||||
double lat = 51.0,
|
||||
double speed = 40.0,
|
||||
double alt = 1000.0,
|
||||
}) =>
|
||||
TrackPoint(
|
||||
id: nextId++,
|
||||
tripId: 1,
|
||||
segmentId: segmentId,
|
||||
timestamp: ts,
|
||||
latitude: lat,
|
||||
longitude: -114.0,
|
||||
speedKmh: speed,
|
||||
altitudeM: alt,
|
||||
);
|
||||
|
||||
List<TrackPoint> run(int n,
|
||||
{int segmentId = 1, int startTs = 0, double startLat = 51.0}) =>
|
||||
List.generate(
|
||||
n,
|
||||
(i) => point(
|
||||
segmentId: segmentId,
|
||||
ts: startTs + i * 1000,
|
||||
lat: startLat + i * 0.0001),
|
||||
);
|
||||
|
||||
setUp(() => nextId = 1);
|
||||
|
||||
group('the cross-batch anchor — the subtle bug this class exists to avoid', () {
|
||||
test('distance across many small batches matches one big batch', () {
|
||||
final points = run(100);
|
||||
|
||||
final batched = Accumulator();
|
||||
for (var i = 0; i < points.length; i += 7) {
|
||||
batched.fold(points.sublist(i, (i + 7).clamp(0, points.length)));
|
||||
}
|
||||
final single = Accumulator()..fold(points);
|
||||
|
||||
expect(batched.distanceM, closeTo(single.distanceM, 1e-6),
|
||||
reason:
|
||||
'losing the anchor between flushes silently under-reports distance');
|
||||
});
|
||||
|
||||
test('accumulated distance agrees with the batch statistics', () {
|
||||
final points = run(100);
|
||||
|
||||
final acc = Accumulator();
|
||||
for (var i = 0; i < points.length; i += 25) {
|
||||
acc.fold(points.sublist(i, (i + 25).clamp(0, points.length)));
|
||||
}
|
||||
final summary = computeSummary(points);
|
||||
|
||||
expect(acc.distanceM, closeTo(summary.distanceM, 0.5));
|
||||
expect(acc.movingMillis, summary.movingMillis);
|
||||
expect(acc.maxSpeedKmh, closeTo(summary.maxSpeedKmh, 1e-6));
|
||||
expect(acc.pointCount, summary.pointCount);
|
||||
});
|
||||
});
|
||||
|
||||
group('segment boundaries', () {
|
||||
test('distance does not span a segment boundary', () {
|
||||
// Second segment starts a full degree of latitude away — a trailered gap.
|
||||
final acc = Accumulator();
|
||||
acc.fold(run(5, segmentId: 1, startLat: 51.0));
|
||||
acc.fold(run(5, segmentId: 2, startTs: 600000, startLat: 52.0));
|
||||
|
||||
expect(acc.distanceM, lessThan(200.0),
|
||||
reason: 'the ~111 km gap leaked into distance: ${acc.distanceM} m');
|
||||
});
|
||||
|
||||
test('onSegmentChanged clears the anchor even within one batch stream', () {
|
||||
final acc = Accumulator();
|
||||
acc.fold(run(5, segmentId: 1, startLat: 51.0));
|
||||
acc.onSegmentChanged();
|
||||
acc.fold(run(5, segmentId: 1, startTs: 600000, startLat: 52.0));
|
||||
|
||||
expect(acc.distanceM, lessThan(200.0),
|
||||
reason: 'anchor was not cleared: ${acc.distanceM} m');
|
||||
});
|
||||
});
|
||||
|
||||
group('moving time', () {
|
||||
test('moving time ignores samples below the speed noise floor', () {
|
||||
final acc = Accumulator();
|
||||
acc.fold(List.generate(30, (i) => point(ts: i * 1000, speed: 40.0)));
|
||||
acc.fold(
|
||||
List.generate(30, (i) => point(ts: (30 + i) * 1000, speed: 0.0)));
|
||||
|
||||
expect(acc.movingMillis, 29000);
|
||||
});
|
||||
|
||||
test('a long gap between fixes does not inject phantom moving time', () {
|
||||
final acc = Accumulator();
|
||||
acc.fold([
|
||||
point(ts: 0, speed: 90.0),
|
||||
point(ts: 120000, lat: 51.001, speed: 90.0),
|
||||
]);
|
||||
expect(acc.movingMillis, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('elevation', () {
|
||||
test('elevation gain is visible before the climb ends', () {
|
||||
// A steady ascent that never turns back down. Reading the committed total alone
|
||||
// would report zero, so the live figure has to include the pending run.
|
||||
final acc = Accumulator();
|
||||
acc.fold(List.generate(101, (i) => point(ts: i * 1000, alt: 1000.0 + i)));
|
||||
expect(acc.elevationGain(), closeTo(100.0, 6.0));
|
||||
});
|
||||
|
||||
test('a parked bike does not accumulate phantom climbing', () {
|
||||
final rng = _Lcg(7);
|
||||
final acc = Accumulator();
|
||||
acc.fold(List.generate(
|
||||
600,
|
||||
(i) => point(
|
||||
ts: i * 1000,
|
||||
speed: 0.0,
|
||||
alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
|
||||
));
|
||||
expect(acc.elevationGain(), lessThan(60.0),
|
||||
reason: 'phantom climbing: ${acc.elevationGain()} m');
|
||||
});
|
||||
});
|
||||
|
||||
group('restore', () {
|
||||
test('restore continues from persisted totals rather than starting over', () {
|
||||
final acc = Accumulator();
|
||||
acc.restore(
|
||||
distanceM: 5000.0,
|
||||
movingMillis: 300000,
|
||||
maxSpeedKmh: 95.0,
|
||||
elevationGainM: 120.0,
|
||||
pointCount: 600,
|
||||
lastPoint: null,
|
||||
);
|
||||
acc.fold(run(11));
|
||||
|
||||
expect(acc.distanceM, greaterThan(5000.0), reason: 'distance went backwards');
|
||||
expect(acc.distanceM, closeTo(5111.2, 5.0));
|
||||
expect(acc.pointCount, 611);
|
||||
expect(acc.maxSpeedKmh, closeTo(95.0, 1e-6));
|
||||
expect(acc.elevationGain(), greaterThanOrEqualTo(120.0),
|
||||
reason: 'persisted elevation was lost');
|
||||
});
|
||||
|
||||
test('restore keeps a higher persisted max speed', () {
|
||||
final acc = Accumulator();
|
||||
acc.restore(
|
||||
distanceM: 0.0,
|
||||
movingMillis: 0,
|
||||
maxSpeedKmh: 150.0,
|
||||
elevationGainM: 0.0,
|
||||
pointCount: 0,
|
||||
lastPoint: null,
|
||||
);
|
||||
acc.fold(run(5));
|
||||
expect(acc.maxSpeedKmh, closeTo(150.0, 1e-6));
|
||||
});
|
||||
});
|
||||
|
||||
test('reset clears everything', () {
|
||||
final acc = Accumulator();
|
||||
acc.fold(run(20));
|
||||
acc.reset();
|
||||
|
||||
expect(acc.distanceM, closeTo(0.0, 1e-9));
|
||||
expect(acc.movingMillis, 0);
|
||||
expect(acc.maxSpeedKmh, closeTo(0.0, 1e-9));
|
||||
expect(acc.pointCount, 0);
|
||||
expect(acc.elevationGain(), closeTo(0.0, 1e-9));
|
||||
});
|
||||
|
||||
test('folding an empty batch is a no-op', () {
|
||||
final acc = Accumulator();
|
||||
acc.fold(const []);
|
||||
expect(acc.pointCount, 0);
|
||||
expect(acc.distanceM, closeTo(0.0, 1e-9));
|
||||
});
|
||||
}
|
||||
207
rippr-flutter-src/test/ride_export_test.dart
Normal file
207
rippr-flutter-src/test/ride_export_test.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/export/ride_export.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// Ported from `com.rippr.export.RideExportTest`.
|
||||
///
|
||||
/// Like the Kotlin original, this parses the output with a **real** parser rather than
|
||||
/// matching substrings. Structural validity is the whole point: a malformed export fails
|
||||
/// invisibly until some other tool rejects the file.
|
||||
void main() {
|
||||
var nextId = 1;
|
||||
|
||||
const trip = Trip(
|
||||
id: 1,
|
||||
startedAt: 1700000000000,
|
||||
endedAt: 1700000600000,
|
||||
state: TripState.completed,
|
||||
distanceM: 12345.6,
|
||||
maxSpeedKmh: 118.4,
|
||||
);
|
||||
|
||||
TrackPoint point(int segmentId, int i) => TrackPoint(
|
||||
id: nextId++,
|
||||
tripId: 1,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1700000000000 + i * 1000,
|
||||
latitude: 51.0447 + i * 0.0001,
|
||||
longitude: -114.0719,
|
||||
speedKmh: 72.0,
|
||||
altitudeM: 1045.0,
|
||||
);
|
||||
|
||||
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
|
||||
{required int segmentCount, required int perSegment}) {
|
||||
final segments = List.generate(
|
||||
segmentCount,
|
||||
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||
);
|
||||
final points = <TrackPoint>[
|
||||
for (final s in segments)
|
||||
for (var i = 0; i < perSegment; i++) point(s.id, i),
|
||||
];
|
||||
return (
|
||||
segments: segments,
|
||||
points: points,
|
||||
total: segmentCount * perSegment
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() => nextId = 1);
|
||||
|
||||
group('gpx', () {
|
||||
test('gpx is well-formed and carries the expected structure', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 5);
|
||||
|
||||
final doc =
|
||||
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
|
||||
|
||||
expect(doc.rootElement.name.local, 'gpx');
|
||||
expect(doc.rootElement.getAttribute('version'), '1.1');
|
||||
expect(doc.findAllElements('trk').length, 1);
|
||||
});
|
||||
|
||||
test('one trkseg per segment, so pauses survive the round trip', () {
|
||||
final r = ride(segmentCount: 3, perSegment: 4);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkseg').length, 3);
|
||||
});
|
||||
|
||||
test('every raw point is exported with no decimation', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 250);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkpt').length, r.total,
|
||||
reason: 'decimation must never reach an export');
|
||||
});
|
||||
|
||||
test('timestamps are ISO 8601 in UTC', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
final time = doc.findAllElements('time').elementAt(1).innerText;
|
||||
|
||||
expect(time, '2023-11-14T22:13:20Z');
|
||||
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
|
||||
});
|
||||
|
||||
test('coordinates and elevation land on the right attributes', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
final trkpt = doc.findAllElements('trkpt').first;
|
||||
|
||||
expect(trkpt.getAttribute('lat'), '51.0447000');
|
||||
expect(trkpt.getAttribute('lon'), '-114.0719000');
|
||||
expect(doc.findAllElements('ele').first.innerText, '1045.0');
|
||||
});
|
||||
|
||||
test('speed is exported in metres per second', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
// 72 km/h is exactly 20 m/s.
|
||||
expect(doc.findAllElements('speed').first.innerText, '20.0');
|
||||
});
|
||||
|
||||
test('a hostile trip name still produces valid xml', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 2);
|
||||
const nasty = 'Sam & Dave\'s <ride> "fast" ';
|
||||
|
||||
// Would throw if the name were interpolated unescaped.
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
|
||||
|
||||
expect(doc.findAllElements('name').first.innerText, nasty);
|
||||
});
|
||||
|
||||
test('escaping covers every xml metacharacter', () {
|
||||
expect(escapeXml('&<>"\''), '&<>"'');
|
||||
});
|
||||
|
||||
test('an empty ride still produces a valid document', () {
|
||||
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
|
||||
|
||||
expect(doc.rootElement.name.local, 'gpx');
|
||||
expect(doc.findAllElements('trkpt').length, 0);
|
||||
expect(doc.findAllElements('trkseg').length, 0);
|
||||
});
|
||||
|
||||
test('points whose segment is missing are still exported', () {
|
||||
final orphan = [point(99, 0)];
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkpt').length, 1,
|
||||
reason: 'no point may be silently dropped');
|
||||
});
|
||||
});
|
||||
|
||||
group('geojson', () {
|
||||
test('geojson coordinates are longitude first', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
final coords = ((((json['features'] as List).first
|
||||
as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['coordinates'] as List).first as List;
|
||||
|
||||
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
|
||||
reason: 'longitude must come first');
|
||||
expect(coords[1] as double, closeTo(51.0447, 1e-6));
|
||||
expect(coords[2] as double, closeTo(1045.0, 1e-6));
|
||||
});
|
||||
|
||||
test('geojson has one LineString feature per segment', () {
|
||||
final r = ride(segmentCount: 3, perSegment: 4);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
final features = json['features'] as List;
|
||||
|
||||
expect(features.length, 3);
|
||||
expect(
|
||||
((features.first as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['type'],
|
||||
'LineString');
|
||||
});
|
||||
|
||||
test('geojson exports every raw point', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 120);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
var exported = 0;
|
||||
for (final f in json['features'] as List) {
|
||||
exported += (((f as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['coordinates'] as List).length;
|
||||
}
|
||||
|
||||
expect(exported, r.total);
|
||||
});
|
||||
|
||||
test('a hostile name is escaped in json too', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final json = jsonDecode(
|
||||
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect((json['properties'] as Map<String, dynamic>)['name'],
|
||||
r'He said "go" \ fast');
|
||||
});
|
||||
|
||||
test('geojson of an empty ride parses', () {
|
||||
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
|
||||
as Map<String, dynamic>;
|
||||
expect((json['features'] as List).length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
120
rippr-flutter-src/test/ride_map_test.dart
Normal file
120
rippr-flutter-src/test/ride_map_test.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/components/ride_map.dart';
|
||||
import 'package:rippr/src/ui/theme.dart';
|
||||
|
||||
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
|
||||
/// geometry, which is where every real map bug in this project has lived, is fully
|
||||
/// exercisable.
|
||||
void main() {
|
||||
TrackPoint p(int segmentId, int i, {double speed = 40, double lat = 51.0}) =>
|
||||
TrackPoint(
|
||||
id: i + 1,
|
||||
tripId: 1,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1000 + i * 1000,
|
||||
latitude: lat + i * 0.0005,
|
||||
longitude: -114.0,
|
||||
speedKmh: speed,
|
||||
altitudeM: 1000,
|
||||
);
|
||||
|
||||
Future<List<Polyline>> render(
|
||||
WidgetTester tester, {
|
||||
required List<TrackPoint> points,
|
||||
required List<Segment> segments,
|
||||
}) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: Scaffold(body: RideMap(points: points, segments: segments)),
|
||||
));
|
||||
await tester.pump();
|
||||
final layers = tester
|
||||
.widgetList<PolylineLayer>(find.byType(PolylineLayer))
|
||||
.toList();
|
||||
return layers.isEmpty ? const [] : layers.first.polylines.cast<Polyline>();
|
||||
}
|
||||
|
||||
testWidgets('an empty ride says so instead of drawing a blank map',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: const Scaffold(
|
||||
body: RideMap(points: [], segments: []),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(find.text('No path recorded'), findsOneWidget);
|
||||
expect(find.byType(FlutterMap), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a pause leaves a gap: segments are never joined', (tester) async {
|
||||
// Two segments a degree apart. If they were joined, one polyline would span ~111 km.
|
||||
final points = [
|
||||
for (var i = 0; i < 6; i++) p(1, i, lat: 51.0),
|
||||
for (var i = 0; i < 6; i++) p(2, i, lat: 52.0),
|
||||
];
|
||||
const segments = [
|
||||
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
|
||||
];
|
||||
|
||||
final polylines = await render(tester, points: points, segments: segments);
|
||||
|
||||
expect(polylines, isNotEmpty);
|
||||
for (final line in polylines) {
|
||||
final lats = line.points.map((c) => c.latitude);
|
||||
// No single polyline may straddle the gap between the two rides.
|
||||
expect(lats.every((l) => l < 51.5) || lats.every((l) => l > 51.5), isTrue,
|
||||
reason: 'a polyline spanned the pause');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('decimation reduces rendered vertices but keeps the shape',
|
||||
(tester) async {
|
||||
// A dense straight run: Douglas-Peucker should collapse it hard.
|
||||
final points = [for (var i = 0; i < 400; i++) p(1, i)];
|
||||
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
|
||||
|
||||
final polylines = await render(tester, points: points, segments: segments);
|
||||
|
||||
final rendered =
|
||||
polylines.fold<int>(0, (a, line) => a + line.points.length);
|
||||
expect(rendered, lessThan(points.length),
|
||||
reason: 'render-only decimation should reduce vertex count');
|
||||
expect(rendered, greaterThan(1));
|
||||
});
|
||||
|
||||
testWidgets('a stationary ride still renders without zooming to infinity',
|
||||
(tester) async {
|
||||
// Every point at one spot — degenerate bounds. Fitting these would zoom past the
|
||||
// tile server's maximum and render an empty grid, the v2.0 short-ride bug.
|
||||
final points = [
|
||||
for (var i = 0; i < 5; i++)
|
||||
TrackPoint(
|
||||
id: i + 1,
|
||||
tripId: 1,
|
||||
segmentId: 1,
|
||||
timestamp: 1000 + i * 1000,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 0,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
];
|
||||
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: Scaffold(body: RideMap(points: points, segments: segments)),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||
expect(map.options.initialZoom, shortRideZoom);
|
||||
expect(map.options.maxZoom, maxTileZoom,
|
||||
reason: 'exceeding OSM max tile zoom renders an empty grid');
|
||||
});
|
||||
}
|
||||
302
rippr-flutter-src/test/ride_statistics_test.dart
Normal file
302
rippr-flutter-src/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
rippr-flutter-src/test/telemetry_test.dart
Normal file
111
rippr-flutter-src/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);
|
||||
});
|
||||
}
|
||||
152
rippr-flutter-src/test/telemetry_uploader_test.dart
Normal file
152
rippr-flutter-src/test/telemetry_uploader_test.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/telemetry/telemetry_uploader.dart';
|
||||
|
||||
/// Ported from `com.rippr.TelemetryUploaderTest`.
|
||||
///
|
||||
/// The Kotlin suite used MockWebServer plus an in-memory fake DAO. Here a `MockClient`
|
||||
/// stands in for the network and a real in-memory Drift database stands in for the DAO —
|
||||
/// closer to production, and still no device required.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<void> seed(int count) async {
|
||||
final tripId =
|
||||
await db.insertTrip(const Trip(startedAt: 1000, state: TripState.recording));
|
||||
final segmentId = await db.insertSegment(tripId, 1000);
|
||||
await db.insertPoints([
|
||||
for (var i = 0; i < count; i++)
|
||||
TrackPoint(
|
||||
tripId: tripId,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1000 + i,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
TelemetryUploader uploader({
|
||||
required http.Client client,
|
||||
String endpoint = 'https://example.test/ingest',
|
||||
}) =>
|
||||
TelemetryUploader(
|
||||
db: db,
|
||||
endpoint: endpoint,
|
||||
deviceId: 'device-abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
test('an empty endpoint disables upload entirely', () async {
|
||||
var called = false;
|
||||
final client = MockClient((_) async {
|
||||
called = true;
|
||||
return http.Response('', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client, endpoint: '').uploadPending();
|
||||
|
||||
expect(result, isA<UploadDisabled>());
|
||||
expect(called, isFalse, reason: 'recording must work with no server at all');
|
||||
});
|
||||
|
||||
test('a successful run marks points synced', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('{}', 200));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadSuccess>());
|
||||
expect((result as UploadSuccess).uploaded, 5);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a rejected batch leaves the backlog intact for retry', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('nope', 500));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 5,
|
||||
reason: 'nothing may be marked synced when the server refused it');
|
||||
});
|
||||
|
||||
test('a network error is swallowed and retried later', () async {
|
||||
await seed(3);
|
||||
final client = MockClient((_) async => throw http.ClientException('offline'));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 3);
|
||||
});
|
||||
|
||||
test('the payload carries trip and segment identity per point', () async {
|
||||
await seed(2);
|
||||
String? captured;
|
||||
final client = MockClient((req) async {
|
||||
captured = req.body;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
await uploader(client: client).uploadPending();
|
||||
|
||||
final json = jsonDecode(captured!) as Map<String, dynamic>;
|
||||
expect(json['device_id'], 'device-abc');
|
||||
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||
// Batches are drawn by id and can straddle a boundary, so identity travels with the
|
||||
// point rather than the batch.
|
||||
expect(first.containsKey('trip_id'), isTrue);
|
||||
expect(first.containsKey('segment_id'), isTrue);
|
||||
});
|
||||
|
||||
test('a backlog larger than one batch is sent across several requests',
|
||||
() async {
|
||||
// Two full batches plus a remainder.
|
||||
await seed(uploadBatchSize * 2 + 7);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(requests, 3);
|
||||
expect((result as UploadSuccess).uploaded, uploadBatchSize * 2 + 7);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a failure partway through reports what did land', () async {
|
||||
await seed(uploadBatchSize + 10);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', requests == 1 ? 200 : 503);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadPartial>());
|
||||
expect((result as UploadPartial).uploaded, uploadBatchSize);
|
||||
expect(await db.countUnsynced(), 10,
|
||||
reason: 'the first batch landed; the rest must survive for retry');
|
||||
});
|
||||
}
|
||||
352
rippr-flutter-src/test/trip_repository_test.dart
Normal file
352
rippr-flutter-src/test/trip_repository_test.dart
Normal file
@@ -0,0 +1,352 @@
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
|
||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||
///
|
||||
/// Both were **instrumented** suites needing a device and a booted emulator. Against
|
||||
/// Drift on the Dart VM they are ordinary unit tests.
|
||||
///
|
||||
/// In-memory databases only — the native suite once wiped a real device's rides by
|
||||
/// running against the production singleton.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late TripRepository repo;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
repo = TripRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<void> addPoint(TripHandle handle, int ts,
|
||||
{double lat = 51.0, double speed = 40.0, double alt = 1000.0}) =>
|
||||
repo.appendPoints([
|
||||
TrackPoint(
|
||||
tripId: handle.tripId,
|
||||
segmentId: handle.segmentId,
|
||||
timestamp: ts,
|
||||
latitude: lat,
|
||||
longitude: -114.0,
|
||||
speedKmh: speed,
|
||||
altitudeM: alt,
|
||||
),
|
||||
]);
|
||||
|
||||
group('lifecycle', () {
|
||||
test('full lifecycle produces one trip and two segments', () async {
|
||||
final h1 = await repo.startTrip(1000);
|
||||
await addPoint(h1, 1000);
|
||||
await addPoint(h1, 2000);
|
||||
|
||||
await repo.pauseTrip(3000);
|
||||
final h2 = (await repo.resumeTrip(4000))!;
|
||||
await addPoint(h2, 5000);
|
||||
|
||||
final tripId = await repo.completeTrip(6000);
|
||||
|
||||
expect(tripId, h1.tripId);
|
||||
expect(h2.segmentId, isNot(h1.segmentId),
|
||||
reason: 'resume must open a fresh segment');
|
||||
|
||||
final segments = await repo.segmentsForTrip(h1.tripId);
|
||||
expect(segments.length, 2);
|
||||
expect(segments.every((s) => !s.isOpen), isTrue,
|
||||
reason: 'completing must close the open segment');
|
||||
expect((await repo.tripById(h1.tripId))!.state, TripState.completed);
|
||||
expect(await repo.activeTrip(), isNull);
|
||||
});
|
||||
|
||||
test('pause closes the segment but leaves the trip open', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
|
||||
final trip = (await repo.activeTrip())!;
|
||||
expect(trip.state, TripState.paused);
|
||||
expect(trip.endedAt, isNull, reason: 'pause must not end the trip');
|
||||
expect(await db.openSegment(h.tripId), isNull);
|
||||
});
|
||||
|
||||
test('startTrip adopts an already active trip instead of creating a second',
|
||||
() async {
|
||||
final first = await repo.startTrip(1000);
|
||||
final second = await repo.startTrip(2000);
|
||||
|
||||
expect(second.tripId, first.tripId);
|
||||
expect(second.segmentId, first.segmentId);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('resume while already recording is a no-op', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
final resumed = await repo.resumeTrip(2000);
|
||||
|
||||
expect(resumed!.segmentId, h.segmentId,
|
||||
reason: 'must not open a duplicate segment');
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||
});
|
||||
|
||||
test('double pause is a no-op', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
expect(await repo.pauseTrip(2000), isTrue);
|
||||
expect(await repo.pauseTrip(3000), isTrue);
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||
});
|
||||
|
||||
test('transitions with no active trip are safe no-ops', () async {
|
||||
expect(await repo.pauseTrip(1000), isFalse);
|
||||
expect(await repo.resumeTrip(1000), isNull);
|
||||
expect(await repo.completeTrip(1000), isNull);
|
||||
expect(await repo.discardTrip(), isFalse);
|
||||
});
|
||||
|
||||
test('start after complete begins a fresh trip', () async {
|
||||
final first = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
final second = await repo.startTrip(3000);
|
||||
|
||||
expect(second.tripId, isNot(first.tripId));
|
||||
expect(await db.countTrips(), 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('discard', () {
|
||||
test('discard removes the trip and all its points', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await addPoint(h, 1000);
|
||||
await addPoint(h, 2000);
|
||||
|
||||
expect(await repo.discardTrip(), isTrue);
|
||||
|
||||
expect(await repo.tripById(h.tripId), isNull);
|
||||
expect(await db.countPointsForTrip(h.tripId), 0);
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 0);
|
||||
});
|
||||
|
||||
test('discard leaves earlier completed trips alone', () async {
|
||||
final keep = await repo.startTrip(1000);
|
||||
await addPoint(keep, 1000);
|
||||
await repo.completeTrip(2000);
|
||||
|
||||
final throwaway = await repo.startTrip(3000);
|
||||
await addPoint(throwaway, 3000);
|
||||
await repo.discardTrip();
|
||||
|
||||
expect(await repo.tripById(keep.tripId), isNotNull);
|
||||
expect(await db.countPointsForTrip(keep.tripId), 1);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('streams', () {
|
||||
test('active trip stream tracks the lifecycle', () async {
|
||||
expect(await repo.watchActiveTrip().first, isNull);
|
||||
|
||||
final h = await repo.startTrip(1000);
|
||||
expect((await repo.watchActiveTrip().first)?.id, h.tripId);
|
||||
|
||||
await repo.completeTrip(2000);
|
||||
expect(await repo.watchActiveTrip().first, isNull);
|
||||
});
|
||||
|
||||
test('completed trips stream excludes the active trip', () async {
|
||||
final done = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
await repo.startTrip(3000);
|
||||
|
||||
final completed = await repo.watchCompletedTrips().first;
|
||||
expect(completed.length, 1);
|
||||
expect(completed.single.id, done.tripId);
|
||||
});
|
||||
});
|
||||
|
||||
group('rename', () {
|
||||
test('rename stores null rather than an empty string', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
|
||||
await repo.renameTrip(h.tripId, 'Morning loop');
|
||||
expect((await repo.tripById(h.tripId))!.name, 'Morning loop');
|
||||
|
||||
await repo.renameTrip(h.tripId, '');
|
||||
expect((await repo.tripById(h.tripId))!.name, isNull,
|
||||
reason: 'an empty string would diverge from the date-label branch');
|
||||
|
||||
await repo.renameTrip(h.tripId, ' ');
|
||||
expect((await repo.tripById(h.tripId))!.name, isNull);
|
||||
});
|
||||
|
||||
test('rename trims surrounding whitespace', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.renameTrip(h.tripId, ' Sunday blast ');
|
||||
expect((await repo.tripById(h.tripId))!.name, 'Sunday blast');
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a fresh repository over the same database sees the active trip',
|
||||
() async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await addPoint(h, 1000);
|
||||
|
||||
// Simulates the process being killed and rebuilt: new repository, same file.
|
||||
final revived = TripRepository(db);
|
||||
|
||||
final active = await revived.activeTrip();
|
||||
expect(active, isNotNull,
|
||||
reason: 'recording state must survive process death');
|
||||
expect(active!.id, h.tripId);
|
||||
});
|
||||
|
||||
test('restart after pause resumes into a new segment', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
|
||||
final revived = TripRepository(db);
|
||||
final resumed = (await revived.resumeTrip(3000))!;
|
||||
|
||||
expect(resumed.tripId, h.tripId);
|
||||
expect(resumed.segmentId, isNot(h.segmentId));
|
||||
expect((await revived.activeTrip())!.state, TripState.recording);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge — ported from MergeTest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('merge', () {
|
||||
Future<int> completedTrip({
|
||||
required int startedAt,
|
||||
required int endedAt,
|
||||
int points = 3,
|
||||
String? name,
|
||||
double lat = 51.0,
|
||||
}) async {
|
||||
final h = await repo.startTrip(startedAt);
|
||||
for (var i = 0; i < points; i++) {
|
||||
await addPoint(h, startedAt + i * 1000, lat: lat + i * 0.0001);
|
||||
}
|
||||
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||
await repo.completeTrip(endedAt);
|
||||
return h.tripId;
|
||||
}
|
||||
|
||||
test('merge reparents everything and deletes the absorbed row', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
final survivor = await repo.mergeTrips(a, b);
|
||||
|
||||
expect(survivor, a);
|
||||
expect(await repo.tripById(b), isNull);
|
||||
expect(await db.countPointsForTrip(a), 6);
|
||||
expect(await db.countSegmentsForTrip(a), 2);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('selection order does not decide the survivor', () async {
|
||||
final earlier = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final later = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
// Passed later-first on purpose.
|
||||
expect(await repo.mergeTrips(later, earlier), earlier);
|
||||
});
|
||||
|
||||
test('merged aggregates are recomputed, not summed', () async {
|
||||
// The two rides are a degree of latitude apart — ~111 km. If aggregates were
|
||||
// summed, or if the segments were joined, that gap would appear as distance.
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000, lat: 51.0);
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, lat: 52.0);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
final merged = (await repo.tripById(a))!;
|
||||
expect(merged.pointCount, 6);
|
||||
expect(merged.distanceM, lessThan(200.0),
|
||||
reason:
|
||||
'the ~111 km gap leaked into distance: ${merged.distanceM} m');
|
||||
});
|
||||
|
||||
test('the join remains a segment boundary', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
final segments = await repo.segmentsForTrip(a);
|
||||
expect(segments.length, 2,
|
||||
reason: 'segments must never be joined by a merge');
|
||||
});
|
||||
|
||||
test('endedAt becomes the later of the two', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.endedAt, 15000);
|
||||
});
|
||||
|
||||
test('an unnamed survivor inherits the other name', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.name, 'Part two');
|
||||
});
|
||||
|
||||
test('an existing survivor name is kept', () async {
|
||||
final a = await completedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'The good one');
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.name, 'The good one');
|
||||
});
|
||||
|
||||
test('merging a trip with itself is rejected', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
expect(await repo.mergeTrips(a, a), isNull);
|
||||
expect(await db.countPointsForTrip(a), 3);
|
||||
});
|
||||
|
||||
test('merging an active trip is rejected', () async {
|
||||
final done = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final active = await repo.startTrip(10000);
|
||||
|
||||
expect(await repo.mergeTrips(done, active.tripId), isNull);
|
||||
expect(await db.countTrips(), 2, reason: 'nothing may be deleted');
|
||||
});
|
||||
|
||||
test('merging a missing trip is rejected', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
expect(await repo.mergeTrips(a, 9999), isNull);
|
||||
expect(await repo.tripById(a), isNotNull);
|
||||
});
|
||||
|
||||
test('merge is atomic and leaves no orphans', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
// Every surviving point and segment must belong to the survivor.
|
||||
final allPoints = await db.allPoints();
|
||||
expect(allPoints.every((p) => p.tripId == a), isTrue);
|
||||
expect(allPoints.length, 6);
|
||||
|
||||
final segments = await repo.segmentsForTrip(a);
|
||||
expect(segments.every((s) => s.tripId == a), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
344
rippr-flutter-src/test/widget_test.dart
Normal file
344
rippr-flutter-src/test/widget_test.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/recording/location_source.dart';
|
||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||
import 'package:rippr/src/ui/theme.dart';
|
||||
import 'package:rippr/src/ui/trips/trips_screen.dart';
|
||||
|
||||
/// **The first UI tests this project has ever had.**
|
||||
///
|
||||
/// `docs/v3/BACKLOG.md` names "zero UI tests across six screens" as v2's largest coverage
|
||||
/// gap: everything was verified by manual screenshot, which is how a 64 sp speed figure
|
||||
/// once shipped rendering black-on-black. Flutter makes these cheap enough that carrying
|
||||
/// that debt into the port would be indefensible.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late TripRepository repo;
|
||||
late FakeLocationSource source;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
repo = TripRepository(db);
|
||||
source = FakeLocationSource();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await source.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
/// Runs a widget test and then tears the tree down cleanly.
|
||||
///
|
||||
/// `flutter_test` asserts that no `Timer` is pending once the tree is disposed. Two
|
||||
/// things here legitimately hold one: the elapsed clock on the record screen, and
|
||||
/// Drift's stream-query cache, which keeps a query alive briefly after its last
|
||||
/// listener leaves so re-subscribing is cheap. Removing the tree cancels the first;
|
||||
/// pumping past the keep-alive window clears the second.
|
||||
void screenTest(String description, Future<void> Function(WidgetTester) body) {
|
||||
testWidgets(description, (tester) async {
|
||||
await body(tester);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pumps a screen that has a live ride on it.
|
||||
///
|
||||
/// `pumpAndSettle` cannot be used once a ride is active: the elapsed clock ticks every
|
||||
/// second forever, so "settled" never arrives. Two explicit frames are enough to build
|
||||
/// and then reflect the streamed trip.
|
||||
Future<void> pumpLive(WidgetTester tester, Widget widget) async {
|
||||
await tester.pumpWidget(widget);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
/// Wraps a screen with the real theme and an in-memory stack.
|
||||
///
|
||||
/// The theme is deliberately included: the black-on-black bug was invisible to logic
|
||||
/// tests and only a rendered widget can catch its equivalent.
|
||||
Widget host(Widget child, {bool map = true}) => ProviderScope(
|
||||
overrides: [
|
||||
databaseProvider.overrideWithValue(db),
|
||||
locationSourceProvider.overrideWithValue(source),
|
||||
// The map is off by default in tests that are not about the map: it fetches
|
||||
// tiles, which a widget test cannot serve, and it changes scroll geometry.
|
||||
mapEnabledProvider.overrideWith((ref) => map),
|
||||
],
|
||||
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||
);
|
||||
|
||||
Future<int> seedCompletedTrip({
|
||||
required int startedAt,
|
||||
required int endedAt,
|
||||
String? name,
|
||||
int points = 5,
|
||||
double startLat = 51.0,
|
||||
}) async {
|
||||
final h = await repo.startTrip(startedAt);
|
||||
await repo.appendPoints([
|
||||
for (var i = 0; i < points; i++)
|
||||
TrackPoint(
|
||||
tripId: h.tripId,
|
||||
segmentId: h.segmentId,
|
||||
timestamp: startedAt + i * 1000,
|
||||
latitude: startLat + i * 0.0001,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40.0 + i,
|
||||
altitudeM: 1000.0 + i,
|
||||
),
|
||||
]);
|
||||
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||
await repo.completeTrip(endedAt);
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
return h.tripId;
|
||||
}
|
||||
|
||||
group('record screen', () {
|
||||
screenTest('idle shows Ready and only START', (tester) async {
|
||||
await tester.pumpWidget(host(const RecordScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Ready'), findsOneWidget);
|
||||
expect(find.byKey(const Key('start')), findsOneWidget);
|
||||
expect(find.byKey(const Key('pause')), findsNothing);
|
||||
expect(find.byKey(const Key('stop')), findsNothing);
|
||||
// A resting screen must not look like a ride going nowhere.
|
||||
expect(find.text('Distance'), findsNothing);
|
||||
});
|
||||
|
||||
screenTest('the headline is live speed, not max speed', (tester) async {
|
||||
// v2.0 shipped max speed as the headline and it read as a frozen, broken screen on
|
||||
// a real ride, because a max figure only moves when you beat it. This guards the
|
||||
// 2.0.1 fix.
|
||||
await tester.pumpWidget(host(const RecordScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SPEED'), findsOneWidget);
|
||||
expect(find.text('MAX SPEED'), findsNothing);
|
||||
});
|
||||
|
||||
screenTest('recording swaps to PAUSE and STOP, with no DISCARD',
|
||||
(tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
|
||||
expect(find.byKey(const Key('pause')), findsOneWidget);
|
||||
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||
// Destructive actions must not sit next to Pause during a live ride — a gloved
|
||||
// mis-tap at speed would lose the whole recording.
|
||||
expect(find.byKey(const Key('discard')), findsNothing);
|
||||
expect(find.byKey(const Key('start')), findsNothing);
|
||||
});
|
||||
|
||||
screenTest('paused offers RESUME, STOP and DISCARD', (tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
|
||||
expect(find.byKey(const Key('resume')), findsOneWidget);
|
||||
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discard')), findsOneWidget);
|
||||
expect(find.text('Paused'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('discard asks before destroying anything', (tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
|
||||
await tester.tap(find.byKey(const Key('discard')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Discard this ride?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.activeTrip(), isNotNull,
|
||||
reason: 'cancelling must not delete the ride');
|
||||
});
|
||||
|
||||
screenTest('text is legible against the dark ground', (tester) async {
|
||||
// The regression this exists for: removing Compose's Surface left LocalContentColor
|
||||
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
|
||||
// test could catch it. This asserts the rendered colour differs from the ground.
|
||||
await tester.pumpWidget(host(const RecordScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final speed = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('speed')),
|
||||
matching: find.text('0'),
|
||||
),
|
||||
);
|
||||
final colour = speed.style?.color;
|
||||
expect(colour, isNotNull, reason: 'the headline must name its colour');
|
||||
expect(colour, isNot(ripprBackground));
|
||||
expect(colour, isNot(Colors.black));
|
||||
});
|
||||
});
|
||||
|
||||
group('trips list', () {
|
||||
screenTest('empty state explains what to do', (tester) async {
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('No rides yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('completed rides are listed newest first', (tester) async {
|
||||
await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'Older ride');
|
||||
await seedCompletedTrip(
|
||||
startedAt: 100000, endedAt: 200000, name: 'Newer ride');
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final newer = tester.getTopLeft(find.text('Newer ride')).dy;
|
||||
final older = tester.getTopLeft(find.text('Older ride')).dy;
|
||||
expect(newer, lessThan(older));
|
||||
});
|
||||
|
||||
screenTest('an active ride does not appear in the list', (tester) async {
|
||||
await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'Finished');
|
||||
await repo.startTrip(100000);
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.byKey(const Key('trip-1')), findsOneWidget);
|
||||
expect(find.text('Finished'), findsOneWidget);
|
||||
// Only the completed one.
|
||||
expect(find.byType(Card), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('Merge enables at exactly two selections', (tester) async {
|
||||
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await seedCompletedTrip(startedAt: 10000, endedAt: 15000);
|
||||
final c = await seedCompletedTrip(startedAt: 20000, endedAt: 25000);
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// One selected: merge is present but disabled.
|
||||
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||
isNull,
|
||||
reason: 'merging one ride is meaningless',
|
||||
);
|
||||
|
||||
// Two: enabled.
|
||||
await tester.tap(find.byKey(Key('trip-$b')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
// Three: ambiguous, so disabled again.
|
||||
await tester.tap(find.byKey(Key('trip-$c')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||
isNull,
|
||||
reason: 'merge combines exactly two rides',
|
||||
);
|
||||
});
|
||||
|
||||
screenTest('deleting asks first and then removes the ride',
|
||||
(tester) async {
|
||||
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('delete')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Delete 1 ride'), findsOneWidget);
|
||||
// "Delete" appears both in the toolbar and in the dialog; target the dialog's.
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('Delete'),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.tripById(a), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('trip detail', () {
|
||||
screenTest('shows the authoritative summary', (tester) async {
|
||||
final id = await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sunday blast'), findsOneWidget);
|
||||
expect(find.text('DISTANCE'), findsOneWidget);
|
||||
expect(find.text('Max speed'), findsOneWidget);
|
||||
expect(find.text('Segments'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('a missing ride says so instead of rendering blank',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('charts degrade gracefully below two points', (tester) async {
|
||||
final id = await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 2000, points: 1);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Both charts need intervals; one point has none. An unexplained blank box would
|
||||
// read as a bug.
|
||||
//
|
||||
// The detail body is a ListView, so the second chart is not built until it is
|
||||
// scrolled into view -- asserting without scrolling would silently check only one.
|
||||
expect(find.textContaining('Not enough points'), findsOneWidget);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Not enough points'), findsWidgets);
|
||||
expect(find.text('Elevation profile'.toUpperCase()), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('renaming updates the title', (tester) async {
|
||||
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('rename')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const Key('name-field')), 'Coast run');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Coast run'), findsOneWidget);
|
||||
expect((await repo.tripById(id))!.name, 'Coast run');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user