Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221. The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
This commit is contained in:
@@ -3,6 +3,7 @@ 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/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
|
||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||
@@ -187,6 +188,84 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity type — V3-01
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('activity', () {
|
||||
test('a brand new database defaults a fresh trip to motorcycle', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.motorcycle);
|
||||
});
|
||||
|
||||
test('starting a new trip defaults to whatever activity was last used, '
|
||||
'with no picker required', () async {
|
||||
final first = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
await repo.setActivity(first.tripId, Activity.walking);
|
||||
|
||||
final second = await repo.startTrip(3000);
|
||||
|
||||
expect((await repo.tripById(second.tripId))!.activity, Activity.walking,
|
||||
reason: 'the new ride should inherit the most recently created trip\'s '
|
||||
'activity, not silently reset to motorcycle');
|
||||
});
|
||||
|
||||
test('an explicit activity overrides the last-used default', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.bicycle);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.bicycle);
|
||||
});
|
||||
|
||||
test('resuming an active trip never changes its activity', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.running);
|
||||
await repo.pauseTrip(2000);
|
||||
// Started again with no explicit activity -- must adopt the paused trip's own,
|
||||
// not fall back to whatever "last used" would otherwise resolve to.
|
||||
final resumed = await repo.startTrip(3000);
|
||||
|
||||
expect(resumed.tripId, h.tripId);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.running);
|
||||
});
|
||||
|
||||
test('setActivity recomputes aggregates under the new profile', () async {
|
||||
// A slow walking pace: under the motorcycle noise floor (1.5 km/h) but above
|
||||
// walking's own (0.3), so moving time genuinely differs between the two profiles.
|
||||
final h = await repo.startTrip(1000, activity: Activity.motorcycle);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await addPoint(h, 1000 + i * 1000, speed: 0.7);
|
||||
}
|
||||
await repo.completeTrip(11000);
|
||||
// completeTrip() alone does not recompute aggregates -- the recorder does that
|
||||
// separately on stop(). Mirror that here rather than relying on a leftover value.
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
|
||||
final asMotorcycle = (await repo.tripById(h.tripId))!;
|
||||
expect(asMotorcycle.movingMillis, 0,
|
||||
reason: 'a motorcycle profile must read a 0.7 km/h pace as noise');
|
||||
|
||||
await repo.setActivity(h.tripId, Activity.walking);
|
||||
|
||||
final asWalking = (await repo.tripById(h.tripId))!;
|
||||
expect(asWalking.activity, Activity.walking);
|
||||
expect(asWalking.movingMillis, greaterThan(0),
|
||||
reason: 'recomputing under the walking profile must recognise the same '
|
||||
'fixes as real movement');
|
||||
// Sanity check that this really did come from ActivityProfile.walking and not a
|
||||
// coincidence -- computing with the profile explicitly must agree.
|
||||
expect(
|
||||
asWalking.movingMillis,
|
||||
greaterThan(asMotorcycle.movingMillis),
|
||||
);
|
||||
// The bicycle/motorcycle-equivalent profile does not exist as a category here;
|
||||
// just confirm the profile object itself expresses the difference this test
|
||||
// exercises indirectly through the repository.
|
||||
expect(
|
||||
ActivityProfile.walking.noiseFloorKmh,
|
||||
lessThan(ActivityProfile.motorcycle.noiseFloorKmh),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a fresh repository over the same database sees the active trip',
|
||||
() async {
|
||||
@@ -349,4 +428,181 @@ void main() {
|
||||
expect(segments.every((s) => s.tripId == a), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('split (V3-10)', () {
|
||||
/// A completed, multi-segment ride: [segmentSpecs] each become one paused-and-resumed
|
||||
/// segment, `points` fixes per segment, one degree of latitude apart so an accidental
|
||||
/// distance leak across the split is unmissable.
|
||||
Future<int> multiSegmentTrip(
|
||||
List<({int startedAt, int endedAt})> segmentSpecs, {
|
||||
int points = 3,
|
||||
}) async {
|
||||
var h = await repo.startTrip(segmentSpecs.first.startedAt);
|
||||
for (var s = 0; s < segmentSpecs.length; s++) {
|
||||
final spec = segmentSpecs[s];
|
||||
for (var i = 0; i < points; i++) {
|
||||
await addPoint(h, spec.startedAt + i * 1000, lat: 51.0 + s * 1.0);
|
||||
}
|
||||
if (s < segmentSpecs.length - 1) {
|
||||
await repo.pauseTrip(spec.endedAt);
|
||||
h = (await repo.resumeTrip(segmentSpecs[s + 1].startedAt))!;
|
||||
}
|
||||
}
|
||||
await repo.completeTrip(segmentSpecs.last.endedAt);
|
||||
return h.tripId;
|
||||
}
|
||||
|
||||
test('point counts sum to the original and nothing is orphaned', () async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
], points: 3);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
|
||||
expect(newId, isNotNull);
|
||||
final original = (await repo.tripById(id))!;
|
||||
final split = (await repo.tripById(newId!))!;
|
||||
expect(original.pointCount + split.pointCount, 6);
|
||||
final allPoints = await db.allPoints();
|
||||
expect(
|
||||
allPoints.every((p) => p.tripId == id || p.tripId == newId),
|
||||
isTrue,
|
||||
reason: 'no point may belong to neither resulting trip',
|
||||
);
|
||||
});
|
||||
|
||||
test("neither trip's distance includes the gap between them", () async {
|
||||
// Segments a whole degree of latitude apart -- ~111 km. If the gap leaked in
|
||||
// (e.g. by keeping the old aggregate rather than recomputing), it would dwarf
|
||||
// the few-metre hops inside each segment.
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
|
||||
final original = (await repo.tripById(id))!;
|
||||
final split = (await repo.tripById(newId!))!;
|
||||
expect(original.distanceM, lessThan(200.0));
|
||||
expect(split.distanceM, lessThan(200.0));
|
||||
});
|
||||
|
||||
test('both trips get plausible startedAt/endedAt from their own segments',
|
||||
() async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
|
||||
final original = (await repo.tripById(id))!;
|
||||
final split = (await repo.tripById(newId!))!;
|
||||
expect(original.startedAt, 1000);
|
||||
expect(original.endedAt, 2000,
|
||||
reason: 'must come from the last kept segment, not the pre-split trip row');
|
||||
expect(split.startedAt, 10000);
|
||||
expect(split.endedAt, 11000);
|
||||
});
|
||||
|
||||
test('the split stays a segment boundary on both sides', () async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
(startedAt: 20000, endedAt: 21000),
|
||||
]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
|
||||
expect(await repo.segmentsForTrip(id), hasLength(1));
|
||||
expect(await repo.segmentsForTrip(newId!), hasLength(2));
|
||||
});
|
||||
|
||||
test('a single-segment trip cannot be split', () async {
|
||||
final id = await multiSegmentTrip([(startedAt: 1000, endedAt: 2000)]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
expect(await repo.splitTrip(id, segments.single.id), isNull);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('splitting at the first segment is rejected -- nothing would remain before it',
|
||||
() async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
expect(await repo.splitTrip(id, segments.first.id), isNull);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('splitting an active trip is rejected', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await addPoint(h, 1000);
|
||||
await repo.pauseTrip(2000);
|
||||
final h2 = (await repo.resumeTrip(3000))!;
|
||||
await addPoint(h2, 3000);
|
||||
|
||||
final segments = await repo.segmentsForTrip(h.tripId);
|
||||
expect(await repo.splitTrip(h.tripId, segments[1].id), isNull);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('an unknown segment id is rejected', () async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
expect(await repo.splitTrip(id, 9999), isNull);
|
||||
});
|
||||
|
||||
test('split then merge is a round trip back to the original aggregates',
|
||||
() async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
// multiSegmentTrip only inserts points; the aggregate columns are otherwise only
|
||||
// ever updated by the recording engine's periodic flush (bypassed here), so they
|
||||
// must be computed explicitly to have a real baseline to compare against.
|
||||
await repo.recomputeAggregates(id);
|
||||
final before = (await repo.tripById(id))!;
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
final survivor = await repo.mergeTrips(id, newId!);
|
||||
|
||||
final after = (await repo.tripById(survivor!))!;
|
||||
expect(after.pointCount, before.pointCount);
|
||||
expect(after.distanceM, closeTo(before.distanceM, 1e-6));
|
||||
expect(after.startedAt, before.startedAt);
|
||||
expect(after.endedAt, before.endedAt);
|
||||
});
|
||||
|
||||
test('split is atomic and leaves no orphans', () async {
|
||||
final id = await multiSegmentTrip([
|
||||
(startedAt: 1000, endedAt: 2000),
|
||||
(startedAt: 10000, endedAt: 11000),
|
||||
]);
|
||||
final segments = await repo.segmentsForTrip(id);
|
||||
|
||||
final newId = await repo.splitTrip(id, segments[1].id);
|
||||
|
||||
final allSegments = [
|
||||
...await repo.segmentsForTrip(id),
|
||||
...await repo.segmentsForTrip(newId!),
|
||||
];
|
||||
expect(allSegments.length, 2);
|
||||
final allPoints = await db.allPoints();
|
||||
expect(allPoints.every((p) => p.tripId == id || p.tripId == newId), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user