Add the Rippr Flutter port: source, history bundle, and installable APK
The port runs on Android and iOS and is feature-complete; the native Android app is superseded but kept, since it is still the only version that has recorded real rides. rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different from the native com.rippr so both install side by side. Recording the same ride on both at once is the strongest available check that the port is faithful. Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS has no APK equivalent and must be built and signed through Xcode with a free Apple ID, which gives a 7-day profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
352
rippr-flutter-src/test/trip_repository_test.dart
Normal file
352
rippr-flutter-src/test/trip_repository_test.dart
Normal file
@@ -0,0 +1,352 @@
|
||||
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';
|
||||
|
||||
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||
///
|
||||
/// Both were **instrumented** suites needing a device and a booted emulator. Against
|
||||
/// Drift on the Dart VM they are ordinary unit tests.
|
||||
///
|
||||
/// In-memory databases only — the native suite once wiped a real device's rides by
|
||||
/// running against the production singleton.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late TripRepository repo;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
repo = TripRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<void> addPoint(TripHandle handle, int ts,
|
||||
{double lat = 51.0, double speed = 40.0, double alt = 1000.0}) =>
|
||||
repo.appendPoints([
|
||||
TrackPoint(
|
||||
tripId: handle.tripId,
|
||||
segmentId: handle.segmentId,
|
||||
timestamp: ts,
|
||||
latitude: lat,
|
||||
longitude: -114.0,
|
||||
speedKmh: speed,
|
||||
altitudeM: alt,
|
||||
),
|
||||
]);
|
||||
|
||||
group('lifecycle', () {
|
||||
test('full lifecycle produces one trip and two segments', () async {
|
||||
final h1 = await repo.startTrip(1000);
|
||||
await addPoint(h1, 1000);
|
||||
await addPoint(h1, 2000);
|
||||
|
||||
await repo.pauseTrip(3000);
|
||||
final h2 = (await repo.resumeTrip(4000))!;
|
||||
await addPoint(h2, 5000);
|
||||
|
||||
final tripId = await repo.completeTrip(6000);
|
||||
|
||||
expect(tripId, h1.tripId);
|
||||
expect(h2.segmentId, isNot(h1.segmentId),
|
||||
reason: 'resume must open a fresh segment');
|
||||
|
||||
final segments = await repo.segmentsForTrip(h1.tripId);
|
||||
expect(segments.length, 2);
|
||||
expect(segments.every((s) => !s.isOpen), isTrue,
|
||||
reason: 'completing must close the open segment');
|
||||
expect((await repo.tripById(h1.tripId))!.state, TripState.completed);
|
||||
expect(await repo.activeTrip(), isNull);
|
||||
});
|
||||
|
||||
test('pause closes the segment but leaves the trip open', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
|
||||
final trip = (await repo.activeTrip())!;
|
||||
expect(trip.state, TripState.paused);
|
||||
expect(trip.endedAt, isNull, reason: 'pause must not end the trip');
|
||||
expect(await db.openSegment(h.tripId), isNull);
|
||||
});
|
||||
|
||||
test('startTrip adopts an already active trip instead of creating a second',
|
||||
() async {
|
||||
final first = await repo.startTrip(1000);
|
||||
final second = await repo.startTrip(2000);
|
||||
|
||||
expect(second.tripId, first.tripId);
|
||||
expect(second.segmentId, first.segmentId);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('resume while already recording is a no-op', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
final resumed = await repo.resumeTrip(2000);
|
||||
|
||||
expect(resumed!.segmentId, h.segmentId,
|
||||
reason: 'must not open a duplicate segment');
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||
});
|
||||
|
||||
test('double pause is a no-op', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
expect(await repo.pauseTrip(2000), isTrue);
|
||||
expect(await repo.pauseTrip(3000), isTrue);
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||
});
|
||||
|
||||
test('transitions with no active trip are safe no-ops', () async {
|
||||
expect(await repo.pauseTrip(1000), isFalse);
|
||||
expect(await repo.resumeTrip(1000), isNull);
|
||||
expect(await repo.completeTrip(1000), isNull);
|
||||
expect(await repo.discardTrip(), isFalse);
|
||||
});
|
||||
|
||||
test('start after complete begins a fresh trip', () async {
|
||||
final first = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
final second = await repo.startTrip(3000);
|
||||
|
||||
expect(second.tripId, isNot(first.tripId));
|
||||
expect(await db.countTrips(), 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('discard', () {
|
||||
test('discard removes the trip and all its points', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await addPoint(h, 1000);
|
||||
await addPoint(h, 2000);
|
||||
|
||||
expect(await repo.discardTrip(), isTrue);
|
||||
|
||||
expect(await repo.tripById(h.tripId), isNull);
|
||||
expect(await db.countPointsForTrip(h.tripId), 0);
|
||||
expect(await db.countSegmentsForTrip(h.tripId), 0);
|
||||
});
|
||||
|
||||
test('discard leaves earlier completed trips alone', () async {
|
||||
final keep = await repo.startTrip(1000);
|
||||
await addPoint(keep, 1000);
|
||||
await repo.completeTrip(2000);
|
||||
|
||||
final throwaway = await repo.startTrip(3000);
|
||||
await addPoint(throwaway, 3000);
|
||||
await repo.discardTrip();
|
||||
|
||||
expect(await repo.tripById(keep.tripId), isNotNull);
|
||||
expect(await db.countPointsForTrip(keep.tripId), 1);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('streams', () {
|
||||
test('active trip stream tracks the lifecycle', () async {
|
||||
expect(await repo.watchActiveTrip().first, isNull);
|
||||
|
||||
final h = await repo.startTrip(1000);
|
||||
expect((await repo.watchActiveTrip().first)?.id, h.tripId);
|
||||
|
||||
await repo.completeTrip(2000);
|
||||
expect(await repo.watchActiveTrip().first, isNull);
|
||||
});
|
||||
|
||||
test('completed trips stream excludes the active trip', () async {
|
||||
final done = await repo.startTrip(1000);
|
||||
await repo.completeTrip(2000);
|
||||
await repo.startTrip(3000);
|
||||
|
||||
final completed = await repo.watchCompletedTrips().first;
|
||||
expect(completed.length, 1);
|
||||
expect(completed.single.id, done.tripId);
|
||||
});
|
||||
});
|
||||
|
||||
group('rename', () {
|
||||
test('rename stores null rather than an empty string', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
|
||||
await repo.renameTrip(h.tripId, 'Morning loop');
|
||||
expect((await repo.tripById(h.tripId))!.name, 'Morning loop');
|
||||
|
||||
await repo.renameTrip(h.tripId, '');
|
||||
expect((await repo.tripById(h.tripId))!.name, isNull,
|
||||
reason: 'an empty string would diverge from the date-label branch');
|
||||
|
||||
await repo.renameTrip(h.tripId, ' ');
|
||||
expect((await repo.tripById(h.tripId))!.name, isNull);
|
||||
});
|
||||
|
||||
test('rename trims surrounding whitespace', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.renameTrip(h.tripId, ' Sunday blast ');
|
||||
expect((await repo.tripById(h.tripId))!.name, 'Sunday blast');
|
||||
});
|
||||
});
|
||||
|
||||
group('process death', () {
|
||||
test('a fresh repository over the same database sees the active trip',
|
||||
() async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await addPoint(h, 1000);
|
||||
|
||||
// Simulates the process being killed and rebuilt: new repository, same file.
|
||||
final revived = TripRepository(db);
|
||||
|
||||
final active = await revived.activeTrip();
|
||||
expect(active, isNotNull,
|
||||
reason: 'recording state must survive process death');
|
||||
expect(active!.id, h.tripId);
|
||||
});
|
||||
|
||||
test('restart after pause resumes into a new segment', () async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.pauseTrip(2000);
|
||||
|
||||
final revived = TripRepository(db);
|
||||
final resumed = (await revived.resumeTrip(3000))!;
|
||||
|
||||
expect(resumed.tripId, h.tripId);
|
||||
expect(resumed.segmentId, isNot(h.segmentId));
|
||||
expect((await revived.activeTrip())!.state, TripState.recording);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge — ported from MergeTest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('merge', () {
|
||||
Future<int> completedTrip({
|
||||
required int startedAt,
|
||||
required int endedAt,
|
||||
int points = 3,
|
||||
String? name,
|
||||
double lat = 51.0,
|
||||
}) async {
|
||||
final h = await repo.startTrip(startedAt);
|
||||
for (var i = 0; i < points; i++) {
|
||||
await addPoint(h, startedAt + i * 1000, lat: lat + i * 0.0001);
|
||||
}
|
||||
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||
await repo.completeTrip(endedAt);
|
||||
return h.tripId;
|
||||
}
|
||||
|
||||
test('merge reparents everything and deletes the absorbed row', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
final survivor = await repo.mergeTrips(a, b);
|
||||
|
||||
expect(survivor, a);
|
||||
expect(await repo.tripById(b), isNull);
|
||||
expect(await db.countPointsForTrip(a), 6);
|
||||
expect(await db.countSegmentsForTrip(a), 2);
|
||||
expect(await db.countTrips(), 1);
|
||||
});
|
||||
|
||||
test('selection order does not decide the survivor', () async {
|
||||
final earlier = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final later = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
// Passed later-first on purpose.
|
||||
expect(await repo.mergeTrips(later, earlier), earlier);
|
||||
});
|
||||
|
||||
test('merged aggregates are recomputed, not summed', () async {
|
||||
// The two rides are a degree of latitude apart — ~111 km. If aggregates were
|
||||
// summed, or if the segments were joined, that gap would appear as distance.
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000, lat: 51.0);
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, lat: 52.0);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
final merged = (await repo.tripById(a))!;
|
||||
expect(merged.pointCount, 6);
|
||||
expect(merged.distanceM, lessThan(200.0),
|
||||
reason:
|
||||
'the ~111 km gap leaked into distance: ${merged.distanceM} m');
|
||||
});
|
||||
|
||||
test('the join remains a segment boundary', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
final segments = await repo.segmentsForTrip(a);
|
||||
expect(segments.length, 2,
|
||||
reason: 'segments must never be joined by a merge');
|
||||
});
|
||||
|
||||
test('endedAt becomes the later of the two', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.endedAt, 15000);
|
||||
});
|
||||
|
||||
test('an unnamed survivor inherits the other name', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.name, 'Part two');
|
||||
});
|
||||
|
||||
test('an existing survivor name is kept', () async {
|
||||
final a = await completedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'The good one');
|
||||
final b =
|
||||
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
expect((await repo.tripById(a))!.name, 'The good one');
|
||||
});
|
||||
|
||||
test('merging a trip with itself is rejected', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
expect(await repo.mergeTrips(a, a), isNull);
|
||||
expect(await db.countPointsForTrip(a), 3);
|
||||
});
|
||||
|
||||
test('merging an active trip is rejected', () async {
|
||||
final done = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final active = await repo.startTrip(10000);
|
||||
|
||||
expect(await repo.mergeTrips(done, active.tripId), isNull);
|
||||
expect(await db.countTrips(), 2, reason: 'nothing may be deleted');
|
||||
});
|
||||
|
||||
test('merging a missing trip is rejected', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
expect(await repo.mergeTrips(a, 9999), isNull);
|
||||
expect(await repo.tripById(a), isNotNull);
|
||||
});
|
||||
|
||||
test('merge is atomic and leaves no orphans', () async {
|
||||
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||
|
||||
await repo.mergeTrips(a, b);
|
||||
|
||||
// Every surviving point and segment must belong to the survivor.
|
||||
final allPoints = await db.allPoints();
|
||||
expect(allPoints.every((p) => p.tripId == a), isTrue);
|
||||
expect(allPoints.length, 6);
|
||||
|
||||
final segments = await repo.segmentsForTrip(a);
|
||||
expect(segments.every((s) => s.tripId == a), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user