fix: master consolidation fixes for Schema 8, legacy ride sync, performance timer isolation, and sync inspector

This commit is contained in:
Developer 2026-10-13 10:24:24 -04:00
parent f9e2bd6bed
commit 35f2b6982a
16 changed files with 875 additions and 93 deletions

View File

@ -559,8 +559,11 @@ class TestAtomicUsageTrackerAndRollover(unittest.TestCase):
usage_tracker=tracker, usage_tracker=tracker,
fixture_mode=True, fixture_mode=True,
) )
# Fetch alerts in fixture mode # Fetch alerts in fixture mode with pinned reference time matching fixture pubMillis
alerts = adapter.fetch_alerts(min_lat=34.2, min_lng=-85.2, max_lat=34.3, max_lng=-85.1) 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) self.assertGreater(len(alerts), 0)
# Excluded: reservations and executions must remain 0 # Excluded: reservations and executions must remain 0

View File

@ -192,8 +192,8 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
const document = (await response.json()) as FirestoreDocument; const document = (await response.json()) as FirestoreDocument;
const fields = document.fields ?? {}; const fields = document.fields ?? {};
const ownerId = firestoreString(fields.ownerId); const ownerId = firestoreString(fields.ownerId) ?? firestoreString(fields.uid);
const storedId = firestoreString(fields.id); const storedId = firestoreString(fields.id) ?? firestoreString(fields.remoteRideId);
const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey); const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey);
if (ownerId !== uid) return 'unauthorized'; if (ownerId !== uid) return 'unauthorized';

View File

@ -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));
});
});

View File

@ -58,14 +58,48 @@ service cloud.firestore {
function validRideCreate(userId, rideId) { function validRideCreate(userId, rideId) {
return ownsUser(userId) return ownsUser(userId)
&& request.resource.data.ownerId == 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) return ownsUser(userId)
&& resource.data.ownerId == userId && hasValidRideIdentity(resource.data, userId, rideId)
&& request.resource.data.ownerId == resource.data.ownerId && hasValidRideIdentity(request.resource.data, userId, rideId)
&& request.resource.data.id == resource.data.id; && preservesExistingRideIdentity();
} }
function isBlockedBy(sourceUid, targetUid) { function isBlockedBy(sourceUid, targetUid) {
@ -123,9 +157,10 @@ service cloud.firestore {
match /rides/{rideId} { match /rides/{rideId} {
allow create: if validRideCreate(userId, rideId); allow create: if validRideCreate(userId, rideId);
allow update: if validRideUpdate(userId); allow update: if validRideUpdate(userId, rideId);
allow list: if ownsUser(userId); 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} { match /events/{eventId} {
allow read, write: if ownsUser(userId); allow read, write: if ownsUser(userId);

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'tables.dart'; import 'tables.dart';
import 'ride_timestamp_migration.dart';
part 'database.g.dart'; part 'database.g.dart';
@ -18,12 +19,13 @@ class AppDatabase extends _$AppDatabase {
static final Random _random = Random.secure(); static final Random _random = Random.secure();
@override @override
int get schemaVersion => 7; int get schemaVersion => 8;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async { onCreate: (Migrator m) async {
await m.createAll(); await m.createAll();
await createRideTimestampArchive(this);
// cloudRideId is generated by Drift on fresh databases. The uniqueness // cloudRideId is generated by Drift on fresh databases. The uniqueness
// constraint is kept as an explicit index so the nullable migration // constraint is kept as an explicit index so the nullable migration
// column remains compatible with older generated source versions. // column remains compatible with older generated source versions.
@ -94,29 +96,14 @@ class AppDatabase extends _$AppDatabase {
if (from < 7) { if (from < 7) {
await m.addColumn(rides, rides.cloudOwnerUid); await m.addColumn(rides, rides.cloudOwnerUid);
} }
if (from < 8) {
await migrateRideTimestamps(this);
}
}, },
); );
DateTime? _decodeStoredDateTime(Object raw) { DateTime? _decodeStoredDateTime(Object raw) {
if (raw is DateTime) return raw.toUtc(); return readRideDateTime(raw);
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);
} }
String _legacyCloudRideId(int localRideId, DateTime startTimeUtc) { String _legacyCloudRideId(int localRideId, DateTime startTimeUtc) {

View File

@ -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']]);
}
});

View File

@ -3,9 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.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'; import '../../performance/performance_timer_service.dart';
/// Performance run summary chip / card (0-60 and 0-100 timing). /// 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 { class PerformanceSummary extends ConsumerWidget {
final bool compact; final bool compact;
@ -16,14 +21,40 @@ class PerformanceSummary extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final attempt = ref.watch(performanceLaunchProvider);
final perfData = ref.watch(performanceTimerProvider); final perfData = ref.watch(performanceTimerProvider);
final lastResult = perfData.lastResult;
final isDark = Theme.of(context).brightness == Brightness.dark; 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) { if (perfData.state == PerformanceState.timing) {
final elapsed = perfData.currentElapsedSeconds ?? 0.0; final elapsed = perfData.currentElapsedSeconds ?? 0.0;
final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100'; final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100';
return _buildTimingChip(context, targetStr, elapsed);
}
// 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( return GestureDetector(
onTap: () => context.push('/launch'), onTap: () => context.push('/launch'),
child: Container( child: Container(
@ -53,11 +84,7 @@ class PerformanceSummary extends ConsumerWidget {
); );
} }
if (lastResult == null) { Widget _buildResultChip(BuildContext context, String targetStr, double timeSeconds, bool isDark) {
return const SizedBox.shrink();
}
final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100';
return GestureDetector( return GestureDetector(
onTap: () => context.push('/launch'), onTap: () => context.push('/launch'),
child: Container( child: Container(
@ -76,7 +103,7 @@ class PerformanceSummary extends ConsumerWidget {
const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber), const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s', '$targetStr: ${timeSeconds.toStringAsFixed(2)}s',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 10, fontSize: 10,
@ -90,3 +117,4 @@ class PerformanceSummary extends ConsumerWidget {
); );
} }
} }

View File

@ -26,12 +26,14 @@ class PerformanceStateData {
final PerformanceTarget target; final PerformanceTarget target;
final double? currentElapsedSeconds; final double? currentElapsedSeconds;
final PerformanceRunResult? lastResult; final PerformanceRunResult? lastResult;
final bool isArmed;
PerformanceStateData({ PerformanceStateData({
this.state = PerformanceState.idle, this.state = PerformanceState.idle,
this.target = PerformanceTarget.sixty, this.target = PerformanceTarget.sixty,
this.currentElapsedSeconds, this.currentElapsedSeconds,
this.lastResult, this.lastResult,
this.isArmed = false,
}); });
PerformanceStateData copyWith({ PerformanceStateData copyWith({
@ -39,12 +41,14 @@ class PerformanceStateData {
PerformanceTarget? target, PerformanceTarget? target,
double? currentElapsedSeconds, double? currentElapsedSeconds,
PerformanceRunResult? lastResult, PerformanceRunResult? lastResult,
bool? isArmed,
}) { }) {
return PerformanceStateData( return PerformanceStateData(
state: state ?? this.state, state: state ?? this.state,
target: target ?? this.target, target: target ?? this.target,
currentElapsedSeconds: currentElapsedSeconds ?? this.currentElapsedSeconds, currentElapsedSeconds: currentElapsedSeconds ?? this.currentElapsedSeconds,
lastResult: lastResult ?? this.lastResult, lastResult: lastResult ?? this.lastResult,
isArmed: isArmed ?? this.isArmed,
); );
} }
} }
@ -72,7 +76,19 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
void setTarget(PerformanceTarget target) { void setTarget(PerformanceTarget target) {
_rideIdAtAttemptStart = null; _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) { void _onTelemetryUpdate(TelemetrySample sample) {
@ -85,7 +101,7 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
state = state.copyWith(state: PerformanceState.idle); state = state.copyWith(state: PerformanceState.idle);
} }
if (state.state == PerformanceState.timing) { if (state.state == PerformanceState.timing) {
state = state.copyWith(state: PerformanceState.aborted); state = state.copyWith(state: PerformanceState.aborted, isArmed: false);
} }
return; return;
} }
@ -95,7 +111,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
final speedMph = sample.speed!; 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 (speedMph < _speedThresholdMph) {
if (state.state != PerformanceState.ready) { if (state.state != PerformanceState.ready) {
state = state.copyWith(state: PerformanceState.ready); state = state.copyWith(state: PerformanceState.ready);
@ -155,14 +172,23 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
state: PerformanceState.completed, state: PerformanceState.completed,
currentElapsedSeconds: preciseElapsedSec, currentElapsedSeconds: preciseElapsedSec,
lastResult: result, lastResult: result,
isArmed: false,
); );
} else { } else {
// Flagged as impossible GPS artifact // 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) { } else if (speedMph <= _abortThresholdMph && elapsedSec > 2.0) {
// Aborted (came to a stop without reaching target speed) // 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 { } else {
// Still timing // Still timing
state = state.copyWith(currentElapsedSeconds: elapsedSec); state = state.copyWith(currentElapsedSeconds: elapsedSec);

View File

@ -222,10 +222,10 @@ class _SyncStatusInspectorScreenState
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStatColumn('Total Items', '${items.length}', colors.cyberCyan), Expanded(child: _buildStatColumn('Total Items', '${items.length}', colors.cyberCyan)),
_buildStatColumn('Synced', '$synced', colors.launchGreen), Expanded(child: _buildStatColumn('Synced', '$synced', colors.launchGreen)),
_buildStatColumn('Pending', '$pending', colors.warningAmber), Expanded(child: _buildStatColumn('Pending', '$pending', colors.warningAmber)),
_buildStatColumn('Needs Action', '$failed', colors.raceRed), Expanded(child: _buildStatColumn('Needs Action', '$failed', colors.raceRed)),
], ],
), ),
); );
@ -395,8 +395,13 @@ class _SyncStatusInspectorScreenState
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant)), Text(label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant)),
Text( const SizedBox(width: 12),
Expanded(
child: Text(
value, value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: TextStyle( style: TextStyle(
fontFamily: isMono ? 'JetBrains Mono' : null, fontFamily: isMono ? 'JetBrains Mono' : null,
fontSize: 11, fontSize: 11,
@ -406,6 +411,7 @@ class _SyncStatusInspectorScreenState
: (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null), : (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null),
), ),
), ),
),
], ],
), ),
); );

View File

@ -4,6 +4,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:drift/drift.dart' show Variable; import 'package:drift/drift.dart' show Variable;
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../../database/ride_timestamp_migration.dart';
import '../auth/auth_service.dart'; import '../auth/auth_service.dart';
class CloudRideMetadataDocument { class CloudRideMetadataDocument {
@ -234,8 +235,8 @@ class CloudReconciliationService {
[ [
cloudRideId, cloudRideId,
ownerId, ownerId,
startTime.toUtc().millisecondsSinceEpoch, _db.typeMapping.mapToSqlVariable(startTime),
endTime?.toUtc().millisecondsSinceEpoch, _db.typeMapping.mapToSqlVariable(endTime),
_asDouble(data['distanceMiles']) ?? 0.0, _asDouble(data['distanceMiles']) ?? 0.0,
_asDouble(data['topSpeedMph']) ?? 0.0, _asDouble(data['topSpeedMph']) ?? 0.0,
_asDouble(data['maxLeanLeft']) ?? 0.0, _asDouble(data['maxLeanLeft']) ?? 0.0,
@ -296,11 +297,6 @@ class CloudReconciliationService {
DateTime? _readDateTime(Object? value) { DateTime? _readDateTime(Object? value) {
if (value is Timestamp) return value.toDate().toUtc(); if (value is Timestamp) return value.toDate().toUtc();
if (value is DateTime) return value.toUtc(); return readRideDateTime(value);
if (value is num) {
return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true);
}
final parsed = DateTime.tryParse(value?.toString() ?? '');
return parsed?.toUtc();
} }
} }

View File

@ -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( test(
'clean install restores metadata, duration, telemetry, and is idempotent', 'clean install restores metadata, duration, telemetry, and is idempotent',
() async { () async {
@ -181,6 +202,8 @@ void main() {
expect(first.performanceRunsRestored, 1); 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.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.movingTime, 600);
expect(rides.single.stoppedTime, 45); expect(rides.single.stoppedTime, 45);
expect(samples, hasLength(2)); expect(samples, hasLength(2));

View File

@ -51,7 +51,8 @@ void main() {
expect(await db.ensureCloudRideId(legacyRideId), repaired); 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) { final connection = NativeDatabase.memory(setup: (rawDb) {
rawDb.execute(''' rawDb.execute('''
CREATE TABLE rides ( CREATE TABLE rides (
@ -81,7 +82,7 @@ void main() {
) )
'''); ''');
final start = DateTime.utc(2026, 8, 31, 21, 7, 5); 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'); rawDb.execute('PRAGMA user_version = 5');
}); });
@ -98,6 +99,7 @@ void main() {
final indexes = await db.customSelect('PRAGMA index_list(rides)').get(); final indexes = await db.customSelect('PRAGMA index_list(rides)').get();
expect(indexes.any((row) => row.data['name'] == 'rides_cloud_ride_id_idx'), isTrue); 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 { test('fresh database creation creates cloud identity column and unique index', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());

View File

@ -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);
});
});
}

View File

@ -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();
});
}

View File

@ -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());
});
}
}

View File

@ -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.