package world import ( "sort" "testing" "github.com/stretchr/testify/require" ) func TestRenderParamsCanvasSize(t *testing.T) { t.Parallel() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, } require.Equal(t, 150, params.CanvasWidthPx()) require.Equal(t, 120, params.CanvasHeightPx()) } func TestRenderParamsCameraZoomFp(t *testing.T) { t.Parallel() params := RenderParams{ CameraZoom: 1.25, } zoomFp, err := params.CameraZoomFp() require.NoError(t, err) require.Equal(t, 1250, zoomFp) } func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) { t.Parallel() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 50 * SCALE, CameraYWorldFp: 70 * SCALE, CameraZoom: 2.0, } rect, err := params.ExpandedCanvasWorldRect() require.NoError(t, err) require.Equal(t, 12500, rect.minX) require.Equal(t, 87500, rect.maxX) require.Equal(t, 40000, rect.minY) require.Equal(t, 100000, rect.maxY) } func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) { t.Parallel() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: -10 * SCALE, CameraYWorldFp: 3 * SCALE, CameraZoom: 1.0, } rect, err := params.ExpandedCanvasWorldRect() require.NoError(t, err) require.Equal(t, -85000, rect.minX) require.Equal(t, 65000, rect.maxX) require.Equal(t, -57000, rect.minY) require.Equal(t, 63000, rect.maxY) } func TestRenderParamsValidate(t *testing.T) { t.Parallel() params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 123456, CameraYWorldFp: -987654, CameraZoom: 1.0, } require.NoError(t, params.Validate()) } func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) { t.Parallel() tests := []RenderParams{ { ViewportWidthPx: 0, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }, { ViewportWidthPx: 100, ViewportHeightPx: 0, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }, { ViewportWidthPx: -1, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }, { ViewportWidthPx: 100, ViewportHeightPx: -1, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }, } for _, params := range tests { require.ErrorIs(t, params.Validate(), errInvalidViewportSize) } } func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) { t.Parallel() tests := []RenderParams{ { ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: -1, MarginYPx: 20, CameraZoom: 1.0, }, { ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: -1, CameraZoom: 1.0, }, } for _, params := range tests { require.ErrorIs(t, params.Validate(), errInvalidMargins) } } func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) { t.Parallel() tests := []RenderParams{ { ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 0, }, { ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: -1, }, { ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 0.0004, }, } for _, params := range tests { require.EqualError(t, params.Validate(), "invalid camera zoom") } } func TestExpandedCanvasWorldRect(t *testing.T) { t.Parallel() rect := expandedCanvasWorldRect( 50*SCALE, 70*SCALE, 150, 120, 2*SCALE, ) require.Equal(t, 12500, rect.minX) require.Equal(t, 87500, rect.maxX) require.Equal(t, 40000, rect.minY) require.Equal(t, 100000, rect.maxY) } func TestExpandedCanvasWorldRectPanics(t *testing.T) { t.Parallel() require.Panics(t, func() { _ = expandedCanvasWorldRect(0, 0, 0, 10, SCALE) }) require.Panics(t, func() { _ = expandedCanvasWorldRect(0, 0, 10, 0, SCALE) }) require.Panics(t, func() { _ = expandedCanvasWorldRect(0, 0, 10, 10, 0) }) } func TestWorldRenderRejectsNilDrawer(t *testing.T) { t.Parallel() w := NewWorld(10, 10) err := w.Render(nil, RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }) require.ErrorIs(t, err, errNilDrawer) } func TestWorldRenderRejectsInvalidParams(t *testing.T) { t.Parallel() w := NewWorld(10, 10) err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ ViewportWidthPx: 0, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraZoom: 1.0, }) require.ErrorIs(t, err, errInvalidViewportSize) } func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) { t.Parallel() w := NewWorld(10, 10) err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, }) require.ErrorIs(t, err, errGridNotBuilt) } func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) { t.Parallel() w := NewWorld(10, 10) // Render relies on the spatial grid being built. // In production UI this is done via IndexOnViewportChange. w.IndexOnViewportChange(100, 80, 1.0) err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 12345, CameraYWorldFp: -67890, CameraZoom: 1.0, }) require.NoError(t, err) } func TestTileWorldRect_NoWrapSingleTile(t *testing.T) { t.Parallel() worldW := 100 worldH := 80 rect := Rect{minX: 10, maxX: 30, minY: 5, maxY: 25} tiles := tileWorldRect(rect, worldW, worldH) require.Len(t, tiles, 1) require.Equal(t, 0, tiles[0].OffsetX) require.Equal(t, 0, tiles[0].OffsetY) require.Equal(t, 10, tiles[0].Rect.minX) require.Equal(t, 30, tiles[0].Rect.maxX) require.Equal(t, 5, tiles[0].Rect.minY) require.Equal(t, 25, tiles[0].Rect.maxY) } func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) { t.Parallel() worldW := 100 worldH := 80 // Crosses the X boundary once: [30..130) maps to: // tile 0: [30..100) offset 0 // tile 1: [0..30) offset 100 rect := Rect{minX: 30, maxX: 130, minY: 10, maxY: 20} tiles := tileWorldRect(rect, worldW, worldH) require.Len(t, tiles, 2) require.Equal(t, 0, tiles[0].OffsetX) require.Equal(t, 0, tiles[0].OffsetY) require.Equal(t, Rect{minX: 30, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) require.Equal(t, 100, tiles[1].OffsetX) require.Equal(t, 0, tiles[1].OffsetY) require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect) } func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) { t.Parallel() worldW := 100 worldH := 80 // Crosses boundary around 0: [-20..20) maps to: // tile -1: [80..100) offset -100 // tile 0: [0..20) offset 0 rect := Rect{minX: -20, maxX: 20, minY: 10, maxY: 20} tiles := tileWorldRect(rect, worldW, worldH) require.Len(t, tiles, 2) require.Equal(t, -100, tiles[0].OffsetX) require.Equal(t, 0, tiles[0].OffsetY) require.Equal(t, Rect{minX: 80, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) require.Equal(t, 0, tiles[1].OffsetX) require.Equal(t, 0, tiles[1].OffsetY) require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect) } func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) { t.Parallel() worldW := 100 worldH := 80 // Crosses both X and Y boundaries once. // X: [30..130) => tile 0 [30..100), tile 1 [0..30) // Y: [60..100) => tile 0 [60..80), tile 1 [0..20) rect := Rect{minX: 30, maxX: 130, minY: 60, maxY: 100} tiles := tileWorldRect(rect, worldW, worldH) require.Len(t, tiles, 4) // Order: tx ascending, then ty ascending. require.Equal(t, 0, tiles[0].OffsetX) require.Equal(t, 0, tiles[0].OffsetY) require.Equal(t, Rect{minX: 30, maxX: 100, minY: 60, maxY: 80}, tiles[0].Rect) require.Equal(t, 0, tiles[1].OffsetX) require.Equal(t, 80, tiles[1].OffsetY) require.Equal(t, Rect{minX: 30, maxX: 100, minY: 0, maxY: 20}, tiles[1].Rect) require.Equal(t, 100, tiles[2].OffsetX) require.Equal(t, 0, tiles[2].OffsetY) require.Equal(t, Rect{minX: 0, maxX: 30, minY: 60, maxY: 80}, tiles[2].Rect) require.Equal(t, 100, tiles[3].OffsetX) require.Equal(t, 80, tiles[3].OffsetY) require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect) } func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) { t.Parallel() worldW := 100 worldH := 80 require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 10, minY: 0, maxY: 0}, worldW, worldH)) require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) } func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) { t.Parallel() require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 0, 10) }) require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) }) } func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) { t.Parallel() w := NewWorld(10, 10) tiles := []WorldTile{ { Rect: Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}, }, } _, err := w.collectCandidatesForTiles(tiles) require.ErrorIs(t, err, errGridNotBuilt) } func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // 5x5 grid // Circle in the middle, radius big enough to cover multiple cells. id, err := w.AddCircle(5, 5, 2.2) require.NoError(t, err) // Build index. w.indexObject(w.objects[id]) // Query whole world tile. items := w.collectCandidatesForTile(Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}) // The circle is indexed into multiple cells, but must appear only once in candidates. require.Len(t, items, 1) require.Equal(t, id, items[0].ID()) } func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // cells are [0..2), [2..4), ... id, err := w.AddPoint(1.0, 1.0) // (1000,1000) => cell (0,0) require.NoError(t, err) w.indexObject(w.objects[id]) // Query exactly the first cell as half-open rect. r := Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: 2 * SCALE} items := w.collectCandidatesForTile(r) require.Len(t, items, 1) require.Equal(t, id, items[0].ID()) // Query adjacent cell (should not contain the point). r2 := Rect{minX: 2 * SCALE, maxX: 4 * SCALE, minY: 0, maxY: 2 * SCALE} items2 := w.collectCandidatesForTile(r2) require.Empty(t, items2) } func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Circle near the left edge crossing X=0 boundary. // With radius 1.0 it covers [-0.5..1.5] in world units => wrap indexes both left and right sides. id, err := w.AddCircle(0.5, 5.0, 1.0) require.NoError(t, err) w.indexObject(w.objects[id]) leftStrip := WorldTile{ Rect: Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: w.H}, } rightStrip := WorldTile{ Rect: Rect{minX: w.W - 2*SCALE, maxX: w.W, minY: 0, maxY: w.H}, } batches, err := w.collectCandidatesForTiles([]WorldTile{leftStrip, rightStrip}) require.NoError(t, err) require.Len(t, batches, 2) // Expect the circle candidate to appear in both batches (different render offsets later). require.Len(t, batches[0].Items, 1) require.Equal(t, id, batches[0].Items[0].ID()) require.Len(t, batches[1].Items, 1) require.Equal(t, id, batches[1].Items[0].ID()) } func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { t.Parallel() // World: 100x80 real => 100000x80000 fixed. w := NewWorld(100, 80) // Build any grid (cell size doesn't matter for this test, but grid must exist). w.resetGrid(10 * SCALE) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 50 * SCALE, CameraYWorldFp: 40 * SCALE, CameraZoom: 2.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) require.Equal(t, 150, plan.CanvasWidthPx) require.Equal(t, 120, plan.CanvasHeightPx) require.Equal(t, 2*SCALE, plan.ZoomFp) require.Len(t, plan.Tiles, 1) td := plan.Tiles[0] require.Equal(t, 0, td.ClipX) require.Equal(t, 0, td.ClipY) require.Equal(t, 150, td.ClipW) require.Equal(t, 120, td.ClipH) require.Equal(t, 0, td.Tile.OffsetX) require.Equal(t, 0, td.Tile.OffsetY) } func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { t.Parallel() // World: 10x10 => 10000x10000 fixed. w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Use zoom=1 and the same canvas geometry as earlier: 150x120. params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) require.Equal(t, 150, plan.CanvasWidthPx) require.Equal(t, 120, plan.CanvasHeightPx) require.Equal(t, SCALE, plan.ZoomFp) type interval struct { start int end int } // Full tile size in pixels for one whole world width/height at the current zoom. fullTileXPx := worldSpanFixedToCanvasPx(w.W, plan.ZoomFp) fullTileYPx := worldSpanFixedToCanvasPx(w.H, plan.ZoomFp) require.Greater(t, fullTileXPx, 0) require.Greater(t, fullTileYPx, 0) // ---- X coverage ---- // Take the first Y strip only to avoid duplicates across Y. intervalsX := make([]interval, 0, len(plan.Tiles)) for _, td := range plan.Tiles { if td.ClipY == 0 && td.ClipH > 0 && td.ClipW > 0 { intervalsX = append(intervalsX, interval{ start: td.ClipX, end: td.ClipX + td.ClipW, }) // A single tile must never exceed one whole world-tile width in pixels. require.LessOrEqual(t, td.ClipW, fullTileXPx, "tile width must not exceed one world tile in pixels") } } require.NotEmpty(t, intervalsX) sort.Slice(intervalsX, func(i, j int) bool { if intervalsX[i].start != intervalsX[j].start { return intervalsX[i].start < intervalsX[j].start } return intervalsX[i].end < intervalsX[j].end }) require.Equal(t, 0, intervalsX[0].start) cursorX := intervalsX[0].end for i := 1; i < len(intervalsX); i++ { require.Equal(t, cursorX, intervalsX[i].start, "gap/overlap in X coverage between intervals %d and %d", i-1, i) cursorX = intervalsX[i].end } require.Equal(t, plan.CanvasWidthPx, cursorX) // ---- Y coverage ---- // Take the first X strip only to avoid duplicates across X. intervalsY := make([]interval, 0, len(plan.Tiles)) for _, td := range plan.Tiles { if td.ClipX == 0 && td.ClipW > 0 && td.ClipH > 0 { intervalsY = append(intervalsY, interval{ start: td.ClipY, end: td.ClipY + td.ClipH, }) // A single tile must never exceed one whole world-tile height in pixels. require.LessOrEqual(t, td.ClipH, fullTileYPx, "tile height must not exceed one world tile in pixels") } } require.NotEmpty(t, intervalsY) sort.Slice(intervalsY, func(i, j int) bool { if intervalsY[i].start != intervalsY[j].start { return intervalsY[i].start < intervalsY[j].start } return intervalsY[i].end < intervalsY[j].end }) require.Equal(t, 0, intervalsY[0].start) cursorY := intervalsY[0].end for i := 1; i < len(intervalsY); i++ { require.Equal(t, cursorY, intervalsY[i].start, "gap/overlap in Y coverage between intervals %d and %d", i-1, i) cursorY = intervalsY[i].end } require.Equal(t, plan.CanvasHeightPx, cursorY) } func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Put one circle in the world and index it, it will occupy multiple cells. id, err := w.AddCircle(5, 5, 2.2) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlanStageA(params) require.NoError(t, err) require.NotEmpty(t, plan.Tiles) // Find any tile that has candidates; expect the circle to appear at most once per tile. for _, td := range plan.Tiles { if len(td.Candidates) == 0 { continue } seen := map[PrimitiveID]struct{}{} for _, it := range td.Candidates { _, ok := seen[it.ID()] require.False(t, ok, "candidate duplicated within a tile") seen[it.ID()] = struct{}{} } } } func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) { t.Parallel() w := NewWorld(10, 10) // Initialize state via a full render commit. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } w.resetGrid(2 * SCALE) require.NoError(t, w.CommitFullRedrawState(params)) require.True(t, w.renderState.initialized) w.ForceFullRedrawNext() require.False(t, w.renderState.initialized) }