FB-03: grid-snapped HUD widgets with auto-fit, centered text

Replace free-form fractional HUD widget positioning with a 4x8 int grid
(col/row/colSpan/rowSpan), reject drag/resize moves that would overlap
another visible widget, and re-place a re-enabled metric into the next
free grid slot instead of stacking it on an occupier. Drag/resize now
track a local pixel offset during the gesture and only snap/commit once
at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it
never overflows and stays centered at any grid size.
This commit is contained in:
2026-08-24 15:53:00 -05:00
parent 04127d7237
commit 2d118b46a2
10 changed files with 1306 additions and 198 deletions

View File

@@ -96,10 +96,10 @@ void main() {
...config.hudLayout,
HudMetric.speed: const HudWidgetLayout(
metric: HudMetric.speed,
x: 0.33,
y: 0.44,
width: 0.25,
height: 0.1,
col: 2,
row: 3,
colSpan: 2,
rowSpan: 1,
visible: true,
),
};
@@ -107,12 +107,27 @@ void main() {
await config.setHudLayout(edited);
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.33);
expect(reloaded[HudMetric.speed]!.y, 0.44);
expect(reloaded[HudMetric.speed]!.col, 2);
expect(reloaded[HudMetric.speed]!.row, 3);
});
test('a metric missing from a saved layout (e.g. one added in a later app '
'version) falls back to its default rather than being omitted', () async {
SharedPreferences.setMockInitialValues({
'hud_layout': '{"speed": {"col": 1, "row": 1, "colSpan": 1, "rowSpan": 2, '
'"visible": true}}',
});
final config = await Config.load();
final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet());
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(layout[HudMetric.distance]!.col, expectedDefault.col);
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
});
test('an old-shaped (pre-grid) saved layout falls back to defaults rather than '
'crashing', () async {
SharedPreferences.setMockInitialValues({
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
'"visible": true}}',
@@ -121,9 +136,8 @@ void main() {
final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet());
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(layout[HudMetric.distance]!.x, expectedDefault.x);
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.speed);
expect(layout[HudMetric.speed]!.col, expectedDefault.col);
});
test('corrupted stored JSON falls back to defaults rather than crashing',

View File

@@ -5,9 +5,26 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// A representative HUD child -- structurally the same FittedBox(label + value)
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
/// referenced directly from outside its library.
Widget _representativeHudChild() => const FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('SPEED', style: TextStyle(fontSize: 10, letterSpacing: 1)),
SizedBox(height: 4),
Text('12.3 km/h', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
),
);
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
/// the same way other drag interactions in this codebase are tested (see
/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
@@ -71,7 +88,9 @@ void main() {
final speedFinder = find.text('Speed');
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(const Offset(40, 60));
// Large enough to guarantee at least one grid cell of movement in both axes
// regardless of the test surface's exact pixel size.
await gesture.moveBy(const Offset(300, 300));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
@@ -80,8 +99,8 @@ void main() {
await tester.pumpAndSettle();
final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, isNot(before.x));
expect(after.y, isNot(before.y));
expect(after.col, isNot(before.col));
expect(after.row, isNot(before.row));
});
testWidgets('a drag while not editing does not move the widget', (tester) async {
@@ -99,8 +118,8 @@ void main() {
expect(find.byKey(const Key('hud-edit-done')), findsNothing);
final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, before.x);
expect(after.y, before.y);
expect(after.col, before.col);
expect(after.row, before.row);
});
testWidgets('only visible metrics render a widget', (tester) async {
@@ -110,4 +129,138 @@ void main() {
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
});
testWidgets('a drag ending over a cell occupied by another visible widget leaves '
'the dragged widget\'s rendered position unchanged', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
final before = tester.getTopLeft(speedFinder);
// Speed (col 0, row 0) dragged exactly one cell right lands squarely on
// avgSpeed's (col 1, row 0) cell -- both share the same row-0 default rowSpan, so
// this is a genuine, not merely edge-touching, overlap.
final gesture = await tester.startGesture(tester.getCenter(find.text('Speed')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(Offset(tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('hud-edit-done')));
await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.speed]!.col, 0,
reason: 'the rejected move must not change the persisted grid cell either');
expect(tester.getTopLeft(speedFinder), before);
});
testWidgets('a resize past the point of colliding with a sibling leaves the widget '
'at its pre-gesture size', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
final beforeSize = tester.getSize(speedFinder);
final handleFinder = find.descendant(
of: speedFinder,
matching: find.byKey(const Key('hud-resize-handle')),
);
// Speed defaults to colSpan 1 at col 0; growing it across the full grid width
// would swallow avgSpeed/distance/elapsedTime's row-0 slots.
final cellWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns;
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
await gesture.moveBy(Offset(cellWidth * hudGridColumns, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('hud-edit-done')));
await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.speed]!.colSpan, 1,
reason: 'the rejected resize must not change the persisted span either');
expect(tester.getSize(speedFinder), beforeSize);
});
testWidgets('HUD widget text never overflows at the smallest (1x1) allowed size',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: const HudWidgetLayout(
metric: HudMetric.speed,
col: 0,
row: 0,
colSpan: hudMinSpan,
rowSpan: hudMinSpan,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets(
'HUD widget text never overflows at the largest (max col/row span) allowed size',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: const HudWidgetLayout(
metric: HudMetric.speed,
col: 0,
row: 0,
colSpan: hudMaxColSpan,
rowSpan: hudMaxRowSpan,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_layout_controller.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
@@ -11,59 +12,123 @@ void main() {
}
group('HudLayoutController', () {
test('updatePosition updates only the given metric, clamped', () async {
test('updatePosition updates only the given metric, clamped to the grid', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.distance]!;
controller.updatePosition(HudMetric.speed, 5.0, 5.0);
// Row 4 is unused by any default layout, so this can't collide.
controller.updatePosition(HudMetric.speed, 0, 4);
expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0));
expect(controller.state[HudMetric.distance]!.x, before.x,
expect(controller.state[HudMetric.speed]!.row, 4);
expect(controller.state[HudMetric.distance]!.col, before.col,
reason: 'moving one metric must not disturb another');
expect(controller.state[HudMetric.distance]!.row, before.row);
});
test('updatePosition clamps a candidate that lands outside the grid', () async {
final controller = HudLayoutController(await freshConfig());
controller.updatePosition(HudMetric.speed, 99, 99);
final speed = controller.state[HudMetric.speed]!;
expect(speed.col, lessThanOrEqualTo(hudGridColumns - speed.colSpan));
expect(speed.row, lessThanOrEqualTo(hudGridRows - speed.rowSpan));
});
test('updateSize clamps to the legibility floor/ceiling', () async {
final controller = HudLayoutController(await freshConfig());
controller.updateSize(HudMetric.speed, 0.0, 0.0);
expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0));
expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0));
controller.updateSize(HudMetric.speed, 0, 0);
expect(controller.state[HudMetric.speed]!.colSpan, hudMinSpan);
expect(controller.state[HudMetric.speed]!.rowSpan, hudMinSpan);
});
test('updatePosition onto a cell occupied by another visible metric is a no-op', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.speed]!;
final avgSpeed = controller.state[HudMetric.avgSpeed]!;
// avgSpeed is visible by default -- try to drag speed directly on top of it.
controller.updatePosition(HudMetric.speed, avgSpeed.col, avgSpeed.row);
expect(controller.state[HudMetric.speed]!.col, before.col);
expect(controller.state[HudMetric.speed]!.row, before.row);
});
test('updateSize that would overlap a sibling is a no-op', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.speed]!;
// Growing speed (default col 0, row 0) to span all 4 columns would swallow
// avgSpeed/distance/elapsedTime's row-0 slots.
controller.updateSize(HudMetric.speed, hudGridColumns, before.rowSpan);
expect(controller.state[HudMetric.speed]!.colSpan, before.colSpan);
expect(controller.state[HudMetric.speed]!.rowSpan, before.rowSpan);
});
test('setVisible(false) then setVisible(true) restores the last saved position, '
'not a fresh default', () async {
'when that position is still free', () async {
final controller = HudLayoutController(await freshConfig());
controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6);
final movedX = controller.state[HudMetric.avgSpeed]!.x;
// Row 4 is unused by any default layout, so this can't collide with anything.
controller.updatePosition(HudMetric.avgSpeed, 0, 4);
final moved = controller.state[HudMetric.avgSpeed]!;
controller.setVisible(HudMetric.avgSpeed, false);
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
controller.setVisible(HudMetric.avgSpeed, true);
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
expect(controller.state[HudMetric.avgSpeed]!.x, movedX,
expect(controller.state[HudMetric.avgSpeed]!.col, moved.col,
reason: 'a metric with a real saved position keeps it when re-enabled');
expect(controller.state[HudMetric.avgSpeed]!.row, moved.row);
});
test('setVisible(true) when the metric\'s last position now collides with '
'another visible widget places it somewhere else instead', () async {
final controller = HudLayoutController(await freshConfig());
final maxSpeedDefault = controller.state[HudMetric.maxSpeed]!;
// maxSpeed starts hidden at its default slot; move a currently-visible metric
// onto that exact slot so it's occupied by the time maxSpeed is re-enabled.
controller.updatePosition(
HudMetric.speed,
maxSpeedDefault.col,
maxSpeedDefault.row,
);
controller.setVisible(HudMetric.maxSpeed, true);
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
expect(maxSpeed.visible, isTrue);
final others = controller.state.values.where(
(l) => l.visible && l.metric != HudMetric.maxSpeed,
);
for (final other in others) {
expect(hudRectsOverlap(maxSpeed, other), isFalse,
reason: 'the re-enabled metric must not stack on any other visible widget');
}
});
test('persist writes the current state to Config', () async {
final config = await freshConfig();
final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
controller.updatePosition(HudMetric.speed, 0, 4);
await controller.persist();
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.4);
expect(reloaded[HudMetric.speed]!.row, 4);
});
test('edits before persist are not visible to a fresh read of Config', () async {
final config = await freshConfig();
final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
controller.updatePosition(HudMetric.speed, 0, 4);
// No persist() call yet.
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, isNot(0.4),
expect(reloaded[HudMetric.speed]!.row, isNot(4),
reason: 'a drag in progress must only touch in-memory state -- '
'persisting every frame is the ticket\'s own named risk');
});

View File

@@ -2,53 +2,69 @@ 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.
/// FB-03: the JSON round-trip, grid-clamp math, overlap check, and free-slot search
/// are all exercised directly with fixed inputs -- no real gestures needed to test
/// any of these, 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,
col: 1,
row: 2,
colSpan: 3,
rowSpan: 2,
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.col, original.col);
expect(decoded.row, original.row);
expect(decoded.colSpan, original.colSpan);
expect(decoded.rowSpan, original.rowSpan);
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 decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'col': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(decoded.x, default_.x);
expect(decoded.width, default_.width);
expect(decoded.col, default_.col);
expect(decoded.colSpan, default_.colSpan);
});
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 decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'col': 1, 'row': 1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
expect(decoded.x, default_.x);
expect(decoded.col, default_.col);
});
test('an old-shaped (pre-grid) saved layout falls back to the default rather '
'than crashing', () {
final decoded = HudWidgetLayout.fromJson(
HudMetric.speed,
{'x': 0.1, 'y': 0.2, 'width': 0.3, 'height': 0.15, 'visible': true},
);
final default_ = HudWidgetLayout.defaultFor(HudMetric.speed);
expect(decoded.col, default_.col);
expect(decoded.row, default_.row);
expect(decoded.colSpan, default_.colSpan);
expect(decoded.rowSpan, default_.rowSpan);
});
});
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('every metric gets a distinct, non-overlapping default rect', () {
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
for (var i = 0; i < layouts.length; i++) {
for (var j = i + 1; j < layouts.length; j++) {
expect(hudRectsOverlap(layouts[i], layouts[j]), isFalse,
reason: 'no two metrics should default to overlapping rects');
}
}
});
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
@@ -64,65 +80,146 @@ void main() {
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
});
test('every default is already within the valid clamp bounds', () {
test('every default is already within the valid grid 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);
expect(layout.clampedToGrid().col, layout.col);
expect(layout.clampedToGrid().row, layout.row);
expect(layout.clampedToGrid().colSpan, layout.colSpan);
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
}
});
});
group('clamped', () {
group('clampedToGrid', () {
const base = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.5,
y: 0.5,
width: 0.3,
height: 0.15,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
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 right/bottom edge is pulled back onto the '
'grid', () {
final result = base.copyWith(col: 20, row: 20).clampedToGrid();
expect(result.col, hudGridColumns - base.colSpan);
expect(result.row, hudGridRows - base.rowSpan);
});
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 position dragged past the left/top edge is pulled back onto the grid', () {
final result = base.copyWith(col: -5, row: -5).clampedToGrid();
expect(result.col, 0);
expect(result.row, 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);
final result = base.copyWith(colSpan: 0, rowSpan: 0).clampedToGrid();
expect(result.colSpan, hudMinSpan);
expect(result.rowSpan, hudMinSpan);
});
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);
final result = base.copyWith(colSpan: 99, rowSpan: 99).clampedToGrid();
expect(result.colSpan, hudMaxColSpan);
expect(result.rowSpan, hudMaxRowSpan);
});
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));
final result = base.copyWith(col: 3, colSpan: 4).clampedToGrid();
expect(result.colSpan, hudMaxColSpan);
// col itself is still clamped against the (now-bounded) span so the widget
// never sits even partially off the grid either.
expect(result.col + result.colSpan, lessThanOrEqualTo(hudGridColumns));
});
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);
expect(base.clampedToGrid().col, base.col);
expect(base.clampedToGrid().row, base.row);
expect(base.clampedToGrid().colSpan, base.colSpan);
expect(base.clampedToGrid().rowSpan, base.rowSpan);
});
});
group('hudRectsOverlap', () {
const a = HudWidgetLayout(
metric: HudMetric.speed,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
visible: true,
);
test('two identical rects overlap', () {
expect(hudRectsOverlap(a, a), isTrue);
});
test('two rects sharing only an edge (touching, not overlapping) do not overlap', () {
final rightNeighbor = a.copyWith(col: a.col + a.colSpan);
expect(hudRectsOverlap(a, rightNeighbor), isFalse);
final belowNeighbor = a.copyWith(row: a.row + a.rowSpan);
expect(hudRectsOverlap(a, belowNeighbor), isFalse);
});
test('two disjoint rects do not overlap', () {
final far = a.copyWith(col: a.col + a.colSpan + 3, row: a.row + a.rowSpan + 3);
expect(hudRectsOverlap(a, far), isFalse);
});
test('a rect fully containing another overlaps', () {
const big = HudWidgetLayout(
metric: HudMetric.distance,
col: 0,
row: 0,
colSpan: 4,
rowSpan: 4,
visible: true,
);
expect(hudRectsOverlap(big, a), isTrue);
});
test('two partially-overlapping rects overlap', () {
final overlapping = a.copyWith(col: a.col + 1, row: a.row + 1);
expect(hudRectsOverlap(a, overlapping), isTrue);
});
});
group('nextFreeSlot', () {
test('returns a rect that does not collide with any occupied visible layout, '
'when the metric\'s own default position is taken', () {
final occupier = HudWidgetLayout.defaultFor(HudMetric.speed);
final occupied = {HudMetric.speed: occupier};
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: occupied,
colSpan: occupier.colSpan,
rowSpan: occupier.rowSpan,
);
expect(hudRectsOverlap(result, occupier), isFalse);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is empty', () {
final result = HudWidgetLayout.nextFreeSlot(HudMetric.speed, occupied: {});
expect(result.col, 0);
expect(result.row, 0);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is all-hidden', () {
final hidden = HudWidgetLayout.defaultFor(HudMetric.speed).copyWith(visible: false);
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: {HudMetric.speed: hidden},
);
expect(result.col, 0);
expect(result.row, 0);
});
});
}