MotorSignal-App/test/pro_csv_export_verification...

334 lines
12 KiB
Dart

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