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 = {}; 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); }); }); }