From c227244d5fcde3336c455faf4a2c7987092c6c76 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 10 Sep 2026 18:01:58 -0400 Subject: [PATCH] fix: separate ride actions from truthful recording status claims --- .../widgets/evolution_portrait_dashboard.dart | 89 ++++- .../dashboard/widgets/ride_status_chip.dart | 61 ++- test/canonical_dashboard_responsive_test.dart | 11 +- ...lution_dashboard_truthful_status_test.dart | 10 +- test/rid3c2_truthful_recording_ui_test.dart | 347 ++++++++++++++++++ 5 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 test/rid3c2_truthful_recording_ui_test.dart diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index f2797ae..a6cdb7c 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/telemetry_sample.dart'; +import '../../../models/recording_health_status.dart'; import '../../rides/ride_state_machine.dart'; import '../../records/records_service.dart'; @@ -28,7 +29,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final records = ref.watch(recordsServiceProvider); - final isRecording = session.state == RideState.riding; + + // RID-3C-2 FROZEN PRINCIPLE: RideState controls ACTIONS; the RID-3C-1 + // RecordingHealthContract / RecordingHealthStatus controls CLAIMS. + // + // `hasActiveRide` is the ACTION source only. It decides whether the primary + // control offers Stop or Start, and it must NEVER depend on + // canReportRecording, GPS freshness, IMU freshness, database health or + // watchdog health. A degraded or still-warming-up ride MUST remain stoppable: + // an unhealthy ride may never become impossible to stop. + final hasActiveRide = session.state == RideState.riding; + + // The visible recording-status CLAIM comes only from the authoritative health + // status resolved by RID-3C-1. This widget performs NO health calculation of + // its own: recordingActive is already the product of canReportRecording plus + // the frozen evidence thresholds, so "RECORDING" can never be claimed from + // RideState alone, nor from service or location-stack presence. + final String statusLabel; + final Color statusColor; + if (!hasActiveRide) { + // No active ride: nothing is being recorded however healthy the location + // stack looks. recordingComplete also lands here, because completion means + // the ride is no longer active and must not be presented as recording. + statusLabel = 'STANDBY'; + statusColor = Colors.white70; + } else { + statusLabel = switch (session.healthStatus) { + // Health model authorizes the healthy active claim. + RecordingHealthStatus.recordingActive => 'RECORDING', + // Ride exists but required evidence has not completed yet. Stop stays + // available; promotion to RECORDING happens without rider interaction. + RecordingHealthStatus.recordingArmed => 'WARMING UP', + RecordingHealthStatus.recordingStarting => 'WARMING UP', + // Still recording, but one or more evidence domains are stale. DEGRADED + // does NOT mean the ride ended, telemetry was discarded, the ride failed, + // or the service stopped. No auto-stop, no navigation, no state clearing. + RecordingHealthStatus.recordingDegraded => 'DEGRADED', + // RID-3B-1 finalization has priority and is never shown as RECORDING. + RecordingHealthStatus.recordingFinalizing => 'FINALIZING', + // Unreachable alongside an active ride; must not present as recording. + RecordingHealthStatus.recordingComplete => 'STANDBY', + // Not currently produced by production code; non-claiming and truthful. + RecordingHealthStatus.recordingFailed => 'DEGRADED', + }; + // Existing MotorSignal theme semantics from the checkpointed health model: + // active keeps the current cyan treatment, armed is amber (the same token + // this bar already uses for a 2D GPS fix), degraded is orange (the same + // token already used for GPS WEAK - a warning, not danger/red), and + // finalizing is purple. No new visual system is introduced. + statusColor = session.healthStatus.color; + } + final speed = currentSpeedMph.clamp(0.0, 220.0); final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph); final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); @@ -87,14 +138,12 @@ class EvolutionPortraitDashboard extends ConsumerWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: isRecording - ? const Color(0xFF00E5FF).withValues(alpha: 0.15) + color: hasActiveRide + ? statusColor.withValues(alpha: 0.15) : const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(4), border: Border.all( - color: isRecording - ? const Color(0xFF00E5FF) - : Colors.white24, + color: hasActiveRide ? statusColor : Colors.white24, width: 0.8, ), ), @@ -106,21 +155,22 @@ class EvolutionPortraitDashboard extends ConsumerWidget { height: 6, decoration: BoxDecoration( shape: BoxShape.circle, - color: isRecording - ? const Color(0xFF00E5FF) - : Colors.white54, + color: hasActiveRide ? statusColor : Colors.white54, ), ), const SizedBox(width: 6), + // RID-3C-2: a single compact primary status indicator. The + // claim is always text, never colour-only, and carries no + // animation, modal, snackbar or debug counter, so the + // dashboard stays glanceable and the rider never has to + // monitor MotorSignal while riding. Text( - isRecording ? 'RECORDING' : 'STANDBY', + statusLabel, style: TextStyle( fontFamily: 'JetBrains Mono', fontSize: 10, fontWeight: FontWeight.w700, - color: isRecording - ? const Color(0xFF00E5FF) - : Colors.white70, + color: hasActiveRide ? statusColor : Colors.white70, letterSpacing: 0.5, ), ), @@ -361,21 +411,24 @@ class EvolutionPortraitDashboard extends ConsumerWidget { ref.read(rideStateMachineProvider.notifier).startRide(); } }, + // RID-3C-2: the ACTION control stays purely RideState-driven and + // is deliberately independent of statusLabel/statusColor, so Stop + // remains available while warming up, degraded, or finalizing. style: ElevatedButton.styleFrom( - backgroundColor: isRecording ? const Color(0xFF3D0000) : const Color(0xFF00252A), - foregroundColor: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), + backgroundColor: hasActiveRide ? const Color(0xFF3D0000) : const Color(0xFF00252A), + foregroundColor: hasActiveRide ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( - color: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), + color: hasActiveRide ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), width: 1.5, ), ), elevation: 6, - shadowColor: isRecording ? const Color(0xFFFF2A2A).withValues(alpha: 0.4) : const Color(0xFF00E5FF).withValues(alpha: 0.4), + shadowColor: hasActiveRide ? const Color(0xFFFF2A2A).withValues(alpha: 0.4) : const Color(0xFF00E5FF).withValues(alpha: 0.4), ), child: Text( - isRecording ? 'STOP SESSION' : 'START SESSION', + hasActiveRide ? 'STOP SESSION' : 'START SESSION', style: const TextStyle( fontFamily: 'Sora', fontSize: 16, diff --git a/lib/features/dashboard/widgets/ride_status_chip.dart b/lib/features/dashboard/widgets/ride_status_chip.dart index 5e639e2..cd2e366 100644 --- a/lib/features/dashboard/widgets/ride_status_chip.dart +++ b/lib/features/dashboard/widgets/ride_status_chip.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../rides/ride_state_machine.dart'; +import '../../../models/recording_health_status.dart'; /// Authoritative ride status badge/chip. /// @@ -13,12 +14,25 @@ class RideStatusChip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch( - rideStateMachineProvider.select((s) => s.state), + // RID-3C-2: RideState decides WHICH lifecycle branch applies; the RID-3C-1 + // health status decides the CLAIM inside an active ride. This widget performs + // no health calculation of its own - recordingActive is already the product of + // canReportRecording plus the frozen evidence thresholds, so RECORDING can + // never be claimed from RideState or service presence alone. + // + // The select stays narrow (state + healthStatus only), so the chip still does + // NOT rebuild on 20 Hz telemetry ticks. healthStatus changes only on real + // warm-up / degradation / recovery / finalization transitions. + final (state, health) = ref.watch( + rideStateMachineProvider.select((s) => (s.state, s.healthStatus)), ); - final isRecording = state == RideState.riding; + final isActiveRide = state == RideState.riding; final isPaused = state == RideState.paused; + // The glow/emphasis treatment belongs to a genuine healthy recording claim + // only, never to a warm-up or degraded ride. + final claimsRecording = + isActiveRide && health == RecordingHealthStatus.recordingActive; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; @@ -26,9 +40,38 @@ class RideStatusChip extends ConsumerWidget { final Color badgeColor; final String label; - if (isRecording) { - badgeColor = AppColors.raceRed; - label = 'RECORDING'; + if (isActiveRide) { + switch (health) { + case RecordingHealthStatus.recordingActive: + // Existing healthy/active treatment for this chip, preserved. + badgeColor = AppColors.raceRed; + label = 'RECORDING'; + break; + case RecordingHealthStatus.recordingArmed: + case RecordingHealthStatus.recordingStarting: + // Ride exists but required evidence has not completed yet. Neutral / + // informational; the Stop action remains available elsewhere. + badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + label = 'WARMING UP'; + break; + case RecordingHealthStatus.recordingFinalizing: + // RID-3B-1 finalization has priority and is never shown as RECORDING. + badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + label = 'FINALIZING'; + break; + case RecordingHealthStatus.recordingDegraded: + case RecordingHealthStatus.recordingFailed: + // Warning, NOT danger: DEGRADED means the ride is still active and still + // stoppable - not ended, discarded, failed or service-stopped. + badgeColor = AppColors.warningAmber; + label = 'DEGRADED'; + break; + case RecordingHealthStatus.recordingComplete: + // Unreachable alongside an active ride; must not present as recording. + badgeColor = theme.colorScheme.onSurfaceVariant; + label = 'STANDBY'; + break; + } } else if (isPaused) { badgeColor = AppColors.warningAmber; label = 'PAUSED'; @@ -43,10 +86,10 @@ class RideStatusChip extends ConsumerWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( - color: badgeColor.withValues(alpha: isRecording ? 0.18 : 0.10), + color: badgeColor.withValues(alpha: claimsRecording ? 0.18 : 0.10), borderRadius: BorderRadius.circular(6), border: Border.all( - color: badgeColor.withValues(alpha: isRecording ? 0.8 : 0.35), + color: badgeColor.withValues(alpha: claimsRecording ? 0.8 : 0.35), width: 1.0, ), ), @@ -59,7 +102,7 @@ class RideStatusChip extends ConsumerWidget { decoration: BoxDecoration( shape: BoxShape.circle, color: badgeColor, - boxShadow: isRecording + boxShadow: claimsRecording ? [ BoxShadow( color: badgeColor.withValues(alpha: 0.6), diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index cb50d7d..2326b2c 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -276,7 +276,13 @@ void main() { await tester.pump(); expect(find.text('Stop Session'), findsOneWidget); - expect(find.text('RECORDING'), findsOneWidget); + // RID-3C-2: the status chip is a health CLAIM, not a RideState claim. A ride + // started here has no GPS/IMU/DB evidence yet, so the truthful label is + // WARMING UP; RECORDING requires healthStatus == recordingActive. This test's + // purpose is that the session SURVIVES the resize, so the same claim must + // appear both before and after rotation. + expect(find.text('WARMING UP'), findsOneWidget); + expect(find.text('RECORDING'), findsNothing); // Rotate to landscape configureViewport(tester, 800, 360); @@ -284,7 +290,8 @@ void main() { // Verify ride did NOT stop or reset expect(find.text('Stop Session'), findsOneWidget); - expect(find.text('RECORDING'), findsOneWidget); + expect(find.text('WARMING UP'), findsOneWidget); + expect(find.text('RECORDING'), findsNothing); }); testWidgets('Alert region displays alert banner and handles dismissal', ( diff --git a/test/evolution_dashboard_truthful_status_test.dart b/test/evolution_dashboard_truthful_status_test.dart index 1854d06..f81330a 100644 --- a/test/evolution_dashboard_truthful_status_test.dart +++ b/test/evolution_dashboard_truthful_status_test.dart @@ -65,8 +65,14 @@ void main() { expect(find.text('HEADSET'), findsNothing); expect(find.text('92%'), findsNothing); - // Recording status badge - expect(find.text('RECORDING'), findsOneWidget); + // RID-3C-2: the visible badge is a health CLAIM, not a RideState claim. This + // fixture builds RideSession(state: riding) with the DEFAULT + // healthStatus: recordingArmed and no health evidence, so the truthful label + // is WARMING UP. RideState.riding alone is never sufficient for RECORDING. + // The Stop action remains available throughout - see U2/U9 in + // test/rid3c2_truthful_recording_ui_test.dart. + expect(find.text('WARMING UP'), findsOneWidget); + expect(find.text('RECORDING'), findsNothing); }); testWidgets('displays GPS 2D FIX when accuracy is 25m', (tester) async { diff --git a/test/rid3c2_truthful_recording_ui_test.dart b/test/rid3c2_truthful_recording_ui_test.dart new file mode 100644 index 0000000..c65c128 --- /dev/null +++ b/test/rid3c2_truthful_recording_ui_test.dart @@ -0,0 +1,347 @@ +// RID-3C-2 permanent regression suite: truthful UI recording status. +// +// Locks the frozen separation of concerns: +// RideState -> ACTIONS (Start / Stop availability) +// RecordingHealthStatus / contract -> CLAIMS (the visible status label) +// +// A degraded or warming-up ride must ALWAYS remain stoppable, and "RECORDING" +// must never be claimed from RideState or service presence alone. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/models/recording_health_status.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/evolution_portrait_dashboard.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; + +/// A contract with every evidence domain satisfied, so `canReportRecording` is +/// genuinely true rather than assumed. +const _healthyContract = RecordingHealthContract( + isForegroundServiceAlive: true, + isTelemetryPipelineActive: true, + isAtLeastOneSensorActive: true, + isDatabasePersistenceOperational: true, + isValidSessionId: true, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + Future pumpDashboard( + WidgetTester tester, { + required RideSession session, + TelemetrySample? telemetry, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: MaterialApp( + home: Scaffold( + // No scroll wrapper: the dashboard's root Column uses flex children and + // therefore requires bounded height, exactly as the existing + // evolution_dashboard_truthful_status_test.dart pumps it. + body: EvolutionPortraitDashboard( + currentSpeedMph: 45.0, + heading: 180.0, + leanAngle: 12.0, + accelG: 0.2, + lateralG: 0.1, + session: session, + telemetry: telemetry, + ), + ), + ), + ), + ); + await tester.pump(); + } + + /// Asserts the single primary status indicator reads exactly [label]. + void expectStatusLabel(String label) { + expect(find.text(label), findsOneWidget, + reason: 'primary status indicator must read $label'); + } + + /// Asserts no healthy recording claim is visible anywhere. + void expectNoRecordingClaim() { + expect(find.text('RECORDING'), findsNothing, + reason: 'RECORDING is a health claim and must not be rendered'); + } + + /// Asserts the Stop ACTION is present and actionable. Stop availability is + /// RideState-driven and must be independent of health. + void expectStopAvailable(WidgetTester tester) { + final finder = find.widgetWithText(ElevatedButton, 'STOP SESSION'); + expect(finder, findsOneWidget, reason: 'Stop action must remain available'); + expect(tester.widget(finder).onPressed, isNotNull, + reason: 'Stop must be actionable, never disabled by health'); + } + + /// Asserts the Start ACTION is offered when no ride is active. + void expectStartAvailable(WidgetTester tester) { + expect(find.widgetWithText(ElevatedButton, 'START SESSION'), findsOneWidget); + expect(find.text('STOP SESSION'), findsNothing); + } + + group('RID-3C-2 truthful recording status UI', () { + testWidgets('U1 idle shows STANDBY and offers Start', (tester) async { + await pumpDashboard(tester, session: RideSession()); + expectStatusLabel('STANDBY'); + expectNoRecordingClaim(); + expectStartAvailable(tester); + }); + + testWidgets('U2 warm-up shows WARMING UP and Stop stays available', + (tester) async { + // RideState is already riding but RID-3C-1 health is still armed: the ride + // exists, the evidence has not completed. This is the exact state produced + // by startRide() before the first GPS/IMU/DB evidence arrives. + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingArmed, + startTime: DateTime(2026, 9, 10, 12), + ), + ); + expectStatusLabel('WARMING UP'); + expectNoRecordingClaim(); + expectStopAvailable(tester); + }); + + testWidgets('U3 healthy evidence shows RECORDING', (tester) async { + final session = RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingActive, + health: _healthyContract, + ); + // Guard the premise: the label is earned by the frozen contract, not by + // RideState. + expect(session.health.canReportRecording, isTrue); + await pumpDashboard(tester, session: session); + expectStatusLabel('RECORDING'); + expectStopAvailable(tester); + }); + + testWidgets('U4 degraded shows DEGRADED and Stop stays available', + (tester) async { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingDegraded, + ), + ); + expectStatusLabel('DEGRADED'); + expectStopAvailable(tester); + // DEGRADED does not mean stopped, ended, discarded or failed. + expect(find.text('STOP SESSION'), findsOneWidget); + }); + + testWidgets('U5 degraded never renders a healthy RECORDING claim', + (tester) async { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingDegraded, + ), + ); + expectNoRecordingClaim(); + for (final claim in ['RECORDING', 'HEALTHY RECORDING', 'ACTIVE RIDE']) { + expect(find.textContaining(claim), findsNothing, + reason: 'no healthy recording claim may be visible while degraded'); + } + }); + + testWidgets('U6 recovery renders DEGRADED then RECORDING for the same ride', + (tester) async { + const rideId = 7; + await pumpDashboard( + tester, + session: RideSession( + dbId: rideId, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingDegraded, + ), + ); + expectStatusLabel('DEGRADED'); + + // Same ride identity recovers purely from authoritative state: no new ride, + // no rider interaction, no GPS/service restart, no dbId change. + await pumpDashboard( + tester, + session: RideSession( + dbId: rideId, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingActive, + health: _healthyContract, + ), + ); + expectStatusLabel('RECORDING'); + expect(find.text('DEGRADED'), findsNothing); + expectStopAvailable(tester); + }); + testWidgets('U7 finalizing shows FINALIZING, never RECORDING', + (tester) async { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingFinalizing, + ), + ); + expectStatusLabel('FINALIZING'); + expectNoRecordingClaim(); + // RID-3B-1 finalization keeps the ride active, so Stop stays available and + // health updates may not override the finalizing claim. + expectStopAvailable(tester); + }); + + testWidgets('U8 a completed ride leaves no lingering RECORDING claim', + (tester) async { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.finished, + healthStatus: RecordingHealthStatus.recordingComplete, + ), + ); + expectStatusLabel('STANDBY'); + expectNoRecordingClaim(); + expectStartAvailable(tester); + }); + + testWidgets( + 'U9 ACTION/CLAIM separation: Stop survives warm-up AND degraded', + (tester) async { + // MANDATORY: an unhealthy or still-warming-up ride must never become + // impossible to stop. Actions come from RideState; claims come from health. + for (final status in [ + RecordingHealthStatus.recordingArmed, + RecordingHealthStatus.recordingDegraded, + RecordingHealthStatus.recordingFinalizing, + ]) { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: status, + ), + ); + expectStopAvailable(tester); + expectNoRecordingClaim(); + expect(find.text('START SESSION'), findsNothing, + reason: 'an active ride must never offer Start for status $status'); + } + }); + testWidgets('U10 service/location presence alone is never RECORDING', + (tester) async { + // A perfect GPS fix is flowing, so the location stack is demonstrably + // healthy and the GPS indicator reports a 3D fix - yet with no active ride + // the status claim must remain STANDBY. + await pumpDashboard( + tester, + session: RideSession(), + telemetry: TelemetrySample( + timestamp: DateTime(2026, 9, 10, 12), + gpsAccuracy: 4.0, + gpsTimestamp: DateTime(2026, 9, 10, 12), + latitude: 34.0, + longitude: -85.0, + speed: 45.0, + heading: 90.0, + ), + ); + expect(find.text('GPS 3D FIX'), findsOneWidget, + reason: 'premise: the location stack really is healthy'); + expectStatusLabel('STANDBY'); + expectNoRecordingClaim(); + }); + + testWidgets('U11 every status exposes text, never colour alone', + (tester) async { + final cases = { + RecordingHealthStatus.recordingArmed: 'WARMING UP', + RecordingHealthStatus.recordingActive: 'RECORDING', + RecordingHealthStatus.recordingDegraded: 'DEGRADED', + RecordingHealthStatus.recordingFinalizing: 'FINALIZING', + }; + for (final entry in cases.entries) { + await pumpDashboard( + tester, + session: RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: entry.key, + health: entry.key == RecordingHealthStatus.recordingActive + ? _healthyContract + : const RecordingHealthContract(), + ), + ); + final finder = find.text(entry.value); + expect(finder, findsOneWidget, + reason: '${entry.key} must render a textual label'); + expect(tester.widget(finder).data, entry.value, + reason: 'status must not be communicated by colour alone'); + } + await pumpDashboard(tester, session: RideSession()); + expect(find.text('STANDBY'), findsOneWidget); + }); + + testWidgets('U12 ordinary degradation demands no rider attention', + (tester) async { + final degraded = RideSession( + dbId: 7, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingDegraded, + ); + await pumpDashboard(tester, session: degraded); + expectStatusLabel('DEGRADED'); + + void expectNoAttentionSurface(String phase) { + for (final type in [Dialog, AlertDialog, SimpleDialog, SnackBar]) { + expect(find.byType(type), findsNothing, + reason: '$phase: no modal, dialog or snackbar for ordinary health ' + 'degradation'); + } + // A ModalBarrier exists in every MaterialApp route overlay, so its mere + // presence is not an attention surface. What matters is that it never + // blocks input or awaits acknowledgement, which would make the rider + // interact with MotorSignal while riding. + for (final barrier + in tester.widgetList(find.byType(ModalBarrier))) { + expect(barrier.onDismiss, isNull, + reason: '$phase: no blocking modal barrier'); + expect(barrier.dismissible, isFalse, + reason: '$phase: no acknowledgement-gated overlay'); + } + } + + expectNoAttentionSurface('immediately'); + // Settle additional time: nothing repeating, flashing or acknowledgement- + // gated may appear, so the rider never has to monitor MotorSignal. + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expectNoAttentionSurface('after settling'); + expectStatusLabel('DEGRADED'); + expectStopAvailable(tester); + }); + }); +}