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/domain/models.dart'; /// Ported from `com.rippr.data.SchemaTest`. /// /// **These were instrumented tests requiring a device.** Against Drift on the Dart VM /// they are plain unit tests: faster, and runnable with no emulator booted. That is one /// of the concrete wins of the port. /// /// Every test uses an in-memory database. The native suite once wiped a real device's /// rides by running against the production singleton in `setUp`, so this is not /// negotiable — see `docs/TESTING.md` in the native repo. void main() { late AppDatabase db; setUp(() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; db = AppDatabase(NativeDatabase.memory()); }); tearDown(() async => db.close()); Future<(int tripId, int segmentId)> seedTrip({int startedAt = 1000}) async { final tripId = await db.insertTrip(Trip(startedAt: startedAt, state: TripState.recording)); final segmentId = await db.insertSegment(tripId, startedAt); return (tripId, segmentId); } TrackPoint point(int tripId, int segmentId, int ts, {double speed = 50.0}) => TrackPoint( tripId: tripId, segmentId: segmentId, timestamp: ts, latitude: 51.0447, longitude: -114.0719, speedKmh: speed, altitudeM: 1045.0, ); group('cascade', () { test('deleting a trip cascades to segments and points', () async { final (tripId, segmentId) = await seedTrip(); await db.insertPoints( List.generate(5, (i) => point(tripId, segmentId, 1000 + i))); expect(await db.countPointsForTrip(tripId), 5); expect(await db.countSegmentsForTrip(tripId), 1); await db.deleteTrip(tripId); expect(await db.countPointsForTrip(tripId), 0); expect(await db.countSegmentsForTrip(tripId), 0); expect(await db.getTrip(tripId), isNull); }); test('foreign keys are actually enforced', () async { // SQLite defaults foreign_keys to OFF. Room enabled it; Drift does not, so the // pragma in beforeOpen is load-bearing. Without it the CASCADE above is // decorative and every delete silently orphans its points. final result = await db.customSelect('PRAGMA foreign_keys').getSingle(); expect(result.data.values.first, 1, reason: 'foreign_keys pragma is off — CASCADE would do nothing'); }); test('a point cannot reference a trip that does not exist', () async { await expectLater( db.insertPoint(point(9999, 9999, 1000)), throwsA(anything), ); }); }); group('active trip', () { test('an open trip is the active one and a closed trip is not', () async { final (tripId, _) = await seedTrip(); expect((await db.getActiveTrip())?.id, tripId); await db.closeTrip(tripId, 5000); expect(await db.getActiveTrip(), isNull); }); test('watchActiveTrip emits the open trip', () async { final (tripId, _) = await seedTrip(); expect((await db.watchActiveTrip().first)?.id, tripId); }); test('completed trips are ordered newest first', () async { final a = await db.insertTrip( const Trip(startedAt: 1000, state: TripState.recording)); final b = await db.insertTrip( const Trip(startedAt: 3000, state: TripState.recording)); final c = await db.insertTrip( const Trip(startedAt: 2000, state: TripState.recording)); for (final id in [a, b, c]) { await db.closeTrip(id, 9000); } final listed = await db.watchCompletedTrips().first; expect(listed.map((t) => t.startedAt).toList(), [3000, 2000, 1000]); }); }); group('point ordering', () { test('points come back by segment then id, not by timestamp', () async { // Timestamps are GPS-derived and can jump; id is monotonic in write order. The // ordering contract is what keeps a ride walkable with pauses intact. final (tripId, seg1) = await seedTrip(); final seg2 = await db.insertSegment(tripId, 2000); await db.insertPoints([ point(tripId, seg2, 500), // deliberately the earliest timestamp point(tripId, seg1, 9000), point(tripId, seg1, 1000), ]); final ordered = await db.pointsForTrip(tripId); expect(ordered.map((p) => p.segmentId).toList(), [seg1, seg1, seg2]); expect(ordered.first.timestamp, 9000, reason: 'must not be re-sorted by timestamp'); }); test('lastInSegment returns the highest id, the recorder anchor', () async { final (tripId, segmentId) = await seedTrip(); await db.insertPoints( List.generate(5, (i) => point(tripId, segmentId, 1000 + i))); final last = await db.lastInSegment(segmentId); expect(last!.timestamp, 1004); }); test('openSegment finds the one still being recorded into', () async { final (tripId, seg1) = await seedTrip(); await db.closeSegment(seg1, 2000); final seg2 = await db.insertSegment(tripId, 3000); expect((await db.openSegment(tripId))?.id, seg2); await db.closeSegment(seg2, 4000); expect(await db.openSegment(tripId), isNull); }); }); group('stats guards', () { test('a trip with no points reports zeros rather than failing', () async { final (tripId, _) = await seedTrip(); final stats = await db.watchTripStats(tripId).first; expect(stats.pointCount, 0); expect(stats.maxSpeedKmh, 0.0); expect(stats.avgSpeedKmh, 0.0); expect(stats.firstTimestamp, 0); expect(stats.durationMillis, 0); }); test('stats aggregate over the trip', () async { final (tripId, segmentId) = await seedTrip(); await db.insertPoints([ point(tripId, segmentId, 1000, speed: 10), point(tripId, segmentId, 2000, speed: 50), point(tripId, segmentId, 3000, speed: 30), ]); final stats = await db.watchTripStats(tripId).first; expect(stats.pointCount, 3); expect(stats.maxSpeedKmh, 50.0); expect(stats.avgSpeedKmh, closeTo(30.0, 1e-9)); expect(stats.durationMillis, 2000); expect(stats.pendingUpload, 3); }); }); group('upload backlog', () { test('unsynced points come back oldest first and mark cleanly', () async { final (tripId, segmentId) = await seedTrip(); await db.insertPoints( List.generate(10, (i) => point(tripId, segmentId, 1000 + i))); expect(await db.countUnsynced(), 10); final batch = await db.unsyncedPoints(4); expect(batch.length, 4); expect(batch.first.timestamp, 1000); await db.markSynced(batch.map((p) => p.id).toList()); expect(await db.countUnsynced(), 6); expect((await db.unsyncedPoints(1)).first.timestamp, 1004); }); test('marking an empty list is a no-op', () async { final (tripId, segmentId) = await seedTrip(); await db.insertPoints([point(tripId, segmentId, 1000)]); await db.markSynced(const []); expect(await db.countUnsynced(), 1); }); }); group('aggregates', () { test('updateAggregates writes only the running totals', () async { final (tripId, _) = await seedTrip(); await db.renameTrip(tripId, 'Morning loop'); await db.updateAggregates( id: tripId, distanceM: 1234.5, movingMillis: 60000, maxSpeedKmh: 88.5, elevationGainM: 42.0, pointCount: 99, ); final trip = (await db.getTrip(tripId))!; expect(trip.distanceM, 1234.5); expect(trip.movingMillis, 60000); expect(trip.maxSpeedKmh, 88.5); expect(trip.elevationGainM, 42.0); expect(trip.pointCount, 99); expect(trip.name, 'Morning loop', reason: 'unrelated columns must survive'); expect(trip.endedAt, isNull, reason: 'must not close the trip'); }); test('state round-trips through the enum column', () async { final (tripId, _) = await seedTrip(); await db.setTripState(tripId, TripState.paused); expect((await db.getTrip(tripId))!.state, TripState.paused); await db.closeTrip(tripId, 5000); expect((await db.getTrip(tripId))!.state, TripState.completed); }); test('renaming to null clears the name rather than storing empty', () async { final (tripId, _) = await seedTrip(); await db.renameTrip(tripId, 'Something'); await db.renameTrip(tripId, null); expect((await db.getTrip(tripId))!.name, isNull); }); }); }