343 lines
13 KiB
Dart
343 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
|
import 'package:motorcycle_dash/core/theme/theme_mode_provider.dart';
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
|
import 'package:motorcycle_dash/features/settings/diagnostics_screen.dart';
|
|
import 'package:motorcycle_dash/features/settings/field_test_live_log_screen.dart';
|
|
import 'package:motorcycle_dash/features/settings/field_test_report_screen.dart';
|
|
import 'package:motorcycle_dash/features/field_test/field_test_pre_ride_check_screen.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
|
import 'package:motorcycle_dash/services/github/github_sync_service.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:motorcycle_dash/services/location_service.dart';
|
|
import 'package:motorcycle_dash/models/telemetry_sample.dart' as live_sample;
|
|
import 'package:motorcycle_dash/models/field_test_event.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);
|
|
}
|
|
|
|
class _FakeLocationService extends ChangeNotifier implements LocationService {
|
|
@override
|
|
Future<bool> requestPermissions() async => true;
|
|
@override
|
|
Future<bool> hasPermissions() async => true;
|
|
@override
|
|
void Function(Position, bool, String?)? onGpsDiagnostic;
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late AppDatabase db;
|
|
late SharedPreferences prefs;
|
|
|
|
setUp(() async {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
SharedPreferences.setMockInitialValues({});
|
|
prefs = await SharedPreferences.getInstance();
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
final canonicalViewports = <String, Size>{
|
|
'320x640 (very narrow phone)': const Size(320, 640),
|
|
'400x800 (standard phone)': const Size(400, 800),
|
|
'412x915 (tall modern phone)': const Size(412, 915),
|
|
'640x320 (short landscape)': const Size(640, 320),
|
|
'780x360 (wide landscape)': const Size(780, 360),
|
|
'800x360 (wide landscape)': const Size(800, 360),
|
|
'840x390 (ultra-wide landscape)': const Size(840, 390),
|
|
'768x1024 (portrait tablet)': const Size(768, 1024),
|
|
'1024x768 (landscape tablet)': const Size(1024, 768),
|
|
};
|
|
|
|
Widget buildTestHost({
|
|
required Widget child,
|
|
FieldTestLogger? fieldTestLogger,
|
|
Stream<live_sample.TelemetrySample>? telemetryStream,
|
|
}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
databaseProvider.overrideWithValue(db),
|
|
locationServiceProvider.overrideWith((ref) => _FakeLocationService()),
|
|
if (fieldTestLogger != null)
|
|
fieldTestLoggerProvider.overrideWithValue(fieldTestLogger),
|
|
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
|
|
if (telemetryStream != null)
|
|
telemetryStreamProvider.overrideWith((ref) => telemetryStream),
|
|
],
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
final themeMode = ref.watch(appThemeModeProvider);
|
|
return MaterialApp(
|
|
themeMode: themeMode,
|
|
theme: AppTheme.lightTheme,
|
|
darkTheme: AppTheme.darkTheme,
|
|
home: child,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
group('Canonical 9-Viewport DiagnosticsScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
final telemetryController = StreamController<live_sample.TelemetrySample>.broadcast();
|
|
addTearDown(() => telemetryController.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const DiagnosticsScreen(),
|
|
telemetryStream: telemetryController.stream,
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Sensor Diagnostics'), findsOneWidget);
|
|
expect(find.text('SENSOR HEALTH & SIGNAL'), findsOneWidget);
|
|
expect(find.text('GPS & NAVIGATION METRICS'), findsOneWidget);
|
|
expect(find.text('IMU, G-FORCE & ORIENTATION'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Canonical 9-Viewport FieldTestLiveLogScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const FieldTestLiveLogScreen(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('Black-Box Live Log'), findsOneWidget);
|
|
expect(find.text('Telemetry Rate'), findsOneWidget);
|
|
expect(find.text('GPS Accuracy'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Canonical 9-Viewport FieldTestReportScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders empty state cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const FieldTestReportScreen(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('Forensic Field Test Report'), findsOneWidget);
|
|
expect(find.textContaining('No rides recorded yet'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('renders populated forensic report on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
// Insert test ride and summary
|
|
final now = DateTime.now();
|
|
final rideId = await db.into(db.rides).insert(
|
|
RidesCompanion.insert(
|
|
startTime: now.subtract(const Duration(minutes: 10)),
|
|
distanceMiles: const drift.Value(5.2),
|
|
movingTime: const drift.Value(500),
|
|
stoppedTime: const drift.Value(100),
|
|
),
|
|
);
|
|
|
|
await db.into(db.fieldTestSummaries).insert(
|
|
FieldTestSummariesCompanion.insert(
|
|
rideId: rideId,
|
|
startTime: now.subtract(const Duration(minutes: 10)),
|
|
durationSeconds: const drift.Value(600),
|
|
totalSamples: const drift.Value(12000),
|
|
validSamples: const drift.Value(11980),
|
|
suspectSamples: const drift.Value(15),
|
|
rejectedSamples: const drift.Value(5),
|
|
validPercentage: const drift.Value(99.8),
|
|
suspectPercentage: const drift.Value(0.1),
|
|
rejectedPercentage: const drift.Value(0.1),
|
|
gpsFixCount: const drift.Value(11950),
|
|
gpsStaleEventCount: const drift.Value(2),
|
|
gpsJumpRejectCount: const drift.Value(1),
|
|
telemetryGapCount: const drift.Value(0),
|
|
imuSaturationCount: const drift.Value(0),
|
|
accelerationEventCount: const drift.Value(4),
|
|
brakingEventCount: const drift.Value(3),
|
|
zeroTo60Count: const drift.Value(1),
|
|
zeroTo100Count: const drift.Value(0),
|
|
databaseSamplesWritten: const drift.Value(12000),
|
|
databaseSamplesLost: const drift.Value(0),
|
|
eventCount: const drift.Value(10),
|
|
rideQualityScore: const drift.Value(96),
|
|
rideQualityRating: const drift.Value('EXCELLENT'),
|
|
),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: FieldTestReportScreen(rideId: rideId),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('FIELD TEST RESULT'), findsOneWidget);
|
|
expect(find.text('EXCELLENT'), findsOneWidget);
|
|
expect(find.text('96'), findsOneWidget);
|
|
expect(find.text('SENSOR HEALTH & PIPELINE'), findsOneWidget);
|
|
expect(find.text('PERFORMANCE SPRINT METRICS'), findsOneWidget);
|
|
expect(find.text('FORENSIC EVENT TIMELINE'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Canonical 9-Viewport FieldTestPreRideCheckScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const FieldTestPreRideCheckScreen(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
expect(find.text('Field Test Pre-Ride Check'), findsOneWidget);
|
|
expect(find.text('REQUIRED SYSTEM CHECKS'), findsOneWidget);
|
|
expect(find.text('RECOMMENDED PRE-FLIGHT CHECKS'), findsOneWidget);
|
|
expect(find.text('SQLite Database Storage'), findsOneWidget);
|
|
expect(find.text('Drift SQLite Storage verified'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Field Test Active Session & State Preservation Across Viewport & Theme', () {
|
|
testWidgets('preserves active logging session when resized and theme changed', (tester) async {
|
|
tester.view.physicalSize = const Size(400, 800);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
final testLogger = FieldTestLogger(db);
|
|
|
|
testLogger.startSession(77);
|
|
testLogger.log(
|
|
eventType: FieldTestEventType.sampleAccepted,
|
|
severity: EventSeverity.info,
|
|
message: 'Telemetry sample 20Hz accepted',
|
|
);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
databaseProvider.overrideWithValue(db),
|
|
fieldTestLoggerProvider.overrideWithValue(testLogger),
|
|
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
|
|
],
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
final themeMode = ref.watch(appThemeModeProvider);
|
|
return MaterialApp(
|
|
themeMode: themeMode,
|
|
theme: AppTheme.lightTheme,
|
|
darkTheme: AppTheme.darkTheme,
|
|
home: const FieldTestLiveLogScreen(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
|
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
|
|
|
// Resize viewport to landscape tablet
|
|
tester.view.physicalSize = const Size(1024, 768);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
|
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
|
|
|
// Toggle Theme Mode to Light
|
|
container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.light);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
|
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
|
|
|
// Toggle Theme Mode back to Dark
|
|
container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.dark);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('RECORDING (Ride #77)'), findsOneWidget);
|
|
expect(find.text('Telemetry sample 20Hz accepted'), findsOneWidget);
|
|
|
|
testLogger.dispose();
|
|
container.dispose();
|
|
});
|
|
});
|
|
}
|