275 lines
9.6 KiB
Dart
275 lines
9.6 KiB
Dart
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<TelemetrySample> controller;
|
|
PerformanceTimerService? timer;
|
|
|
|
setUp(() {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
controller = StreamController<TelemetrySample>.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<void>.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<void>.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<void>.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<void>.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<TelemetrySample>.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<TelemetrySample>.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<TelemetrySample>.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<TelemetrySample>.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);
|
|
});
|
|
});
|
|
}
|