Replaces the scrolling stats-card layout with the full-bleed Map HUD: UI-04's draggable/resizable HUD widgets float over UI-01's shared background map (full opacity on this tab), and the old Pause/Stop/Resume/ Discard buttons become full-bleed, icon-only segments matching the mockup's tertiary-container/error-container colors exactly. The idle state keeps its prior compact layout deliberately -- HUD widgets only appear once a ride exists, preserving the existing "a resting screen must not look like a ride going nowhere" guarantee rather than reinterpreting it. State-machine logic (ticker, wakelock, speed subscription, error handling) is untouched; every pre-existing record-screen test passed unchanged against the rebuilt screen. Added tests that actually tap Start/Pause/Stop and verify engine state changes, confirm HUD widgets render over the map rather than replacing it, and verify the mounted-mode speed digit's real contrast ratio against GlassPanel's translucent surface specifically (the ticket's own named risk). Corrects a UI-04 mistake found while implementing this ticket: the HUD's default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a guess made before reading the actual Map HUD mockup HTML closely. The real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to match and updated every test that asserted the old order. Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new showLocationMarker flag, and explicit tertiaryContainer/errorContainer tokens to ripprColors so the control bar matches the design system's literal values rather than an auto-derived tonal palette. Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop all correctly drive the trip state machine with the full live map behind everything. A lengthy false alarm during this verification (taps appearing to do nothing) turned out to be a screenshot-scale mis-measurement on the verification side, not an app defect -- resolved by sampling pixel colors directly from the raw screenshot to find the control bar's true on-screen position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
129 lines
4.8 KiB
Dart
129 lines
4.8 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:rippr/src/hud/hud_metric.dart';
|
|
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
|
|
|
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
|
|
/// -- no real gestures needed to test either, per the ticket's own Tests section.
|
|
void main() {
|
|
group('JSON round trip', () {
|
|
test('encodes and decodes exactly', () {
|
|
const original = HudWidgetLayout(
|
|
metric: HudMetric.speed,
|
|
x: 0.1,
|
|
y: 0.2,
|
|
width: 0.3,
|
|
height: 0.15,
|
|
visible: true,
|
|
);
|
|
|
|
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
|
|
|
|
expect(decoded.x, original.x);
|
|
expect(decoded.y, original.y);
|
|
expect(decoded.width, original.width);
|
|
expect(decoded.height, original.height);
|
|
expect(decoded.visible, original.visible);
|
|
});
|
|
|
|
test('a malformed entry falls back to the default rather than crashing', () {
|
|
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
|
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
|
|
|
|
expect(decoded.x, default_.x);
|
|
expect(decoded.width, default_.width);
|
|
});
|
|
|
|
test('a missing field falls back to the default rather than crashing', () {
|
|
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
|
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
|
|
|
|
expect(decoded.x, default_.x);
|
|
});
|
|
});
|
|
|
|
group('defaultFor', () {
|
|
test('every metric gets a distinct default position', () {
|
|
final positions = HudMetric.values
|
|
.map(HudWidgetLayout.defaultFor)
|
|
.map((l) => '${l.x},${l.y}')
|
|
.toSet();
|
|
expect(positions.length, HudMetric.values.length,
|
|
reason: 'no two metrics should default to overlapping positions');
|
|
});
|
|
|
|
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
|
|
'Dist/Time) start visible', () {
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isTrue);
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
|
|
});
|
|
|
|
test('metrics beyond the fixed row start hidden', () {
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isFalse);
|
|
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
|
|
});
|
|
|
|
test('every default is already within the valid clamp bounds', () {
|
|
for (final metric in HudMetric.values) {
|
|
final layout = HudWidgetLayout.defaultFor(metric);
|
|
expect(layout.clamped().x, layout.x);
|
|
expect(layout.clamped().y, layout.y);
|
|
expect(layout.clamped().width, layout.width);
|
|
expect(layout.clamped().height, layout.height);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('clamped', () {
|
|
const base = HudWidgetLayout(
|
|
metric: HudMetric.speed,
|
|
x: 0.5,
|
|
y: 0.5,
|
|
width: 0.3,
|
|
height: 0.15,
|
|
visible: true,
|
|
);
|
|
|
|
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
|
|
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
|
|
expect(result.x, 1.0 - base.width);
|
|
expect(result.y, 1.0 - base.height);
|
|
});
|
|
|
|
test('a position dragged past the left/top edge is pulled back on-screen', () {
|
|
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
|
|
expect(result.x, 0.0);
|
|
expect(result.y, 0.0);
|
|
});
|
|
|
|
test('a resize below the legibility floor is corrected up to the minimum', () {
|
|
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
|
|
expect(result.width, hudMinWidthFraction);
|
|
expect(result.height, hudMinHeightFraction);
|
|
});
|
|
|
|
test('a resize above the sane ceiling is corrected down to the maximum', () {
|
|
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
|
|
expect(result.width, hudMaxWidthFraction);
|
|
expect(result.height, hudMaxHeightFraction);
|
|
});
|
|
|
|
test('shrinking to fit happens before repositioning, so a widget resized past '
|
|
'the edge shrinks rather than silently relocates', () {
|
|
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
|
|
expect(result.width, hudMaxWidthFraction);
|
|
// x itself is still clamped against the (now-bounded) width so the widget
|
|
// never sits partially off-screen either.
|
|
expect(result.x + result.width, lessThanOrEqualTo(1.0));
|
|
});
|
|
|
|
test('an already-valid layout is unchanged', () {
|
|
expect(base.clamped().x, base.x);
|
|
expect(base.clamped().y, base.y);
|
|
expect(base.clamped().width, base.width);
|
|
expect(base.clamped().height, base.height);
|
|
});
|
|
});
|
|
}
|