V3-01: activity type per ride
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>
This commit is contained in:
64
test/activity_profile_test.dart
Normal file
64
test/activity_profile_test.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/telemetry/telemetry.dart';
|
||||
|
||||
/// Ported concept check for V3-01: activity type must drive real behaviour, not just a
|
||||
/// label.
|
||||
void main() {
|
||||
test(
|
||||
'the motorcycle profile reproduces the constants the app shipped with before '
|
||||
'activity type existed',
|
||||
() {
|
||||
// ActivityProfile.motorcycle deliberately duplicates these as literals rather than
|
||||
// importing them, to avoid a domain-layer dependency cycle -- this is the promised
|
||||
// check that the two never drift apart silently.
|
||||
expect(ActivityProfile.motorcycle.noiseFloorKmh, speedNoiseFloorKmh);
|
||||
expect(ActivityProfile.motorcycle.elevationThresholdM, 3.0);
|
||||
expect(ActivityProfile.motorcycle.elevationWindowSamples, 15);
|
||||
expect(ActivityProfile.motorcycle.accuracyGateM, 50.0);
|
||||
expect(ActivityProfile.motorcycle.histogramBucketKmh, 10);
|
||||
},
|
||||
);
|
||||
|
||||
test('forActivity covers every Activity value with no fallback surprises', () {
|
||||
for (final activity in Activity.values) {
|
||||
// Must not throw -- an unhandled case here would be a runtime crash on a
|
||||
// brand-new trip, not a compile error, since the switch is exhaustive over the
|
||||
// enum today but a future Activity value could slip through review.
|
||||
expect(() => ActivityProfile.forActivity(activity), returnsNormally);
|
||||
}
|
||||
});
|
||||
|
||||
test('slower activities get a lower noise floor than a motorcycle', () {
|
||||
final motorcycle = ActivityProfile.forActivity(Activity.motorcycle);
|
||||
final walking = ActivityProfile.forActivity(Activity.walking);
|
||||
final running = ActivityProfile.forActivity(Activity.running);
|
||||
|
||||
expect(walking.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh));
|
||||
expect(running.noiseFloorKmh, lessThan(motorcycle.noiseFloorKmh));
|
||||
expect(
|
||||
walking.noiseFloorKmh,
|
||||
lessThan(running.noiseFloorKmh),
|
||||
reason: 'a walk is slower than a run, so real movement sits even closer to noise',
|
||||
);
|
||||
});
|
||||
|
||||
test('running and walking use a finer speed histogram than a motorcycle', () {
|
||||
// 10 km/h bands are useless for an activity that rarely exceeds 10 km/h at all.
|
||||
expect(ActivityProfile.forActivity(Activity.running).histogramBucketKmh, 1);
|
||||
expect(ActivityProfile.forActivity(Activity.walking).histogramBucketKmh, 1);
|
||||
expect(ActivityProfile.forActivity(Activity.motorcycle).histogramBucketKmh, 10);
|
||||
});
|
||||
|
||||
test('scooter and other fall back to the safest generic profile', () {
|
||||
expect(
|
||||
ActivityProfile.forActivity(Activity.scooter),
|
||||
same(ActivityProfile.motorcycle),
|
||||
);
|
||||
expect(
|
||||
ActivityProfile.forActivity(Activity.other),
|
||||
same(ActivityProfile.motorcycle),
|
||||
);
|
||||
});
|
||||
}
|
||||
150
test/migration_test.dart
Normal file
150
test/migration_test.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||
|
||||
@@ -299,4 +300,57 @@ void main() {
|
||||
expect(elevationProfile([point(ts: 0)]).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('activity profile changes real output (V3-01)', () {
|
||||
test('a slow walk reads as stationary under the motorcycle floor, but not under '
|
||||
"walking's own", () {
|
||||
// 20 fixes a second apart, each ~0.7 km/h -- real walking pace, comfortably under
|
||||
// the motorcycle noise floor (1.5) but above the walking one (0.3). This is the
|
||||
// whole point of activity profiles: without one, a recorded walk would show
|
||||
// 00:00:00 moving time despite every fix showing genuine movement.
|
||||
final points = List.generate(
|
||||
20,
|
||||
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0000065, speed: 0.7),
|
||||
);
|
||||
|
||||
final asMotorcycle =
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle);
|
||||
final asWalking = computeSummary(points, profile: ActivityProfile.walking);
|
||||
|
||||
expect(asMotorcycle.movingMillis, 0,
|
||||
reason: 'the motorcycle floor must reject real walking speed as noise');
|
||||
expect(asWalking.movingMillis, greaterThan(0),
|
||||
reason: "the walking profile must recognise its own pace as movement");
|
||||
});
|
||||
|
||||
test('elevation window size changes what counts as a real climb', () {
|
||||
// A short, shallow rise across only a handful of samples. A wider averaging
|
||||
// window (running/walking) smooths it away as noise; the narrower motorcycle
|
||||
// window banks more of it as real.
|
||||
final points = List.generate(
|
||||
10,
|
||||
(i) => point(ts: i * 1000, alt: 1000.0 + i * 0.5),
|
||||
);
|
||||
|
||||
final motorcycle =
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle).elevationGainM;
|
||||
final running =
|
||||
computeSummary(points, profile: ActivityProfile.running).elevationGainM;
|
||||
|
||||
expect(running, lessThanOrEqualTo(motorcycle),
|
||||
reason: "running's wider smoothing window must not report MORE gain than "
|
||||
"motorcycle's narrower one on the same climb");
|
||||
});
|
||||
|
||||
test('defaults to the motorcycle profile when none is given', () {
|
||||
final points = List.generate(
|
||||
5,
|
||||
(i) => point(ts: i * 1000, speed: 40.0),
|
||||
);
|
||||
expect(
|
||||
computeSummary(points),
|
||||
computeSummary(points, profile: ActivityProfile.motorcycle),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/activity_profile.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
|
||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||
@@ -187,6 +188,84 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity type — V3-01
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('activity', () {
|
||||
test('a brand new database defaults a fresh trip to motorcycle', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.motorcycle);
|
||||
});
|
||||
|
||||
test('starting a new trip defaults to whatever activity was last used, '
|
||||
'with no picker required', () async {
|
||||
final first = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
await repo.setActivity(first.tripId, Activity.walking);
|
||||
|
||||
final second = await repo.startTrip(3000);
|
||||
|
||||
expect((await repo.tripById(second.tripId))!.activity, Activity.walking,
|
||||
reason: 'the new ride should inherit the most recently created trip\'s '
|
||||
'activity, not silently reset to motorcycle');
|
||||
});
|
||||
|
||||
test('an explicit activity overrides the last-used default', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.bicycle);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.bicycle);
|
||||
});
|
||||
|
||||
test('resuming an active trip never changes its activity', () async {
|
||||
final h = await repo.startTrip(1000, activity: Activity.running);
|
||||
await repo.pauseTrip(2000);
|
||||
// Started again with no explicit activity -- must adopt the paused trip's own,
|
||||
// not fall back to whatever "last used" would otherwise resolve to.
|
||||
final resumed = await repo.startTrip(3000);
|
||||
|
||||
expect(resumed.tripId, h.tripId);
|
||||
expect((await repo.tripById(h.tripId))!.activity, Activity.running);
|
||||
});
|
||||
|
||||
test('setActivity recomputes aggregates under the new profile', () async {
|
||||
// A slow walking pace: under the motorcycle noise floor (1.5 km/h) but above
|
||||
// walking's own (0.3), so moving time genuinely differs between the two profiles.
|
||||
final h = await repo.startTrip(1000, activity: Activity.motorcycle);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await addPoint(h, 1000 + i * 1000, speed: 0.7);
|
||||
}
|
||||
await repo.completeTrip(11000);
|
||||
// completeTrip() alone does not recompute aggregates -- the recorder does that
|
||||
// separately on stop(). Mirror that here rather than relying on a leftover value.
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
|
||||
final asMotorcycle = (await repo.tripById(h.tripId))!;
|
||||
expect(asMotorcycle.movingMillis, 0,
|
||||
reason: 'a motorcycle profile must read a 0.7 km/h pace as noise');
|
||||
|
||||
await repo.setActivity(h.tripId, Activity.walking);
|
||||
|
||||
final asWalking = (await repo.tripById(h.tripId))!;
|
||||
expect(asWalking.activity, Activity.walking);
|
||||
expect(asWalking.movingMillis, greaterThan(0),
|
||||
reason: 'recomputing under the walking profile must recognise the same '
|
||||
'fixes as real movement');
|
||||
// Sanity check that this really did come from ActivityProfile.walking and not a
|
||||
// coincidence -- computing with the profile explicitly must agree.
|
||||
expect(
|
||||
asWalking.movingMillis,
|
||||
greaterThan(asMotorcycle.movingMillis),
|
||||
);
|
||||
// The bicycle/motorcycle-equivalent profile does not exist as a category here;
|
||||
// just confirm the profile object itself expresses the difference this test
|
||||
// exercises indirectly through the repository.
|
||||
expect(
|
||||
ActivityProfile.walking.noiseFloorKmh,
|
||||
lessThan(ActivityProfile.motorcycle.noiseFloorKmh),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a fresh repository over the same database sees the active trip',
|
||||
() async {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/activity_display.dart';
|
||||
import 'package:rippr/src/recording/location_source.dart';
|
||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||
@@ -282,6 +283,27 @@ void main() {
|
||||
|
||||
expect(await repo.tripById(a), isNull);
|
||||
});
|
||||
|
||||
screenTest('a tile shows its own activity icon (V3-01)', (tester) async {
|
||||
final motorcycleTrip =
|
||||
await seedCompletedTrip(startedAt: 1000, endedAt: 5000, name: 'Ride');
|
||||
final bikeTrip =
|
||||
await seedCompletedTrip(startedAt: 10000, endedAt: 15000, name: 'Bike');
|
||||
await repo.setActivity(bikeTrip, Activity.bicycle);
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Icon iconFor(int tripId) => tester.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byKey(Key('trip-$tripId')),
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
);
|
||||
|
||||
expect(iconFor(motorcycleTrip).icon, activityIcon(Activity.motorcycle));
|
||||
expect(iconFor(bikeTrip).icon, activityIcon(Activity.bicycle));
|
||||
});
|
||||
});
|
||||
|
||||
group('trip detail', () {
|
||||
@@ -340,5 +362,23 @@ void main() {
|
||||
expect(find.text('Coast run'), findsOneWidget);
|
||||
expect((await repo.tripById(id))!.name, 'Coast run');
|
||||
});
|
||||
|
||||
screenTest('editing the activity updates the row and recomputes aggregates',
|
||||
(tester) async {
|
||||
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(activityLabel(Activity.motorcycle)), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('activity-row')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(Key('activity-${Activity.walking.name}')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(activityLabel(Activity.walking)), findsOneWidget);
|
||||
expect((await repo.tripById(id))!.activity, Activity.walking);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user