Three tables with CASCADE foreign keys, indices, WAL, and a real MigrationStrategy from schema version 1. No destructive fallback, ever. Two things needed deliberate handling. SQLite defaults foreign_keys to OFF and Drift, unlike Room, does not enable it -- without the pragma every CASCADE is decorative, so there is now a test that reads the pragma back. And Drift both snake_cases columns and names row classes after tables; build.yaml sets case_from_dart_to_sql: preserve so the schema stays column-for-column identical to Room's, and @DataClassName keeps row types from colliding with the domain models. SchemaTest was instrumented and needed a device; the Drift version is a plain unit test that runs in under a second with no emulator. 16 tests. 95 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
8.5 KiB
Dart
249 lines
8.5 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/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);
|
|
});
|
|
});
|
|
}
|