334 lines
12 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|