V3-10: trip splitting

TripRepository.splitTrip mirrors mergeTrips' transaction shape: splits at a segment boundary, moves the target segment and everything after it to a new trip, recomputes both trips' aggregates from scratch. startedAt/endedAt derive from each trip's actual remaining segments, not copied from the pre-split row. Trip detail gained a split action with a segment-boundary picker and a naming confirmation; a single-segment ride explains why it can't split instead of offering a dead control.
This commit is contained in:
2026-08-17 19:23:44 -05:00
parent a1e99684e0
commit 0e605ef174
7 changed files with 417 additions and 2 deletions

View File

@@ -428,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);
});
});
}