From 2935e578e43402169535f08591c4fc71a45910c1 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 18:30:26 +0200 Subject: [PATCH 1/3] Clamp tile spanX to columns in TileGridEngine layout place(tile:atRow:col:) wrote grid cells using the unclamped tile.size.spanX while the placement loop and canPlace use min(spanX, columns). A tile wider than the grid (e.g. a .large tile in a 2-column grid) wrote past the row end and trapped with an index out of range. snapshot() reported the same unclamped span, so the reported span exceeded the reserved cells. Clamp spanX to columns in both place() and snapshot() so the reserved cells, the written cells, and the reported span agree. No behavior change for the in-app column counts (4/8/12, max tile width 4). Co-Authored-By: Claude Opus 4.8 --- .../TileGridEngine/TileGridEngine.swift | 4 +- .../TileGridEngineClampTests.swift | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift diff --git a/Packages/TileGridEngine/Sources/TileGridEngine/TileGridEngine.swift b/Packages/TileGridEngine/Sources/TileGridEngine/TileGridEngine.swift index 16f9e79..5892b3a 100644 --- a/Packages/TileGridEngine/Sources/TileGridEngine/TileGridEngine.swift +++ b/Packages/TileGridEngine/Sources/TileGridEngine/TileGridEngine.swift @@ -199,7 +199,7 @@ public struct TileGridEngine: Sendable { id: id, row: r, column: c, - spanX: tile.size.spanX, + spanX: min(tile.size.spanX, columns), spanY: tile.size.spanY ) ) @@ -258,7 +258,7 @@ public struct TileGridEngine: Sendable { } func place(tile: Tile, atRow row: Int, col: Int) { - let spanX = tile.size.spanX + let spanX = min(tile.size.spanX, columns) let spanY = tile.size.spanY ensureRows(row + spanY) diff --git a/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift new file mode 100644 index 0000000..39b83c5 --- /dev/null +++ b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift @@ -0,0 +1,51 @@ +import Testing +import TileGridEngine + +/// Tests verifying that tiles wider than the grid are clamped to the column +/// count rather than writing cells out of bounds. +struct TileGridEngineClampTests { + + @Test + func place_tileWiderThanGrid_clampsWithoutCrashing() { + // A `.large` tile has spanX 4; a 2-column grid is narrower than that. + // Before clamping, layout wrote grid cells past the row width and trapped. + let engine = TileGridEngine( + columns: 2, + tiles: [Tile(title: "x", size: .large)] + ) + + let snapshot = engine.snapshot() + + // Laid out without a trap and reports the clamped span. + #expect(snapshot.placements.count == 1) + #expect(snapshot.placements.first?.spanX == 2) + } + + @Test + func place_columnsOne_anyTileClamps() { + // spanX 2 in a single-column grid must clamp to 1. + let engine = TileGridEngine( + columns: 1, + tiles: [Tile(title: "s", size: .small)] + ) + + let snapshot = engine.snapshot() + + #expect(snapshot.placements.count == 1) + #expect(snapshot.placements.first?.spanX == 1) + } + + @Test + func place_tileNarrowerThanGrid_spanUnchanged() { + // Regression guard for in-app sizing: a `.large` tile (spanX 4) in a + // grid at least 4 wide keeps its full span. + let engine = TileGridEngine( + columns: 4, + tiles: [Tile(title: "l", size: .large)] + ) + + let snapshot = engine.snapshot() + + #expect(snapshot.placements.first?.spanX == 4) + } +} From 7a149bca8afba51a0561be2096ff5a1071fe5399 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 21:41:32 +0200 Subject: [PATCH 2/3] Use /** */ block form for multi-line test comments Convert the two multi-line // explanatory comments added in the clamp regression tests to the project's /** ... */ block convention. Co-Authored-By: Claude Opus 4.8 --- .../TileGridEngineClampTests.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift index 39b83c5..d3d3441 100644 --- a/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift +++ b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift @@ -7,8 +7,10 @@ struct TileGridEngineClampTests { @Test func place_tileWiderThanGrid_clampsWithoutCrashing() { - // A `.large` tile has spanX 4; a 2-column grid is narrower than that. - // Before clamping, layout wrote grid cells past the row width and trapped. + /** + A `.large` tile has spanX 4; a 2-column grid is narrower than that. + Before clamping, layout wrote grid cells past the row width and trapped. + */ let engine = TileGridEngine( columns: 2, tiles: [Tile(title: "x", size: .large)] @@ -37,8 +39,10 @@ struct TileGridEngineClampTests { @Test func place_tileNarrowerThanGrid_spanUnchanged() { - // Regression guard for in-app sizing: a `.large` tile (spanX 4) in a - // grid at least 4 wide keeps its full span. + /** + Regression guard for in-app sizing: a `.large` tile (spanX 4) in a + grid at least 4 wide keeps its full span. + */ let engine = TileGridEngine( columns: 4, tiles: [Tile(title: "l", size: .large)] From 214186962f6c63bd0d4e1f86edb292b2d2fc7b29 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Thu, 16 Jul 2026 22:16:29 +0200 Subject: [PATCH 3/3] Assert clamp test on written cells, not just snapshot span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit place_tileWiderThanGrid_clampsWithoutCrashing previously asserted only on snapshot().placements.first?.spanX, which snapshot() recomputes from tile.size — so it validated the snapshot() clamp but not the cells place() actually wrote (that was only covered implicitly by "did not trap"). Assert on engine.cells: every row stays within the column count, and a .large tile reserves a full 4x2 block with exactly one origin. Co-Authored-By: Claude Opus 4.8 --- .../TileGridEngineClampTests.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift index d3d3441..d6a17b3 100644 --- a/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift +++ b/Packages/TileGridEngine/Tests/TileGridEngineTests/TileGridEngineClampTests.swift @@ -11,14 +11,25 @@ struct TileGridEngineClampTests { A `.large` tile has spanX 4; a 2-column grid is narrower than that. Before clamping, layout wrote grid cells past the row width and trapped. */ - let engine = TileGridEngine( - columns: 2, - tiles: [Tile(title: "x", size: .large)] - ) + let tile = Tile(title: "x", size: .large) + let engine = TileGridEngine(columns: 2, tiles: [tile]) - let snapshot = engine.snapshot() + // Assert on the cells `place()` actually wrote — not just the span + // `snapshot()` recomputes — so a wrong-region write is caught too. + let cells = engine.cells - // Laid out without a trap and reports the clamped span. + // Every row stays within the column count (the OOB write is gone). + #expect(cells.allSatisfy { $0.count == 2 }) + + // `.large` clamps to spanX 2 and keeps spanY 4: a fully reserved 4x2 block. + #expect(cells.count == 4) + let ownedCells = cells.flatMap { $0 }.filter { $0.tileID == tile.id } + #expect(ownedCells.count == 8) + let originCells = cells.flatMap { $0 }.filter { if case .origin = $0 { return true } else { return false } } + #expect(originCells.count == 1) + + // And the snapshot reports the matching clamped span. + let snapshot = engine.snapshot() #expect(snapshot.placements.count == 1) #expect(snapshot.placements.first?.spanX == 2) }