Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK

V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
This commit is contained in:
2026-08-19 13:36:04 -05:00
parent 14289befb0
commit 4c634354bd
48 changed files with 4436 additions and 224 deletions

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

View File

@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// A structural invariant from V3-04, checked directly rather than trusted: rendering
/// belongs to a visible screen's widget lifecycle, never to the recording engine. If this
/// ever starts failing, a map reference has leaked into code that keeps running while the
/// phone is pocketed and the screen is off.
void main() {
test('RecordingEngine never imports the map', () {
final source = File('lib/src/recording/recording_engine.dart').readAsStringSync();
expect(source.contains('ride_map'), isFalse);
expect(source.contains('flutter_map'), isFalse);
});
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves
/// `configProvider` null, so this is its first direct coverage.
void main() {
Future<Config> freshConfig([Map<String, Object> initial = const {}]) async {
SharedPreferences.setMockInitialValues(initial);
return Config.load();
}
group('unitSystem', () {
test('an explicit choice always wins over the locale default', () async {
final config = await freshConfig();
await config.setUnitSystem(UnitSystem.imperial);
expect(config.unitSystem, UnitSystem.imperial);
await config.setUnitSystem(UnitSystem.metric);
expect(config.unitSystem, UnitSystem.metric);
});
test('with nothing stored, falls back to a locale default without throwing', () async {
// The exact value is environment-dependent (it reads the real platform locale),
// so this asserts the fallback path is safe to reach, not a specific answer.
final config = await freshConfig();
expect(config.unitSystem, isA<UnitSystem>());
});
});
group('mapEnabled', () {
test('defaults to true', () async {
final config = await freshConfig();
expect(config.mapEnabled, isTrue);
});
test('round-trips through set/get', () async {
final config = await freshConfig();
await config.setMapEnabled(false);
expect(config.mapEnabled, isFalse);
});
});
group('mountedMode', () {
test('defaults to false', () async {
final config = await freshConfig();
expect(config.mountedMode, isFalse);
});
test('round-trips through set/get', () async {
final config = await freshConfig();
await config.setMountedMode(true);
expect(config.mountedMode, isTrue);
});
});
group('crashReportingEnabled', () {
test('defaults to false', () async {
final config = await freshConfig();
expect(config.crashReportingEnabled, isFalse);
});
test('round-trips through set/get', () async {
final config = await freshConfig();
await config.setCrashReportingEnabled(true);
expect(config.crashReportingEnabled, isTrue);
});
});
group('uploadEndpoint', () {
test('defaults to empty, not null or a placeholder', () async {
final config = await freshConfig();
expect(config.uploadEndpoint, '');
});
test('trims whitespace on write', () async {
final config = await freshConfig();
await config.setUploadEndpoint(' https://example.test/ingest ');
expect(config.uploadEndpoint, 'https://example.test/ingest');
});
});
group('deviceId', () {
test('is generated once and then stable across reads', () async {
final config = await freshConfig();
final first = config.deviceId;
final second = config.deviceId;
expect(first, second);
expect(first, isNotEmpty);
});
test('is a v4 UUID', () async {
final config = await freshConfig();
// xxxxxxxx-xxxx-4xxx-{8,9,a,b}xxx-xxxxxxxxxxxx
final v4 = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
expect(v4.hasMatch(config.deviceId), isTrue,
reason: 'got ${config.deviceId}');
});
test('persists across a fresh Config over the same preferences', () async {
SharedPreferences.setMockInitialValues({});
final first = await Config.load();
final id = first.deviceId;
final second = await Config.load();
expect(second.deviceId, id,
reason: 'a server distinguishing riders needs this to be stable');
});
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/crash/crash_reporter.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// V3-12: a crash reporter in a location app is a privacy surface. Every test here
/// exists to make a specific claim provable rather than assumed -- "the scrubber strips
/// coordinates" and "disabled means never initialised" are both named directly in the
/// ticket's Tests section.
void main() {
group('shouldInitializeCrashReporting', () {
test('requires enabled, release, and a configured dsn all at once', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: 'https://x'),
isTrue,
);
});
test('the user toggle being off means the client is never initialised', () {
expect(
shouldInitializeCrashReporting(enabled: false, isDebug: false, dsn: 'https://x'),
isFalse,
);
});
test('debug builds send nothing, even if enabled', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: true, dsn: 'https://x'),
isFalse,
);
});
test('no dsn configured means no client, regardless of the toggle', () {
expect(
shouldInitializeCrashReporting(enabled: true, isDebug: false, dsn: ''),
isFalse,
);
});
});
group('scrubExtra', () {
test('strips every coordinate-shaped key', () {
final scrubbed = scrubExtra({
'latitude': 51.0447,
'longitude': -114.0719,
'lat': 51.0,
'lon': -114.0,
'startLat': 51.0,
'gps_coords': '51.0,-114.0',
'altitude': 1045.0,
'safe_field': 'kept',
})!;
expect(scrubbed.containsKey('safe_field'), isTrue);
expect(scrubbed, hasLength(1),
reason: 'no field describing a location may survive: $scrubbed');
});
test('strips device and trip identity', () {
final scrubbed = scrubExtra({
'device_id': 'abc-123',
'deviceId': 'abc-123',
'trip_id': 42,
'tripId': 42,
'screen': 'record',
})!;
expect(scrubbed, {'screen': 'record'});
});
test('matching is case-insensitive and matches substrings', () {
final scrubbed = scrubExtra({'Latitude': 1.0, 'nested.longitude': 2.0})!;
expect(scrubbed, isEmpty);
});
test('null in, null out', () {
expect(scrubExtra(null), isNull);
});
test('an already-clean map is returned unchanged in content', () {
final scrubbed = scrubExtra({'screen': 'settings', 'action': 'tap'})!;
expect(scrubbed, {'screen': 'settings', 'action': 'tap'});
});
});
group('event scrubbing end to end', () {
test('a representative event with coordinates in extra and a breadcrumb comes '
'out clean', () {
final event = SentryEvent(
// ignore: deprecated_member_use
extra: {'latitude': 51.0447, 'longitude': -114.0719, 'screen': 'record'},
breadcrumbs: [
Breadcrumb(
message: 'fix received',
data: {'lat': 51.0, 'lon': -114.0, 'accuracy': 5.0},
),
],
);
// Exercises the same path SentryOptions.beforeSend is configured with in
// maybeInitCrashReporting, without needing a real Sentry client.
// ignore: deprecated_member_use
final scrubbedExtra = scrubExtra(event.extra)!;
final scrubbedBreadcrumbData = scrubExtra(event.breadcrumbs!.single.data)!;
expect(scrubbedExtra.containsKey('latitude'), isFalse);
expect(scrubbedExtra.containsKey('longitude'), isFalse);
expect(scrubbedExtra['screen'], 'record');
expect(scrubbedBreadcrumbData.containsKey('lat'), isFalse);
expect(scrubbedBreadcrumbData.containsKey('lon'), isFalse);
expect(scrubbedBreadcrumbData['accuracy'], 5.0,
reason: 'accuracy is not a coordinate and carries no location on its own');
});
});
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/export/ride_export.dart';
import 'package:rippr/src/ui/format.dart';
/// V3-03: distance and speed units. Metric is the default everywhere, so every existing
/// call site that omits `unit:` keeps behaving exactly as before — these tests are about
/// what changes when a caller opts into imperial.
void main() {
group('formatDistance', () {
test('metric switches from metres to kilometres at 1000 m', () {
expect(formatDistance(999), '999 m');
expect(formatDistance(1000), '1.0 km');
expect(formatDistance(12345.6), '12.3 km');
});
test('imperial switches from feet to miles at 1000 ft', () {
// 1000 ft is ~304.8 m.
expect(formatDistance(100, unit: UnitSystem.imperial), '328 ft');
expect(
formatDistance(305, unit: UnitSystem.imperial),
'0.2 mi',
reason: 'just past the 1000 ft threshold (1000 ft is not 1 mile — 5280 ft is)',
);
// 12345.6 m is ~7.67 mi.
expect(formatDistance(12345.6, unit: UnitSystem.imperial), '7.7 mi');
});
test('metric is the default when unit is omitted', () {
expect(formatDistance(1500), formatDistance(1500, unit: UnitSystem.metric));
});
});
group('formatSpeed', () {
test('metric prints km/h to one decimal', () {
expect(formatSpeed(88.5), '88.5 km/h');
});
test('imperial converts to mph', () {
// 100 km/h is ~62.1 mph.
expect(formatSpeed(100, unit: UnitSystem.imperial), '62.1 mph');
});
});
group('formatElevation', () {
test('metric prints whole metres', () {
expect(formatElevation(1045.7), '1045 m');
});
test('imperial converts to whole feet', () {
// 1000 m is ~3280.84 ft.
expect(formatElevation(1000, unit: UnitSystem.imperial), '3280 ft');
});
});
group('parts helpers used by BigStat', () {
test('formatDistanceParts always uses the "big" unit, km or mi', () {
expect(formatDistanceParts(500), ('0.5', 'km'));
expect(formatDistanceParts(1609.344, unit: UnitSystem.imperial), ('1.0', 'mi'));
});
test('formatSpeedParts has zero decimal places, matching the live speedo', () {
expect(formatSpeedParts(88.6), ('89', 'km/h'));
expect(formatSpeedParts(100, unit: UnitSystem.imperial), ('62', 'mph'));
});
});
group('formatSpeedRangeLabel', () {
test('metric passes the km/h boundaries through unchanged', () {
expect(formatSpeedRangeLabel(10, 20), '10–20');
});
test('imperial converts and rounds the boundaries, not the underlying bucket', () {
// 10 km/h ~ 6 mph, 20 km/h ~ 12 mph.
expect(formatSpeedRangeLabel(10, 20, unit: UnitSystem.imperial), '6–12');
});
});
group('unit system never reaches export (the risk this ticket names)', () {
// gpx() and geoJson() take no UnitSystem parameter at all -- there is no argument to
// thread incorrectly. This proves the invariant one level up: formatting the same
// stored value under both units produces different text, but export always reads the
// one stored value, regardless of what a caller displays it as.
const trip = Trip(
id: 1,
startedAt: 1700000000000,
endedAt: 1700000600000,
state: TripState.completed,
distanceM: 12345.6,
maxSpeedKmh: 88.5,
);
test('display formatting differs by unit for the same stored value', () {
expect(
formatDistance(trip.distanceM, unit: UnitSystem.metric),
isNot(formatDistance(trip.distanceM, unit: UnitSystem.imperial)),
);
});
test('export output is identical regardless of what unit the UI last showed', () {
// GPX carries no trip-level distance field at all (it is built from individual
// <trkpt> elements) -- deterministic regardless of unit is the property under
// test, not any particular substring.
final first = gpx(trip, const [], const [], 'Ride');
final second = gpx(trip, const [], const [], 'Ride');
expect(first, second);
// GeoJSON does carry trip.distanceM in its properties, and it is the raw metric
// figure -- 12345.6, never a converted "7.7 mi".
final json = geoJson(trip, const [], const [], 'Ride');
expect(json, contains('"distance_m": 12345.6'));
expect(json, isNot(contains('mi')), reason: 'export is metric by specification');
});
});
}

View File

@@ -0,0 +1,180 @@
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));
});
}

View File

@@ -336,6 +336,50 @@ void main() {
reason: 'the dead time is a real gap and must render as one');
});
test('a recording trip found at restart reports an unexpected stop (V3-12)',
() async {
await engine.start();
await ride(5);
await engine.dispose();
String? reportedReason;
final revived = RecordingEngine(
repository: repo,
locationSource: source,
clock: () => fakeNow,
onUnexpectedStop: (reason) => reportedReason = reason,
);
addTearDown(revived.dispose);
fakeNow = 20000;
await revived.restoreAfterProcessDeath();
expect(reportedReason, isNotNull,
reason: 'the recording stopped without anyone choosing that -- the exact '
'failure V3-12 exists to surface');
});
test('a cleanly-completed trip reports nothing at restart', () async {
await engine.start();
await ride(5);
await engine.stop();
await engine.dispose();
var called = false;
final revived = RecordingEngine(
repository: repo,
locationSource: source,
clock: () => fakeNow,
onUnexpectedStop: (_) => called = true,
);
addTearDown(revived.dispose);
await revived.restoreAfterProcessDeath();
expect(called, isFalse,
reason: 'a rider who pressed Stop is not a crash and must not be reported');
});
test('the dead time is never measured as distance', () async {
// The bug this guards is in the native app: after a crash the open segment is
// adopted rather than closed, so computeSummary -- the authoritative pass at trip

View File

@@ -141,6 +141,33 @@ void main() {
expect(doc.findAllElements('trkpt').length, 1,
reason: 'no point may be silently dropped');
});
test('<type> reflects the trip activity (V3-14)', () {
final r = ride(segmentCount: 1, perSegment: 1);
final bike = trip.copyWith(activity: Activity.bicycle);
final doc = XmlDocument.parse(gpx(bike, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('type').single.innerText, 'cycling');
});
test('<type> is absent, not empty, for Activity.other (V3-14)', () {
final r = ride(segmentCount: 1, perSegment: 1);
final other = trip.copyWith(activity: Activity.other);
final doc = XmlDocument.parse(gpx(other, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('type'), isEmpty,
reason: 'an empty <type/> would falsely claim the element exists');
});
test('gpxActivityType covers every activity without throwing', () {
for (final activity in Activity.values) {
// Must not throw; only Activity.other is allowed to map to null.
final type = gpxActivityType(activity);
expect(type == null, activity == Activity.other);
}
});
});
group('geojson', () {

View File

@@ -0,0 +1,135 @@
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/data/trip_repository.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/notification/ride_notification_controller.dart';
import 'package:rippr/src/notification/ride_notification_coordinator.dart';
import 'package:rippr/src/notification/ride_notification_text.dart';
import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/recording/recording_engine.dart';
/// V3-06: the plugin call itself needs a device (see the ticket's Tests section), but the
/// text it is given, and the routing from a tapped action back to the engine, do not. The
/// coordinator tests check the database after an action, not the UI -- the project's
/// standing rule for anything touching recording state.
void main() {
Trip trip({
TripState state = TripState.recording,
double distanceM = 12345,
int movingMillis = 45 * 60 * 1000 + 12 * 1000,
}) => Trip(
id: 1,
startedAt: 0,
state: state,
distanceM: distanceM,
movingMillis: movingMillis,
);
group('rideNotificationText', () {
test('recording: distance and elapsed, no prefix', () {
expect(rideNotificationText(trip()), '12.3 km · 00:45:12');
});
test('paused: prefixed so the shade reads correctly without opening the app', () {
expect(
rideNotificationText(trip(state: TripState.paused)),
'Paused · 12.3 km · 00:45:12',
);
});
test('honours the unit system, exactly like every other display surface', () {
expect(
rideNotificationText(trip(), unit: UnitSystem.imperial),
contains('mi'),
);
});
});
group('RideNotificationCoordinator', () {
late AppDatabase db;
late TripRepository repo;
late FakeLocationSource source;
late RecordingEngine engine;
late FakeRideNotificationController controller;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
repo = TripRepository(db);
source = FakeLocationSource();
engine = RecordingEngine(repository: repo, locationSource: source);
controller = FakeRideNotificationController();
});
tearDown(() async {
await source.dispose();
await db.close();
});
test('shows the notification when a trip starts and cancels it when it ends',
() async {
final coordinator = RideNotificationCoordinator(
controller: controller,
engine: engine,
tripStream: repo.watchActiveTrip(),
);
await repo.startTrip(1000);
await pumpEventQueue();
expect(controller.visible, isTrue);
expect(controller.lastText, isNotNull);
await repo.completeTrip(2000);
await pumpEventQueue();
expect(controller.visible, isFalse);
expect(controller.cancelCalls, greaterThan(0));
await coordinator.dispose();
});
test('a tapped Pause action actually pauses the ride -- checked in the '
'database, not the notification', () async {
final coordinator = RideNotificationCoordinator(
controller: controller,
engine: engine,
tripStream: repo.watchActiveTrip(),
);
await repo.startTrip(1000);
await pumpEventQueue();
controller.simulateAction(RideNotificationAction.pause);
await pumpEventQueue();
final active = await repo.activeTrip();
expect(active?.state, TripState.paused);
await coordinator.dispose();
});
test('a tapped Resume action only resumes an actually-paused trip', () async {
final coordinator = RideNotificationCoordinator(
controller: controller,
engine: engine,
tripStream: repo.watchActiveTrip(),
);
await repo.startTrip(1000);
await pumpEventQueue();
// Recording, not paused: a Resume action here would be bogus.
controller.simulateAction(RideNotificationAction.resume);
await pumpEventQueue();
expect((await repo.activeTrip())?.state, TripState.recording);
await repo.pauseTrip(2000);
await pumpEventQueue();
controller.simulateAction(RideNotificationAction.resume);
await pumpEventQueue();
expect((await repo.activeTrip())?.state, TripState.recording);
await coordinator.dispose();
});
});
}

View File

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

View File

@@ -0,0 +1,133 @@
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/data/route_plan_repository.dart';
import 'package:rippr/src/data/trip_repository.dart';
import 'package:rippr/src/geo/geo.dart' as geo;
/// V3-07: mirrors trip_repository_test.dart's shape -- in-memory Drift, real repository,
/// no mocks. A route plan has no state machine, so most of what is tested here is
/// straight-line distance staying correct after every mutation.
void main() {
late AppDatabase db;
late RoutePlanRepository routes;
late TripRepository trips;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
routes = RoutePlanRepository(db);
trips = TripRepository(db);
});
tearDown(() async => db.close());
test('a new route plan has no waypoints and zero distance', () async {
final id = await routes.createRoutePlan(1000);
final plan = await routes.routePlanById(id);
expect(plan, isNotNull);
expect(plan!.distanceM, 0.0);
expect(await routes.waypointsFor(id), isEmpty);
});
test('adding waypoints appends in order and updates distance live', () async {
final id = await routes.createRoutePlan(1000);
await routes.addWaypoint(id, 51.0, -114.0);
await routes.addWaypoint(id, 51.01, -114.0);
await routes.addWaypoint(id, 51.02, -114.0);
final waypoints = await routes.waypointsFor(id);
expect(waypoints.map((w) => w.ordinal), [0, 1, 2]);
final expected = geo.pathLengthMeters([
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
]);
final plan = await routes.routePlanById(id);
expect(plan!.distanceM, expected);
expect(expected, greaterThan(0));
});
test('moving a waypoint updates distance', () async {
final id = await routes.createRoutePlan(1000);
await routes.addWaypoint(id, 51.0, -114.0);
await routes.addWaypoint(id, 51.01, -114.0);
final before = (await routes.routePlanById(id))!.distanceM;
final target = (await routes.waypointsFor(id)).last;
await routes.moveWaypoint(id, target.id, 52.0, -114.0);
final after = (await routes.routePlanById(id))!.distanceM;
expect(after, isNot(before));
});
test('deleting a waypoint closes the ordinal gap and updates distance', () async {
final id = await routes.createRoutePlan(1000);
await routes.addWaypoint(id, 51.0, -114.0);
await routes.addWaypoint(id, 51.01, -114.0);
await routes.addWaypoint(id, 51.02, -114.0);
final middle = (await routes.waypointsFor(id))[1];
await routes.deleteWaypoint(id, middle.id);
final remaining = await routes.waypointsFor(id);
expect(remaining.length, 2);
expect(remaining.map((w) => w.ordinal), [0, 1]);
final expected = geo.pathLengthMeters([
for (final w in remaining) geo.LatLon(w.latitude, w.longitude),
]);
expect((await routes.routePlanById(id))!.distanceM, expected);
});
test('reordering renumbers ordinals and leaves distance internally consistent',
() async {
final id = await routes.createRoutePlan(1000);
await routes.addWaypoint(id, 51.0, -114.0);
await routes.addWaypoint(id, 51.01, -114.0);
await routes.addWaypoint(id, 51.02, -114.0);
await routes.reorderWaypoint(id, 0, 2);
final reordered = await routes.waypointsFor(id);
expect(reordered.map((w) => w.ordinal), [0, 1, 2]);
expect(reordered.map((w) => w.latitude), [51.01, 51.02, 51.0]);
});
test('renaming updates the row', () async {
final id = await routes.createRoutePlan(1000);
await routes.renameRoutePlan(id, 'Coast loop');
expect((await routes.routePlanById(id))!.name, 'Coast loop');
});
test('deleting a route plan cascades to its waypoints', () async {
final id = await routes.createRoutePlan(1000);
await routes.addWaypoint(id, 51.0, -114.0);
await routes.addWaypoint(id, 51.01, -114.0);
await routes.deleteRoutePlan(id);
expect(await routes.routePlanById(id), isNull);
expect(await db.waypointsForRoute(id), isEmpty);
});
test('route plans never appear alongside trips and never affect ride totals',
() async {
await routes.createRoutePlan(1000, name: 'Plan A');
final routeId = await routes.createRoutePlan(2000, name: 'Plan B');
await routes.addWaypoint(routeId, 51.0, -114.0);
await routes.addWaypoint(routeId, 52.0, -114.0);
final h = await trips.startTrip(3000);
await trips.appendPoints([]);
await trips.completeTrip(4000);
final completedTrips = await trips.db.watchCompletedTrips().first;
expect(completedTrips.length, 1);
expect(completedTrips.single.id, h.tripId);
// Nothing about a route plan's distance leaked into the trip.
expect(completedTrips.single.distanceM, 0.0);
});
}

View File

@@ -0,0 +1,205 @@
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.dart';
import 'package:rippr/src/ui/routes/route_planner_screen.dart';
import 'package:rippr/src/ui/routes/routes_list_screen.dart';
import 'package:rippr/src/ui/theme.dart';
/// V3-07: the repository is already covered end to end in
/// route_plan_repository_test.dart (including "routes never appear in the rides list");
/// this covers the screens that drive it.
void main() {
late AppDatabase db;
late RoutePlanRepository repo;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
repo = RoutePlanRepository(db);
});
tearDown(() async => db.close());
Widget host(Widget child) => ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
child: MaterialApp(theme: ripprTheme(), home: child),
);
/// Same shape as widget_test.dart's `screenTest`: Drift's stream-query cache keeps a
/// query alive briefly after its last listener leaves, which reads to `flutter_test`
/// as a pending Timer unless the tree is fully torn down and pumped past the
/// keep-alive window first.
void screenTest(String description, Future<void> Function(WidgetTester) body) {
testWidgets(description, (tester) async {
await body(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
}
group('RoutesListScreen', () {
screenTest('empty state explains what to do', (tester) async {
await tester.pumpWidget(host(const RoutesListScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('No routes yet'), findsOneWidget);
});
screenTest('tapping new route creates one and opens it', (tester) async {
int? opened;
await tester.pumpWidget(
host(RoutesListScreen(onOpenRoute: (id) => opened = id)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-route')));
await tester.pumpAndSettle();
expect(opened, isNotNull);
// Not `db.watchRoutePlans().first`: a fresh Stream subscription's first emission
// depends on a Timer inside Drift's stream-query store that never fires under
// flutter_test's fake zone without a pump driving it -- this hung for a real ten
// minutes before being traced back to exactly that. A plain Future-returning call
// has no such dependency.
final plan = await repo.routePlanById(opened!);
expect(plan, isNotNull);
});
screenTest('deleting a route removes it from the list', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Coast loop');
await tester.pumpWidget(host(const RoutesListScreen()));
await tester.pumpAndSettle();
expect(find.text('Coast loop'), findsOneWidget);
await tester.tap(find.byKey(Key('delete-route-$id')));
await tester.pumpAndSettle();
expect(find.text('Coast loop'), findsNothing);
expect(await repo.routePlanById(id), isNull);
});
});
group('RoutePlannerScreen', () {
// A real TileLayer tries real network fetches that never resolve in the test
// harness, so `pumpAndSettle` would hang forever waiting for it to go idle --
// exactly the reason record_screen's own map tests use a bounded pump instead (see
// `pumpLive` in widget_test.dart). A few explicit frames are enough here too.
Future<void> pumpMap(WidgetTester tester, Widget widget) async {
await tester.pumpWidget(widget);
// The waypoints stream (and therefore the map's initial camera, which is read
// only once at construction -- see the screen's own comment) needs a handful of
// frames to resolve its first value; a bounded loop rather than pumpAndSettle,
// since once the map is up its TileLayer never goes idle in this harness.
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
}
screenTest('tapping the map adds a pin and the distance label updates',
(tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
expect(find.text('0 m'), findsOneWidget);
expect(find.textContaining('0 pins'), findsOneWidget);
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(
tester.getCenter(find.byType(FlutterMap)) + const Offset(40, 40),
);
await tester.pump(const Duration(milliseconds: 50));
final waypoints = await repo.waypointsFor(id);
expect(waypoints, hasLength(2));
expect(find.textContaining('2 pins'), findsOneWidget);
final label = tester.widget<Text>(find.byKey(const Key('route-distance')));
expect(label.data, isNot('0 m'));
});
screenTest('tapping a pin deletes it', (tester) async {
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears
// in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders.
expect(find.byType(CircleAvatar), findsNWidgets(2));
await tester.tap(find.text('1')); // the first pin's label
await tester.pump();
expect(await repo.waypointsFor(id), hasLength(1));
});
screenTest('renaming updates the app bar title', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Old name');
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
await tester.tap(find.byKey(const Key('rename-route')));
await tester.pump();
await tester.enterText(
find.byKey(const Key('route-name-field')),
'Sunday coast run',
);
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.text('Sunday coast run'), findsOneWidget);
});
screenTest('a missing route says so instead of a blank map', (tester) async {
await tester.pumpWidget(host(const RoutePlannerScreen(routeId: 9999)));
await tester.pumpAndSettle();
expect(find.textContaining('no longer exists'), findsOneWidget);
});
screenTest('the offline-tiles download button is disabled with no pins '
'(V3-11)', (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final button = tester.widget<IconButton>(
find.byKey(const Key('download-tiles')),
);
expect(button.onPressed, isNull);
expect(button.tooltip, contains('Add pins'));
});
screenTest('downloading shows a tile count and size estimate before any '
'request (V3-11)', (tester) async {
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final button = tester.widget<IconButton>(
find.byKey(const Key('download-tiles')),
);
expect(button.onPressed, isNotNull);
await tester.tap(find.byKey(const Key('download-tiles')));
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
// network involved yet), but this test stops before confirming, precisely to
// avoid needing a fake HTTP layer for what the pure downloadTiles/tile_cache/
// tile_math test suites already cover directly.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.textContaining('Download'), findsWidgets);
expect(find.textContaining('tiles?'), findsOneWidget);
expect(find.textContaining('MB'), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/settings/settings_screen.dart';
import 'package:rippr/src/ui/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// V3-02: every `Config` value must be viewable and editable here, and every write must
/// actually reach `Config` -- not just update local widget state that looks right until
/// the next app launch.
void main() {
late Config config;
setUp(() async {
SharedPreferences.setMockInitialValues({});
config = await Config.load();
});
Widget host() => ProviderScope(
overrides: [configProvider.overrideWith((ref) => config)],
child: MaterialApp(theme: ripprTheme(), home: const SettingsScreen()),
);
testWidgets('the map switch reflects and writes through to Config', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('map-enabled-switch')))
.value;
expect(initial, isTrue, reason: 'Config.mapEnabled defaults to true');
await tester.tap(find.byKey(const Key('map-enabled-switch')));
await tester.pumpAndSettle();
expect(config.mapEnabled, isFalse,
reason: 'the switch must write through to Config, not just local state');
});
testWidgets('changing the map switch is visible to other providers immediately',
(tester) async {
// V3-02's acceptance criterion: "changing the map toggle takes effect without an
// app restart". mapEnabledProvider is what trip detail actually reads.
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(SettingsScreen)),
);
expect(container.read(mapEnabledProvider), isTrue);
await tester.tap(find.byKey(const Key('map-enabled-switch')));
await tester.pumpAndSettle();
expect(container.read(mapEnabledProvider), isFalse);
});
testWidgets('the unit selector writes through to Config', (tester) async {
// The default depends on the test runner's own locale (see config_test.dart), so
// start from an explicit, known value rather than assuming metric.
await config.setUnitSystem(UnitSystem.metric);
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.tap(find.text('Imperial'));
await tester.pumpAndSettle();
expect(config.unitSystem, UnitSystem.imperial);
await tester.tap(find.text('Metric'));
await tester.pumpAndSettle();
expect(config.unitSystem, UnitSystem.metric);
});
testWidgets('the mounted mode switch writes through to Config (V3-05)',
(tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('mounted-mode-switch')))
.value;
expect(initial, isFalse, reason: 'a pocketed ride is still the common case');
await tester.tap(find.byKey(const Key('mounted-mode-switch')));
await tester.pumpAndSettle();
expect(config.mountedMode, isTrue);
});
testWidgets('the crash reporting switch writes through to Config (V3-12)',
(tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('crash-reporting-switch')))
.value;
expect(initial, isFalse, reason: 'off until a privacy policy exists');
await tester.tap(find.byKey(const Key('crash-reporting-switch')));
await tester.pumpAndSettle();
expect(config.crashReportingEnabled, isTrue);
});
/// The Sync section has been pushed below the default test viewport by every section
/// added above it since this suite was first written (V3-05, then V3-12) -- a
/// `ListView` does not mount elements outside its viewport/cache extent, so `find`
/// cannot see them until scrolled into view.
Future<void> scrollToSync(WidgetTester tester) async {
await tester.drag(find.byType(ListView).first, const Offset(0, -600));
await tester.pumpAndSettle();
}
group('upload endpoint', () {
testWidgets('a valid https URL is saved', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
'https://example.test/ingest',
);
await tester.tap(find.byKey(const Key('save-endpoint')));
await tester.pumpAndSettle();
expect(config.uploadEndpoint, 'https://example.test/ingest');
expect(find.text('Endpoint saved'), findsOneWidget);
});
testWidgets('an invalid endpoint is rejected with a readable message, not '
'silently stored', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
'not a url at all',
);
await tester.tap(find.byKey(const Key('save-endpoint')));
await tester.pumpAndSettle();
expect(config.uploadEndpoint, '',
reason: 'garbage input must never reach storage');
expect(find.textContaining('http'), findsWidgets);
});
testWidgets('a non-http scheme is rejected', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(
find.byKey(const Key('endpoint-field')),
'ftp://example.test/ingest',
);
await tester.tap(find.byKey(const Key('save-endpoint')));
await tester.pumpAndSettle();
expect(config.uploadEndpoint, '');
});
testWidgets('clearing the endpoint is a valid way to disable upload',
(tester) async {
await config.setUploadEndpoint('https://example.test/ingest');
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
await tester.enterText(find.byKey(const Key('endpoint-field')), '');
await tester.tap(find.byKey(const Key('save-endpoint')));
await tester.pumpAndSettle();
expect(config.uploadEndpoint, '');
expect(find.text('Upload disabled'), findsOneWidget);
});
});
testWidgets('the device id is shown and a copy control exists', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollToSync(tester);
expect(find.text(config.deviceId), findsOneWidget);
expect(find.byKey(const Key('copy-device-id')), findsOneWidget);
// Tapping must not throw even though no real clipboard exists in the test harness.
await tester.tap(find.byKey(const Key('copy-device-id')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('shows a spinner rather than crashing while Config is still loading',
(tester) async {
await tester.pumpWidget(
const ProviderScope(
child: MaterialApp(home: SettingsScreen()),
),
);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/ui/theme.dart';
/// V3-16's acceptance criterion is "contrast ratios meet WCAG AA for body text" --
/// asserted directly here rather than trusted from a palette chosen by eye. AA is 4.5:1
/// for normal text, 3:1 for large text (18pt, or 14pt bold and up -- the headline
/// figures in `BigStat`).
void main() {
group('contrastRatio', () {
test('black on white is the maximum possible ratio, 21:1', () {
expect(
contrastRatio(const Color(0xFF000000), const Color(0xFFFFFFFF)),
closeTo(21.0, 0.01),
);
});
test('a colour against itself is 1:1', () {
const c = Color(0xFFFF5722);
expect(contrastRatio(c, c), closeTo(1.0, 0.01));
});
test('is symmetric regardless of argument order', () {
const a = Color(0xFF101418);
const b = Color(0xFFF2F5F8);
expect(contrastRatio(a, b), contrastRatio(b, a));
});
});
group('pocketed theme (WCAG AA)', () {
final colors = ripprTheme().colorScheme;
test('body text on the ground meets AA normal text (4.5:1)', () {
expect(contrastRatio(colors.onSurface, ripprBackground), greaterThanOrEqualTo(4.5));
});
test('body text on a surface card meets AA normal text (4.5:1)', () {
expect(contrastRatio(colors.onSurface, colors.surface), greaterThanOrEqualTo(4.5));
});
test('the primary accent on the ground meets AA large text (3:1)', () {
// The accent is used for headline figures and buttons, not small body copy --
// held to the large-text threshold, matching how it's actually used on screen.
expect(contrastRatio(colors.primary, ripprBackground), greaterThanOrEqualTo(3.0));
});
test('the instrument-blue tertiary on the ground meets AA large text (3:1)', () {
expect(contrastRatio(colors.tertiary, ripprBackground), greaterThanOrEqualTo(3.0));
});
test('the instrument-blue tertiary on a surface card meets AA large text (3:1)', () {
expect(contrastRatio(colors.tertiary, colors.surface), greaterThanOrEqualTo(3.0));
});
});
group('mounted theme (WCAG AA)', () {
final colors = ripprMountedTheme().colorScheme;
test('body text on the ground meets AA normal text (4.5:1)', () {
expect(
contrastRatio(colors.onSurface, ripprMountedBackground),
greaterThanOrEqualTo(4.5),
);
});
test('the primary accent on the ground meets AA large text (3:1) -- this is the '
'theme sunlight legibility actually depends on', () {
expect(
contrastRatio(colors.primary, ripprMountedBackground),
greaterThanOrEqualTo(3.0),
);
});
test('the instrument-blue tertiary meets AA normal text on the ground '
'(darkened further than the pocketed theme specifically for this)', () {
expect(
contrastRatio(colors.tertiary, ripprMountedBackground),
greaterThanOrEqualTo(4.5),
);
});
});
}

View File

@@ -0,0 +1,92 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/tiles/tile_cache.dart';
import 'package:rippr/src/tiles/tile_math.dart';
void main() {
late Directory tempDir;
late FileTileCache cache;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('rippr_tile_cache_test_');
});
tearDown(() async {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
Uint8List bytesOfSize(int n) => Uint8List.fromList(List.filled(n, 1));
test('a stored tile round-trips', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
const key = TileKey(12, 100, 200);
await cache.put(key, bytesOfSize(50));
final read = await cache.get(key);
expect(read, isNotNull);
expect(read!.length, 50);
});
test('a tile never written is a miss, not an error', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
expect(await cache.get(const TileKey(1, 1, 1)), isNull);
});
test('sizeBytes reflects everything stored', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
await cache.put(const TileKey(1, 0, 0), bytesOfSize(100));
await cache.put(const TileKey(1, 0, 1), bytesOfSize(200));
expect(await cache.sizeBytes(), 300);
});
test('eviction at the cap removes the least-recently-used tile first', () async {
// Room for two 100-byte tiles at a time.
cache = FileTileCache(directory: tempDir, maxBytes: 200);
const a = TileKey(1, 0, 0);
const b = TileKey(1, 0, 1);
const c = TileKey(1, 0, 2);
await cache.put(a, bytesOfSize(100));
await cache.put(b, bytesOfSize(100));
// Touch `a` so `b` becomes the least-recently-used.
await cache.get(a);
await cache.put(c, bytesOfSize(100));
expect(await cache.get(a), isNotNull, reason: 'recently touched, must survive');
expect(await cache.get(b), isNull, reason: 'least-recently-used, must be evicted');
expect(await cache.get(c), isNotNull, reason: 'just written, must survive');
expect(await cache.sizeBytes(), lessThanOrEqualTo(200));
});
test('the cache never exceeds its cap after many writes', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 500);
for (var i = 0; i < 20; i++) {
await cache.put(TileKey(1, i, 0), bytesOfSize(100));
}
expect(await cache.sizeBytes(), lessThanOrEqualTo(500));
});
test('clear removes every tile and resets size to zero', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
await cache.put(const TileKey(1, 0, 0), bytesOfSize(100));
await cache.put(const TileKey(1, 0, 1), bytesOfSize(100));
await cache.clear();
expect(await cache.sizeBytes(), 0);
expect(await cache.get(const TileKey(1, 0, 0)), isNull);
});
test('a cache re-opened over the same directory sees what was stored', () async {
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
await cache.put(const TileKey(5, 10, 10), bytesOfSize(64));
final reopened = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
expect(await reopened.sizeBytes(), 64);
});
}

View File

@@ -0,0 +1,106 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/tiles/tile_cache.dart';
import 'package:rippr/src/tiles/tile_downloader.dart';
import 'package:rippr/src/tiles/tile_math.dart';
void main() {
late Directory tempDir;
late FileTileCache cache;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('rippr_tile_downloader_test_');
cache = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
});
tearDown(() async {
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
});
List<TileKey> tiles(int n) => [for (var i = 0; i < n; i++) TileKey(1, i, 0)];
test('every tile is fetched exactly once and lands in the cache', () async {
final fetched = <TileKey>[];
final progress = await downloadTiles(
tiles: tiles(5),
cache: cache,
fetchTile: (key) async {
fetched.add(key);
return Uint8List.fromList([1, 2, 3]);
},
delay: Duration.zero,
).toList();
expect(fetched, tiles(5), reason: 'sequential, in order, no duplicates');
expect(progress.last, isA<DownloadProgress>());
expect(progress.last.completed, 5);
expect(progress.last.isDone, isTrue);
for (final t in tiles(5)) {
expect(await cache.get(t), isNotNull);
}
});
test('one failing tile does not abort the rest', () async {
final progress = await downloadTiles(
tiles: tiles(4),
cache: cache,
fetchTile: (key) async {
if (key.x == 1) throw Exception('transient');
return Uint8List.fromList([1]);
},
delay: Duration.zero,
).toList();
expect(progress.last.completed, 3);
expect(progress.last.failed, 1);
expect(progress.last.isDone, isTrue);
expect(await cache.get(const TileKey(1, 1, 0)), isNull);
expect(await cache.get(const TileKey(1, 0, 0)), isNotNull);
expect(await cache.get(const TileKey(1, 2, 0)), isNotNull);
});
test('cancelling mid-download keeps everything already fetched', () async {
final token = CancelToken();
final events = <DownloadProgress>[];
await for (final p in downloadTiles(
tiles: tiles(10),
cache: cache,
fetchTile: (key) async => Uint8List.fromList([1]),
cancelToken: token,
delay: Duration.zero,
)) {
events.add(p);
if (p.completed == 3) token.cancel();
}
final last = events.last;
expect(last.isDone, isFalse,
reason: 'stopped early -- fewer tiles than the total were fetched');
expect(last.completed, lessThan(10));
// Everything fetched before cancellation must still be in the cache -- a
// consistent partial cache, not rolled back to nothing.
for (var i = 0; i < last.completed; i++) {
expect(await cache.get(TileKey(1, i, 0)), isNotNull);
}
});
test('an empty tile list completes immediately with no fetch calls', () async {
var calls = 0;
final progress = await downloadTiles(
tiles: const [],
cache: cache,
fetchTile: (key) async {
calls++;
return Uint8List(0);
},
).toList();
expect(calls, 0);
expect(progress.last.isDone, isTrue);
expect(progress.last.total, 0);
});
}

View File

@@ -0,0 +1,119 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/geo/geo.dart' show LatLon;
import 'package:rippr/src/tiles/tile_math.dart';
void main() {
group('tilesForBounds', () {
test('a single point covers exactly one tile per zoom level', () {
final tiles = tilesForBounds(
minLat: 51.0,
maxLat: 51.0,
minLon: -114.0,
maxLon: -114.0,
minZoom: 10,
maxZoom: 12,
);
// One tile at each of the three zoom levels.
expect(tiles.length, 3);
expect(tiles.map((t) => t.z).toSet(), {10, 11, 12});
});
test('the same box covers more tiles at a higher zoom', () {
final low = tilesForBounds(
minLat: 50.99,
maxLat: 51.01,
minLon: -114.01,
maxLon: -113.99,
minZoom: 10,
maxZoom: 10,
);
final high = tilesForBounds(
minLat: 50.99,
maxLat: 51.01,
minLon: -114.01,
maxLon: -113.99,
minZoom: 14,
maxZoom: 14,
);
expect(high.length, greaterThan(low.length));
});
test('throws rather than silently truncating past the cap', () {
expect(
() => tilesForBounds(
minLat: -80,
maxLat: 80,
minLon: -170,
maxLon: 170,
minZoom: 10,
maxZoom: 14,
),
throwsA(isA<TooManyTilesException>()),
);
});
test('tiles are deduplicated across an overlapping request', () {
final tiles = tilesForBounds(
minLat: 51.0,
maxLat: 51.001,
minLon: -114.0,
maxLon: -113.999,
minZoom: 5,
maxZoom: 5,
);
// A Set literally cannot contain a duplicate; this documents the intent that
// adjacent/overlapping coverage collapses rather than being counted twice.
expect(tiles.toSet().length, tiles.length);
});
});
group('tilesAlongRoute', () {
test('costs far less than the bounding-box rectangle for a route that '
'zigzags across its own bounding box', () {
// A route that ping-pongs in latitude while advancing in longitude -- its
// bounding box is a full rectangle, but the corridor only has to cover the
// narrow path actually ridden through that rectangle.
final route = [
for (var i = 0; i <= 40; i++)
LatLon(51.0 + (i.isEven ? 0.0 : 0.3), -114.0 + i * 0.02),
];
final corridor = tilesAlongRoute(
route,
minZoom: 12,
maxZoom: 12,
bufferMeters: 150,
);
final rectangle = tilesForBounds(
minLat: 51.0,
maxLat: 51.3,
minLon: -114.0,
maxLon: -113.2,
minZoom: 12,
maxZoom: 12,
);
expect(corridor.length, lessThan(rectangle.length),
reason: 'the whole point of a corridor is fewer tiles than the bounding box');
});
test('an empty route needs no tiles', () {
expect(tilesAlongRoute([], minZoom: 10, maxZoom: 12), isEmpty);
});
test('throws past the cap, same as tilesForBounds', () {
final route = [for (var i = 0; i < 500; i++) LatLon(i * 0.1 - 25, i * 0.1 - 25)];
expect(
() => tilesAlongRoute(route, minZoom: 12, maxZoom: 14, bufferMeters: 500),
throwsA(isA<TooManyTilesException>()),
);
});
});
group('estimatedSizeMb', () {
test('scales linearly with tile count', () {
expect(estimatedSizeMb(0), 0);
expect(estimatedSizeMb(1024, avgTileSizeKb: 1), closeTo(1.0, 1e-9));
});
});
}

View File

@@ -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 {
@@ -349,4 +428,181 @@ void main() {
expect(segments.every((s) => s.tripId == a), isTrue);
});
});
group('split (V3-10)', () {
/// A completed, multi-segment ride: [segmentSpecs] each become one paused-and-resumed
/// segment, `points` fixes per segment, one degree of latitude apart so an accidental
/// distance leak across the split is unmissable.
Future<int> multiSegmentTrip(
List<({int startedAt, int endedAt})> segmentSpecs, {
int points = 3,
}) async {
var h = await repo.startTrip(segmentSpecs.first.startedAt);
for (var s = 0; s < segmentSpecs.length; s++) {
final spec = segmentSpecs[s];
for (var i = 0; i < points; i++) {
await addPoint(h, spec.startedAt + i * 1000, lat: 51.0 + s * 1.0);
}
if (s < segmentSpecs.length - 1) {
await repo.pauseTrip(spec.endedAt);
h = (await repo.resumeTrip(segmentSpecs[s + 1].startedAt))!;
}
}
await repo.completeTrip(segmentSpecs.last.endedAt);
return h.tripId;
}
test('point counts sum to the original and nothing is orphaned', () async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
], points: 3);
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
expect(newId, isNotNull);
final original = (await repo.tripById(id))!;
final split = (await repo.tripById(newId!))!;
expect(original.pointCount + split.pointCount, 6);
final allPoints = await db.allPoints();
expect(
allPoints.every((p) => p.tripId == id || p.tripId == newId),
isTrue,
reason: 'no point may belong to neither resulting trip',
);
});
test("neither trip's distance includes the gap between them", () async {
// Segments a whole degree of latitude apart -- ~111 km. If the gap leaked in
// (e.g. by keeping the old aggregate rather than recomputing), it would dwarf
// the few-metre hops inside each segment.
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
final original = (await repo.tripById(id))!;
final split = (await repo.tripById(newId!))!;
expect(original.distanceM, lessThan(200.0));
expect(split.distanceM, lessThan(200.0));
});
test('both trips get plausible startedAt/endedAt from their own segments',
() async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
final original = (await repo.tripById(id))!;
final split = (await repo.tripById(newId!))!;
expect(original.startedAt, 1000);
expect(original.endedAt, 2000,
reason: 'must come from the last kept segment, not the pre-split trip row');
expect(split.startedAt, 10000);
expect(split.endedAt, 11000);
});
test('the split stays a segment boundary on both sides', () async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
(startedAt: 20000, endedAt: 21000),
]);
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
expect(await repo.segmentsForTrip(id), hasLength(1));
expect(await repo.segmentsForTrip(newId!), hasLength(2));
});
test('a single-segment trip cannot be split', () async {
final id = await multiSegmentTrip([(startedAt: 1000, endedAt: 2000)]);
final segments = await repo.segmentsForTrip(id);
expect(await repo.splitTrip(id, segments.single.id), isNull);
expect(await db.countTrips(), 1);
});
test('splitting at the first segment is rejected -- nothing would remain before it',
() async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
final segments = await repo.segmentsForTrip(id);
expect(await repo.splitTrip(id, segments.first.id), isNull);
expect(await db.countTrips(), 1);
});
test('splitting an active trip is rejected', () async {
final h = await repo.startTrip(1000);
await addPoint(h, 1000);
await repo.pauseTrip(2000);
final h2 = (await repo.resumeTrip(3000))!;
await addPoint(h2, 3000);
final segments = await repo.segmentsForTrip(h.tripId);
expect(await repo.splitTrip(h.tripId, segments[1].id), isNull);
expect(await db.countTrips(), 1);
});
test('an unknown segment id is rejected', () async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
expect(await repo.splitTrip(id, 9999), isNull);
});
test('split then merge is a round trip back to the original aggregates',
() async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
// multiSegmentTrip only inserts points; the aggregate columns are otherwise only
// ever updated by the recording engine's periodic flush (bypassed here), so they
// must be computed explicitly to have a real baseline to compare against.
await repo.recomputeAggregates(id);
final before = (await repo.tripById(id))!;
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
final survivor = await repo.mergeTrips(id, newId!);
final after = (await repo.tripById(survivor!))!;
expect(after.pointCount, before.pointCount);
expect(after.distanceM, closeTo(before.distanceM, 1e-6));
expect(after.startedAt, before.startedAt);
expect(after.endedAt, before.endedAt);
});
test('split is atomic and leaves no orphans', () async {
final id = await multiSegmentTrip([
(startedAt: 1000, endedAt: 2000),
(startedAt: 10000, endedAt: 11000),
]);
final segments = await repo.segmentsForTrip(id);
final newId = await repo.splitTrip(id, segments[1].id);
final allSegments = [
...await repo.segmentsForTrip(id),
...await repo.segmentsForTrip(newId!),
];
expect(allSegments.length, 2);
final allPoints = await db.allPoints();
expect(allPoints.every((p) => p.tripId == id || p.tripId == newId), isTrue);
});
});
}

View File

@@ -1,14 +1,19 @@
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
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/recording/wakelock_controller.dart';
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/record/record_screen.dart';
import 'package:rippr/src/ui/theme.dart';
import 'package:rippr/src/ui/trips/trips_screen.dart';
@@ -66,13 +71,24 @@ void main() {
///
/// The theme is deliberately included: the black-on-black bug was invisible to logic
/// tests and only a rendered widget can catch its equivalent.
Widget host(Widget child, {bool map = true}) => ProviderScope(
Widget host(
Widget child, {
bool map = true,
UnitSystem units = UnitSystem.metric,
bool mountedMode = false,
FakeWakelockController? wakelock,
}) => ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
locationSourceProvider.overrideWithValue(source),
// The map is off by default in tests that are not about the map: it fetches
// tiles, which a widget test cannot serve, and it changes scroll geometry.
mapEnabledProvider.overrideWith((ref) => map),
unitSystemProvider.overrideWith((ref) => units),
mountedModeProvider.overrideWith((ref) => mountedMode),
wakelockControllerProvider.overrideWithValue(
wakelock ?? FakeWakelockController(),
),
],
child: MaterialApp(theme: ripprTheme(), home: child),
);
@@ -130,7 +146,7 @@ void main() {
screenTest('recording swaps to PAUSE and STOP, with no DISCARD',
(tester) async {
await repo.startTrip(1000);
await pumpLive(tester, host(const RecordScreen()));
await pumpLive(tester, host(const RecordScreen(), map: false));
expect(find.byKey(const Key('pause')), findsOneWidget);
expect(find.byKey(const Key('stop')), findsOneWidget);
@@ -143,7 +159,7 @@ void main() {
screenTest('paused offers RESUME, STOP and DISCARD', (tester) async {
await repo.startTrip(1000);
await repo.pauseTrip(2000);
await pumpLive(tester, host(const RecordScreen()));
await pumpLive(tester, host(const RecordScreen(), map: false));
expect(find.byKey(const Key('resume')), findsOneWidget);
expect(find.byKey(const Key('stop')), findsOneWidget);
@@ -154,7 +170,7 @@ void main() {
screenTest('discard asks before destroying anything', (tester) async {
await repo.startTrip(1000);
await repo.pauseTrip(2000);
await pumpLive(tester, host(const RecordScreen()));
await pumpLive(tester, host(const RecordScreen(), map: false));
await tester.tap(find.byKey(const Key('discard')));
await tester.pumpAndSettle();
@@ -186,6 +202,166 @@ void main() {
expect(colour, isNot(ripprBackground));
expect(colour, isNot(Colors.black));
});
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
var opened = false;
await tester.pumpWidget(
host(RecordScreen(onOpenSettings: () => opened = true)),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('open-settings')), findsOneWidget);
await tester.tap(find.byKey(const Key('open-settings')));
await tester.pumpAndSettle();
expect(opened, isTrue,
reason: 'the button must actually invoke the callback that navigates');
});
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
var opened = false;
await tester.pumpWidget(
host(RecordScreen(onOpenRoutes: () => opened = true)),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('open-routes')), findsOneWidget);
await tester.tap(find.byKey(const Key('open-routes')));
await tester.pumpAndSettle();
expect(opened, isTrue);
});
screenTest('the live map appears only while recording and the toggle is on '
'(V3-04)', (tester) async {
// Idle, toggle on: no trip to draw, so no map at all.
await tester.pumpWidget(host(const RecordScreen()));
await tester.pumpAndSettle();
expect(find.byKey(const Key('live-map')), findsNothing);
// Recording, toggle off: RecordingEngine has produced a trip, but the map must
// not be constructed at all -- not just hidden.
await repo.startTrip(1000);
await tester.pumpWidget(const SizedBox.shrink());
await pumpLive(tester, host(const RecordScreen(), map: false));
expect(find.byKey(const Key('live-map')), findsNothing);
expect(find.byType(FlutterMap), findsNothing);
// Recording, toggle on: the map is drawn.
await tester.pumpWidget(const SizedBox.shrink());
await pumpLive(tester, host(const RecordScreen()));
expect(find.byKey(const Key('live-map')), findsOneWidget);
});
screenTest('backgrounding the app drops the tile layer (V3-04)',
(tester) async {
final h = await repo.startTrip(1000);
await repo.appendPoints([
TrackPoint(
tripId: h.tripId,
segmentId: h.segmentId,
timestamp: 1000,
latitude: 51.0,
longitude: -114.0,
speedKmh: 20.0,
altitudeM: 1000.0,
),
]);
await pumpLive(tester, host(const RecordScreen()));
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounded: tiles render normally');
// Simulates the platform lifecycle message a real backgrounding sends -- this is
// the standard way to drive AppLifecycleState changes in a widget test.
final message = const StringCodec().encodeMessage('AppLifecycleState.paused');
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
await tester.pump();
expect(find.byType(TileLayer), findsNothing,
reason: 'backgrounded: no tile layer means no tile request can fire');
expect(find.byType(PolylineLayer), findsOneWidget,
reason: 'the drawn path itself is not removed, only tile fetching');
final resumed = const StringCodec().encodeMessage('AppLifecycleState.resumed');
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounding again must resume tiles');
});
screenTest('mounted mode requests the wake lock while recording and releases '
'it on stop (V3-05)', (tester) async {
final wakelock = FakeWakelockController();
await repo.startTrip(1000);
await pumpLive(
tester,
host(const RecordScreen(), mountedMode: true, wakelock: wakelock),
);
expect(wakelock.enabled, isTrue,
reason: 'recording, mounted: the screen must not sleep');
await repo.completeTrip(2000);
await tester.pump();
expect(wakelock.enabled, isFalse,
reason: 'the lock must not survive the ride ending');
});
screenTest('un-mounted, the wake lock is never requested (V3-05)',
(tester) async {
final wakelock = FakeWakelockController();
await repo.startTrip(1000);
await pumpLive(
tester,
host(const RecordScreen(), mountedMode: false, wakelock: wakelock),
);
expect(wakelock.enableCalls, 0,
reason: 'un-mounted behaviour must be exactly as before this ticket');
});
screenTest('leaving the record screen releases the lock even if the ride is '
'still active (V3-05)', (tester) async {
final wakelock = FakeWakelockController();
await repo.startTrip(1000);
await pumpLive(
tester,
host(const RecordScreen(), mountedMode: true, wakelock: wakelock),
);
expect(wakelock.enabled, isTrue);
// Navigating away must not leak the lock -- this is the ticket's named risk: a
// leak flattens the battery silently, after the screen that acquired it is gone.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(wakelock.enabled, isFalse);
expect(wakelock.disableCalls, greaterThan(0));
});
screenTest('the mounted theme is high-contrast and text scales up (V3-05)',
(tester) async {
await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
await tester.pumpAndSettle();
final speed = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('speed')),
matching: find.text('0'),
),
);
expect(speed.style?.fontSize, 64 * mountedTextScale);
expect(speed.style?.color, isNot(ripprBackground),
reason: 'still legible, just against a different (lighter) ground');
final start = tester.getSize(find.byKey(const Key('start')));
expect(start.height, 96,
reason: 'V3-05: 72dp is not enough at speed, with gloves');
});
});
group('trips list', () {
@@ -282,6 +458,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 +537,121 @@ 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);
});
screenTest('switching units changes the rendered distance label (V3-03)',
(tester) async {
final id = await seedCompletedTrip(
startedAt: 1000, endedAt: 5000, points: 11);
final trip = (await repo.tripById(id))!;
await tester.pumpWidget(
host(TripDetailScreen(tripId: id), map: false),
);
await tester.pumpAndSettle();
final (metricValue, metricUnit) = formatDistanceParts(trip.distanceM);
expect(find.text(metricValue), findsWidgets);
// BigStat renders its unit as " $unit", with a leading space, next to the figure.
expect(find.text(' $metricUnit'), findsOneWidget);
// A full unmount first: Riverpod's ProviderScope does not reliably re-seed a
// StateProvider's initial value on an override change alone if the same
// container survives the rebuild, so pumping a second host() directly on top of
// the first would silently keep reading the metric container.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpWidget(
host(TripDetailScreen(tripId: id), map: false, units: UnitSystem.imperial),
);
await tester.pumpAndSettle();
final (imperialValue, imperialUnit) = formatDistanceParts(
trip.distanceM,
unit: UnitSystem.imperial,
);
expect(find.text(' $imperialUnit'), findsOneWidget);
expect(imperialUnit, isNot(metricUnit));
// A short 11-point ride at ~11 m hops is short enough that the two rounded values
// could coincidentally match as text; the unit label changing is what this test is
// really proving, but assert the value differs too when it is safe to.
if (metricValue != imperialValue) {
expect(find.text(imperialValue), findsWidgets);
}
});
screenTest('a single-segment ride explains why it cannot be split (V3-10)',
(tester) async {
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('split')));
await tester.pumpAndSettle();
expect(find.textContaining('nothing to split'), findsOneWidget);
expect(await repo.segmentsForTrip(id), hasLength(1),
reason: 'a rejected split must not touch anything');
});
screenTest('splitting a multi-segment ride creates a second ride (V3-10)',
(tester) async {
final h = await repo.startTrip(1000);
await repo.appendPoints([
TrackPoint(
tripId: h.tripId,
segmentId: h.segmentId,
timestamp: 1000,
latitude: 51.0,
longitude: -114.0,
speedKmh: 40.0,
altitudeM: 1000.0,
),
]);
await repo.pauseTrip(2000);
final h2 = (await repo.resumeTrip(3000))!;
await repo.appendPoints([
TrackPoint(
tripId: h2.tripId,
segmentId: h2.segmentId,
timestamp: 3000,
latitude: 52.0,
longitude: -114.0,
speedKmh: 40.0,
altitudeM: 1000.0,
),
]);
await repo.completeTrip(4000);
final segments = await repo.segmentsForTrip(h.tripId);
expect(segments, hasLength(2));
await tester.pumpWidget(host(TripDetailScreen(tripId: h.tripId), map: false));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('split')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('split-at-${segments[1].id}')));
await tester.pumpAndSettle();
await tester.tap(find.text('Split'));
await tester.pumpAndSettle();
expect(await db.countTrips(), 2);
expect(await repo.segmentsForTrip(h.tripId), hasLength(1));
});
});
}