Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as a column on Trip, and threads it through as real behaviour rather than a label: ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy gate, histogram bucket width, and elevation smoothing window/threshold per activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator and speedHistogram. The motorcycle profile reproduces the exact constants the app shipped with before this existed, and a test asserts they never drift apart. This is the port's first real migration: schemaVersion 1 -> 2, m.addColumn(trips, trips.activity) with a motorcycle default so every existing row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3, not drift_dev's schema tooling) that a real database with real rides upgrades and keeps every trip, segment and point. No picker in front of Start: a new trip defaults to whichever activity was most recently used, derived live from the trips table rather than duplicated into a Config field. Editable afterwards on trip detail, which recomputes aggregates under the new profile immediately -- a walking pace that reads as noise under a motorcycle's floor reads as real movement once the activity is corrected, and there's a test proving exactly that transition. The widget-test pass for the activity-picker sheet caught a real overflow bug: seven options overflowed a Column-based bottom sheet the same way the record screen once did. Fixed with a scrollable ListView + isScrollControlled, same shape as that earlier fix. 188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading proofs in computeSummary, 5 repository, 2 widget. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
151 lines
5.8 KiB
Dart
151 lines
5.8 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);
|
|
});
|
|
}
|