MotorSignal-App/test/performance_timer_launch_is...

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