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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user