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.
609 lines
22 KiB
Dart
609 lines
22 KiB
Dart
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/activity_profile.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');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 {
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|