From 35f2b6982ab2b2195d7ba8e00d030ba5676d3207 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 10:24:24 -0400 Subject: [PATCH] fix: master consolidation fixes for Schema 8, legacy ride sync, performance timer isolation, and sync inspector --- .../motorsignal_api/tests/test_road_alerts.py | 7 +- cloudflare/motorsignal-r2-worker/src/index.ts | 4 +- .../emulator-tests/legacy-rides.rules.test.js | 144 +++++++++ firestore.rules | 49 +++- lib/database/database.dart | 27 +- lib/database/ride_timestamp_migration.dart | 73 +++++ .../widgets/performance_summary.dart | 96 +++--- .../performance_timer_service.dart | 36 ++- .../widgets/sync_status_inspector_screen.dart | 32 +- .../sync/cloud_reconciliation_service.dart | 12 +- test/cloud_restore_manual_test.dart | 23 ++ test/cloud_ride_identity_migration_test.dart | 6 +- ...rformance_timer_launch_isolation_test.dart | 274 ++++++++++++++++++ test/ride_timestamp_migration_test.dart | 112 +++++++ test/sync_status_inspector_layout_test.dart | 54 ++++ tools/rollback_ride_timestamps_v8.sql | 19 ++ 16 files changed, 875 insertions(+), 93 deletions(-) create mode 100644 firebase/emulator-tests/legacy-rides.rules.test.js create mode 100644 lib/database/ride_timestamp_migration.dart create mode 100644 test/performance_timer_launch_isolation_test.dart create mode 100644 test/ride_timestamp_migration_test.dart create mode 100644 test/sync_status_inspector_layout_test.dart create mode 100644 tools/rollback_ride_timestamps_v8.sql diff --git a/backend/motorsignal_api/tests/test_road_alerts.py b/backend/motorsignal_api/tests/test_road_alerts.py index 0dcb568..a27daf8 100644 --- a/backend/motorsignal_api/tests/test_road_alerts.py +++ b/backend/motorsignal_api/tests/test_road_alerts.py @@ -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 diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 850cf3e..263ea68 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -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'; diff --git a/firebase/emulator-tests/legacy-rides.rules.test.js b/firebase/emulator-tests/legacy-rides.rules.test.js new file mode 100644 index 0000000..7ae1f20 --- /dev/null +++ b/firebase/emulator-tests/legacy-rides.rules.test.js @@ -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)); + }); +}); diff --git a/firestore.rules b/firestore.rules index a821685..30c31af 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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); diff --git a/lib/database/database.dart b/lib/database/database.dart index 667b426..a13de26 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -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) { diff --git a/lib/database/ride_timestamp_migration.dart b/lib/database/ride_timestamp_migration.dart new file mode 100644 index 0000000..d4a92ca --- /dev/null +++ b/lib/database/ride_timestamp_migration.dart @@ -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 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 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(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']]); + } +}); diff --git a/lib/features/dashboard/widgets/performance_summary.dart b/lib/features/dashboard/widgets/performance_summary.dart index d3a8dc4..ce92099 100644 --- a/lib/features/dashboard/widgets/performance_summary.dart +++ b/lib/features/dashboard/widgets/performance_summary.dart @@ -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 { ); } } + diff --git a/lib/features/performance/performance_timer_service.dart b/lib/features/performance/performance_timer_service.dart index 21ca331..091bb9e 100644 --- a/lib/features/performance/performance_timer_service.dart +++ b/lib/features/performance/performance_timer_service.dart @@ -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 { 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 { 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 { 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 { 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); diff --git a/lib/features/settings/widgets/sync_status_inspector_screen.dart b/lib/features/settings/widgets/sync_status_inspector_screen.dart index 634a6e0..d32452d 100644 --- a/lib/features/settings/widgets/sync_status_inspector_screen.dart +++ b/lib/features/settings/widgets/sync_status_inspector_screen.dart @@ -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), + ), ), ), ], diff --git a/lib/services/sync/cloud_reconciliation_service.dart b/lib/services/sync/cloud_reconciliation_service.dart index 5d66bbb..271674e 100644 --- a/lib/services/sync/cloud_reconciliation_service.dart +++ b/lib/services/sync/cloud_reconciliation_service.dart @@ -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); } } diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart index 3777a84..ece0185 100644 --- a/test/cloud_restore_manual_test.dart +++ b/test/cloud_restore_manual_test.dart @@ -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)); diff --git a/test/cloud_ride_identity_migration_test.dart b/test/cloud_ride_identity_migration_test.dart index 0dc2c0a..7fbec0f 100644 --- a/test/cloud_ride_identity_migration_test.dart +++ b/test/cloud_ride_identity_migration_test.dart @@ -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()); diff --git a/test/performance_timer_launch_isolation_test.dart b/test/performance_timer_launch_isolation_test.dart new file mode 100644 index 0000000..0fb7cdd --- /dev/null +++ b/test/performance_timer_launch_isolation_test.dart @@ -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 controller; + PerformanceTimerService? timer; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + controller = StreamController.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.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.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.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.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.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.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.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.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); + }); + }); +} diff --git a/test/ride_timestamp_migration_test.dart b/test/ride_timestamp_migration_test.dart new file mode 100644 index 0000000..5090680 --- /dev/null +++ b/test/ride_timestamp_migration_test.dart @@ -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.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.from(r)).toList(); + final children = raw.select('SELECT * FROM telemetry_samples').map((r) => Map.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.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.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())); + 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(); + }); +} diff --git a/test/sync_status_inspector_layout_test.dart b/test/sync_status_inspector_layout_test.dart new file mode 100644 index 0000000..8057a15 --- /dev/null +++ b/test/sync_status_inspector_layout_test.dart @@ -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()); + }); + } +} diff --git a/tools/rollback_ride_timestamps_v8.sql b/tools/rollback_ride_timestamps_v8.sql new file mode 100644 index 0000000..6f55abc --- /dev/null +++ b/tools/rollback_ride_timestamps_v8.sql @@ -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.