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).
262 lines
9.3 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|