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:
2026-08-15 01:13:45 -05:00
parent 398157b1f5
commit d7d9854dc9
5 changed files with 3749 additions and 0 deletions

248
test/database_test.dart Normal file
View 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);
});
});
}