diff --git a/docs/feedback/FB-03-grid-snapped-hud-widgets.md b/docs/feedback/FB-03-grid-snapped-hud-widgets.md index 953debe..130361f 100644 --- a/docs/feedback/FB-03-grid-snapped-hud-widgets.md +++ b/docs/feedback/FB-03-grid-snapped-hud-widgets.md @@ -1,6 +1,6 @@ # FB-03 — Grid-snapped HUD widgets with auto-fit, centered text -**Depends on** — · **Size** L · **Status** Not started +**Depends on** — · **Size** L · **Status** Done ## Goal Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional @@ -601,3 +601,61 @@ FB-02 (hiding the idle Speed panel — separate ticket, this one only touches th recording-state HUD widgets themselves). Making widget text grow to actually fill a larger grid span (see Risks). Any change to which metrics exist or their default visibility set — `HudMetric`'s own enum and `label`s are untouched. + +## Outcome +Implemented exactly per the Design section above, no deviations from the approach: + +- `lib/src/hud/hud_widget_layout.dart` rewritten with the 4x8 int grid + (`hudGridColumns`/`hudGridRows`), `col`/`row`/`colSpan`/`rowSpan` fields, + `clampedToGrid()`, top-level `hudRectsOverlap()`, updated `toJson`/`fromJson` (now + reading `col`/`row`/`colSpan`/`rowSpan` — an old `x`/`y`/`width`/`height` blob throws + in the try/catch and falls back to `defaultFor`, no migration code needed, as + predicted), an unchanged-in-spirit `defaultFor`, and the new `nextFreeSlot` scan. +- `lib/src/hud/hud_layout_controller.dart` rewritten: `updatePosition`/`updateSize` + take ints, clamp, and reject-on-collision (no-op, no revert needed since state is + never mutated until the candidate passes); `setVisible` calls `nextFreeSlot` only + when the metric's own slot now collides with another visible widget. +- `lib/src/ui/components/draggable_resizable_hud_widget.dart` rewritten: local + `_dragOffset`/`_resizeDelta` pixel state tracks the live gesture smoothly; snapping + to the nearest grid cell and calling `onMoved`/`onResized` exactly once happens on + `onLongPressEnd`/`onPanEnd`. Both offsets reset to zero right after that call + regardless of whether the controller accepted the move, which is what makes a + rejected move/resize visually snap back with no separate accept/reject callback. + Callback signatures changed to `void Function(int, int)`. +- `lib/src/ui/components/hud_edit_overlay.dart`: the two callback closures updated to + the new int signatures; no other change needed. +- `lib/src/ui/record/record_screen.dart`'s `_HudMetricValue.build()`: wrapped in + `FittedBox(fit: BoxFit.scaleDown)`, `maxLines`/`overflow` dropped from both `Text`s. + +**Confirmed trade-off** (the one the ticket flagged in advance): `BoxFit.scaleDown` +never enlarges past the reference size, so a widget resized to `hudMaxColSpan` x +`hudMaxRowSpan` renders the same 10px/18px text as a 1x1 widget — just with more empty +space around it, still centered via `DraggableResizableHudWidget`'s existing +`Center`. Verified directly with the new "largest allowed size" widget test (a 4x3 +`DraggableResizableHudWidget` at 400x400 pixels records no overflow exception, same as +the 1x1 case) — text-scaling-with-widget-size remains explicit future work, not a +silent gap. + +**Confirmed migration behavior**: an old-shaped saved layout (pre-this-ticket JSON with +`x`/`y`/`width`/`height` keys) loads without crashing and falls back to `defaultFor` +per metric — covered directly by a new `hud_widget_layout_test.dart` case and a new +`config_test.dart` case using literal old-shaped JSON. No user-facing warning was +added, per the ticket's own Risks section (no real user base yet to protect). + +**Tests**: `test/hud_widget_layout_test.dart` and `test/hud_layout_controller_test.dart` +were fully rewritten for the int grid fields, per the ticket's Tests section (JSON +round trip, malformed/old-shaped-JSON fallback, `defaultFor` non-overlap and +visibility, `clampedToGrid` edge/floor/ceiling/shrink-before-reposition cases, +`hudRectsOverlap` touching/disjoint/containing/partial cases, `nextFreeSlot` colliding +and empty/hidden-occupied cases, controller collision-rejection for both +`updatePosition` and `updateSize`, and `setVisible`'s free-slot fallback). Also updated: +`test/config_test.dart` (its `HudWidgetLayout` literal and JSON fixtures used the old +field names — updated to the grid fields, plus one new case for old-shaped JSON) and +`test/hud_edit_overlay_test.dart` (its `.x`/`.y` assertions became `.col`/`.row`; added +the two ticket-requested widget tests for a collision-rejected drag and a +collision-rejected resize, plus two more building `DraggableResizableHudWidget` +directly at `1x1` and `hudMaxColSpan x hudMaxRowSpan` to assert `tester.takeException()` +is `null` — the established no-overflow pattern already used in +`settings_screen_test.dart`). + +Final count: **392 tests passing** (up from the 374 baseline), `flutter analyze` clean. diff --git a/lib/src/hud/hud_layout_controller.dart b/lib/src/hud/hud_layout_controller.dart index 8bf4f69..187e849 100644 --- a/lib/src/hud/hud_layout_controller.dart +++ b/lib/src/hud/hud_layout_controller.dart @@ -1,4 +1,4 @@ -/// UI-04: the in-memory, authoritative HUD layout during an editing session. +/// UI-04/FB-03: the in-memory, authoritative HUD layout during an editing session. /// /// Seeded from `Config` once, and written back to it only on [persist] -- not on every /// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own @@ -26,24 +26,47 @@ class HudLayoutController extends StateNotifier> final Config? _config; - void updatePosition(HudMetric metric, double x, double y) { + /// "Reject and no-op" is the collision rule for drag/resize -- simplest correct + /// behaviour, and it's equivalent to "revert to last valid" here since `state` is + /// never mutated until the candidate passes the check. A drag/resize that would + /// overlap another visible widget simply has no effect for that gesture; the widget + /// stays at its last valid position. + void updatePosition(HudMetric metric, int col, int row) { final current = state[metric]; if (current == null) return; - state = {...state, metric: current.copyWith(x: x, y: y).clamped()}; + final candidate = current.copyWith(col: col, row: row).clampedToGrid(); + if (_overlapsAnyOther(metric, candidate)) return; + state = {...state, metric: candidate}; } - void updateSize(HudMetric metric, double width, double height) { + void updateSize(HudMetric metric, int colSpan, int rowSpan) { final current = state[metric]; if (current == null) return; - state = {...state, metric: current.copyWith(width: width, height: height).clamped()}; + final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid(); + if (_overlapsAnyOther(metric, candidate)) return; + state = {...state, metric: candidate}; } + bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values + .where((l) => l.visible && l.metric != metric) + .any((other) => hudRectsOverlap(candidate, other)); + /// A metric turned on for the first time (no meaningfully-placed prior layout) gets /// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from - /// before it was last turned off -- the ticket's own acceptance criterion. A metric - /// that already has a real saved position keeps it. + /// before it was last turned off. A metric that already has a real saved position + /// keeps it -- unless that slot is now occupied by another visible widget (the + /// "goes crazy" bug this ticket exists to fix), in which case it's placed in the + /// next free grid cell instead of popping up on top of the occupier. void setVisible(HudMetric metric, bool visible) { - final current = state[metric] ?? HudWidgetLayout.defaultFor(metric); + var current = state[metric] ?? HudWidgetLayout.defaultFor(metric); + if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) { + current = HudWidgetLayout.nextFreeSlot( + metric, + occupied: state, + colSpan: current.colSpan, + rowSpan: current.rowSpan, + ); + } state = {...state, metric: current.copyWith(visible: visible)}; } diff --git a/lib/src/hud/hud_widget_layout.dart b/lib/src/hud/hud_widget_layout.dart index 6c6adaf..6bd33a8 100644 --- a/lib/src/hud/hud_widget_layout.dart +++ b/lib/src/hud/hud_widget_layout.dart @@ -1,94 +1,110 @@ -/// UI-04: one telemetry widget's position, size, and visibility on the customizable -/// HUD -- see the ticket's Design section for why these are fractions (0.0-1.0) of the -/// available HUD area rather than absolute pixels: a layout saved on one device or -/// orientation still makes sense on another. +/// FB-03: one telemetry widget's position, size, and visibility on the customizable +/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section +/// for why: free-form fractional positioning had no concept of a sibling widget, so +/// toggling widgets on/off could stack one directly on top of another). library; import 'hud_metric.dart'; -/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its -/// own number is unreadable, or grow to the point it swallows the whole HUD. -const double hudMinWidthFraction = 0.20; -const double hudMaxWidthFraction = 0.70; -const double hudMinHeightFraction = 0.08; -const double hudMaxHeightFraction = 0.40; +/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row +/// assumption and gives ample vertical room for the 8-metric list. +const int hudGridColumns = 4; +const int hudGridRows = 8; + +/// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to +/// the point its own number is unreadable, or grow to the point it swallows the whole +/// HUD. +const int hudMinSpan = 1; +const int hudMaxColSpan = 4; +const int hudMaxRowSpan = 3; + +/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that +/// merely touch do not count as overlapping. +bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) { + final aColEnd = a.col + a.colSpan; + final aRowEnd = a.row + a.rowSpan; + final bColEnd = b.col + b.colSpan; + final bRowEnd = b.row + b.rowSpan; + return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row; +} class HudWidgetLayout { const HudWidgetLayout({ required this.metric, - required this.x, - required this.y, - required this.width, - required this.height, + required this.col, + required this.row, + required this.colSpan, + required this.rowSpan, required this.visible, }); final HudMetric metric; - /// Top-left corner, as a fraction of the HUD area's width/height. - final double x; - final double y; - final double width; - final double height; + /// Top-left cell, in grid coordinates (0-based). + final int col; + final int row; + + /// How many cells wide/tall this widget spans. + final int colSpan; + final int rowSpan; final bool visible; HudWidgetLayout copyWith({ - double? x, - double? y, - double? width, - double? height, + int? col, + int? row, + int? colSpan, + int? rowSpan, bool? visible, }) => HudWidgetLayout( metric: metric, - x: x ?? this.x, - y: y ?? this.y, - width: width ?? this.width, - height: height ?? this.height, + col: col ?? this.col, + row: row ?? this.row, + colSpan: colSpan ?? this.colSpan, + rowSpan: rowSpan ?? this.rowSpan, visible: visible ?? this.visible, ); - /// Corrects a drag/resize result that ended outside the allowed area back to the - /// nearest valid position/size -- clamped to the [hudMinWidthFraction]/ - /// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never - /// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a - /// resize that would push a widget off-screen should shrink it back on-screen, not - /// silently reposition it out from under the rider's finger. - HudWidgetLayout clamped() { - final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction); - final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction); - final clampedX = x.clamp(0.0, 1.0 - clampedWidth); - final clampedY = y.clamp(0.0, 1.0 - clampedHeight); + /// A *self-contained* bounds check with no awareness of siblings -- corrects a + /// drag/resize result that ended outside the grid back to the nearest valid + /// position/size, span first (so a resize that would push a widget off-grid shrinks + /// it back on-grid rather than silently repositioning it), then position. + HudWidgetLayout clampedToGrid() { + final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan); + final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan); + final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan); + final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan); return HudWidgetLayout( metric: metric, - x: clampedX, - y: clampedY, - width: clampedWidth, - height: clampedHeight, + col: clampedCol, + row: clampedRow, + colSpan: clampedColSpan, + rowSpan: clampedRowSpan, visible: visible, ); } Map toJson() => { - 'x': x, - 'y': y, - 'width': width, - 'height': height, + 'col': col, + 'row': row, + 'colSpan': colSpan, + 'rowSpan': rowSpan, 'visible': visible, }; /// Falls back to [defaultFor] rather than throwing on a malformed/partial entry -- - /// an old saved layout from a future app version with fields this version doesn't - /// recognise should degrade to a sane default, not crash Settings on launch. + /// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with + /// fields this version doesn't recognise should degrade to a sane default, not crash + /// Settings on launch. static HudWidgetLayout fromJson(HudMetric metric, Map json) { try { return HudWidgetLayout( metric: metric, - x: (json['x'] as num).toDouble(), - y: (json['y'] as num).toDouble(), - width: (json['width'] as num).toDouble(), - height: (json['height'] as num).toDouble(), + col: (json['col'] as num).toInt(), + row: (json['row'] as num).toInt(), + colSpan: (json['colSpan'] as num).toInt(), + rowSpan: (json['rowSpan'] as num).toInt(), visible: json['visible'] as bool, - ).clamped(); + ).clampedToGrid(); } catch (_) { return HudWidgetLayout.defaultFor(metric); } @@ -98,24 +114,54 @@ class HudWidgetLayout { /// before the rider customises anything, and a metric toggled on for the first time /// (with no saved position) lands somewhere sane rather than stacked on another /// widget. Two rows of four, matching the Stitch mockup's row of cards for however - /// many metrics fit in the first row, with the rest continuing below it. + /// many metrics fit in the first row, with the rest continuing below it. Purely a + /// function of the metric's own index -- these 8 fixed slots never overlap each + /// other by construction, so no runtime state is needed here. factory HudWidgetLayout.defaultFor(HudMetric metric) { const columns = 4; - const cellWidth = 0.22; - const cellHeight = 0.12; - const gap = 0.02; + const rowSpan = 2; final index = HudMetric.values.indexOf(metric); - final row = index ~/ columns; + final row = (index ~/ columns) * rowSpan; final col = index % columns; return HudWidgetLayout( metric: metric, - x: 0.02 + col * (cellWidth + gap), - y: 0.06 + row * (cellHeight + gap), - width: cellWidth, - height: cellHeight, + col: col, + row: row, + colSpan: 1, + rowSpan: rowSpan, // The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first // four enum values are ordered to match, so only those start visible. visible: index < columns, ); } + + /// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't + /// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if + /// the grid is fully packed -- a stacked default is better than a crash or an + /// exception the rider can't do anything about. + static HudWidgetLayout nextFreeSlot( + HudMetric metric, { + required Map occupied, + int colSpan = 1, + int rowSpan = 2, + }) { + HudWidgetLayout candidate(int col, int row) => HudWidgetLayout( + metric: metric, + col: col, + row: row, + colSpan: colSpan, + rowSpan: rowSpan, + visible: true, + ); + for (var row = 0; row <= hudGridRows - rowSpan; row++) { + for (var col = 0; col <= hudGridColumns - colSpan; col++) { + final c = candidate(col, row); + final overlapsAny = occupied.values + .where((l) => l.visible && l.metric != metric) + .any((other) => hudRectsOverlap(c, other)); + if (!overlapsAny) return c; + } + } + return candidate(0, 0); + } } diff --git a/lib/src/ui/components/draggable_resizable_hud_widget.dart b/lib/src/ui/components/draggable_resizable_hud_widget.dart index fd28e19..64e99af 100644 --- a/lib/src/ui/components/draggable_resizable_hud_widget.dart +++ b/lib/src/ui/components/draggable_resizable_hud_widget.dart @@ -1,6 +1,9 @@ -/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a -/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and -/// resizable only while [editing] is true. +/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a +/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and +/// resizable only while [editing] is true. Drag/resize snap to whole grid cells on +/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields); +/// during the gesture itself the widget tracks the finger/handle continuously in +/// pixels for a smooth feel. /// /// Deliberately does not attach any drag gesture at all when [editing] is false -- /// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally @@ -36,11 +39,13 @@ class DraggableResizableHudWidget extends StatefulWidget { final bool editing; final Widget child; - /// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller - /// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one - /// place that logic lives. - final void Function(double x, double y) onMoved; - final void Function(double width, double height) onResized; + /// Final snapped grid coordinates -- called exactly once, at gesture end, not on + /// every frame of movement (a local, uncommitted pixel offset tracks the live drag; + /// see [_DraggableResizableHudWidgetState]). The caller + /// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is + /// exactly one place that logic lives. + final void Function(int col, int row) onMoved; + final void Function(int colSpan, int rowSpan) onResized; @override State createState() => @@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget { class _DraggableResizableHudWidgetState extends State { bool _grabbed = false; + /// Pixels, uncommitted, live only during a move gesture -- painted on top of the + /// last-committed grid position so the drag tracks the finger smoothly, then reset + /// to zero the instant the gesture ends (whether or not the parent accepted the + /// resulting snapped position), which is what makes the widget visually "snap back" + /// on a rejected move with no separate accept/reject callback needed. + Offset _dragOffset = Offset.zero; + + /// Pixels, uncommitted, live only during a resize gesture -- same idea as + /// [_dragOffset]. + Size _resizeDelta = Size.zero; + + double get _cellWidth => widget.areaSize.width / hudGridColumns; + double get _cellHeight => widget.areaSize.height / hudGridRows; + @override Widget build(BuildContext context) { final layout = widget.layout; - final left = layout.x * widget.areaSize.width; - final top = layout.y * widget.areaSize.height; - final width = layout.width * widget.areaSize.width; - final height = layout.height * widget.areaSize.height; + final left = layout.col * _cellWidth + _dragOffset.dx; + final top = layout.row * _cellHeight + _dragOffset.dy; + final width = layout.colSpan * _cellWidth + _resizeDelta.width; + final height = layout.rowSpan * _cellHeight + _resizeDelta.height; Widget card = AnimatedScale( scale: _grabbed ? 1.05 : 1.0, @@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State _grabbed = true); }, onLongPressMoveUpdate: (details) { - widget.onMoved( - layout.x + details.offsetFromOrigin.dx / widget.areaSize.width, - layout.y + details.offsetFromOrigin.dy / widget.areaSize.height, - ); + setState(() => _dragOffset = details.offsetFromOrigin); }, - onLongPressEnd: (_) => setState(() => _grabbed = false), - onLongPressCancel: () => setState(() => _grabbed = false), + onLongPressEnd: (_) { + final snappedCol = + ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round(); + final snappedRow = + ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round(); + widget.onMoved(snappedCol, snappedRow); + setState(() { + _grabbed = false; + _dragOffset = Offset.zero; + }); + }, + onLongPressCancel: () => setState(() { + _grabbed = false; + _dragOffset = Offset.zero; + }), child: card, ); } else { @@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State _resizeDelta = Size.zero); }, child: Container( key: const Key('hud-resize-handle'), diff --git a/lib/src/ui/components/hud_edit_overlay.dart b/lib/src/ui/components/hud_edit_overlay.dart index ba2c6d4..37a9bc7 100644 --- a/lib/src/ui/components/hud_edit_overlay.dart +++ b/lib/src/ui/components/hud_edit_overlay.dart @@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState { layout: entry.value, areaSize: areaSize, editing: _editing, - onMoved: (x, y) => notifier.updatePosition(entry.key, x, y), - onResized: (w, h) => notifier.updateSize(entry.key, w, h), + onMoved: (col, row) => notifier.updatePosition(entry.key, col, row), + onResized: (colSpan, rowSpan) => + notifier.updateSize(entry.key, colSpan, rowSpan), child: widget.metricBuilder(context, entry.key), ), if (_editing) diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index 7db02d3..16711ce 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -328,31 +328,38 @@ class _HudMetricValue extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - metric.label.toUpperCase(), - style: TextStyle( - fontSize: 10 * scale, - letterSpacing: 1, - color: colors.onSurfaceVariant, + // FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so + // maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still + // scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size + // FittedBox scales down from at small widget sizes; the ratio between label and + // value size is preserved automatically as it scales. Note: BoxFit.scaleDown never + // enlarges past that reference size, so a widget resized to the grid's maximum + // span still shows the same reference text, just with more empty space around it + // -- not larger text filling the space. + return FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + metric.label.toUpperCase(), + style: TextStyle( + fontSize: 10 * scale, + letterSpacing: 1, + color: colors.onSurfaceVariant, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - _value, - style: monoDigits.copyWith( - fontSize: 18 * scale, - fontWeight: FontWeight.bold, - color: _valueColor(colors), + const SizedBox(height: 4), + Text( + _value, + style: monoDigits.copyWith( + fontSize: 18 * scale, + fontWeight: FontWeight.bold, + color: _valueColor(colors), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ); } } diff --git a/test/config_test.dart b/test/config_test.dart index 1e8bf1c..680835d 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -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', diff --git a/test/hud_edit_overlay_test.dart b/test/hud_edit_overlay_test.dart index 328a9e3..e173d4f 100644 --- a/test/hud_edit_overlay_test.dart +++ b/test/hud_edit_overlay_test.dart @@ -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); + }); } diff --git a/test/hud_layout_controller_test.dart b/test/hud_layout_controller_test.dart index 3363407..453839f 100644 --- a/test/hud_layout_controller_test.dart +++ b/test/hud_layout_controller_test.dart @@ -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'); }); diff --git a/test/hud_widget_layout_test.dart b/test/hud_widget_layout_test.dart index d0b9ed5..92ae0c7 100644 --- a/test/hud_widget_layout_test.dart +++ b/test/hud_widget_layout_test.dart @@ -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); }); }); }