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:
152
rippr-flutter-src/test/telemetry_uploader_test.dart
Normal file
152
rippr-flutter-src/test/telemetry_uploader_test.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/telemetry/telemetry_uploader.dart';
|
||||
|
||||
/// Ported from `com.rippr.TelemetryUploaderTest`.
|
||||
///
|
||||
/// The Kotlin suite used MockWebServer plus an in-memory fake DAO. Here a `MockClient`
|
||||
/// stands in for the network and a real in-memory Drift database stands in for the DAO —
|
||||
/// closer to production, and still no device required.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<void> seed(int count) async {
|
||||
final tripId =
|
||||
await db.insertTrip(const Trip(startedAt: 1000, state: TripState.recording));
|
||||
final segmentId = await db.insertSegment(tripId, 1000);
|
||||
await db.insertPoints([
|
||||
for (var i = 0; i < count; i++)
|
||||
TrackPoint(
|
||||
tripId: tripId,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1000 + i,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
TelemetryUploader uploader({
|
||||
required http.Client client,
|
||||
String endpoint = 'https://example.test/ingest',
|
||||
}) =>
|
||||
TelemetryUploader(
|
||||
db: db,
|
||||
endpoint: endpoint,
|
||||
deviceId: 'device-abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
test('an empty endpoint disables upload entirely', () async {
|
||||
var called = false;
|
||||
final client = MockClient((_) async {
|
||||
called = true;
|
||||
return http.Response('', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client, endpoint: '').uploadPending();
|
||||
|
||||
expect(result, isA<UploadDisabled>());
|
||||
expect(called, isFalse, reason: 'recording must work with no server at all');
|
||||
});
|
||||
|
||||
test('a successful run marks points synced', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('{}', 200));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadSuccess>());
|
||||
expect((result as UploadSuccess).uploaded, 5);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a rejected batch leaves the backlog intact for retry', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('nope', 500));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 5,
|
||||
reason: 'nothing may be marked synced when the server refused it');
|
||||
});
|
||||
|
||||
test('a network error is swallowed and retried later', () async {
|
||||
await seed(3);
|
||||
final client = MockClient((_) async => throw http.ClientException('offline'));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 3);
|
||||
});
|
||||
|
||||
test('the payload carries trip and segment identity per point', () async {
|
||||
await seed(2);
|
||||
String? captured;
|
||||
final client = MockClient((req) async {
|
||||
captured = req.body;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
await uploader(client: client).uploadPending();
|
||||
|
||||
final json = jsonDecode(captured!) as Map<String, dynamic>;
|
||||
expect(json['device_id'], 'device-abc');
|
||||
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||
// Batches are drawn by id and can straddle a boundary, so identity travels with the
|
||||
// point rather than the batch.
|
||||
expect(first.containsKey('trip_id'), isTrue);
|
||||
expect(first.containsKey('segment_id'), isTrue);
|
||||
});
|
||||
|
||||
test('a backlog larger than one batch is sent across several requests',
|
||||
() async {
|
||||
// Two full batches plus a remainder.
|
||||
await seed(uploadBatchSize * 2 + 7);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(requests, 3);
|
||||
expect((result as UploadSuccess).uploaded, uploadBatchSize * 2 + 7);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a failure partway through reports what did land', () async {
|
||||
await seed(uploadBatchSize + 10);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', requests == 1 ? 200 : 503);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadPartial>());
|
||||
expect((result as UploadPartial).uploaded, uploadBatchSize);
|
||||
expect(await db.countUnsynced(), 10,
|
||||
reason: 'the first batch landed; the rest must survive for retry');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user