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.
234 lines
8.7 KiB
Dart
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);
|
|
});
|
|
}
|