fix: separate ride actions from truthful recording status claims
This commit is contained in:
parent
ec759ea3c5
commit
c227244d5f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<void> 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<ElevatedButton>(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, String>{
|
||||
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<Text>(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<ModalBarrier>(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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue