diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py index bec65c01..85f8ad2c 100644 --- a/src/vegas_mode/geometry.py +++ b/src/vegas_mode/geometry.py @@ -222,16 +222,19 @@ def find_blank_cut( return target ink = column_has_ink(img, threshold) - lo = max(0, target - search_radius) - hi = min(width - 1, target + search_radius) + + # target may legitimately equal width (a cut after the last column), but + # there is no column to inspect there, so both bounds stop at width - 1. + lo = max(0, min(target - search_radius, width - 1)) + hi = max(0, min(target + search_radius, width - 1)) # Walk outwards from target so the nearest gap wins. for offset in range(0, search_radius + 1): right = target + offset - if right <= hi and not ink[right]: + if lo <= right <= hi and not ink[right]: return right left = target - offset - if left >= lo and not ink[left]: + if lo <= left <= hi and not ink[left]: return left return target diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index a17724e4..e1e5589a 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -928,3 +928,44 @@ class TestBudgetUsesMeasuredGaps: p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream()) assert p._join_plugin_rows(selected).width <= DISPLAY_W + + +class TestRotationAcrossMultipleCycles: + """ + The single-image crop advances a window across cycles. The second and later + passes are where start + budget can land exactly on the image width, which + crashed find_blank_cut in the field and lost that plugin's content for the + cycle. First-pass-only tests never reach it. + """ + + def test_window_advances_over_many_cycles_without_error(self): + # Mirrors the field case: 1840px stocks strip, 1536px budget, so the + # second pass starts at 1536 and start + budget == 3072 -> clamped to + # the 1840 width, i.e. target == img.width. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + strip = canvas([(0, 1840)], width=1840) + widths = [] + for _ in range(8): + adapter.invalidate_cache('ledmatrix-stocks') + images = adapter.get_content(NativePlugin([strip]), 'ledmatrix-stocks') + assert images, "content must never be lost mid-rotation" + widths.append(images[0].width) + assert all(w > 0 for w in widths) + + def test_offset_wraps_back_to_zero_at_the_end(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + strip = canvas([(0, 1840)], width=1840) + seen_reset = False + for _ in range(6): + adapter.invalidate_cache('s') + adapter.get_content(NativePlugin([strip]), 's') + if adapter._item_offsets.get('s', 0) == 0: + seen_reset = True + assert seen_reset, "window should wrap round rather than stall at the end" + + def test_multi_row_rotation_never_returns_empty(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + rows = [canvas([(0, 200)], width=200) for _ in range(7)] + for _ in range(10): + adapter.invalidate_cache('rows') + assert adapter.get_content(NativePlugin(rows), 'rows') diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py index 2573fc16..5a336fc0 100644 --- a/test/test_vegas_geometry.py +++ b/test/test_vegas_geometry.py @@ -10,6 +10,7 @@ from src.vegas_mode.geometry import ( content_bounds, dead_window_stats, edge_blank, + find_blank_cut, separation_gap, trim_to_content, window_coverage_stats, @@ -319,3 +320,51 @@ class TestSeparationGap: # flat gap; measured separation lifts them to the 24px target. card = paint(make_img(150), 0, 150) assert separation_gap(card, card, target=24, minimum=8) == 24 + + +class TestFindBlankCut: + def test_snaps_to_the_nearest_gap(self): + img = paint(make_img(200), 0, 90) + paint(img, 110, 200) + # 100 is inside the 90..110 gap already. + assert find_blank_cut(img, 100, 20) == 100 + + def test_walks_outwards_to_find_a_gap(self): + img = paint(make_img(200), 0, 95) + paint(img, 105, 200) + cut = find_blank_cut(img, 90, 20) + assert 95 <= cut < 105 + + def test_solid_ink_returns_the_target(self): + assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100 + + def test_target_at_image_width_does_not_index_past_the_end(self): + # A cut after the last column is legal. Indexing ink[width] raised + # IndexError in the field, losing that plugin's content for the cycle. + # Reached once the rotation offset advances so start + budget lands + # exactly on the image width. + img = paint(make_img(1840), 0, 1840) + assert find_blank_cut(img, 1840, 32) == 1840 + + def test_target_past_image_width_is_clamped(self): + img = paint(make_img(100), 0, 100) + assert find_blank_cut(img, 500, 32) == 100 + + def test_target_at_width_with_a_trailing_gap_snaps_back(self): + # Content 0..179, blank 180..199. The nearest blank column to 200 is + # 199, not the start of the gap — nearest is what keeps the cut as + # close as possible to the requested budget. + img = paint(make_img(200), 0, 180) + assert find_blank_cut(img, 200, 32) == 199 + + def test_zero_radius_returns_the_target(self): + assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50 + + def test_negative_target_is_clamped_to_zero(self): + assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0 + + @pytest.mark.parametrize("target", [0, 1, 50, 99, 100]) + def test_never_raises_across_the_range(self, target): + img = paint(make_img(100), 0, 100) + cut = find_blank_cut(img, target, 16) + assert 0 <= cut <= 100