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 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 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); }); }); }