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

13
build.yaml Normal file
View File

@@ -0,0 +1,13 @@
targets:
$default:
builders:
drift_dev:
options:
# Preserve Dart field names as SQL column names instead of snake_casing them.
#
# This keeps the schema column-for-column identical to the native app's Room
# schema (app/schemas/com.rippr.data.AppDatabase/2.json), which means the raw
# SQL in database.dart can stay byte-identical to the Kotlin DAO queries it was
# ported from -- much easier to review -- and leaves the door open to reading an
# old Room database file directly if an importer is ever wanted.
case_from_dart_to_sql: preserve

View File

@@ -277,3 +277,43 @@ comment in `run.sh` says so explicitly.
its ~965 lines of tests, and proven equivalent rather than assumed equivalent. its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
Next: **T08 (Drift schema)**, the first task that touches persistence. Next: **T08 (Drift schema)**, the first task that touches persistence.
---
## T08 — Drift schema · **complete**
`lib/src/data/database.dart` (+ generated `database.g.dart`). **16 tests.**
Mirrors `app/schemas/com.rippr.data.AppDatabase/2.json`: three tables, CASCADE foreign
keys, indices on `tripId` / `segmentId` / `synced`, WAL with `synchronous = NORMAL`.
Starts at Dart schema version 1 with a real `MigrationStrategy` — the destructive
fallback never comes back.
### Foreign keys are OFF by default in SQLite
Room switched them on for us. **Drift does not.** Without `PRAGMA foreign_keys = ON` in
`beforeOpen`, every `CASCADE` in the schema is decorative and deleting a trip silently
orphans all of its points. There is now a test that reads the pragma back and asserts it
is `1`, because this is invisible until data is already wrong.
### Two collisions worth recording
**Drift generates row classes named after the table.** `Trips` → `Trip`, colliding with
the domain model of the same name and producing 21 confusing analyzer errors of the form
*"Trip can't be assigned to Trip"*. Fixed with `@DataClassName('TripRow')` etc. The
mapping functions `_toTrip` / `_toSegment` / `_toPoint` convert row → domain, so the
domain layer stays unaware Drift exists.
**Drift snake_cases column names.** `speedKmh` became `speed_kmh`, which broke the one
raw-SQL query (the live stats aggregate) with `no such column: speedKmh`. Rather than 30
`.named()` annotations, `build.yaml` sets `case_from_dart_to_sql: preserve`. That keeps
the schema column-for-column identical to Room's, lets the raw SQL stay byte-identical to
the Kotlin DAO query it was ported from, and leaves a Room-file importer possible later.
### The instrumented-to-unit win, realised
`SchemaTest` needed a device and an emulator. The Drift equivalent runs on the Dart VM in
well under a second with nothing booted. `TripRepositoryTest` and `MergeTest` (441 more
lines) should convert the same way in T09.
**95 tests passing, analyze clean.**

429
lib/src/data/database.dart Normal file
View File

@@ -0,0 +1,429 @@
/// Drift schema, mirroring the Room schema at
/// `app/schemas/com.rippr.data.AppDatabase/2.json` in the native repo.
///
/// Ported from `com.rippr.data.AppDatabase` plus the three DAOs.
///
/// ## Two invariants carried over verbatim
///
/// **No destructive migration, ever.** v2 removed `fallbackToDestructiveMigration()`
/// because rides are real data. This starts at Dart schema version 1 with a real
/// [MigrationStrategy] from day one; reinstating a destructive fallback would silently
/// delete every stored ride on the next version bump.
///
/// **Foreign keys must be switched on explicitly.** SQLite defaults `foreign_keys` to
/// OFF. Room turned it on for us; Drift does not. Without the pragma in [beforeOpen] the
/// `CASCADE` deletes below are decorative, and deleting a trip would silently orphan
/// every one of its points. There is a test for exactly this.
library;
import 'package:drift/drift.dart';
import '../domain/models.dart' as domain;
part 'database.g.dart';
/// One ride, from pressing Start to pressing Stop.
///
/// The aggregate columns are denormalised on purpose — accumulated as points arrive and
/// recomputed authoritatively on completion, so the trips list never touches the point
/// table.
@DataClassName('TripRow')
@TableIndex(name: 'idx_trips_ended', columns: {#endedAt})
class Trips extends Table {
@override
String get tableName => 'trips';
IntColumn get id => integer().autoIncrement()();
IntColumn get startedAt => integer()();
/// Null while the ride is still active. This column *is* the fact of an in-progress
/// ride, which is why recording state survives process death.
IntColumn get endedAt => integer().nullable()();
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
TextColumn get name => text().nullable()();
TextColumn get state => textEnum<domain.TripState>()();
RealColumn get distanceM => real().withDefault(const Constant(0))();
IntColumn get movingMillis => integer().withDefault(const Constant(0))();
RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))();
RealColumn get elevationGainM => real().withDefault(const Constant(0))();
IntColumn get pointCount => integer().withDefault(const Constant(0))();
}
/// One pause-free stretch of recording within a trip.
///
/// This layer is what makes pause correct rather than cosmetic.
@DataClassName('SegmentRow')
@TableIndex(name: 'idx_segments_trip', columns: {#tripId})
class Segments extends Table {
@override
String get tableName => 'segments';
IntColumn get id => integer().autoIncrement()();
IntColumn get tripId =>
integer().references(Trips, #id, onDelete: KeyAction.cascade)();
IntColumn get startedAt => integer()();
/// Null while this segment is still being recorded into.
IntColumn get endedAt => integer().nullable()();
}
/// A single GPS fix.
@DataClassName('TrackPointRow')
@TableIndex(name: 'idx_points_trip', columns: {#tripId})
@TableIndex(name: 'idx_points_segment', columns: {#segmentId})
@TableIndex(name: 'idx_points_synced', columns: {#synced})
class TrackPoints extends Table {
@override
String get tableName => 'track_points';
IntColumn get id => integer().autoIncrement()();
IntColumn get tripId =>
integer().references(Trips, #id, onDelete: KeyAction.cascade)();
IntColumn get segmentId =>
integer().references(Segments, #id, onDelete: KeyAction.cascade)();
IntColumn get timestamp => integer()();
RealColumn get latitude => real()();
RealColumn get longitude => real()();
RealColumn get speedKmh => real()();
RealColumn get altitudeM => real()();
RealColumn get accuracyM => real().withDefault(const Constant(0))();
RealColumn get bearingDeg => real().withDefault(const Constant(0))();
/// Set once the point has been accepted by the remote endpoint.
BoolColumn get synced => boolean().withDefault(const Constant(false))();
}
@DriftDatabase(tables: [Trips, Segments, TrackPoints])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
beforeOpen: (details) async {
// Non-negotiable: without this the CASCADE relationships above do nothing.
await customStatement('PRAGMA foreign_keys = ON');
// A ride is unrecoverable if a write is lost to a crash mid-flush, but full
// sync on every insert at 2 Hz burns battery. WAL with NORMAL sync is the
// standard compromise and survives app crashes; only an OS-level crash can
// lose the last few points.
await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA synchronous = NORMAL');
},
);
// --- Trips ---------------------------------------------------------------
/// The in-progress ride, or null. This is the source of truth for "are we recording" —
/// it survives process death, which an in-memory flag cannot.
Stream<domain.Trip?> watchActiveTrip() => (select(trips)
..where((t) => t.endedAt.isNull())
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1))
.watchSingleOrNull()
.map((r) => r == null ? null : _toTrip(r));
Future<domain.Trip?> getActiveTrip() async {
final row = await (select(trips)
..where((t) => t.endedAt.isNull())
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
return row == null ? null : _toTrip(row);
}
Stream<List<domain.Trip>> watchCompletedTrips() => (select(trips)
..where((t) => t.endedAt.isNotNull())
..orderBy([(t) => OrderingTerm.desc(t.startedAt)]))
.watch()
.map((rows) => rows.map(_toTrip).toList());
Stream<domain.Trip?> watchTrip(int id) =>
(select(trips)..where((t) => t.id.equals(id)))
.watchSingleOrNull()
.map((r) => r == null ? null : _toTrip(r));
Future<domain.Trip?> getTrip(int id) async {
final row =
await (select(trips)..where((t) => t.id.equals(id))).getSingleOrNull();
return row == null ? null : _toTrip(row);
}
Future<int> insertTrip(domain.Trip trip) => into(trips).insert(
TripsCompanion.insert(
startedAt: trip.startedAt,
endedAt: Value(trip.endedAt),
name: Value(trip.name),
state: trip.state,
distanceM: Value(trip.distanceM),
movingMillis: Value(trip.movingMillis),
maxSpeedKmh: Value(trip.maxSpeedKmh),
elevationGainM: Value(trip.elevationGainM),
pointCount: Value(trip.pointCount),
),
);
Future<void> renameTrip(int id, String? name) =>
(update(trips)..where((t) => t.id.equals(id)))
.write(TripsCompanion(name: Value(name)));
Future<void> setTripState(int id, domain.TripState state) =>
(update(trips)..where((t) => t.id.equals(id)))
.write(TripsCompanion(state: Value(state)));
Future<void> closeTrip(int id, int endedAt,
{domain.TripState state = domain.TripState.completed}) =>
(update(trips)..where((t) => t.id.equals(id))).write(
TripsCompanion(endedAt: Value(endedAt), state: Value(state)));
/// Persists the running totals. Called once per writer flush (~every 2 s), so it stays
/// a narrow targeted update rather than a full row rewrite.
Future<void> updateAggregates({
required int id,
required double distanceM,
required int movingMillis,
required double maxSpeedKmh,
required double elevationGainM,
required int pointCount,
}) =>
(update(trips)..where((t) => t.id.equals(id))).write(TripsCompanion(
distanceM: Value(distanceM),
movingMillis: Value(movingMillis),
maxSpeedKmh: Value(maxSpeedKmh),
elevationGainM: Value(elevationGainM),
pointCount: Value(pointCount),
));
/// Segments and points go with it via CASCADE.
Future<void> deleteTrip(int id) =>
(delete(trips)..where((t) => t.id.equals(id))).go();
Future<int> countTrips() async =>
(await select(trips).get()).length;
/// Test/maintenance helper. Segments and points follow via CASCADE.
Future<void> deleteAllTrips() => delete(trips).go();
// --- Segments ------------------------------------------------------------
Future<int> insertSegment(int tripId, int startedAt) =>
into(segments).insert(
SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt),
);
Future<List<domain.Segment>> segmentsForTrip(int tripId) async {
final rows = await (select(segments)
..where((s) => s.tripId.equals(tripId))
..orderBy([(s) => OrderingTerm.asc(s.id)]))
.get();
return rows.map(_toSegment).toList();
}
/// The segment currently being recorded into, if any.
Future<domain.Segment?> openSegment(int tripId) async {
final row = await (select(segments)
..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull())
..orderBy([(s) => OrderingTerm.desc(s.id)])
..limit(1))
.getSingleOrNull();
return row == null ? null : _toSegment(row);
}
Future<void> closeSegment(int id, int endedAt) =>
(update(segments)..where((s) => s.id.equals(id)))
.write(SegmentsCompanion(endedAt: Value(endedAt)));
/// Used by merge: re-parents a trip's segments onto the surviving trip.
Future<void> reparentSegments(int oldTripId, int newTripId) =>
(update(segments)..where((s) => s.tripId.equals(oldTripId)))
.write(SegmentsCompanion(tripId: Value(newTripId)));
Future<int> countSegmentsForTrip(int tripId) async =>
(await (select(segments)..where((s) => s.tripId.equals(tripId))).get())
.length;
// --- Track points --------------------------------------------------------
/// Batched insert — the recorder buffers points and flushes them in groups.
Future<void> insertPoints(List<domain.TrackPoint> points) async {
if (points.isEmpty) return;
await batch((b) {
b.insertAll(
trackPoints,
points.map((p) => TrackPointsCompanion.insert(
tripId: p.tripId,
segmentId: p.segmentId,
timestamp: p.timestamp,
latitude: p.latitude,
longitude: p.longitude,
speedKmh: p.speedKmh,
altitudeM: p.altitudeM,
accuracyM: Value(p.accuracyM),
bearingDeg: Value(p.bearingDeg),
synced: Value(p.synced),
)),
);
});
}
Future<int> insertPoint(domain.TrackPoint p) =>
into(trackPoints).insert(TrackPointsCompanion.insert(
tripId: p.tripId,
segmentId: p.segmentId,
timestamp: p.timestamp,
latitude: p.latitude,
longitude: p.longitude,
speedKmh: p.speedKmh,
altitudeM: p.altitudeM,
accuracyM: Value(p.accuracyM),
bearingDeg: Value(p.bearingDeg),
synced: Value(p.synced),
));
/// Ordered by segment then id so consumers walk the ride in recording order with pause
/// boundaries intact. **Not** ordered by timestamp: that value is GPS-derived and can
/// jump, whereas id is monotonic in write order.
Future<List<domain.TrackPoint>> pointsForTrip(int tripId) async {
final rows = await (select(trackPoints)
..where((p) => p.tripId.equals(tripId))
..orderBy([
(p) => OrderingTerm.asc(p.segmentId),
(p) => OrderingTerm.asc(p.id),
]))
.get();
return rows.map(_toPoint).toList();
}
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
final rows = await (select(trackPoints)
..where((p) => p.segmentId.equals(segmentId))
..orderBy([(p) => OrderingTerm.asc(p.id)]))
.get();
return rows.map(_toPoint).toList();
}
/// The anchor a restarted recorder needs to continue accumulating distance.
Future<domain.TrackPoint?> lastInSegment(int segmentId) async {
final row = await (select(trackPoints)
..where((p) => p.segmentId.equals(segmentId))
..orderBy([(p) => OrderingTerm.desc(p.id)])
..limit(1))
.getSingleOrNull();
return row == null ? null : _toPoint(row);
}
Future<int> countPointsForTrip(int tripId) async =>
(await (select(trackPoints)..where((p) => p.tripId.equals(tripId))).get())
.length;
/// Live stats for one trip.
///
/// The COALESCE guards in the Kotlin original existed because Room throws on null for
/// non-null fields; they are kept because the semantics are what matter — a trip with
/// no points yet must read as zeros, not as an error.
Stream<domain.RideStats> watchTripStats(int tripId) {
final q = customSelect(
'''
SELECT COUNT(*) AS pointCount,
COALESCE(MAX(speedKmh), 0.0) AS maxSpeedKmh,
COALESCE(AVG(speedKmh), 0.0) AS avgSpeedKmh,
COALESCE(MIN(timestamp), 0) AS firstTimestamp,
COALESCE(MAX(timestamp), 0) AS lastTimestamp,
COALESCE(SUM(CASE WHEN synced = 0 THEN 1 ELSE 0 END), 0) AS pendingUpload
FROM track_points
WHERE tripId = ?
''',
variables: [Variable.withInt(tripId)],
readsFrom: {trackPoints},
);
return q.watchSingle().map((row) => domain.RideStats(
pointCount: row.read<int>('pointCount'),
maxSpeedKmh: row.read<double>('maxSpeedKmh'),
avgSpeedKmh: row.read<double>('avgSpeedKmh'),
firstTimestamp: row.read<int>('firstTimestamp'),
lastTimestamp: row.read<int>('lastTimestamp'),
pendingUpload: row.read<int>('pendingUpload'),
));
}
// --- Upload backlog ------------------------------------------------------
// Batches are drawn by id and can straddle a segment or trip boundary, which is why
// the payload carries trip/segment identity per point rather than per batch.
Future<List<domain.TrackPoint>> unsyncedPoints(int limit) async {
final rows = await (select(trackPoints)
..where((p) => p.synced.equals(false))
..orderBy([(p) => OrderingTerm.asc(p.id)])
..limit(limit))
.get();
return rows.map(_toPoint).toList();
}
Future<void> markSynced(List<int> ids) async {
if (ids.isEmpty) return;
await (update(trackPoints)..where((p) => p.id.isIn(ids)))
.write(const TrackPointsCompanion(synced: Value(true)));
}
Future<int> countUnsynced() async =>
(await (select(trackPoints)..where((p) => p.synced.equals(false))).get())
.length;
// --- Maintenance ---------------------------------------------------------
/// Used by merge: points carry tripId directly, so they re-parent alongside segments.
Future<void> reparentPoints(int oldTripId, int newTripId) =>
(update(trackPoints)..where((p) => p.tripId.equals(oldTripId)))
.write(TrackPointsCompanion(tripId: Value(newTripId)));
Future<List<domain.TrackPoint>> allPoints() async {
final rows = await (select(trackPoints)
..orderBy([(p) => OrderingTerm.asc(p.id)]))
.get();
return rows.map(_toPoint).toList();
}
}
// --- Row → domain mapping ---------------------------------------------------
// Kept as free functions rather than extension getters so the domain layer stays
// entirely unaware that Drift exists.
domain.Trip _toTrip(TripRow r) => domain.Trip(
id: r.id,
startedAt: r.startedAt,
endedAt: r.endedAt,
name: r.name,
state: r.state,
distanceM: r.distanceM,
movingMillis: r.movingMillis,
maxSpeedKmh: r.maxSpeedKmh,
elevationGainM: r.elevationGainM,
pointCount: r.pointCount,
);
domain.Segment _toSegment(SegmentRow r) => domain.Segment(
id: r.id,
tripId: r.tripId,
startedAt: r.startedAt,
endedAt: r.endedAt,
);
domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint(
id: r.id,
tripId: r.tripId,
segmentId: r.segmentId,
timestamp: r.timestamp,
latitude: r.latitude,
longitude: r.longitude,
speedKmh: r.speedKmh,
altitudeM: r.altitudeM,
accuracyM: r.accuracyM,
bearingDeg: r.bearingDeg,
synced: r.synced,
);

3019
lib/src/data/database.g.dart Normal file

File diff suppressed because it is too large Load Diff

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