Files
rippr/test/hud_widget_layout_test.dart
uhryniuk 7b452b7b81 FB-09: size default HUD widgets by title, not a fixed uniform cell
Adds minColSpanForLabel as a character-count proxy for how many grid
columns a metric's title needs, reworks HudWidgetLayout.defaultFor into
a left-to-right row-packing layout using it, enforces the same minimum
as a floor on manual resize, and splits _HudMetricValue's FittedBox so
the title renders at a fixed reference size while only the value
shrinks/grows to fill the remaining space.

Confirms a real, documented interaction with FB-08's _reflowRow: its
even column split has no awareness of a member's title-driven minimum
and can push one below it. Left unfixed per this ticket's own
Out-of-scope section -- _reflowRow's algorithm is FB-08's, not this
ticket's, to change.

Test count 426 -> 434, all green. flutter analyze unchanged (same 4
pre-existing, unrelated info-level issues).
2026-08-25 00:28:40 -05:00

262 lines
9.3 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';
/// 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,
col: 1,
row: 2,
colSpan: 3,
rowSpan: 2,
visible: true,
);
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
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, {'col': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
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, {'col': 1, 'row': 1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
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, 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/'
'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 grid bounds', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(layout.clampedToGrid().col, layout.col);
expect(layout.clampedToGrid().row, layout.row);
expect(layout.clampedToGrid().colSpan, layout.colSpan);
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
}
});
test('every metric\'s colSpan is at least its own title-driven minimum', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(
layout.colSpan,
greaterThanOrEqualTo(minColSpanForLabel(metric.label)),
);
}
});
test('no row\'s members ever sum past hudGridColumns (the packing '
'algorithm\'s own invariant)', () {
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
final byRow = <int, int>{};
for (final layout in layouts) {
byRow[layout.row] = (byRow[layout.row] ?? 0) + layout.colSpan;
}
for (final sum in byRow.values) {
expect(sum, lessThanOrEqualTo(hudGridColumns));
}
});
});
group('minColSpanForLabel', () {
test('a label under 10 characters fits one column', () {
expect(minColSpanForLabel(HudMetric.speed.label), 1);
});
test('a label under 15 characters needs two', () {
expect(minColSpanForLabel(HudMetric.elapsedTime.label), 2);
});
test('a label of 15 or more characters needs three', () {
expect(minColSpanForLabel(HudMetric.pointsCaptured.label), 3);
});
});
group('clampedToGrid', () {
const base = HudWidgetLayout(
metric: HudMetric.speed,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
visible: true,
);
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 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(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(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(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.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);
});
});
}