All lifecycle transitions inside Drift transactions, each idempotent: start (adopting an active trip rather than duplicating it), pause, resume, complete, discard, rename, delete, merge, and the authoritative aggregate recomputation. Room.withTransaction maps onto Drift's transaction() almost exactly, so this was the most mechanical port so far. 26 tests, green first run. Merge keeps its hard-won properties: segments are never joined, so the boundary between two merged rides stays a segment boundary like a pause, and aggregates are recomputed rather than summed because distance is not additive across the gap. The regression test puts the two rides a degree of latitude apart and asserts the ~111km gap never reaches distanceM. Dropped a mergeTableUpdates helper before committing -- Drift's update() already notifies dependent streams, so it was scaffolding that earned nothing. TripRepositoryTest, MergeTest and SchemaTest were 666 lines of instrumented tests needing a booted emulator. All three now run as unit tests in ~2 seconds. Phase 2 done: 121 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
12 KiB
Dart
353 lines
12 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/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);
|
|
});
|
|
});
|
|
}
|