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:
64
rippr-flutter-src/test/activity_profile_test.dart
Normal file
64
rippr-flutter-src/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),
|
||||
);
|
||||
});
|
||||
}
|
||||
15
rippr-flutter-src/test/architecture_test.dart
Normal file
15
rippr-flutter-src/test/architecture_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
113
rippr-flutter-src/test/config_test.dart
Normal file
113
rippr-flutter-src/test/config_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
114
rippr-flutter-src/test/crash_reporter_test.dart
Normal file
114
rippr-flutter-src/test/crash_reporter_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
115
rippr-flutter-src/test/format_test.dart
Normal file
115
rippr-flutter-src/test/format_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
180
rippr-flutter-src/test/migration_test.dart
Normal file
180
rippr-flutter-src/test/migration_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
135
rippr-flutter-src/test/ride_notification_test.dart
Normal file
135
rippr-flutter-src/test/ride_notification_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
133
rippr-flutter-src/test/route_plan_repository_test.dart
Normal file
133
rippr-flutter-src/test/route_plan_repository_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
205
rippr-flutter-src/test/route_planner_screen_test.dart
Normal file
205
rippr-flutter-src/test/route_planner_screen_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
211
rippr-flutter-src/test/settings_screen_test.dart
Normal file
211
rippr-flutter-src/test/settings_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
82
rippr-flutter-src/test/theme_test.dart
Normal file
82
rippr-flutter-src/test/theme_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
92
rippr-flutter-src/test/tile_cache_test.dart
Normal file
92
rippr-flutter-src/test/tile_cache_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
106
rippr-flutter-src/test/tile_downloader_test.dart
Normal file
106
rippr-flutter-src/test/tile_downloader_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
119
rippr-flutter-src/test/tile_math_test.dart
Normal file
119
rippr-flutter-src/test/tile_math_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user