import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart'; import 'package:motorcycle_dash/features/history/ride_detail_screen.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; import 'package:motorcycle_dash/services/export_service.dart'; import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart'; import 'package:motorcycle_dash/services/github/github_sync_service.dart'; class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { @override GitHubUploadState get state => GitHubUploadState.idle; @override String get statusMessage => ''; @override Set get queuedRideIds => const {}; @override bool isRideUploaded(int rideId) => false; @override String? getRideCommitSha(int rideId) => null; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late ExportService exportService; late FieldTestExportService fieldTestExportService; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); exportService = ExportService(db); fieldTestExportService = FieldTestExportService(db); }); tearDown(() async { await db.close(); }); group('Pro CSV Export Verification with Isolated Test Entitlement', () { final testStartTime = DateTime.utc(2026, 6, 15, 14, 0, 0); final testEndTime = DateTime.utc(2026, 6, 15, 14, 45, 0); final testRide = Ride( id: 42, startTime: testStartTime, endTime: testEndTime, distanceMiles: 32.4, topSpeedMph: 72.5, movingTime: 2500, stoppedTime: 200, distance: 32.4, topSpeed: 72.5, averageSpeed: 45.2, elevationGain: 450.0, elevationLoss: 420.0, maxLeanLeft: 38.5, maxLeanRight: 41.2, maxAccelG: 0.82, maxBrakingG: 0.91, ); // Seed known telemetry samples for ride #42 final sample1Time = DateTime.utc(2026, 6, 15, 14, 0, 1); final sample2Time = DateTime.utc(2026, 6, 15, 14, 0, 2); final sample3Time = DateTime.utc(2026, 6, 15, 14, 0, 3); setUp(() async { await db.into(db.rides).insert( RidesCompanion.insert( id: const Value(42), startTime: testStartTime, endTime: Value(testEndTime), distanceMiles: const Value(32.4), topSpeedMph: const Value(72.5), movingTime: const Value(2500), stoppedTime: const Value(200), averageSpeed: const Value(45.2), elevationGain: const Value(450.0), elevationLoss: const Value(420.0), maxLeanLeft: const Value(38.5), maxLeanRight: const Value(41.2), maxAccelG: const Value(0.82), maxBrakingG: const Value(0.91), ), ); await db.into(db.telemetrySamples).insert( TelemetrySamplesCompanion.insert( rideId: 42, timestamp: sample1Time, latitude: const Value(34.567890), longitude: const Value(-84.123456), altitude: const Value(450.2), speed: const Value(55.4), heading: const Value(180.5), gpsAccuracy: const Value(1.8), leanAngle: const Value(-24.5), pitchAngle: const Value(3.2), accelX: const Value(0.12), accelY: const Value(0.34), accelZ: const Value(0.98), longitudinalG: const Value(0.25), lateralG: const Value(-0.42), ), ); await db.into(db.telemetrySamples).insert( TelemetrySamplesCompanion.insert( rideId: 42, timestamp: sample2Time, latitude: const Value(34.567950), longitude: const Value(-84.123510), altitude: const Value(451.0), speed: const Value(58.1), heading: const Value(182.0), gpsAccuracy: const Value(1.6), leanAngle: const Value(-28.2), pitchAngle: const Value(2.8), accelX: const Value(0.15), accelY: const Value(0.40), accelZ: const Value(0.95), longitudinalG: const Value(0.31), lateralG: const Value(-0.48), ), ); await db.into(db.telemetrySamples).insert( TelemetrySamplesCompanion.insert( rideId: 42, timestamp: sample3Time, latitude: const Value(34.568010), longitude: const Value(-84.123570), altitude: const Value(451.8), speed: const Value(61.0), heading: const Value(183.5), gpsAccuracy: const Value(1.5), leanAngle: const Value(-15.0), pitchAngle: const Value(1.9), accelX: const Value(0.10), accelY: const Value(0.22), accelZ: const Value(0.99), longitudinalG: const Value(0.18), lateralG: const Value(-0.25), ), ); // Seed a known field test event await db.into(db.fieldTestEvents).insert( FieldTestEventsCompanion.insert( rideId: const Value(42), timestamp: sample2Time, eventType: 'LEAN_PEAK', severity: 'INFO', message: 'Maximum lean angle reached in turn 3', speed: const Value(58.1), latitude: const Value(34.567950), longitude: const Value(-84.123510), gpsAccuracy: const Value(1.6), gpsAgeMs: const Value(50), leanAngle: const Value(-28.2), longitudinalG: const Value(0.31), lateralG: const Value(-0.48), overallConfidence: const Value(0.985), metadata: const Value('{"turn_id": 3}'), ), ); }); test('1. Free tier entitlement denies CSV export capability', () { const freeState = EntitlementState.free(); final freeAccess = FeatureAccessService(freeState); expect(freeState.isPro, isFalse); expect(freeState.canExportCsv, isFalse); expect(freeAccess.canAccess(MotorSignalFeature.csvExport), isFalse); }); test('2. Legitimate isolated test entitlement grants CSV export capability', () { final now = DateTime.now(); final proState = EntitlementState.proMonthlyActive( productId: 'motorsignal_pro_monthly', purchaseTokenHash: 'test_token_hash_qa_isolated', expiresAt: now.add(const Duration(days: 30)), lastVerifiedAt: now, ); final proAccess = FeatureAccessService(proState); expect(proState.isPro, isTrue); expect(proState.canExportCsv, isTrue); expect(proAccess.canAccess(MotorSignalFeature.csvExport), isTrue); expect(proState.verificationStatus, EntitlementVerificationStatus.verified); expect(proState.source, PurchaseSource.googlePlay); }); test('3. Exported ride CSV contents match database samples row-by-row and column-by-column', () async { final csvContent = await exportService.exportRideCsv(42); final lines = csvContent.trim().split('\n').map((l) => l.trim()).toList(); // Check header row expect( lines[0], 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG', ); // Check total row count: 1 header + 3 data rows expect(lines.length, 4); // Verify row 1 final row1 = lines[1].split(','); expect(DateTime.parse(row1[0]).toUtc(), sample1Time); expect(double.parse(row1[1]), closeTo(34.567890, 0.000001)); expect(double.parse(row1[2]), closeTo(-84.123456, 0.000001)); expect(double.parse(row1[3]), closeTo(450.2, 0.01)); expect(double.parse(row1[4]), closeTo(55.4, 0.01)); expect(double.parse(row1[5]), closeTo(180.5, 0.01)); expect(double.parse(row1[6]), closeTo(1.8, 0.01)); expect(double.parse(row1[7]), closeTo(-24.5, 0.01)); expect(double.parse(row1[8]), closeTo(3.2, 0.01)); expect(double.parse(row1[9]), closeTo(0.12, 0.01)); expect(double.parse(row1[10]), closeTo(0.34, 0.01)); expect(double.parse(row1[11]), closeTo(0.98, 0.01)); expect(double.parse(row1[12]), closeTo(0.25, 0.01)); expect(double.parse(row1[13]), closeTo(-0.42, 0.01)); // Verify row 2 final row2 = lines[2].split(','); expect(DateTime.parse(row2[0]).toUtc(), sample2Time); expect(double.parse(row2[1]), closeTo(34.567950, 0.000001)); expect(double.parse(row2[2]), closeTo(-84.123510, 0.000001)); expect(double.parse(row2[3]), closeTo(451.0, 0.01)); expect(double.parse(row2[4]), closeTo(58.1, 0.01)); expect(double.parse(row2[7]), closeTo(-28.2, 0.01)); expect(double.parse(row2[12]), closeTo(0.31, 0.01)); expect(double.parse(row2[13]), closeTo(-0.48, 0.01)); // Verify row 3 final row3 = lines[3].split(','); expect(DateTime.parse(row3[0]).toUtc(), sample3Time); expect(double.parse(row3[1]), closeTo(34.568010, 0.000001)); expect(double.parse(row3[2]), closeTo(-84.123570, 0.000001)); expect(double.parse(row3[3]), closeTo(451.8, 0.01)); expect(double.parse(row3[4]), closeTo(61.0, 0.01)); expect(double.parse(row3[7]), closeTo(-15.0, 0.01)); expect(double.parse(row3[12]), closeTo(0.18, 0.01)); expect(double.parse(row3[13]), closeTo(-0.25, 0.01)); }); test('4. Forensic FieldTestEvents CSV export matches Step 15 schema', () async { final eventsCsv = await fieldTestExportService.generateFieldTestEventsCsv(42); final lines = eventsCsv.trim().split('\n').map((l) => l.trim()).toList(); expect( lines[0], 'timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata', ); expect(lines.length, 2); final eventRow = lines[1]; expect(eventRow, contains('LEAN_PEAK')); expect(eventRow, contains('INFO')); expect(eventRow, contains('"Maximum lean angle reached in turn 3"')); expect(eventRow, contains('58.10')); expect(eventRow, contains('34.567950')); expect(eventRow, contains('-84.123510')); expect(eventRow, contains('-28.2')); expect(eventRow, contains('0.310')); expect(eventRow, contains('-0.480')); expect(eventRow, contains('0.985')); expect(eventRow, contains('"{""turn_id"": 3}"')); }); testWidgets('5. RideDetailScreen UI shows ProBadge under Free tier and hides under Pro tier', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; addTearDown(() => tester.view.resetPhysicalSize()); final freeState = const EntitlementState.free(); final proState = EntitlementState.proMonthlyActive( productId: 'motorsignal_pro_monthly', purchaseTokenHash: 'test_token_hash_qa_isolated', expiresAt: DateTime.now().add(const Duration(days: 30)), lastVerifiedAt: DateTime.now(), ); // Phase A: Pump with Free Tier await tester.pumpWidget( ProviderScope( overrides: [ databaseProvider.overrideWithValue(db), githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), featureAccessProvider.overrideWithValue(FeatureAccessService(freeState)), ], child: MaterialApp( home: RideDetailScreen(ride: testRide), ), ), ); await tester.pumpAndSettle(); // Free user must see EXPORT CSV with ProBadge expect(find.text('EXPORT CSV'), findsOneWidget); expect(find.byType(ProBadge), findsWidgets); // Phase B: Pump with Pro Tier await tester.pumpWidget( ProviderScope( overrides: [ databaseProvider.overrideWithValue(db), githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), featureAccessProvider.overrideWithValue(FeatureAccessService(proState)), ], child: MaterialApp( home: RideDetailScreen(ride: testRide), ), ), ); await tester.pumpAndSettle(); // Pro user must see EXPORT CSV without ProBadge expect(find.text('EXPORT CSV'), findsOneWidget); expect(find.byType(ProBadge), findsNothing); }); }); }