T08: Drift schema mirroring the Room baseline
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>
This commit is contained in:
248
test/database_test.dart
Normal file
248
test/database_test.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user