Files
rippr/test/migration_test.dart
uhryniuk 9ac0be6ee5 FB-05: closed-loop routes in Route Planner
Add an isClosedLoop toggle to route plans (schema v3 -> v4 migration),
threaded through the domain model, repository distance recomputation,
the overflow menu, and the drawn polyline, so a route can loop back to
its first waypoint.
2026-08-24 16:22:51 -05:00

234 lines
8.7 KiB
Dart

import 'dart:io';
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';
import 'package:sqlite3/sqlite3.dart' as sqlite3;
/// V3-01's migration test — the one that matters more than the feature it belongs to.
///
/// The destructive fallback is gone for good (see the doc comment on [AppDatabase]), so
/// this is the port's first real schema migration, and getting the path right here is
/// what makes every later one trustworthy.
///
/// Builds the v1 schema with raw SQL via `package:sqlite3` directly, rather than
/// drift_dev's schema-versioning tooling — deliberately: that tooling needs a
/// `build.yaml`-declared set of frozen schema snapshots this project has not adopted, and
/// a hand-built v1 table is a faithful enough stand-in. It is copied column-for-column
/// from `Trips`/`Segments`/`TrackPoints` as they were before the `activity` column was
/// added, using the same names — `build.yaml`'s `case_from_dart_to_sql: preserve` means
/// there is no snake_case translation to get wrong.
void main() {
late Directory tempDir;
late File dbFile;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('rippr_migration_test_');
dbFile = File('${tempDir.path}/v1.sqlite');
});
tearDown(() async {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
/// Writes a database file containing exactly the v1 schema, seeded with one trip, one
/// closed segment, and a handful of points -- then closes it, so Drift can open the
/// same file fresh.
void seedV1Database() {
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
CREATE TABLE trips (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL,
name TEXT NULL,
state TEXT NOT NULL,
distanceM REAL NOT NULL DEFAULT 0,
movingMillis INTEGER NOT NULL DEFAULT 0,
maxSpeedKmh REAL NOT NULL DEFAULT 0,
elevationGainM REAL NOT NULL DEFAULT 0,
pointCount INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_trips_ended ON trips (endedAt);
CREATE TABLE segments (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
startedAt INTEGER NOT NULL,
endedAt INTEGER NULL
);
CREATE INDEX idx_segments_trip ON segments (tripId);
CREATE TABLE track_points (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
tripId INTEGER NOT NULL REFERENCES trips (id) ON DELETE CASCADE,
segmentId INTEGER NOT NULL REFERENCES segments (id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
speedKmh REAL NOT NULL,
altitudeM REAL NOT NULL,
accuracyM REAL NOT NULL DEFAULT 0,
bearingDeg REAL NOT NULL DEFAULT 0,
synced INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_points_trip ON track_points (tripId);
CREATE INDEX idx_points_segment ON track_points (segmentId);
CREATE INDEX idx_points_synced ON track_points (synced);
''');
raw.execute('''
INSERT INTO trips (id, startedAt, endedAt, name, state, distanceM, movingMillis,
maxSpeedKmh, elevationGainM, pointCount)
VALUES (1, 1000, 5000, 'Old ride', 'completed', 111.2, 4000, 40.0, 12.0, 3);
''');
raw.execute('''
INSERT INTO segments (id, tripId, startedAt, endedAt) VALUES (1, 1, 1000, 5000);
''');
for (var i = 0; i < 3; i++) {
raw.execute('''
INSERT INTO track_points
(tripId, segmentId, timestamp, latitude, longitude, speedKmh, altitudeM)
VALUES (1, 1, ${1000 + i * 1000}, ${51.0 + i * 0.0001}, -114.0, 40.0, 1000.0);
''');
}
// Drift persists its schema version in SQLite's own user_version pragma. Setting it
// to 1 here is what makes AppDatabase (schemaVersion 2) believe an upgrade is due
// when it opens this file, rather than treating it as a fresh database.
raw.execute('PRAGMA user_version = 1;');
raw.close();
}
test('a v1 database migrates and keeps every ride and point', () async {
seedV1Database();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final trip = await db.getTrip(1);
expect(trip, isNotNull, reason: 'the pre-existing ride must survive the migration');
expect(trip!.name, 'Old ride');
expect(trip.distanceM, closeTo(111.2, 1e-9));
expect(trip.pointCount, 3);
expect(
trip.activity,
Activity.motorcycle,
reason: 'a ride recorded before activity existed must default to motorcycle, '
'not be left null or reject the row',
);
final points = await db.pointsForTrip(1);
expect(points.length, 3, reason: 'no point may be lost in the upgrade');
final segments = await db.segmentsForTrip(1);
expect(segments.length, 1);
// The column is genuinely usable afterwards, not just present with a default.
await db.setTripActivity(1, Activity.bicycle);
expect((await db.getTrip(1))!.activity, Activity.bicycle);
});
test('a v1 database with several rides migrates all of them', () async {
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
INSERT INTO trips (startedAt, endedAt, state, distanceM, pointCount)
VALUES (10000, 15000, 'completed', 500.0, 10),
(20000, 25000, 'completed', 1000.0, 20);
''');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
final all = await db.watchCompletedTrips().first;
expect(all.length, 3);
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue);
});
test('a v2 database (V3-07) gains route_plans/waypoints and keeps its trips',
() async {
// v2: identical to the v1 seed above, plus the `activity` column V3-01 added.
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
''');
raw.execute('PRAGMA user_version = 2;');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
// The pre-existing ride is untouched by an upgrade that has nothing to do with it.
final trip = await db.getTrip(1);
expect(trip, isNotNull);
expect(trip!.name, 'Old ride');
// The new tables are not just present but usable.
final routeId = await db.insertRoutePlan(
const RoutePlan(name: 'Coast loop', createdAt: 9000),
);
await db.insertWaypoint(
Waypoint(routeId: routeId, ordinal: 0, latitude: 51.0, longitude: -114.0),
);
final waypoints = await db.waypointsForRoute(routeId);
expect(waypoints, hasLength(1));
});
test('a v3 database (FB-05) gains isClosedLoop defaulting to false', () async {
// v3: identical to the v2 seed above, plus the route_plans/waypoints tables V3-07
// added.
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
''');
raw.execute('''
CREATE TABLE route_plans (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL,
activity TEXT NOT NULL DEFAULT 'motorcycle',
distanceM REAL NOT NULL DEFAULT 0,
estimatedMillis INTEGER NULL,
geometry TEXT NULL
);
CREATE TABLE waypoints (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
routeId INTEGER NOT NULL REFERENCES route_plans (id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
name TEXT NULL
);
CREATE INDEX idx_waypoints_route ON waypoints (routeId);
''');
raw.execute('''
INSERT INTO route_plans (id, name, createdAt) VALUES (1, 'Existing route', 8000);
''');
raw.execute('PRAGMA user_version = 3;');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
// The pre-existing route survives with the new column defaulting to false.
final existing = await db.getRoutePlan(1);
expect(existing, isNotNull);
expect(existing!.isClosedLoop, isFalse);
// A freshly inserted route also reads back false by default.
final newId = await db.insertRoutePlan(
const RoutePlan(name: 'New route', createdAt: 9000),
);
expect((await db.getRoutePlan(newId))!.isClosedLoop, isFalse);
// Toggling persists.
await db.setRoutePlanClosedLoop(1, true);
expect((await db.getRoutePlan(1))!.isClosedLoop, isTrue);
});
}