fix: master consolidation fixes for Schema 8, legacy ride sync, performance timer isolation, and sync inspector
This commit is contained in:
parent
f9e2bd6bed
commit
35f2b6982a
|
|
@ -559,8 +559,11 @@ class TestAtomicUsageTrackerAndRollover(unittest.TestCase):
|
|||
usage_tracker=tracker,
|
||||
fixture_mode=True,
|
||||
)
|
||||
# Fetch alerts in fixture mode
|
||||
alerts = adapter.fetch_alerts(min_lat=34.2, min_lng=-85.2, max_lat=34.3, max_lng=-85.1)
|
||||
# Fetch alerts in fixture mode with pinned reference time matching fixture pubMillis
|
||||
fixture_time = datetime(2026, 10, 10, 20, 30, tzinfo=timezone.utc)
|
||||
alerts = adapter.fetch_alerts(
|
||||
min_lat=34.2, min_lng=-85.2, max_lat=34.3, max_lng=-85.1, now=fixture_time
|
||||
)
|
||||
self.assertGreater(len(alerts), 0)
|
||||
|
||||
# Excluded: reservations and executions must remain 0
|
||||
|
|
|
|||
|
|
@ -192,8 +192,8 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
|
|||
|
||||
const document = (await response.json()) as FirestoreDocument;
|
||||
const fields = document.fields ?? {};
|
||||
const ownerId = firestoreString(fields.ownerId);
|
||||
const storedId = firestoreString(fields.id);
|
||||
const ownerId = firestoreString(fields.ownerId) ?? firestoreString(fields.uid);
|
||||
const storedId = firestoreString(fields.id) ?? firestoreString(fields.remoteRideId);
|
||||
const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey);
|
||||
|
||||
if (ownerId !== uid) return 'unauthorized';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';
|
||||
import { deleteDoc, deleteField, doc, getDoc, serverTimestamp, setDoc, updateDoc } from 'firebase/firestore';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const projectId = 'demo-legacy-rules';
|
||||
const rideId = 'ride_legacy_fixture';
|
||||
const ridePath = `users/alice/rides/${rideId}`;
|
||||
const legacy = { uid: 'alice', remoteRideId: rideId, distanceMiles: 2 };
|
||||
const canonical = { ownerId: 'alice', id: rideId, distanceMiles: 2 };
|
||||
let env;
|
||||
beforeAll(async () => {
|
||||
if (process.env.FIRESTORE_EMULATOR_HOST !== '127.0.0.1:8088') {
|
||||
throw new Error('Requires isolated loopback emulator at 127.0.0.1:8088');
|
||||
}
|
||||
env = await initializeTestEnvironment({ projectId, firestore: {
|
||||
host: '127.0.0.1', port: 8088,
|
||||
rules: fs.readFileSync(new URL('../../firestore.rules', import.meta.url), 'utf8'),
|
||||
} });
|
||||
});
|
||||
afterEach(async () => { await env?.clearFirestore(); });
|
||||
afterAll(async () => { await env?.cleanup(); });
|
||||
const owner = () => doc(env.authenticatedContext('alice').firestore(), ridePath);
|
||||
async function seed(data) {
|
||||
await env.withSecurityRulesDisabled(context => setDoc(doc(context.firestore(), ridePath), data));
|
||||
}
|
||||
|
||||
// Mirrors FirebaseSyncProvider.uploadRide's full Firestore map and merge operation;
|
||||
// telemetry metadata is a fixture, never an R2 request or proof of an uploaded bundle.
|
||||
function uploadPayload() {
|
||||
const objectKey = `users/alice/rides/${rideId}/telemetry.zip`;
|
||||
return {
|
||||
id: rideId, ownerId: 'alice', localRideId: 42,
|
||||
startTime: '2026-10-12T10:00:00.000Z', endTime: '2026-10-12T10:01:00.000Z',
|
||||
distanceMiles: 1, topSpeedMph: 60, maxLeanLeft: 12, maxLeanRight: 14,
|
||||
maxAccelG: 0.3, maxBrakingG: 0.2, averageSpeed: 30, movingTime: 60,
|
||||
stoppedTime: 0, elevationGain: 5, elevationLoss: 2, qualityScore: 100,
|
||||
qualityRating: 'EXCELLENT', motorcycleId: null, motorcycleMake: null,
|
||||
motorcycleModel: null, motorcycleYear: null, motorcycleVariant: null,
|
||||
hardBrakingEvents: 0, extremeLeanEvents: 0, highSpeedEvents: 0, totalEventsCount: 0,
|
||||
performanceRuns: [{ type: '0-60', timeMs: 5000, topSpeedReached: 60,
|
||||
timestamp: '2026-10-12T10:00:30.000Z' }],
|
||||
telemetryBundle: { provider: 'cloudflare-r2', objectKey, status: 'uploaded',
|
||||
bundleSizeBytes: 128, sha256: 'a'.repeat(64) },
|
||||
telemetryBundlePath: objectKey, bundleSizeBytes: 128, syncVersion: 3,
|
||||
uploadedAt: serverTimestamp(), updatedAt: serverTimestamp(),
|
||||
software: { semanticVersion: '1.0.0', buildNumber: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
describe('legacy ride compatibility', () => {
|
||||
test('owner gets legacy document', async () => {
|
||||
await seed(legacy);
|
||||
expect((await assertSucceeds(getDoc(owner()))).data()).toEqual(legacy);
|
||||
});
|
||||
test('owner updates legacy metadata without replacing identity', async () => {
|
||||
await seed(legacy);
|
||||
await assertSucceeds(updateDoc(owner(), { distanceMiles: 3 }));
|
||||
});
|
||||
test('owner deletes legacy document', async () => {
|
||||
await seed(legacy);
|
||||
await assertSucceeds(deleteDoc(owner()));
|
||||
});
|
||||
test('full upload merge canonicalizes legacy identity and remains retryable', async () => {
|
||||
await seed(legacy);
|
||||
const payload = uploadPayload();
|
||||
await assertSucceeds(setDoc(owner(), payload, { merge: true }));
|
||||
await assertSucceeds(setDoc(owner(), payload, { merge: true }));
|
||||
const data = (await getDoc(owner())).data();
|
||||
expect(data).toMatchObject({ ...legacy, ...canonical, distanceMiles: 1,
|
||||
performanceRuns: payload.performanceRuns, telemetryBundle: payload.telemetryBundle });
|
||||
expect(data.uploadedAt.toMillis()).toBeGreaterThan(0);
|
||||
await assertSucceeds(deleteDoc(owner()));
|
||||
});
|
||||
test.each([
|
||||
{ ...legacy, ownerId: 'alice' }, { ...legacy, id: rideId },
|
||||
{ ...canonical, uid: 'alice', remoteRideId: rideId }, canonical,
|
||||
])('valid mixed/canonical identity supports get/update/delete: %j', async data => {
|
||||
await seed(data);
|
||||
await assertSucceeds(getDoc(owner()));
|
||||
await assertSucceeds(updateDoc(owner(), { distanceMiles: 3 }));
|
||||
await assertSucceeds(deleteDoc(owner()));
|
||||
});
|
||||
test.each([
|
||||
{ ...legacy, uid: 'bob' }, { ...legacy, remoteRideId: 'other' },
|
||||
{ ...legacy, ownerId: 'bob' }, { ...legacy, id: 'other' },
|
||||
{ ...canonical, uid: 'bob' }, { ...canonical, remoteRideId: 'other' },
|
||||
{ ...canonical, ownerId: 'bob' }, { ...canonical, id: 'other' },
|
||||
{ uid: 'alice' }, { remoteRideId: rideId }, {},
|
||||
{ ...legacy, ownerId: null }, { ...legacy, id: null },
|
||||
])('rejects malformed stored identity and attempted repair: %j', async data => {
|
||||
await seed(data);
|
||||
await assertFails(getDoc(owner()));
|
||||
await assertFails(updateDoc(owner(), { distanceMiles: 3 }));
|
||||
await assertFails(setDoc(owner(), uploadPayload(), { merge: true }));
|
||||
await assertFails(deleteDoc(owner()));
|
||||
});
|
||||
test.each([
|
||||
{ uid: 'bob' }, { remoteRideId: 'other' }, { ownerId: 'bob' }, { id: 'other' },
|
||||
{ uid: null }, { remoteRideId: null }, { uid: deleteField() },
|
||||
{ remoteRideId: deleteField() },
|
||||
])('rejects legacy identity tampering: %j', async patch => {
|
||||
await seed(legacy);
|
||||
await assertFails(updateDoc(owner(), patch));
|
||||
});
|
||||
test.each([
|
||||
{ ownerId: 'bob' }, { id: 'other' }, { uid: 'bob' }, { remoteRideId: 'other' },
|
||||
{ ownerId: deleteField() }, { id: deleteField() },
|
||||
])('rejects canonical identity tampering: %j', async patch => {
|
||||
await seed({ ...legacy, ...canonical });
|
||||
await assertFails(updateDoc(owner(), patch));
|
||||
});
|
||||
test.each([
|
||||
legacy, { id: rideId, uid: 'alice' }, { ownerId: 'alice', remoteRideId: rideId },
|
||||
{ ...canonical, uid: 'bob' }, { ...canonical, remoteRideId: 'other' },
|
||||
{ ...canonical, ownerId: 'bob' }, { ...canonical, id: 'other' },
|
||||
])('new creates require canonical matching identity: %j', async data => {
|
||||
await assertFails(setDoc(owner(), data));
|
||||
});
|
||||
test('full canonical upload create still succeeds', async () => {
|
||||
await assertSucceeds(setDoc(owner(), uploadPayload(), { merge: true }));
|
||||
});
|
||||
test.each(['bob', 'signed-out', 'anonymous-bob'])('%s cannot access Alice legacy ride', async actor => {
|
||||
await seed(legacy);
|
||||
const context = actor === 'signed-out' ? env.unauthenticatedContext()
|
||||
: env.authenticatedContext('bob', actor === 'anonymous-bob'
|
||||
? { firebase: { sign_in_provider: 'anonymous' } } : {});
|
||||
const ref = doc(context.firestore(), ridePath);
|
||||
await assertFails(getDoc(ref));
|
||||
await assertFails(updateDoc(ref, { distanceMiles: 3 }));
|
||||
await assertFails(setDoc(ref, uploadPayload(), { merge: true }));
|
||||
await assertFails(deleteDoc(ref));
|
||||
await assertFails(setDoc(doc(context.firestore(), 'users/alice/rides/new'), { id: 'new', ownerId: 'alice' }));
|
||||
});
|
||||
test('anonymous owner retains existing canonical private-ride policy', async () => {
|
||||
const ref = doc(env.authenticatedContext('alice', {
|
||||
firebase: { sign_in_provider: 'anonymous' },
|
||||
}).firestore(), ridePath);
|
||||
await assertSucceeds(setDoc(ref, canonical));
|
||||
await assertSucceeds(getDoc(ref));
|
||||
await assertSucceeds(updateDoc(ref, { distanceMiles: 3 }));
|
||||
await assertSucceeds(deleteDoc(ref));
|
||||
});
|
||||
});
|
||||
|
|
@ -58,14 +58,48 @@ service cloud.firestore {
|
|||
function validRideCreate(userId, rideId) {
|
||||
return ownsUser(userId)
|
||||
&& request.resource.data.ownerId == userId
|
||||
&& request.resource.data.id == rideId;
|
||||
&& request.resource.data.id == rideId
|
||||
&& !('uid' in request.resource.data)
|
||||
&& !('remoteRideId' in request.resource.data);
|
||||
}
|
||||
|
||||
function validRideUpdate(userId) {
|
||||
function hasValidRideOwner(data, userId) {
|
||||
return ('ownerId' in data || 'uid' in data)
|
||||
&& (!('ownerId' in data) || data.ownerId == userId)
|
||||
&& (!('uid' in data) || data.uid == userId);
|
||||
}
|
||||
|
||||
function hasValidRideId(data, rideId) {
|
||||
return ('id' in data || 'remoteRideId' in data)
|
||||
&& (!('id' in data) || data.id == rideId)
|
||||
&& (!('remoteRideId' in data) || data.remoteRideId == rideId);
|
||||
}
|
||||
|
||||
function hasValidRideIdentity(data, userId, rideId) {
|
||||
return hasValidRideOwner(data, userId)
|
||||
&& hasValidRideId(data, rideId);
|
||||
}
|
||||
|
||||
function preservesExistingRideIdentity() {
|
||||
return (!('ownerId' in resource.data)
|
||||
|| ('ownerId' in request.resource.data
|
||||
&& request.resource.data.ownerId == resource.data.ownerId))
|
||||
&& (!('uid' in resource.data)
|
||||
|| ('uid' in request.resource.data
|
||||
&& request.resource.data.uid == resource.data.uid))
|
||||
&& (!('id' in resource.data)
|
||||
|| ('id' in request.resource.data
|
||||
&& request.resource.data.id == resource.data.id))
|
||||
&& (!('remoteRideId' in resource.data)
|
||||
|| ('remoteRideId' in request.resource.data
|
||||
&& request.resource.data.remoteRideId == resource.data.remoteRideId));
|
||||
}
|
||||
|
||||
function validRideUpdate(userId, rideId) {
|
||||
return ownsUser(userId)
|
||||
&& resource.data.ownerId == userId
|
||||
&& request.resource.data.ownerId == resource.data.ownerId
|
||||
&& request.resource.data.id == resource.data.id;
|
||||
&& hasValidRideIdentity(resource.data, userId, rideId)
|
||||
&& hasValidRideIdentity(request.resource.data, userId, rideId)
|
||||
&& preservesExistingRideIdentity();
|
||||
}
|
||||
|
||||
function isBlockedBy(sourceUid, targetUid) {
|
||||
|
|
@ -123,9 +157,10 @@ service cloud.firestore {
|
|||
|
||||
match /rides/{rideId} {
|
||||
allow create: if validRideCreate(userId, rideId);
|
||||
allow update: if validRideUpdate(userId);
|
||||
allow update: if validRideUpdate(userId, rideId);
|
||||
allow list: if ownsUser(userId);
|
||||
allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId;
|
||||
allow get, delete: if ownsUser(userId)
|
||||
&& hasValidRideIdentity(resource.data, userId, rideId);
|
||||
|
||||
match /events/{eventId} {
|
||||
allow read, write: if ownsUser(userId);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'tables.dart';
|
||||
import 'ride_timestamp_migration.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
|
|
@ -18,12 +19,13 @@ class AppDatabase extends _$AppDatabase {
|
|||
static final Random _random = Random.secure();
|
||||
|
||||
@override
|
||||
int get schemaVersion => 7;
|
||||
int get schemaVersion => 8;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
await createRideTimestampArchive(this);
|
||||
// cloudRideId is generated by Drift on fresh databases. The uniqueness
|
||||
// constraint is kept as an explicit index so the nullable migration
|
||||
// column remains compatible with older generated source versions.
|
||||
|
|
@ -94,29 +96,14 @@ class AppDatabase extends _$AppDatabase {
|
|||
if (from < 7) {
|
||||
await m.addColumn(rides, rides.cloudOwnerUid);
|
||||
}
|
||||
if (from < 8) {
|
||||
await migrateRideTimestamps(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DateTime? _decodeStoredDateTime(Object raw) {
|
||||
if (raw is DateTime) return raw.toUtc();
|
||||
if (raw is num) {
|
||||
final value = raw.toInt();
|
||||
// Drift's native DateTime representation is epoch milliseconds. Accept
|
||||
// microseconds too so migration remains safe across older storage adapters.
|
||||
if (value.abs() >= 100000000000000) {
|
||||
return DateTime.fromMicrosecondsSinceEpoch(value, isUtc: true);
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true);
|
||||
}
|
||||
final text = raw.toString();
|
||||
final parsed = DateTime.tryParse(text);
|
||||
if (parsed != null) return parsed.toUtc();
|
||||
final numeric = int.tryParse(text);
|
||||
if (numeric == null) return null;
|
||||
if (numeric.abs() >= 100000000000000) {
|
||||
return DateTime.fromMicrosecondsSinceEpoch(numeric, isUtc: true);
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(numeric, isUtc: true);
|
||||
return readRideDateTime(raw);
|
||||
}
|
||||
|
||||
String _legacyCloudRideId(int localRideId, DateTime startTimeUtc) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:drift/drift.dart' show Variable;
|
||||
import 'database.dart';
|
||||
|
||||
DateTime? readRideDateTime(Object? value) {
|
||||
if (value is DateTime) return value.toUtc();
|
||||
final numeric = value is num ? value : int.tryParse(value?.toString() ?? '');
|
||||
if (numeric == null) return DateTime.tryParse(value?.toString() ?? '')?.toUtc();
|
||||
if (!numeric.isFinite || numeric != numeric.truncateToDouble()) return null;
|
||||
try {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
canonicalRideSeconds(numeric.toInt()) * 1000, isUtc: true,
|
||||
);
|
||||
} on StateError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only recognize modern ride dates; ambiguous large values require inspection.
|
||||
int canonicalRideSeconds(int value) {
|
||||
if (value >= 946684800000000 && value < 4102444800000000) {
|
||||
return value ~/ 1000000;
|
||||
}
|
||||
if (value >= 946684800000 && value < 4102444800000) {
|
||||
return value ~/ 1000;
|
||||
}
|
||||
if (value.abs() >= 100000000000) {
|
||||
throw StateError('Unrecognized ride timestamp units; original data preserved.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> createRideTimestampArchive(AppDatabase db) => db.customStatement('''
|
||||
CREATE TABLE IF NOT EXISTS ride_timestamp_archive (
|
||||
ride_id INTEGER PRIMARY KEY,
|
||||
original_start_time INTEGER NOT NULL,
|
||||
original_end_time INTEGER,
|
||||
canonical_start_time INTEGER NOT NULL,
|
||||
canonical_end_time INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
Future<void> migrateRideTimestamps(AppDatabase db) => db.transaction(() async {
|
||||
await createRideTimestampArchive(db);
|
||||
final rows = await db.customSelect('SELECT id, start_time, end_time FROM rides').get();
|
||||
for (final row in rows) {
|
||||
final start = row.data['start_time'];
|
||||
final end = row.data['end_time'];
|
||||
if (start is! int || (end != null && end is! int)) {
|
||||
throw StateError('Unrecognized ride timestamp type; original data preserved.');
|
||||
}
|
||||
final normalizedStart = canonicalRideSeconds(start);
|
||||
final normalizedEnd = end == null ? null : canonicalRideSeconds(end as int);
|
||||
if (start == normalizedStart && end == normalizedEnd) continue;
|
||||
await db.customStatement('''
|
||||
INSERT INTO ride_timestamp_archive
|
||||
(ride_id, original_start_time, original_end_time,
|
||||
canonical_start_time, canonical_end_time)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ride_id) DO NOTHING
|
||||
''', [row.data['id'], start, end, normalizedStart, normalizedEnd]);
|
||||
// A retained archive from rollback must still describe this exact record.
|
||||
final archived = await db.customSelect(
|
||||
'SELECT * FROM ride_timestamp_archive WHERE ride_id = ?',
|
||||
variables: [Variable<int>(row.data['id'] as int)],
|
||||
).getSingle();
|
||||
final original = archived.data;
|
||||
if (original['original_start_time'] != start || original['original_end_time'] != end) {
|
||||
throw StateError('Ride changed since archived migration; original data preserved.');
|
||||
}
|
||||
await db.customStatement('UPDATE rides SET start_time = ?, end_time = ? WHERE id = ?',
|
||||
[normalizedStart, normalizedEnd, row.data['id']]);
|
||||
}
|
||||
});
|
||||
|
|
@ -3,9 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../performance/models/performance_launch_models.dart';
|
||||
import '../../performance/performance_providers.dart';
|
||||
import '../../performance/performance_timer_service.dart';
|
||||
|
||||
/// Performance run summary chip / card (0-60 and 0-100 timing).
|
||||
///
|
||||
/// Observes authoritative [performanceLaunchProvider] and falls back to
|
||||
/// [performanceTimerProvider] to display active timing or last completed benchmark.
|
||||
class PerformanceSummary extends ConsumerWidget {
|
||||
final bool compact;
|
||||
|
||||
|
|
@ -16,48 +21,70 @@ class PerformanceSummary extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final attempt = ref.watch(performanceLaunchProvider);
|
||||
final perfData = ref.watch(performanceTimerProvider);
|
||||
final lastResult = perfData.lastResult;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 1. Live timing check: authoritative launch state machine first, then timer service
|
||||
if (attempt.state == PerformanceLaunchState.launched) {
|
||||
final elapsed = attempt.currentElapsedSeconds ?? 0.0;
|
||||
final targetStr = attempt.target == PerformanceLaunchTarget.sixty ? '0-60' : '0-100';
|
||||
return _buildTimingChip(context, targetStr, elapsed);
|
||||
}
|
||||
|
||||
if (perfData.state == PerformanceState.timing) {
|
||||
final elapsed = perfData.currentElapsedSeconds ?? 0.0;
|
||||
final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100';
|
||||
return _buildTimingChip(context, targetStr, elapsed);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/launch'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.telemetryGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.telemetryGreen, width: 1.0),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'$targetStr: ${elapsed.toStringAsFixed(2)}s',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.telemetryGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 2. Completed result check: authoritative launch result first, then timer result
|
||||
if (attempt.lastResult != null) {
|
||||
final result = attempt.lastResult!;
|
||||
final targetStr = result.target == PerformanceLaunchTarget.sixty ? '0-60' : '0-100';
|
||||
return _buildResultChip(context, targetStr, result.accelerationTimeSec, isDark);
|
||||
}
|
||||
|
||||
if (perfData.lastResult != null) {
|
||||
final lastResult = perfData.lastResult!;
|
||||
final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100';
|
||||
return _buildResultChip(context, targetStr, lastResult.timeSeconds, isDark);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildTimingChip(BuildContext context, String targetStr, double elapsed) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/launch'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.telemetryGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppColors.telemetryGreen, width: 1.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'$targetStr: ${elapsed.toStringAsFixed(2)}s',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.telemetryGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (lastResult == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100';
|
||||
Widget _buildResultChip(BuildContext context, String targetStr, double timeSeconds, bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/launch'),
|
||||
child: Container(
|
||||
|
|
@ -76,7 +103,7 @@ class PerformanceSummary extends ConsumerWidget {
|
|||
const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s',
|
||||
'$targetStr: ${timeSeconds.toStringAsFixed(2)}s',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10,
|
||||
|
|
@ -90,3 +117,4 @@ class PerformanceSummary extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ class PerformanceStateData {
|
|||
final PerformanceTarget target;
|
||||
final double? currentElapsedSeconds;
|
||||
final PerformanceRunResult? lastResult;
|
||||
final bool isArmed;
|
||||
|
||||
PerformanceStateData({
|
||||
this.state = PerformanceState.idle,
|
||||
this.target = PerformanceTarget.sixty,
|
||||
this.currentElapsedSeconds,
|
||||
this.lastResult,
|
||||
this.isArmed = false,
|
||||
});
|
||||
|
||||
PerformanceStateData copyWith({
|
||||
|
|
@ -39,12 +41,14 @@ class PerformanceStateData {
|
|||
PerformanceTarget? target,
|
||||
double? currentElapsedSeconds,
|
||||
PerformanceRunResult? lastResult,
|
||||
bool? isArmed,
|
||||
}) {
|
||||
return PerformanceStateData(
|
||||
state: state ?? this.state,
|
||||
target: target ?? this.target,
|
||||
currentElapsedSeconds: currentElapsedSeconds ?? this.currentElapsedSeconds,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
isArmed: isArmed ?? this.isArmed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +76,19 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
|
||||
void setTarget(PerformanceTarget target) {
|
||||
_rideIdAtAttemptStart = null;
|
||||
state = state.copyWith(target: target, state: PerformanceState.ready);
|
||||
state = state.copyWith(
|
||||
target: target,
|
||||
state: PerformanceState.ready,
|
||||
isArmed: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset to idle state.
|
||||
void reset() {
|
||||
_startTime = null;
|
||||
_rideIdAtAttemptStart = null;
|
||||
_maxSpeedMph = 0.0;
|
||||
state = PerformanceStateData();
|
||||
}
|
||||
|
||||
void _onTelemetryUpdate(TelemetrySample sample) {
|
||||
|
|
@ -85,7 +101,7 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
state = state.copyWith(state: PerformanceState.idle);
|
||||
}
|
||||
if (state.state == PerformanceState.timing) {
|
||||
state = state.copyWith(state: PerformanceState.aborted);
|
||||
state = state.copyWith(state: PerformanceState.aborted, isArmed: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -95,7 +111,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
|
||||
final speedMph = sample.speed!;
|
||||
|
||||
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
|
||||
if (state.state == PerformanceState.ready ||
|
||||
(state.state == PerformanceState.idle && state.isArmed)) {
|
||||
if (speedMph < _speedThresholdMph) {
|
||||
if (state.state != PerformanceState.ready) {
|
||||
state = state.copyWith(state: PerformanceState.ready);
|
||||
|
|
@ -155,14 +172,23 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
state: PerformanceState.completed,
|
||||
currentElapsedSeconds: preciseElapsedSec,
|
||||
lastResult: result,
|
||||
isArmed: false,
|
||||
);
|
||||
} else {
|
||||
// Flagged as impossible GPS artifact
|
||||
state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: preciseElapsedSec);
|
||||
state = state.copyWith(
|
||||
state: PerformanceState.aborted,
|
||||
currentElapsedSeconds: preciseElapsedSec,
|
||||
isArmed: false,
|
||||
);
|
||||
}
|
||||
} else if (speedMph <= _abortThresholdMph && elapsedSec > 2.0) {
|
||||
// Aborted (came to a stop without reaching target speed)
|
||||
state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: elapsedSec);
|
||||
state = state.copyWith(
|
||||
state: PerformanceState.aborted,
|
||||
currentElapsedSeconds: elapsedSec,
|
||||
isArmed: false,
|
||||
);
|
||||
} else {
|
||||
// Still timing
|
||||
state = state.copyWith(currentElapsedSeconds: elapsedSec);
|
||||
|
|
|
|||
|
|
@ -222,10 +222,10 @@ class _SyncStatusInspectorScreenState
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatColumn('Total Items', '${items.length}', colors.cyberCyan),
|
||||
_buildStatColumn('Synced', '$synced', colors.launchGreen),
|
||||
_buildStatColumn('Pending', '$pending', colors.warningAmber),
|
||||
_buildStatColumn('Needs Action', '$failed', colors.raceRed),
|
||||
Expanded(child: _buildStatColumn('Total Items', '${items.length}', colors.cyberCyan)),
|
||||
Expanded(child: _buildStatColumn('Synced', '$synced', colors.launchGreen)),
|
||||
Expanded(child: _buildStatColumn('Pending', '$pending', colors.warningAmber)),
|
||||
Expanded(child: _buildStatColumn('Needs Action', '$failed', colors.raceRed)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -395,15 +395,21 @@ class _SyncStatusInspectorScreenState
|
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant)),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: isMono ? 'JetBrains Mono' : null,
|
||||
fontSize: 11,
|
||||
fontWeight: isHighlight ? FontWeight.bold : FontWeight.normal,
|
||||
color: isHighlight
|
||||
? colors.warningAmber
|
||||
: (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
fontFamily: isMono ? 'JetBrains Mono' : null,
|
||||
fontSize: 11,
|
||||
fontWeight: isHighlight ? FontWeight.bold : FontWeight.normal,
|
||||
color: isHighlight
|
||||
? colors.warningAmber
|
||||
: (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||
import 'package:drift/drift.dart' show Variable;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../database/ride_timestamp_migration.dart';
|
||||
import '../auth/auth_service.dart';
|
||||
|
||||
class CloudRideMetadataDocument {
|
||||
|
|
@ -234,8 +235,8 @@ class CloudReconciliationService {
|
|||
[
|
||||
cloudRideId,
|
||||
ownerId,
|
||||
startTime.toUtc().millisecondsSinceEpoch,
|
||||
endTime?.toUtc().millisecondsSinceEpoch,
|
||||
_db.typeMapping.mapToSqlVariable(startTime),
|
||||
_db.typeMapping.mapToSqlVariable(endTime),
|
||||
_asDouble(data['distanceMiles']) ?? 0.0,
|
||||
_asDouble(data['topSpeedMph']) ?? 0.0,
|
||||
_asDouble(data['maxLeanLeft']) ?? 0.0,
|
||||
|
|
@ -296,11 +297,6 @@ class CloudReconciliationService {
|
|||
|
||||
DateTime? _readDateTime(Object? value) {
|
||||
if (value is Timestamp) return value.toDate().toUtc();
|
||||
if (value is DateTime) return value.toUtc();
|
||||
if (value is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true);
|
||||
}
|
||||
final parsed = DateTime.tryParse(value?.toString() ?? '');
|
||||
return parsed?.toUtc();
|
||||
return readRideDateTime(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,27 @@ void main() {
|
|||
);
|
||||
}
|
||||
|
||||
for (final epoch in [1791761491, 1791761491000, 1791761491000000, '1791761491000']) {
|
||||
test('reconciliation decodes numeric epoch $epoch without duplicating local ride', () async {
|
||||
await db.into(db.rides).insert(RidesCompanion.insert(
|
||||
startTime: DateTime.fromMillisecondsSinceEpoch(1791761491000, isUtc: true),
|
||||
));
|
||||
final metadata = CloudReconciliationService(
|
||||
db: db,
|
||||
authService: auth,
|
||||
metadataStore: _MemoryRideMetadataStore(byUid: {
|
||||
'user_a': [CloudRideMetadataDocument(id: 'ride_numeric', data: {
|
||||
'ownerId': 'user_a', 'localRideId': 1, 'startTime': epoch,
|
||||
})],
|
||||
}),
|
||||
);
|
||||
expect((await metadata.reconcileMetadata()).unchanged, 1);
|
||||
final rides = await db.select(db.rides).get();
|
||||
expect(rides, hasLength(1));
|
||||
expect(rides.single.cloudRideId, 'ride_numeric');
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'clean install restores metadata, duration, telemetry, and is idempotent',
|
||||
() async {
|
||||
|
|
@ -181,6 +202,8 @@ void main() {
|
|||
expect(first.performanceRunsRestored, 1);
|
||||
expect(rides, hasLength(1));
|
||||
expect(rides.single.cloudRideId, 'ride_clean_install');
|
||||
expect(rides.single.startTime.toUtc(), DateTime.utc(2026, 9, 3, 10));
|
||||
expect(rides.single.endTime?.toUtc(), DateTime.utc(2026, 9, 3, 10, 12));
|
||||
expect(rides.single.movingTime, 600);
|
||||
expect(rides.single.stoppedTime, 45);
|
||||
expect(samples, hasLength(2));
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ void main() {
|
|||
expect(await db.ensureCloudRideId(legacyRideId), repaired);
|
||||
});
|
||||
|
||||
test('real v5 rides migrate to their legacy MotorCloud identity', () async {
|
||||
for (final divisor in [1, 1000]) {
|
||||
test('v5 rides preserve legacy identity with epoch divisor $divisor', () async {
|
||||
final connection = NativeDatabase.memory(setup: (rawDb) {
|
||||
rawDb.execute('''
|
||||
CREATE TABLE rides (
|
||||
|
|
@ -81,7 +82,7 @@ void main() {
|
|||
)
|
||||
''');
|
||||
final start = DateTime.utc(2026, 8, 31, 21, 7, 5);
|
||||
rawDb.execute('INSERT INTO rides (id, start_time) VALUES (?, ?)', [42, start.millisecondsSinceEpoch]);
|
||||
rawDb.execute('INSERT INTO rides (id, start_time) VALUES (?, ?)', [42, start.millisecondsSinceEpoch ~/ divisor]);
|
||||
rawDb.execute('PRAGMA user_version = 5');
|
||||
});
|
||||
|
||||
|
|
@ -98,6 +99,7 @@ void main() {
|
|||
final indexes = await db.customSelect('PRAGMA index_list(rides)').get();
|
||||
expect(indexes.any((row) => row.data['name'] == 'rides_cloud_ride_id_idx'), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
test('fresh database creation creates cloud identity column and unique index', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
|
||||
import 'package:motorcycle_dash/features/dashboard/widgets/performance_summary.dart';
|
||||
import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart';
|
||||
import 'package:motorcycle_dash/features/performance/performance_providers.dart';
|
||||
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
|
||||
import 'package:motorcycle_dash/features/performance/services/monotonic_clock.dart';
|
||||
import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
|
||||
group('PerformanceTimerService Unarmed Isolation', () {
|
||||
late AppDatabase db;
|
||||
late StreamController<TelemetrySample> controller;
|
||||
PerformanceTimerService? timer;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
controller = StreamController<TelemetrySample>.broadcast();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
timer?.dispose();
|
||||
await controller.close();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('initial state is idle and unarmed', () {
|
||||
timer = PerformanceTimerService(db, controller.stream);
|
||||
expect(timer!.state.state, PerformanceState.idle);
|
||||
expect(timer!.state.isArmed, isFalse);
|
||||
});
|
||||
|
||||
test('unarmed timer ignores stationary GPS and subsequent movement (no auto-ready/auto-launch)', () async {
|
||||
timer = PerformanceTimerService(db, controller.stream);
|
||||
|
||||
final base = DateTime.utc(2026, 10, 12, 12, 0, 0);
|
||||
|
||||
// Stationary sample arrives on app boot
|
||||
controller.add(TelemetrySample(timestamp: base, speed: 0.0, gpsAgeMs: 0));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// MUST remain idle and unarmed
|
||||
expect(timer!.state.state, PerformanceState.idle);
|
||||
expect(timer!.state.isArmed, isFalse);
|
||||
|
||||
// GPS noise / drift / walk arrives (1.2 MPH)
|
||||
controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 200)), speed: 1.2, gpsAgeMs: 0));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// MUST NOT start timing!
|
||||
expect(timer!.state.state, PerformanceState.idle);
|
||||
expect(timer!.state.isArmed, isFalse);
|
||||
expect(timer!.state.currentElapsedSeconds, isNull);
|
||||
});
|
||||
|
||||
test('explicit setTarget arms the timer and enables launch on movement', () async {
|
||||
timer = PerformanceTimerService(db, controller.stream);
|
||||
final base = DateTime.utc(2026, 10, 12, 12, 0, 0);
|
||||
|
||||
timer!.setTarget(PerformanceTarget.sixty);
|
||||
expect(timer!.state.state, PerformanceState.ready);
|
||||
expect(timer!.state.isArmed, isTrue);
|
||||
|
||||
// Stationary sample reaffirms ready
|
||||
controller.add(TelemetrySample(timestamp: base, speed: 0.0, gpsAgeMs: 0));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(timer!.state.state, PerformanceState.ready);
|
||||
|
||||
// Accelerates past 1.0 MPH -> starts timing
|
||||
controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 100)), speed: 3.5, gpsAgeMs: 0));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(timer!.state.state, PerformanceState.timing);
|
||||
expect(timer!.state.currentElapsedSeconds, isNotNull);
|
||||
});
|
||||
|
||||
test('reset clears state back to unarmed idle', () {
|
||||
timer = PerformanceTimerService(db, controller.stream);
|
||||
timer!.setTarget(PerformanceTarget.sixty);
|
||||
expect(timer!.state.isArmed, isTrue);
|
||||
|
||||
timer!.reset();
|
||||
expect(timer!.state.state, PerformanceState.idle);
|
||||
expect(timer!.state.isArmed, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('PerformanceSummary Widget Presentation', () {
|
||||
late SharedPreferences prefs;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
});
|
||||
|
||||
testWidgets('renders SizedBox.shrink when launch and timer are idle and unarmed', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final controller = StreamController<TelemetrySample>.broadcast();
|
||||
addTearDown(() async {
|
||||
await controller.close();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PerformanceSummary(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
// Should not find timer icon or trophy icon
|
||||
expect(find.byIcon(Icons.timer), findsNothing);
|
||||
expect(find.byIcon(Icons.emoji_events_outlined), findsNothing);
|
||||
expect(find.textContaining('0-60'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders active timing chip when performanceLaunchProvider is launched', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final clock = FakeMonotonicClock();
|
||||
|
||||
final attempt = const PerformanceLaunchAttempt(
|
||||
state: PerformanceLaunchState.launched,
|
||||
target: PerformanceLaunchTarget.sixty,
|
||||
currentElapsedSeconds: 2.34,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
performanceLaunchProvider.overrideWith((ref) {
|
||||
return PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: const Stream<TelemetrySample>.empty(),
|
||||
clock: clock,
|
||||
)..state = attempt;
|
||||
}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PerformanceSummary(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.timer), findsOneWidget);
|
||||
expect(find.text('0-60: 2.34s'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders completed result chip when performanceLaunchProvider has lastResult', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final clock = FakeMonotonicClock();
|
||||
|
||||
final result = PerformanceLaunchResult(
|
||||
mode: PerformanceLaunchMode.auto,
|
||||
target: PerformanceLaunchTarget.sixty,
|
||||
accelerationTimeSec: 3.42,
|
||||
topSpeedReachedMph: 61.2,
|
||||
timestamp: DateTime.utc(2026, 10, 12, 12, 0, 0),
|
||||
);
|
||||
|
||||
final attempt = PerformanceLaunchAttempt(
|
||||
state: PerformanceLaunchState.completed,
|
||||
target: PerformanceLaunchTarget.sixty,
|
||||
lastResult: result,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
performanceLaunchProvider.overrideWith((ref) {
|
||||
return PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: const Stream<TelemetrySample>.empty(),
|
||||
clock: clock,
|
||||
)..state = attempt;
|
||||
}),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: PerformanceSummary(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.emoji_events_outlined), findsOneWidget);
|
||||
expect(find.text('0-60: 3.42s'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping timing chip pushes /launch route', (tester) async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final clock = FakeMonotonicClock();
|
||||
|
||||
final attempt = const PerformanceLaunchAttempt(
|
||||
state: PerformanceLaunchState.launched,
|
||||
target: PerformanceLaunchTarget.sixty,
|
||||
currentElapsedSeconds: 1.50,
|
||||
);
|
||||
|
||||
var navigatedToLaunch = false;
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const Scaffold(body: PerformanceSummary()),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/launch',
|
||||
builder: (context, state) {
|
||||
navigatedToLaunch = true;
|
||||
return const Scaffold(body: Text('Launch Screen'));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
performanceLaunchProvider.overrideWith((ref) {
|
||||
return PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: const Stream<TelemetrySample>.empty(),
|
||||
clock: clock,
|
||||
)..state = attempt;
|
||||
}),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.timer), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(PerformanceSummary));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(navigatedToLaunch, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory directory;
|
||||
late File file;
|
||||
|
||||
setUp(() async {
|
||||
directory = await Directory.systemTemp.createTemp('ride-timestamp-test-');
|
||||
file = File('${directory.path}/copy.sqlite');
|
||||
final db = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await db.customSelect('SELECT 1').get();
|
||||
await db.close();
|
||||
});
|
||||
tearDown(() => directory.delete(recursive: true));
|
||||
|
||||
final backupPath = Platform.environment['MOTORSIGNAL_TEST_BACKUP'];
|
||||
if (backupPath != null) {
|
||||
test('verified device backup copy preserves every original table row', () async {
|
||||
await File(backupPath).copy(file.path);
|
||||
final raw = sqlite.sqlite3.open(file.path);
|
||||
final names = raw.select("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.map((r) => r['name'] as String).toList();
|
||||
final before = {
|
||||
for (final name in names)
|
||||
name: raw.select('SELECT * FROM "$name" ORDER BY rowid')
|
||||
.map((r) => Map<String, Object?>.from(r)).toList(),
|
||||
};
|
||||
raw.close();
|
||||
final db = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(db.close);
|
||||
expect(await db.select(db.rides).get(), hasLength(22));
|
||||
expect(await db.select(db.telemetrySamples).get(), hasLength(67798));
|
||||
for (final name in names) {
|
||||
final rows = await db.customSelect('SELECT * FROM "$name" ORDER BY rowid').get();
|
||||
expect(rows.map((r) => r.data).toList(), before[name], reason: name);
|
||||
}
|
||||
expect(await db.customSelect('SELECT * FROM ride_timestamp_archive').get(), isEmpty);
|
||||
expect((await db.customSelect('PRAGMA integrity_check').get()).single.data.values.single, 'ok');
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('v7 mixed dates migrate once with exact originals and children preserved', () async {
|
||||
final raw = sqlite.sqlite3.open(file.path);
|
||||
for (final row in [
|
||||
[1, 1791761491, null],
|
||||
[2, 1791761491123, 1791761501456],
|
||||
[3, 1791761491123456, 1791761501654321],
|
||||
[4, 1791761491, 1791761501000],
|
||||
]) {
|
||||
raw.execute('INSERT INTO rides (id, start_time, end_time, cloud_ride_id) VALUES (?, ?, ?, ?)',
|
||||
[...row, 'ride_preserved_${row.first}']);
|
||||
}
|
||||
raw.execute('INSERT INTO telemetry_samples (ride_id, timestamp, speed) VALUES (2, 1791761491, 17.5)');
|
||||
raw.execute('PRAGMA user_version = 7');
|
||||
final before = raw.select('SELECT * FROM rides ORDER BY id').map((r) => Map<String, Object?>.from(r)).toList();
|
||||
final children = raw.select('SELECT * FROM telemetry_samples').map((r) => Map<String, Object?>.from(r)).toList();
|
||||
raw.close();
|
||||
|
||||
for (var open = 0; open < 2; open++) {
|
||||
final db = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(db.close);
|
||||
final rides = await db.select(db.rides).get();
|
||||
expect(rides, hasLength(4));
|
||||
expect(rides.map((r) => r.startTime.toUtc().millisecondsSinceEpoch), everyElement(1791761491000));
|
||||
expect(rides.first.endTime, isNull);
|
||||
expect(rides.skip(1).map((r) => r.endTime!.toUtc().millisecondsSinceEpoch), everyElement(1791761501000));
|
||||
final archive = await db.customSelect('SELECT * FROM ride_timestamp_archive ORDER BY ride_id').get();
|
||||
expect(archive, hasLength(3));
|
||||
expect(archive.first.data['original_start_time'], 1791761491123);
|
||||
expect(archive[1].data['original_start_time'], 1791761491123456);
|
||||
expect(archive[1].data['original_end_time'], 1791761501654321);
|
||||
expect((await db.customSelect('SELECT * FROM telemetry_samples').get()).map((r) => r.data).toList(), children);
|
||||
final after = (await db.customSelect('SELECT * FROM rides ORDER BY id').get()).map((r) => Map<String, Object?>.from(r.data)).toList();
|
||||
for (var i = 0; i < before.length; i++) {
|
||||
after[i]['start_time'] = before[i]['start_time'];
|
||||
after[i]['end_time'] = before[i]['end_time'];
|
||||
}
|
||||
expect(after, before);
|
||||
await db.close();
|
||||
}
|
||||
|
||||
final restored = sqlite.sqlite3.open(file.path);
|
||||
restored.execute(File('tools/rollback_ride_timestamps_v8.sql').readAsStringSync());
|
||||
expect(restored.select('SELECT * FROM rides ORDER BY id').map((r) => Map<String, Object?>.from(r)).toList(), before);
|
||||
expect(restored.select('PRAGMA user_version').single['user_version'], 7);
|
||||
expect(restored.select('SELECT COUNT(*) AS n FROM ride_timestamp_archive').single['n'], 3);
|
||||
restored.close();
|
||||
});
|
||||
|
||||
test('unrecognized large timestamp aborts without rewriting records', () async {
|
||||
final raw = sqlite.sqlite3.open(file.path);
|
||||
raw.execute('INSERT INTO rides (start_time) VALUES (1791761491123), (9223372036854775807)');
|
||||
raw.execute('PRAGMA user_version = 7');
|
||||
raw.close();
|
||||
final db = AppDatabase.forTesting(NativeDatabase(file));
|
||||
addTearDown(db.close);
|
||||
await expectLater(db.select(db.rides).get(), throwsA(isA<StateError>()));
|
||||
await db.close();
|
||||
final check = sqlite.sqlite3.open(file.path);
|
||||
expect(check.select('SELECT start_time FROM rides ORDER BY id').map((r) => r['start_time']).toList(), [1791761491123, 9223372036854775807]);
|
||||
expect(check.select('PRAGMA user_version').single['user_version'], 7);
|
||||
check.close();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart';
|
||||
import 'package:motorcycle_dash/features/settings/widgets/sync_status_inspector_screen.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
||||
import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart';
|
||||
import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
||||
import 'package:motorcycle_dash/services/sync/sync_providers.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _NoNetworkAuth implements AuthService {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw StateError('Unexpected authentication call: ${invocation.memberName}');
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final size in [const Size(360, 800), const Size(412, 915), const Size(915, 412)]) {
|
||||
testWidgets('long cloud ID stays within inspector at $size', (tester) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final outbox = SyncOutbox(await SharedPreferences.getInstance());
|
||||
const cloudId = 'ride_0123456789abcdef0123456789abcdef';
|
||||
final item = await outbox.enqueue(9, 'firebase');
|
||||
await outbox.update(item.copyWith(remoteId: cloudId));
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final auth = _NoNetworkAuth();
|
||||
await tester.pumpWidget(ProviderScope(overrides: [
|
||||
databaseProvider.overrideWithValue(db),
|
||||
syncOutboxProvider.overrideWithValue(outbox),
|
||||
syncManagerProvider.overrideWith((ref) => SyncManager(outbox)),
|
||||
cloudRestoreControllerProvider.overrideWith((ref) => CloudRestoreController(
|
||||
restoreService: CloudRestoreService(db: db, authService: auth),
|
||||
authService: auth,
|
||||
)),
|
||||
], child: const MaterialApp(home: SyncStatusInspectorScreen())));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text(cloudId));
|
||||
expect(tester.takeException(), isNull);
|
||||
final bounds = tester.getRect(find.text(cloudId));
|
||||
expect(bounds.right, lessThanOrEqualTo(size.width));
|
||||
expect(find.text('Cloud Ride ID'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- Offline database copy only. Stop the app and preserve a verified backup first.
|
||||
-- Aborts if a migrated date changed after migration. On error, ROLLBACK and stop.
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TEMP TABLE timestamp_rollback_guard (ok INTEGER CHECK (ok = 1));
|
||||
INSERT INTO timestamp_rollback_guard
|
||||
SELECT CASE WHEN (SELECT user_version FROM pragma_user_version) = 8
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ride_timestamp_archive a LEFT JOIN rides r ON r.id = a.ride_id
|
||||
WHERE r.id IS NULL OR r.start_time IS NOT a.canonical_start_time
|
||||
OR r.end_time IS NOT a.canonical_end_time
|
||||
) THEN 1 ELSE 0 END;
|
||||
UPDATE rides SET
|
||||
start_time = (SELECT original_start_time FROM ride_timestamp_archive WHERE ride_id = rides.id),
|
||||
end_time = (SELECT original_end_time FROM ride_timestamp_archive WHERE ride_id = rides.id)
|
||||
WHERE id IN (SELECT ride_id FROM ride_timestamp_archive);
|
||||
PRAGMA user_version = 7;
|
||||
DROP TABLE timestamp_rollback_guard;
|
||||
COMMIT;
|
||||
-- Keep the archive for forensic evidence. Original bad dates will be restored.
|
||||
Loading…
Reference in New Issue