feat: restore ride performance data from MotorCloud
This commit is contained in:
parent
cd5f575f9b
commit
8393849f39
|
|
@ -110,6 +110,14 @@ class CloudRestoreController extends StateNotifier<CloudRestoreUiState> {
|
||||||
'${result.telemetrySamplesRestored} telemetry sample(s) restored',
|
'${result.telemetrySamplesRestored} telemetry sample(s) restored',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (result.performanceRunsRestored > 0) {
|
||||||
|
parts.add(
|
||||||
|
'${result.performanceRunsRestored} performance run(s) restored',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.rideEventsRestored > 0) {
|
||||||
|
parts.add('${result.rideEventsRestored} ride event(s) restored');
|
||||||
|
}
|
||||||
if (result.existingRidesMatched > 0 || result.telemetryAlreadyPresent > 0) {
|
if (result.existingRidesMatched > 0 || result.telemetryAlreadyPresent > 0) {
|
||||||
final alreadyPresent =
|
final alreadyPresent =
|
||||||
result.existingRidesMatched > result.telemetryAlreadyPresent
|
result.existingRidesMatched > result.telemetryAlreadyPresent
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ class CloudRestoreResult {
|
||||||
final int telemetryAlreadyPresent;
|
final int telemetryAlreadyPresent;
|
||||||
final int telemetryFailed;
|
final int telemetryFailed;
|
||||||
final int telemetryAuthFailures;
|
final int telemetryAuthFailures;
|
||||||
|
final int performanceRunsRestored;
|
||||||
|
final int performanceRunsAlreadyPresent;
|
||||||
|
final int performanceRunsSkipped;
|
||||||
|
final int performanceRunsFailed;
|
||||||
|
final int rideEventsRestored;
|
||||||
|
final int rideEventsAlreadyPresent;
|
||||||
|
final int rideEventsSkipped;
|
||||||
|
final int rideEventsFailed;
|
||||||
|
final int rideEventsUnavailable;
|
||||||
|
|
||||||
const CloudRestoreResult({
|
const CloudRestoreResult({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
|
|
@ -26,6 +35,15 @@ class CloudRestoreResult {
|
||||||
this.telemetryAlreadyPresent = 0,
|
this.telemetryAlreadyPresent = 0,
|
||||||
this.telemetryFailed = 0,
|
this.telemetryFailed = 0,
|
||||||
this.telemetryAuthFailures = 0,
|
this.telemetryAuthFailures = 0,
|
||||||
|
this.performanceRunsRestored = 0,
|
||||||
|
this.performanceRunsAlreadyPresent = 0,
|
||||||
|
this.performanceRunsSkipped = 0,
|
||||||
|
this.performanceRunsFailed = 0,
|
||||||
|
this.rideEventsRestored = 0,
|
||||||
|
this.rideEventsAlreadyPresent = 0,
|
||||||
|
this.rideEventsSkipped = 0,
|
||||||
|
this.rideEventsFailed = 0,
|
||||||
|
this.rideEventsUnavailable = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
int get telemetryPending => telemetryFailed + telemetryAuthFailures;
|
int get telemetryPending => telemetryFailed + telemetryAuthFailures;
|
||||||
|
|
@ -36,12 +54,32 @@ class CloudRestoreResult {
|
||||||
metadata.conflicts +
|
metadata.conflicts +
|
||||||
metadata.skipped +
|
metadata.skipped +
|
||||||
telemetryFailed +
|
telemetryFailed +
|
||||||
telemetryAuthFailures;
|
telemetryAuthFailures +
|
||||||
|
performanceRunsFailed +
|
||||||
|
rideEventsFailed;
|
||||||
bool get requiresAuthentication => telemetryAuthFailures > 0;
|
bool get requiresAuthentication => telemetryAuthFailures > 0;
|
||||||
bool get hasAnyRestoredData =>
|
bool get hasAnyRestoredData =>
|
||||||
metadataRowsAdded > 0 ||
|
metadataRowsAdded > 0 ||
|
||||||
telemetryRidesRestored > 0 ||
|
telemetryRidesRestored > 0 ||
|
||||||
telemetrySamplesRestored > 0;
|
telemetrySamplesRestored > 0 ||
|
||||||
|
performanceRunsRestored > 0 ||
|
||||||
|
rideEventsRestored > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChildRestoreCounts {
|
||||||
|
final int restored;
|
||||||
|
final int alreadyPresent;
|
||||||
|
final int skipped;
|
||||||
|
final int failed;
|
||||||
|
final int unavailable;
|
||||||
|
|
||||||
|
const _ChildRestoreCounts({
|
||||||
|
this.restored = 0,
|
||||||
|
this.alreadyPresent = 0,
|
||||||
|
this.skipped = 0,
|
||||||
|
this.failed = 0,
|
||||||
|
this.unavailable = 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Additive, resumable MotorCloud restore coordinator.
|
/// Additive, resumable MotorCloud restore coordinator.
|
||||||
|
|
@ -101,6 +139,15 @@ class CloudRestoreService {
|
||||||
var telemetryAlreadyPresent = 0;
|
var telemetryAlreadyPresent = 0;
|
||||||
var telemetryFailed = 0;
|
var telemetryFailed = 0;
|
||||||
var telemetryAuthFailures = 0;
|
var telemetryAuthFailures = 0;
|
||||||
|
var performanceRunsRestored = 0;
|
||||||
|
var performanceRunsAlreadyPresent = 0;
|
||||||
|
var performanceRunsSkipped = 0;
|
||||||
|
var performanceRunsFailed = 0;
|
||||||
|
var rideEventsRestored = 0;
|
||||||
|
var rideEventsAlreadyPresent = 0;
|
||||||
|
var rideEventsSkipped = 0;
|
||||||
|
var rideEventsFailed = 0;
|
||||||
|
var rideEventsUnavailable = 0;
|
||||||
|
|
||||||
for (final document in documents) {
|
for (final document in documents) {
|
||||||
final data = document.data;
|
final data = document.data;
|
||||||
|
|
@ -126,9 +173,7 @@ class CloudRestoreService {
|
||||||
.then((rows) => rows.length);
|
.then((rows) => rows.length);
|
||||||
if (telemetryCount > 0) {
|
if (telemetryCount > 0) {
|
||||||
telemetryAlreadyPresent++;
|
telemetryAlreadyPresent++;
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _r2DownloadService.downloadTelemetryBundle(
|
final result = await _r2DownloadService.downloadTelemetryBundle(
|
||||||
idToken: idToken,
|
idToken: idToken,
|
||||||
cloudRideId: cloudRideId,
|
cloudRideId: cloudRideId,
|
||||||
|
|
@ -136,12 +181,10 @@ class CloudRestoreService {
|
||||||
if (!result.success || result.zipBytes == null) {
|
if (!result.success || result.zipBytes == null) {
|
||||||
if (result.requiresAuth) {
|
if (result.requiresAuth) {
|
||||||
telemetryAuthFailures++;
|
telemetryAuthFailures++;
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
telemetryFailed++;
|
telemetryFailed++;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
final restoredCount = await _restoreTelemetryCsv(
|
final restoredCount = await _restoreTelemetryCsv(
|
||||||
localRide.id,
|
localRide.id,
|
||||||
|
|
@ -157,6 +200,24 @@ class CloudRestoreService {
|
||||||
telemetryFailed++;
|
telemetryFailed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final performanceCounts = await _restorePerformanceRuns(
|
||||||
|
localRide.id,
|
||||||
|
data['performanceRuns'],
|
||||||
|
);
|
||||||
|
performanceRunsRestored += performanceCounts.restored;
|
||||||
|
performanceRunsAlreadyPresent += performanceCounts.alreadyPresent;
|
||||||
|
performanceRunsSkipped += performanceCounts.skipped;
|
||||||
|
performanceRunsFailed += performanceCounts.failed;
|
||||||
|
|
||||||
|
final eventCounts = await _restoreRideEvents(localRide.id, data);
|
||||||
|
rideEventsRestored += eventCounts.restored;
|
||||||
|
rideEventsAlreadyPresent += eventCounts.alreadyPresent;
|
||||||
|
rideEventsSkipped += eventCounts.skipped;
|
||||||
|
rideEventsFailed += eventCounts.failed;
|
||||||
|
rideEventsUnavailable += eventCounts.unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
return CloudRestoreResult(
|
return CloudRestoreResult(
|
||||||
metadata: metadataResult,
|
metadata: metadataResult,
|
||||||
|
|
@ -166,9 +227,107 @@ class CloudRestoreService {
|
||||||
telemetryAlreadyPresent: telemetryAlreadyPresent,
|
telemetryAlreadyPresent: telemetryAlreadyPresent,
|
||||||
telemetryFailed: telemetryFailed,
|
telemetryFailed: telemetryFailed,
|
||||||
telemetryAuthFailures: telemetryAuthFailures,
|
telemetryAuthFailures: telemetryAuthFailures,
|
||||||
|
performanceRunsRestored: performanceRunsRestored,
|
||||||
|
performanceRunsAlreadyPresent: performanceRunsAlreadyPresent,
|
||||||
|
performanceRunsSkipped: performanceRunsSkipped,
|
||||||
|
performanceRunsFailed: performanceRunsFailed,
|
||||||
|
rideEventsRestored: rideEventsRestored,
|
||||||
|
rideEventsAlreadyPresent: rideEventsAlreadyPresent,
|
||||||
|
rideEventsSkipped: rideEventsSkipped,
|
||||||
|
rideEventsFailed: rideEventsFailed,
|
||||||
|
rideEventsUnavailable: rideEventsUnavailable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<_ChildRestoreCounts> _restorePerformanceRuns(
|
||||||
|
int localRideId,
|
||||||
|
Object? rawRuns,
|
||||||
|
) async {
|
||||||
|
if (rawRuns == null) return const _ChildRestoreCounts();
|
||||||
|
if (rawRuns is! Iterable) {
|
||||||
|
return const _ChildRestoreCounts(failed: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored = 0;
|
||||||
|
var alreadyPresent = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
|
for (final rawRun in rawRuns) {
|
||||||
|
try {
|
||||||
|
if (rawRun is! Map) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final type = rawRun['type']?.toString();
|
||||||
|
final timeMs = _nullablePlainDouble(rawRun['timeMs']);
|
||||||
|
final topSpeedReached = _nullablePlainDouble(rawRun['topSpeedReached']);
|
||||||
|
final timestamp = _readDateTime(rawRun['timestamp']);
|
||||||
|
if (type == null ||
|
||||||
|
type.isEmpty ||
|
||||||
|
timeMs == null ||
|
||||||
|
topSpeedReached == null ||
|
||||||
|
timestamp == null) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing =
|
||||||
|
await (_db.select(_db.performanceRuns)
|
||||||
|
..where((run) => run.rideId.equals(localRideId))
|
||||||
|
..where((run) => run.type.equals(type))
|
||||||
|
..where((run) => run.timeMs.equals(timeMs))
|
||||||
|
..where((run) => run.topSpeedReached.equals(topSpeedReached))
|
||||||
|
..where((run) => run.timestamp.equals(timestamp)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (existing != null) {
|
||||||
|
alreadyPresent++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db
|
||||||
|
.into(_db.performanceRuns)
|
||||||
|
.insert(
|
||||||
|
PerformanceRunsCompanion.insert(
|
||||||
|
rideId: drift.Value(localRideId),
|
||||||
|
type: type,
|
||||||
|
timeMs: timeMs,
|
||||||
|
topSpeedReached: topSpeedReached,
|
||||||
|
timestamp: timestamp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
restored++;
|
||||||
|
} catch (_) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ChildRestoreCounts(
|
||||||
|
restored: restored,
|
||||||
|
alreadyPresent: alreadyPresent,
|
||||||
|
skipped: skipped,
|
||||||
|
failed: failed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ChildRestoreCounts> _restoreRideEvents(
|
||||||
|
int localRideId,
|
||||||
|
Map<String, dynamic> rideMetadata,
|
||||||
|
) async {
|
||||||
|
final existingEvents = await (_db.select(
|
||||||
|
_db.rideEvents,
|
||||||
|
)..where((event) => event.rideId.equals(localRideId))).get();
|
||||||
|
if (existingEvents.isNotEmpty) {
|
||||||
|
return _ChildRestoreCounts(alreadyPresent: existingEvents.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalEvents = _asInt(rideMetadata['totalEventsCount']) ?? 0;
|
||||||
|
if (totalEvents > 0) {
|
||||||
|
return _ChildRestoreCounts(unavailable: totalEvents);
|
||||||
|
}
|
||||||
|
return const _ChildRestoreCounts();
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async {
|
Future<int> _restoreTelemetryCsv(int rideId, Uint8List zipBytes) async {
|
||||||
final archive = ZipDecoder().decodeBytes(zipBytes, verify: true);
|
final archive = ZipDecoder().decodeBytes(zipBytes, verify: true);
|
||||||
final file = archive.findFile('telemetry.csv');
|
final file = archive.findFile('telemetry.csv');
|
||||||
|
|
@ -259,6 +418,19 @@ class CloudRestoreService {
|
||||||
return drift.Value(parsed);
|
return drift.Value(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double? _nullablePlainDouble(Object? value) => value is num
|
||||||
|
? value.toDouble()
|
||||||
|
: double.tryParse(value?.toString() ?? '');
|
||||||
|
|
||||||
|
int? _asInt(Object? value) =>
|
||||||
|
value is num ? value.toInt() : int.tryParse(value?.toString() ?? '');
|
||||||
|
|
||||||
|
DateTime? _readDateTime(Object? value) {
|
||||||
|
if (value is DateTime) return value.toUtc();
|
||||||
|
final parsed = DateTime.tryParse(value?.toString() ?? '');
|
||||||
|
return parsed?.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
bool _sameStringList(List<String> actual, List<String> expected) {
|
bool _sameStringList(List<String> actual, List<String> expected) {
|
||||||
if (actual.length != expected.length) return false;
|
if (actual.length != expected.length) return false;
|
||||||
for (var i = 0; i < expected.length; i++) {
|
for (var i = 0; i < expected.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,13 @@ void main() {
|
||||||
ownerId: 'user_a',
|
ownerId: 'user_a',
|
||||||
movingTime: 600,
|
movingTime: 600,
|
||||||
stoppedTime: 45,
|
stoppedTime: 45,
|
||||||
|
performanceRuns: [
|
||||||
|
_performanceRun(
|
||||||
|
type: '0-60',
|
||||||
|
timeMs: 3210,
|
||||||
|
topSpeedReached: 62,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -163,16 +170,25 @@ void main() {
|
||||||
expect(first.metadataRowsAdded, 1);
|
expect(first.metadataRowsAdded, 1);
|
||||||
expect(first.telemetryRidesRestored, 1);
|
expect(first.telemetryRidesRestored, 1);
|
||||||
expect(first.telemetrySamplesRestored, 2);
|
expect(first.telemetrySamplesRestored, 2);
|
||||||
|
expect(first.performanceRunsRestored, 1);
|
||||||
expect(rides, hasLength(1));
|
expect(rides, hasLength(1));
|
||||||
expect(rides.single.cloudRideId, 'ride_clean_install');
|
expect(rides.single.cloudRideId, 'ride_clean_install');
|
||||||
expect(rides.single.movingTime, 600);
|
expect(rides.single.movingTime, 600);
|
||||||
expect(rides.single.stoppedTime, 45);
|
expect(rides.single.stoppedTime, 45);
|
||||||
expect(samples, hasLength(2));
|
expect(samples, hasLength(2));
|
||||||
|
final runs = await db.select(db.performanceRuns).get();
|
||||||
|
expect(runs, hasLength(1));
|
||||||
|
expect(runs.single.rideId, rides.single.id);
|
||||||
|
expect(runs.single.type, '0-60');
|
||||||
|
expect(runs.single.timeMs, 3210);
|
||||||
expect(second.metadataRowsAdded, 0);
|
expect(second.metadataRowsAdded, 0);
|
||||||
expect(second.telemetrySamplesRestored, 0);
|
expect(second.telemetrySamplesRestored, 0);
|
||||||
expect(second.telemetryAlreadyPresent, 1);
|
expect(second.telemetryAlreadyPresent, 1);
|
||||||
|
expect(second.performanceRunsRestored, 0);
|
||||||
|
expect(second.performanceRunsAlreadyPresent, 1);
|
||||||
expect(await db.select(db.rides).get(), hasLength(1));
|
expect(await db.select(db.rides).get(), hasLength(1));
|
||||||
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
|
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
|
||||||
|
expect(await db.select(db.performanceRuns).get(), hasLength(1));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -319,6 +335,218 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'two cloud rides restore performance runs without cross-association',
|
||||||
|
() async {
|
||||||
|
final store = _MemoryRideMetadataStore(
|
||||||
|
byUid: {
|
||||||
|
'user_a': [
|
||||||
|
_cloudRide(
|
||||||
|
id: 'ride_child_a',
|
||||||
|
ownerId: 'user_a',
|
||||||
|
performanceRuns: [
|
||||||
|
_performanceRun(
|
||||||
|
type: '0-60',
|
||||||
|
timeMs: 3000,
|
||||||
|
topSpeedReached: 61,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_cloudRide(
|
||||||
|
id: 'ride_child_b',
|
||||||
|
ownerId: 'user_a',
|
||||||
|
performanceRuns: [
|
||||||
|
_performanceRun(
|
||||||
|
type: '0-100',
|
||||||
|
timeMs: 6400,
|
||||||
|
topSpeedReached: 102,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 11, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service(
|
||||||
|
store: store,
|
||||||
|
client: _downloadClient(_telemetryZip()),
|
||||||
|
).restore();
|
||||||
|
|
||||||
|
final rides = await db.select(db.rides).get();
|
||||||
|
final rideA = rides.singleWhere(
|
||||||
|
(ride) => ride.cloudRideId == 'ride_child_a',
|
||||||
|
);
|
||||||
|
final rideB = rides.singleWhere(
|
||||||
|
(ride) => ride.cloudRideId == 'ride_child_b',
|
||||||
|
);
|
||||||
|
final rideARuns = await (db.select(
|
||||||
|
db.performanceRuns,
|
||||||
|
)..where((run) => run.rideId.equals(rideA.id))).get();
|
||||||
|
final rideBRuns = await (db.select(
|
||||||
|
db.performanceRuns,
|
||||||
|
)..where((run) => run.rideId.equals(rideB.id))).get();
|
||||||
|
|
||||||
|
expect(result.performanceRunsRestored, 2);
|
||||||
|
expect(rideARuns.single.type, '0-60');
|
||||||
|
expect(rideBRuns.single.type, '0-100');
|
||||||
|
expect(rideARuns.single.rideId, isNot(rideBRuns.single.rideId));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'existing local performance and ride events are not overwritten or duplicated',
|
||||||
|
() async {
|
||||||
|
final rideId = await db
|
||||||
|
.into(db.rides)
|
||||||
|
.insert(
|
||||||
|
RidesCompanion.insert(
|
||||||
|
cloudRideId: const drift.Value('ride_existing_children'),
|
||||||
|
startTime: DateTime.utc(2026, 9, 3, 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.into(db.performanceRuns)
|
||||||
|
.insert(
|
||||||
|
PerformanceRunsCompanion.insert(
|
||||||
|
rideId: drift.Value(rideId),
|
||||||
|
type: '0-60',
|
||||||
|
timeMs: 3500,
|
||||||
|
topSpeedReached: 63,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.into(db.rideEvents)
|
||||||
|
.insert(
|
||||||
|
RideEventsCompanion.insert(
|
||||||
|
rideId: rideId,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 10, 2),
|
||||||
|
eventType: 'Hard Braking',
|
||||||
|
value: -0.9,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final store = _MemoryRideMetadataStore(
|
||||||
|
byUid: {
|
||||||
|
'user_a': [
|
||||||
|
_cloudRide(
|
||||||
|
id: 'ride_existing_children',
|
||||||
|
ownerId: 'user_a',
|
||||||
|
totalEventsCount: 1,
|
||||||
|
performanceRuns: [
|
||||||
|
_performanceRun(
|
||||||
|
type: '0-60',
|
||||||
|
timeMs: 3500,
|
||||||
|
topSpeedReached: 63,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
||||||
|
),
|
||||||
|
_performanceRun(
|
||||||
|
type: '0-100',
|
||||||
|
timeMs: 7000,
|
||||||
|
topSpeedReached: 101,
|
||||||
|
timestamp: DateTime.utc(2026, 9, 3, 10, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service(
|
||||||
|
store: store,
|
||||||
|
client: _downloadClient(_telemetryZip()),
|
||||||
|
).restore();
|
||||||
|
|
||||||
|
final runs = await db.select(db.performanceRuns).get();
|
||||||
|
final events = await db.select(db.rideEvents).get();
|
||||||
|
expect(result.performanceRunsRestored, 1);
|
||||||
|
expect(result.performanceRunsAlreadyPresent, 1);
|
||||||
|
expect(result.rideEventsAlreadyPresent, 1);
|
||||||
|
expect(runs, hasLength(2));
|
||||||
|
expect(events, hasLength(1));
|
||||||
|
expect(events.single.eventType, 'Hard Braking');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'malformed performance data does not destroy restored telemetry',
|
||||||
|
() async {
|
||||||
|
final store = _MemoryRideMetadataStore(
|
||||||
|
byUid: {
|
||||||
|
'user_a': [
|
||||||
|
_cloudRide(
|
||||||
|
id: 'ride_bad_performance',
|
||||||
|
ownerId: 'user_a',
|
||||||
|
performanceRuns: [
|
||||||
|
{'type': '0-60', 'timeMs': 'bad'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service(
|
||||||
|
store: store,
|
||||||
|
client: _downloadClient(_telemetryZip(samples: 2)),
|
||||||
|
).restore();
|
||||||
|
|
||||||
|
expect(result.telemetrySamplesRestored, 2);
|
||||||
|
expect(result.performanceRunsSkipped, 1);
|
||||||
|
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
|
||||||
|
expect(await db.select(db.performanceRuns).get(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'canonical ride events are not fabricated from aggregate cloud counts',
|
||||||
|
() async {
|
||||||
|
final store = _MemoryRideMetadataStore(
|
||||||
|
byUid: {
|
||||||
|
'user_a': [
|
||||||
|
_cloudRide(
|
||||||
|
id: 'ride_event_gap',
|
||||||
|
ownerId: 'user_a',
|
||||||
|
totalEventsCount: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service(
|
||||||
|
store: store,
|
||||||
|
client: _downloadClient(_telemetryZip(samples: 1)),
|
||||||
|
).restore();
|
||||||
|
|
||||||
|
expect(result.telemetrySamplesRestored, 1);
|
||||||
|
expect(result.rideEventsUnavailable, 3);
|
||||||
|
expect(await db.select(db.rideEvents).get(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'legacy cloud bundle without child data still restores ride and telemetry',
|
||||||
|
() async {
|
||||||
|
final store = _MemoryRideMetadataStore(
|
||||||
|
byUid: {
|
||||||
|
'user_a': [_cloudRide(id: 'ride_legacy_bundle', ownerId: 'user_a')],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service(
|
||||||
|
store: store,
|
||||||
|
client: _downloadClient(_telemetryZip(samples: 1)),
|
||||||
|
).restore();
|
||||||
|
|
||||||
|
expect(result.metadataRowsAdded, 1);
|
||||||
|
expect(result.telemetrySamplesRestored, 1);
|
||||||
|
expect(result.performanceRunsRestored, 0);
|
||||||
|
expect(result.rideEventsRestored, 0);
|
||||||
|
expect(await db.select(db.rides).get(), hasLength(1));
|
||||||
|
expect(await db.select(db.telemetrySamples).get(), hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'wrong-owner cloud metadata is skipped for the current account',
|
'wrong-owner cloud metadata is skipped for the current account',
|
||||||
() async {
|
() async {
|
||||||
|
|
@ -372,6 +600,8 @@ CloudRideMetadataDocument _cloudRide({
|
||||||
required String ownerId,
|
required String ownerId,
|
||||||
int movingTime = 120,
|
int movingTime = 120,
|
||||||
int stoppedTime = 10,
|
int stoppedTime = 10,
|
||||||
|
List<Map<String, dynamic>>? performanceRuns,
|
||||||
|
int totalEventsCount = 0,
|
||||||
}) {
|
}) {
|
||||||
return CloudRideMetadataDocument(
|
return CloudRideMetadataDocument(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -396,11 +626,28 @@ CloudRideMetadataDocument _cloudRide({
|
||||||
'motorcycleMake': 'Yamaha',
|
'motorcycleMake': 'Yamaha',
|
||||||
'motorcycleModel': 'MT-09',
|
'motorcycleModel': 'MT-09',
|
||||||
'motorcycleVariant': 'SP',
|
'motorcycleVariant': 'SP',
|
||||||
|
'totalEventsCount': totalEventsCount,
|
||||||
|
'performanceRuns': ?performanceRuns,
|
||||||
'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip',
|
'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _performanceRun({
|
||||||
|
required String type,
|
||||||
|
required double timeMs,
|
||||||
|
required double topSpeedReached,
|
||||||
|
DateTime? timestamp,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'type': type,
|
||||||
|
'timeMs': timeMs,
|
||||||
|
'topSpeedReached': topSpeedReached,
|
||||||
|
'timestamp': (timestamp ?? DateTime.utc(2026, 9, 3, 10, 1))
|
||||||
|
.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
MockClient _downloadClient(List<int> zipBytes) {
|
MockClient _downloadClient(List<int> zipBytes) {
|
||||||
return MockClient((request) async {
|
return MockClient((request) async {
|
||||||
final rideId = request.url.queryParameters['rideId']!;
|
final rideId = request.url.queryParameters['rideId']!;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue