package world import ( "github.com/stretchr/testify/require" "image" "image/color" "sort" "testing" ) // TestRenderParamsCanvasSize verifies render Params Canvas Size. 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()) } // TestRenderParamsCameraZoomFp verifies render Params Camera Zoom Fp. 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) } // TestRenderParamsExpandedCanvasWorldRect verifies render Params Expanded Canvas World Rect. 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) } // TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera verifies render Params Expanded Canvas World Rect Allows Out Of World Camera. 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) } // TestRenderParamsValidate verifies render Params Validate. 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()) } // TestRenderParamsValidateRejectsInvalidViewport verifies render Params Validate Rejects Invalid Viewport. 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) } } // TestRenderParamsValidateRejectsInvalidMargins verifies render Params Validate Rejects Invalid Margins. 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) } } // TestRenderParamsValidateRejectsInvalidCameraZoom verifies render Params Validate Rejects Invalid Camera Zoom. 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") } } // TestExpandedCanvasWorldRect verifies expanded Canvas World Rect. 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) } // TestExpandedCanvasWorldRectPanics verifies expanded Canvas World Rect Panics. 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) }) } // TestWorldRenderRejectsNilDrawer verifies world Render Rejects Nil Drawer. 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) } // TestWorldRenderRejectsInvalidParams verifies world Render Rejects Invalid Params. 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) } // TestWorldRenderReturnsErrorWhenGridNotBuilt verifies world Render Returns Error When Grid Not Built. 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) } // TestWorldRenderStageAStubReturnsNilOnValidInput verifies world Render Stage A Stub Returns Nil On Valid Input. 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) } // TestTileWorldRect_NoWrapSingleTile verifies tile World Rect No Wrap Single Tile. 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) } // TestTileWorldRect_WrapX_TwoTiles verifies tile World Rect Wrap X Two Tiles. 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) } // TestTileWorldRect_WrapX_NegativeCoords verifies tile World Rect Wrap X Negative Coords. 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) } // TestTileWorldRect_WrapXY_FourTiles verifies tile World Rect Wrap XY Four Tiles. 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) } // TestTileWorldRect_EmptyRectReturnsNil verifies tile World Rect Empty Rect Returns Nil. 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)) } // TestTileWorldRectPanicsOnInvalidWorldSize verifies tile World Rect Panics On Invalid World Size. 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) }) } // TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt verifies collect Candidates For Tiles Returns Error When Grid Not Built. 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) } // TestCollectCandidatesForTileDedupsWithinOneTile verifies collect Candidates For Tile Dedups Within One Tile. 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()) } // TestCollectCandidatesForTileReturnsPointInCoveredCell verifies collect Candidates For Tile Returns Point In Covered Cell. 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) } // TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides verifies collect Candidates For Tiles Wrap Indexed Circle Appears In Both Sides. func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.SetCircleRadiusScaleFp(SCALE) 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()) } // TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas verifies build Render Plan Stage A Single Tile Clip Is Whole Canvas. 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.buildRenderPlan(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) } // TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps verifies build Render Plan Stage A Tiles Cover Canvas Without Gaps. 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.buildRenderPlan(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) } // TestBuildRenderPlanStageA_CandidatesArePerTileDeduped verifies build Render Plan Stage A Candidates Are Per Tile Deduped. 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.buildRenderPlan(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{}{} } } } // TestWorldForceFullRedrawNextResetsIncrementalState verifies world Force Full Redraw Next Resets Incremental State. 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) } // TestRender_SortsByPriorityWithinTile verifies render Sorts By Priority Within Tile. func TestRender_SortsByPriorityWithinTile(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Same tile. Priorities deliberately mixed. _, err := w.AddCircle(5, 5, 1, CircleWithPriority(500)) require.NoError(t, err) _, err = w.AddLine(1, 5, 9, 5, LineWithPriority(100)) require.NoError(t, err) _, err = w.AddPoint(5, 6, PointWithPriority(300)) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ // default: wrap on }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) // We verify the first occurrence of each primitive kind follows priority order. // Since each object is drawn with Add* + Fill/Stroke immediately, order should match. cmds := d.Commands() firstLine := indexOfFirst(cmds, "AddLine") firstCircle := indexOfFirst(cmds, "AddCircle") firstPoint := indexOfFirst(cmds, "AddPoint") require.NotEqual(t, -1, firstLine) require.NotEqual(t, -1, firstCircle) require.NotEqual(t, -1, firstPoint) require.Less(t, firstLine, firstPoint) require.Less(t, firstPoint, firstCircle) // 300 before 500 } func indexOfFirst(cmds []fakeDrawerCommand, name string) int { for i, c := range cmds { if c.Name == name { return i } } return -1 } type bgOffsetScaleTheme struct { img image.Image anchor BackgroundAnchorMode } func (t bgOffsetScaleTheme) ID() string { return "bgoffset" } func (t bgOffsetScaleTheme) Name() string { return "bgoffset" } func (t bgOffsetScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t bgOffsetScaleTheme) BackgroundImage() image.Image { return t.img } func (t bgOffsetScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } func (t bgOffsetScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (t bgOffsetScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor } func (t bgOffsetScaleTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } func (t bgOffsetScaleTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetScaleTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan verifies render Background Tile Repeat World Anchored Shifts With Pan. func TestRender_BackgroundTileRepeat_WorldAnchored_ShiftsWithPan(t *testing.T) { t.Parallel() w := NewWorld(20, 20) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // tile 4x4 w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorWorld}) params := RenderParams{ ViewportWidthPx: 8, ViewportHeightPx: 8, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } // First render. d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) minX1, minY1 := minDrawImageXY(t, d1) require.Equal(t, -1, minX1) require.Equal(t, -1, minY1) // Pan camera by +1 world unit along both axes (zoom=1 => 1px). params2 := params params2.CameraXWorldFp += 1 * SCALE params2.CameraYWorldFp += 1 * SCALE // Force full redraw to make this test independent of incremental pipeline. w.ForceFullRedrawNext() d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) minX2, minY2 := minDrawImageXY(t, d2) // With world anchoring, moving camera +1 shifts the tiling origin by -1 (mod tile size). require.Equal(t, -2, minX2) require.Equal(t, -2, minY2) } // TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan verifies render Background Tile Repeat Viewport Anchored Does Not Shift With Pan. func TestRender_BackgroundTileRepeat_ViewportAnchored_DoesNotShiftWithPan(t *testing.T) { t.Parallel() w := NewWorld(20, 20) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgOffsetScaleTheme{img: img, anchor: BackgroundAnchorViewport}) params := RenderParams{ ViewportWidthPx: 8, ViewportHeightPx: 8, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) minX1, minY1 := minDrawImageXY(t, d1) params2 := params params2.CameraXWorldFp += 1 * SCALE params2.CameraYWorldFp += 1 * SCALE w.ForceFullRedrawNext() d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) minX2, minY2 := minDrawImageXY(t, d2) // With viewport anchoring, tiling origin is fixed (no camera dependency). require.Equal(t, minX1, minX2) require.Equal(t, minY1, minY2) } func minDrawImageXY(t *testing.T, d *fakePrimitiveDrawer) (int, int) { t.Helper() cmds := d.CommandsByName("DrawImage") require.NotEmpty(t, cmds, "expected DrawImage calls from background tiling") minX := int(cmds[0].Args[0]) minY := int(cmds[0].Args[1]) for _, c := range cmds[1:] { x := int(c.Args[0]) y := int(c.Args[1]) if x < minX { minX = x } if y < minY { minY = y } } return minX, minY } type bgOffsetTheme struct { img image.Image scaleMode BackgroundScaleMode } func (t bgOffsetTheme) ID() string { return "bgscale" } func (t bgOffsetTheme) Name() string { return "bgscale" } func (t bgOffsetTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t bgOffsetTheme) BackgroundImage() image.Image { return t.img } func (t bgOffsetTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } func (t bgOffsetTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } func (t bgOffsetTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } func (t bgOffsetTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } func (t bgOffsetTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgOffsetTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgOffsetTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // TestRender_BackgroundScaleNone_UsesOffsetDrawImage verifies render Background Scale None Uses Offset Draw Image. func TestRender_BackgroundScaleNone_UsesOffsetDrawImage(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleNone}) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) require.NotEmpty(t, d.CommandsByName("DrawImage")) require.Empty(t, d.CommandsByName("DrawImageScaled")) } // TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled verifies render Background Scale Fit Uses Draw Offset Image Scaled. func TestRender_BackgroundScaleFit_UsesDrawOffsetImageScaled(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgOffsetTheme{img: img, scaleMode: BackgroundScaleFit}) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) } type bgScaleTheme struct { img image.Image scaleMode BackgroundScaleMode } func (t bgScaleTheme) ID() string { return "bgscale" } func (t bgScaleTheme) Name() string { return "bgscale" } func (t bgScaleTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t bgScaleTheme) BackgroundImage() image.Image { return t.img } func (t bgScaleTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } func (t bgScaleTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode } func (t bgScaleTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } func (t bgScaleTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } func (t bgScaleTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgScaleTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgScaleTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgScaleTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgScaleTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // TestRender_BackgroundScaleNone_UsesDrawImage verifies render Background Scale None Uses Draw Image. func TestRender_BackgroundScaleNone_UsesDrawImage(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleNone}) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) require.NotEmpty(t, d.CommandsByName("DrawImage")) require.Empty(t, d.CommandsByName("DrawImageScaled")) } // TestRender_BackgroundScaleFit_UsesDrawImageScaled verifies render Background Scale Fit Uses Draw Image Scaled. func TestRender_BackgroundScaleFit_UsesDrawImageScaled(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgScaleTheme{img: img, scaleMode: BackgroundScaleFit}) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) require.NotEmpty(t, d.CommandsByName("DrawImageScaled")) } type bgTheme struct { img image.Image } func (t bgTheme) ID() string { return "bg" } func (t bgTheme) Name() string { return "bg" } func (t bgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t bgTheme) BackgroundImage() image.Image { return t.img } func (t bgTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } func (t bgTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (t bgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } func (t bgTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2} } func (t bgTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t bgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t bgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw verifies render Background Image Draws Before Primitives Full Redraw. func TestRender_BackgroundImage_DrawsBeforePrimitives_FullRedraw(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // 4x4 opaque image img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgTheme{img: img}) _, err := w.AddPoint(5, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iClear := indexOfFirstName(cmds, "ClearAllTo") iBg := indexOfFirstName(cmds, "DrawImage") iPrim := indexOfFirstName(cmds, "AddPoint") require.NotEqual(t, -1, iClear) require.NotEqual(t, -1, iBg) require.NotEqual(t, -1, iPrim) require.Less(t, iClear, iBg) require.Less(t, iBg, iPrim) } // TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift verifies render Background Image Redrawn In Dirty Rects On Incremental Shift. func TestRender_BackgroundImage_RedrawnInDirtyRects_OnIncrementalShift(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) img := image.NewRGBA(image.Rect(0, 0, 4, 4)) w.SetTheme(bgTheme{img: img}) // Ensure state: first full render commits. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Incremental: &IncrementalPolicy{ AllowShiftOnly: false, MaxCatchUpAreaPx: 0, RenderBudgetMs: 0, CoalesceUpdates: false, }, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) // Move camera by 1px right in world (zoom=1 => 1px == 1 unit). params2 := params params2.CameraXWorldFp += 1 * SCALE d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params2)) // In incremental shift path we must see ClearRectTo and DrawImage. require.NotEmpty(t, d2.CommandsByName("CopyShift")) require.NotEmpty(t, d2.CommandsByName("ClearRectTo")) require.NotEmpty(t, d2.CommandsByName("DrawImage")) } // TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips verifies draw Circles From Plan Duplicates Across Tiles And Clips. func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { t.Parallel() // World is 10x10 world units => 10000x10000 fixed. w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Circle near origin so that in expanded canvas (bigger than world) // it will appear in multiple torus tiles. id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000 require.NoError(t, err) w.indexObject(w.objects[id]) // Same geometry as points-only test: // viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) // Expect 4 circle copies, one per tile that covers the expanded canvas. wantNames := []string{ "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", "Save", "ClipRect", "AddCircle", "Fill", "Restore", } require.Equal(t, wantNames, d.CommandNames()) // At zoom=1, 1 world unit -> 1 px, so: // circle center at (1,1) => base copy at (3,3) like point test // radius 1 => 1 px // // The rest are shifted by +10px in X and/or Y due to torus tiling. { clip := requireDrawerCommandAt(t, d, 1) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 2, 10, 10) c := requireDrawerCommandAt(t, d, 2) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 3, 3, 1) } { clip := requireDrawerCommandAt(t, d, 6) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 12, 10, 2) c := requireDrawerCommandAt(t, d, 7) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 3, 13, 1) } { clip := requireDrawerCommandAt(t, d, 11) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 2, 2, 10) c := requireDrawerCommandAt(t, d, 12) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 13, 3, 1) } { clip := requireDrawerCommandAt(t, d, 16) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 12, 2, 2) c := requireDrawerCommandAt(t, d, 17) require.Equal(t, "AddCircle", c.Name) requireCommandArgs(t, c, 13, 13, 1) } } // TestDrawCirclesFromPlan_SkipsTilesWithoutCircles verifies draw Circles From Plan Skips Tiles Without Circles. func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Add only a point, no circles. id, err := w.AddPoint(5, 5) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) // No circles => no commands. require.Empty(t, d.Commands()) } // TestDrawCirclesFromPlan_ProjectsRadiusWithZoom verifies draw Circles From Plan Projects Radius With Zoom. func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) { t.Parallel() w := NewWorld(100, 100) w.resetGrid(10 * SCALE) // radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1. id, err := w.AddCircle(50, 50, 2) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 50 * SCALE, CameraYWorldFp: 50 * SCALE, CameraZoom: 2.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) // There should be at least one AddCircle. cmds := d.CommandsByName("AddCircle") require.NotEmpty(t, cmds) // All circles in this plan should have radius 4px (2 units * 2x zoom). for _, c := range cmds { require.Len(t, c.Args, 3) require.Equal(t, 4.0, c.Args[2]) } } // TestCircles_NoWrap_DoesNotDuplicateAcrossEdges verifies circles No Wrap Does Not Duplicate Across Edges. func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) _, err := w.AddCircle(9, 9, 2) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ DisableWrapScroll: true, Layers: []RenderLayer{RenderLayerCircles}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.CommandsByName("AddCircle") require.Len(t, cmds, 1) // Center must be at (9,9) only, no (-1,*) or (*,-1). require.Equal(t, []float64{9, 9, 2}, cmds[0].Args) } // TestRender_CircleTransparentFill_UsesStrokeNotFill verifies render Circle Transparent Fill Uses Stroke Not Fill. func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) sw := 4.0 circleStyle := w.AddStyleCircle(StyleOverride{ FillColor: color.RGBA{A: 0}, // explicitly transparent StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, StrokeWidthPx: &sw, }) _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100)) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iAdd := indexOfFirstName(cmds, "AddCircle") require.NotEqual(t, -1, iAdd) // After AddCircle we must see Stroke (not Fill). iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds))) iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds))) require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()") require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible") } // TestRender_CircleFillAndStroke_DrawsFillThenStroke verifies render Circle Fill And Stroke Draws Fill Then Stroke. func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) sw := 2.0 styleID := w.AddStyleCircle(StyleOverride{ FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255}, StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, StrokeWidthPx: &sw, }) _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100)) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iAdd := indexOfFirstName(cmds, "AddCircle") require.NotEqual(t, -1, iAdd) iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds))) iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds))) require.NotEqual(t, -1, iFill, "expected Fill() for visible fill") require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke") require.Less(t, iFill, iStroke, "Stroke must be last when both are visible") } // TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies circles Wrap Copies Appear Inside Viewport When Viewport Equals World. func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { t.Parallel() // World 10x10 units => 10px at zoom=1 when viewport==world. w := NewWorld(10, 10) w.SetCircleRadiusScaleFp(SCALE) w.resetGrid(2 * SCALE) type tc struct { name string x, y float64 r float64 wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0 } // Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } tests := []tc{ { name: "bottom boundary wraps to top", x: 5, y: 9, r: 2, // Centers: original at y=9, copy at y=-1. wantCenters: [][2]float64{{5, 9}, {5, -1}}, }, { name: "right boundary wraps to left", x: 9, y: 5, r: 2, wantCenters: [][2]float64{{9, 5}, {-1, 5}}, }, { name: "corner wraps to three extra copies", x: 9, y: 9, r: 2, wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}}, }, { name: "no wrap inside", x: 5, y: 5, r: 2, wantCenters: [][2]float64{{5, 5}}, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() w2 := NewWorld(10, 10) w2.resetGrid(2 * SCALE) _, err := w2.AddCircle(tt.x, tt.y, tt.r) require.NoError(t, err) for _, obj := range w2.objects { w2.indexObject(obj) } plan, err := w2.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawCirclesFromPlan(d, plan, w2.W, w2.H, true, w.circleRadiusScaleFp) cmds := d.CommandsByName("AddCircle") require.Len(t, cmds, len(tt.wantCenters)) // Collect centers (ignore radius for this test). got := make([][2]float64, 0, len(cmds)) for _, c := range cmds { require.Len(t, c.Args, 3) got = append(got, [2]float64{c.Args[0], c.Args[1]}) } // Order is deterministic with our shift generation and tile iteration for margin=0: single tile. require.ElementsMatch(t, tt.wantCenters, got) }) } } // TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop verifies render Shift Only Over Budget Defers Dirty And Catches Up On Stop. func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 4, // threshold=2 MarginYPx: 4, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Incremental: &IncrementalPolicy{ AllowShiftOnly: true, RenderBudgetMs: 1, // 1ms budget }, }, } // First render (full) initializes state. d0 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d0, params)) require.True(t, w.renderState.initialized) // Pretend previous render was very slow => over budget for the next frame. w.renderState.lastRenderDurationNs = 10_000_000 // 10ms // Pan right by 1 unit => incremental shift candidate. params2 := params params2.CameraXWorldFp += 1 * SCALE d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params2)) // Shift-only should call CopyShift but not redraw dirty rects. require.NotEmpty(t, d1.CommandsByName("CopyShift")) require.Empty(t, d1.CommandsByName("ClipRect")) require.NotEmpty(t, w.renderState.pendingDirty) // Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty. w.renderState.lastRenderDurationNs = 0 // under budget params3 := params2 // same camera d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params3)) require.NotEmpty(t, d2.CommandsByName("ClipRect")) require.NotEmpty(t, d2.CommandsByName("AddPoint")) require.Empty(t, w.renderState.pendingDirty) } // TestRender_CatchUpWhilePanning_WhenBackUnderBudget verifies render Catch Up While Panning When Back Under Budget. func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } policy := &IncrementalPolicy{ AllowShiftOnly: true, RenderBudgetMs: 1, } base := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 4, // threshold=2 MarginYPx: 4, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Incremental: policy, }, } // Initial full render. require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) // Frame 1: over budget => shift-only, pendingDirty accumulates. w.renderState.lastRenderDurationNs = 10_000_000 // 10ms p1 := base p1.CameraXWorldFp += 1 * SCALE d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, p1)) require.NotEmpty(t, d1.CommandsByName("CopyShift")) require.Empty(t, d1.CommandsByName("ClipRect")) require.NotEmpty(t, w.renderState.pendingDirty) // Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty). w.renderState.lastRenderDurationNs = 0 p2 := p1 p2.CameraXWorldFp += 1 * SCALE d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, p2)) require.NotEmpty(t, d2.CommandsByName("CopyShift")) require.NotEmpty(t, d2.CommandsByName("ClipRect")) require.NotEmpty(t, d2.CommandsByName("AddPoint")) require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw") } // TestRender_CatchUpLimit_ReducesPendingDirtyGradually verifies render Catch Up Limit Reduces Pending Dirty Gradually. func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } policy := &IncrementalPolicy{ AllowShiftOnly: true, RenderBudgetMs: 1, MaxCatchUpAreaPx: 20, // very small budget } base := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 2, MarginXPx: 4, MarginYPx: 4, // canvasH = 2 + 8 = 10 CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Incremental: policy, }, } // Full init require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) // Over budget => shift-only twice to accumulate pending dirty. w.renderState.lastRenderDurationNs = 10_000_000 p1 := base p1.CameraXWorldFp += 1 * SCALE require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1)) require.NotEmpty(t, w.renderState.pendingDirty) w.renderState.lastRenderDurationNs = 10_000_000 p2 := p1 p2.CameraXWorldFp += 1 * SCALE require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) require.NotEmpty(t, w.renderState.pendingDirty) // Under budget now, but limit catch-up. w.renderState.lastRenderDurationNs = 0 before := len(w.renderState.pendingDirty) require.Greater(t, before, 0) require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) after := len(w.renderState.pendingDirty) // With a tiny MaxCatchUpAreaPx we should not clear everything in one go. require.Greater(t, after, 0) require.Less(t, after, before) } // TestTakeCatchUpRects_RespectsAreaLimit verifies take Catch Up Rects Respects Area Limit. func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) { t.Parallel() pending := []RectPx{ {X: 0, Y: 0, W: 10, H: 1}, // area 10 {X: 0, Y: 1, W: 10, H: 2}, // area 20 {X: 0, Y: 3, W: 10, H: 3}, // area 30 } // Limit 25 => should take first (10) + second (20) would exceed => take only first. sel, rem := takeCatchUpRects(pending, 25) require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel) require.Equal(t, []RectPx{ {X: 0, Y: 1, W: 10, H: 2}, {X: 0, Y: 3, W: 10, H: 3}, }, rem) // Limit 30 => can take first(10) + second(20) exactly. sel, rem = takeCatchUpRects(pending, 30) require.Equal(t, []RectPx{ {X: 0, Y: 0, W: 10, H: 1}, {X: 0, Y: 1, W: 10, H: 2}, }, sel) require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem) // No limit => take all. sel, rem = takeCatchUpRects(pending, 0) require.Len(t, sel, 3) require.Empty(t, rem) } // TestPlanIncrementalPan_NoOp verifies plan Incremental Pan No Op. func TestPlanIncrementalPan_NoOp(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0) require.NoError(t, err) require.Equal(t, IncrementalNoOp, plan.Mode) require.Empty(t, plan.Dirty) } // TestPlanIncrementalPan_FullRedrawOnInvalidCanvas verifies plan Incremental Pan Full Redraw On Invalid Canvas. func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) { t.Parallel() _, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0) require.ErrorIs(t, err, errInvalidCanvasSize) } // TestPlanIncrementalPan_FullRedrawOnTooLargeShift verifies plan Incremental Pan Full Redraw On Too Large Shift. func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) } // TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero verifies plan Incremental Pan Full Redraw When Margin Is Zero And Delta Non Zero. func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) } // TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX verifies plan Incremental Pan Full Redraw When Exceeds Threshold X. func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) { t.Parallel() // marginX=20 => threshold=10, dx=11 => full redraw plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) } // TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY verifies plan Incremental Pan Full Redraw When Exceeds Threshold Y. func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) { t.Parallel() // marginY=20 => threshold=10, dy=-11 => full redraw plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11) require.NoError(t, err) require.Equal(t, IncrementalFullRedraw, plan.Mode) } // TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive verifies plan Incremental Pan Shift Left Strip When Dx Positive. func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) { t.Parallel() // marginX=40 => threshold=20, dx=5 => shift ok plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) require.Equal(t, 5, plan.DxPx) require.Equal(t, 0, plan.DyPx) require.Equal(t, []RectPx{ {X: 0, Y: 0, W: 6, H: 100}, }, plan.Dirty) } // TestPlanIncrementalPan_Shift_RightStripWhenDxNegative verifies plan Incremental Pan Shift Right Strip When Dx Negative. func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) require.Equal(t, []RectPx{ {X: 200 - 8, Y: 0, W: 8, H: 100}, }, plan.Dirty) } // TestPlanIncrementalPan_Shift_TopStripWhenDyPositive verifies plan Incremental Pan Shift Top Strip When Dy Positive. func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) require.Equal(t, []RectPx{ {X: 0, Y: 0, W: 200, H: 10}, }, plan.Dirty) } // TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative verifies plan Incremental Pan Shift Bottom Strip When Dy Negative. func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) require.Equal(t, []RectPx{ {X: 0, Y: 100 - 10, W: 200, H: 10}, }, plan.Dirty) } // TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects verifies plan Incremental Pan Shift Diagonal Returns Two Dirty Rects. func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) // Overlap is allowed; we just require both strips exist. require.Len(t, plan.Dirty, 2) require.ElementsMatch(t, []RectPx{ {X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip {X: 0, Y: 0, W: 200, H: 9}, // top strip }, plan.Dirty) } // TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel verifies plan Incremental Pan Overdraws Dirty Strips By One Pixel. func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) { t.Parallel() plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) require.NoError(t, err) require.Equal(t, IncrementalShift, plan.Mode) // Right strip width should be abs(dx)+1 = 8. require.Equal(t, []RectPx{ {X: 200 - 8, Y: 0, W: 8, H: 100}, }, plan.Dirty) } // TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips verifies render Pan Small Uses Copy Shift And Renders Only Dirty Strips. func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) _, err = w.AddCircle(2, 2, 1) require.NoError(t, err) _, err = w.AddLine(9, 5, 1, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 4, // threshold=2 MarginYPx: 4, // threshold=2 CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } // First render initializes state (full redraw). d0 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d0, params)) // Pan right by 1 unit => dx=-1 => incremental shift expected. params2 := params params2.CameraXWorldFp += 1 * SCALE d := &fakePrimitiveDrawer{} err = w.Render(d, params2) require.NoError(t, err) // Must contain CopyShift for incremental path. require.NotEmpty(t, d.CommandsByName("CopyShift")) // All clip rects should be "small": width <= 1 for dx=-1 strip. clipCmds := d.CommandsByName("ClipRect") require.NotEmpty(t, clipCmds) for _, c := range clipCmds { wPx := int(c.Args[2]) hPx := int(c.Args[3]) require.LessOrEqual(t, wPx, 2) require.LessOrEqual(t, hPx, params2.CanvasHeightPx()) } require.NotEmpty(t, d.CommandsByName("AddPoint")) require.NotEmpty(t, d.CommandsByName("AddCircle")) require.NotEmpty(t, d.CommandsByName("AddLine")) } // TestRender_PanTooLarge_FallsBackToFullRedraw verifies render Pan Too Large Falls Back To Full Redraw. func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 4, // threshold=2 MarginYPx: 4, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d0 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d0, params)) // Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected. params2 := params params2.CameraXWorldFp += 3 * SCALE d := &fakePrimitiveDrawer{} err = w.Render(d, params2) require.NoError(t, err) // Full redraw should NOT call CopyShift. require.Empty(t, d.CommandsByName("CopyShift")) // Full redraw should clear the entire canvas. require.NotEmpty(t, d.CommandsByName("ClearAllTo")) // And should draw something (at least the point). // Depending on your implementation, it might be AddPoint or AddCircle/AddLine as well. require.NotEmpty(t, d.CommandsByName("AddPoint")) } // TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive verifies world Delta Fixed To Canvas Px Remainder Accumulates Positive. func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) { t.Parallel() // zoom=1: px = (deltaWorldFp * 1000) / 1e6 // For deltaWorldFp=1, each step contributes 0 px with remainder, // and after 1000 steps it must become 1 px total. zoomFp := SCALE var rem int64 sum := 0 for i := 0; i < 1000; i++ { sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem) } require.Equal(t, 1, sum) } // TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative verifies world Delta Fixed To Canvas Px Remainder Accumulates Negative. func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) { t.Parallel() zoomFp := SCALE var rem int64 sum := 0 for i := 0; i < 1000; i++ { sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem) } require.Equal(t, -1, sum) } // TestComputePanShiftPx_FirstCallRequiresFullRedraw verifies compute Pan Shift Px First Call Requires Full Redraw. func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } _, _, err := w.ComputePanShiftPx(params) require.ErrorIs(t, err, errIncrementalStateNotReady) } // TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw verifies compute Pan Shift Px Zoom Or Viewport Change Forces Full Redraw. func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) { t.Parallel() w := NewWorld(10, 10) base := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } require.NoError(t, w.CommitFullRedrawState(base)) changed := base changed.CameraZoom = 2.0 _, _, err := w.ComputePanShiftPx(changed) require.ErrorIs(t, err, errIncrementalZoomMismatch) changed2 := base changed2.ViewportWidthPx = 101 _, _, err = w.ComputePanShiftPx(changed2) require.ErrorIs(t, err, errIncrementalZoomMismatch) } // TestComputePanShiftPx_PanRightShiftsImageLeft verifies compute Pan Shift Px Pan Right Shifts Image Left. func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } require.NoError(t, w.CommitFullRedrawState(params)) // Move camera right by 1 world unit => world rect minX increases by 1 unit, // so content moves left by 1px at zoom=1 => image shift should be -1. params2 := params params2.CameraXWorldFp += 1 * SCALE dx, dy, err := w.ComputePanShiftPx(params2) require.NoError(t, err) require.Equal(t, -1, dx) require.Equal(t, 0, dy) } // TestComputePanShiftPx_PanUpShiftsImageDown verifies compute Pan Shift Px Pan Up Shifts Image Down. func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } require.NoError(t, w.CommitFullRedrawState(params)) // Move camera up by 1 world unit => world rect minY decreases by 1 unit, // so content moves down by 1px => image shift should be +1 in dy. params2 := params params2.CameraYWorldFp -= 1 * SCALE dx, dy, err := w.ComputePanShiftPx(params2) require.NoError(t, err) require.Equal(t, 0, dx) require.Equal(t, 1, dy) } // TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel verifies compute Pan Shift Px Sub Pixel Pan Accumulates To One Pixel. func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) { t.Parallel() w := NewWorld(10, 10) params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 80, MarginXPx: 25, MarginYPx: 20, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } require.NoError(t, w.CommitFullRedrawState(params)) // Pan camera right by 0.001 world units (1 fixed-point) 1000 times. // At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1. totalDx := 0 p := params for i := 0; i < 1000; i++ { p.CameraXWorldFp += 1 dx, dy, err := w.ComputePanShiftPx(p) require.NoError(t, err) require.Equal(t, 0, dy) totalDx += dx } require.Equal(t, -1, totalDx) } // TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits verifies torus Shortest Line Segments Tie Case Is Deterministic And Splits. func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) { t.Parallel() // World 10 units => 10000 fixed. worldW := 10 * SCALE worldH := 10 * SCALE // Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000). // Deterministic rule chooses negative delta representation (wrap is applied). l := Line{ X1: 1 * SCALE, Y1: 5 * SCALE, X2: 6 * SCALE, Y2: 5 * SCALE, } segs := torusShortestLineSegments(l, worldW, worldH) // Expect two horizontal segments: // [6000..10000] and [0..1000] at y=5000. require.Len(t, segs, 2) // Direction is deterministic and follows the chosen negative-delta representation. require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0]) require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1]) } // TestLines_NoWrap_TieCaseDoesNotWrap verifies lines No Wrap Tie Case Does Not Wrap. func TestLines_NoWrap_TieCaseDoesNotWrap(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Tie-case along X: 1 -> 6 in world of 10. _, err := w.AddLine(1, 5, 6, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ DisableWrapScroll: true, Layers: []RenderLayer{RenderLayerLines}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) lines := d.CommandsByName("AddLine") require.Len(t, lines, 1) // At zoom=1 and margin=0, world==canvas, so pixels equal world units. require.Equal(t, []float64{1, 5, 6, 5}, lines[0].Args) } // TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips verifies draw Points From Plan Duplicates Across Tiles And Clips. func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { t.Parallel() // World is 10x10 world units => 10000x10000 fixed. w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Place a point near the origin so that expanded canvas (larger than world) // will require torus repetition and the point will appear in multiple tiles. id, err := w.AddPoint(1.0, 1.0) // (1000,1000) require.NoError(t, err) // Index only this object. w.indexObject(w.objects[id]) // Choose viewport such that viewport==world in pixels at zoom=1: // - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px. // - world width=10 units => 10 px. // Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world. params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawPointsFromPlan(d, plan, true) // We expect 4 point copies: // (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1) // due to expanded rect spanning beyond world on both axes. wantNames := []string{ "Save", "ClipRect", "AddPoint", "Fill", "Restore", "Save", "ClipRect", "AddPoint", "Fill", "Restore", "Save", "ClipRect", "AddPoint", "Fill", "Restore", "Save", "ClipRect", "AddPoint", "Fill", "Restore", } require.Equal(t, wantNames, d.CommandNames()) pointRadiusPx := DefaultRenderStyle().PointRadiusPx // Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3). { clip := requireDrawerCommandAt(t, d, 1) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 2, 10, 10) pt := requireDrawerCommandAt(t, d, 2) require.Equal(t, "AddPoint", pt.Name) requireCommandArgs(t, pt, 3, 3, pointRadiusPx) } // Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13). { clip := requireDrawerCommandAt(t, d, 6) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 2, 12, 10, 2) pt := requireDrawerCommandAt(t, d, 7) require.Equal(t, "AddPoint", pt.Name) requireCommandArgs(t, pt, 3, 13, pointRadiusPx) } // Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3). { clip := requireDrawerCommandAt(t, d, 11) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 2, 2, 10) pt := requireDrawerCommandAt(t, d, 12) require.Equal(t, "AddPoint", pt.Name) requireCommandArgs(t, pt, 13, 3, pointRadiusPx) } // Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13). { clip := requireDrawerCommandAt(t, d, 16) require.Equal(t, "ClipRect", clip.Name) requireCommandArgs(t, clip, 12, 12, 2, 2) pt := requireDrawerCommandAt(t, d, 17) require.Equal(t, "AddPoint", pt.Name) requireCommandArgs(t, pt, 13, 13, pointRadiusPx) } } // TestDrawPointsFromPlan_SkipsTilesWithoutPoints verifies draw Points From Plan Skips Tiles Without Points. func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Add only a line, no points. id, err := w.AddLine(2, 2, 8, 2) require.NoError(t, err) w.indexObject(w.objects[id]) params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawPointsFromPlan(d, plan, true) // No points => no drawing commands at all. require.Empty(t, d.Commands()) } // TestWorldRender_PointsOnlyStageA verifies world Render Points Only Stage A. func TestWorldRender_PointsOnlyStageA(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(5, 5) require.NoError(t, err) // Build index. In real UI it happens via IndexOnViewportChange. for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} err = w.Render(d, params) require.NoError(t, err) // At least one point draw should happen. require.Contains(t, d.CommandNames(), "AddPoint") } // TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld verifies points Wrap Copies Appear Inside Viewport When Viewport Equals World. func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { t.Parallel() params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ Style: func() *RenderStyle { s := DefaultRenderStyle() s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1 return &s }(), }, } type tc struct { name string x, y float64 wantCenters [][2]float64 } tests := []tc{ { name: "bottom boundary wraps to top", x: 5, y: 9, wantCenters: [][2]float64{{5, 9}, {5, -1}}, }, { name: "right boundary wraps to left", x: 9, y: 5, wantCenters: [][2]float64{{9, 5}, {-1, 5}}, }, { name: "corner wraps to three extra copies", x: 9, y: 9, wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}}, }, { name: "no wrap inside", x: 5, y: 5, wantCenters: [][2]float64{{5, 5}}, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(tt.x, tt.y) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} style := DefaultRenderStyle() style.PointRadiusPx = 2.0 applyPointStyle(d, style) drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx, true) cmds := d.CommandsByName("AddPoint") require.Len(t, cmds, len(tt.wantCenters)) got := make([][2]float64, 0, len(cmds)) for _, c := range cmds { require.Len(t, c.Args, 3) got = append(got, [2]float64{c.Args[0], c.Args[1]}) } require.ElementsMatch(t, tt.wantCenters, got) }) } } // TestWorldRender_DrawsAllLayersInDefaultOrder verifies world Render Draws All Layers In Default Order. func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(1, 1) require.NoError(t, err) _, err = w.AddCircle(2, 2, 1) require.NoError(t, err) _, err = w.AddLine(9, 5, 1, 5) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d := &fakePrimitiveDrawer{} err = w.Render(d, params) require.NoError(t, err) names := d.CommandNames() require.Contains(t, names, "AddPoint") require.Contains(t, names, "AddCircle") require.Contains(t, names, "AddLine") } // TestSmoke_DrawPointsAndCirclesFromSamePlan verifies smoke Draw Points And Circles From Same Plan. func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) _, err := w.AddPoint(1, 1) require.NoError(t, err) _, err = w.AddCircle(2, 2, 1) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 2, MarginYPx: 2, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } plan, err := w.buildRenderPlan(params) require.NoError(t, err) d := &fakePrimitiveDrawer{} drawPointsFromPlan(d, plan, true) drawCirclesFromPlan(d, plan, w.W, w.H, true, w.circleRadiusScaleFp) names := d.CommandNames() require.Contains(t, names, "AddPoint") require.Contains(t, names, "AddCircle") } // TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile verifies render Applies Style Before Add Commands For First Item In Tile. func TestRender_AppliesStyleBeforeAddCommands_ForFirstItemInTile(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Create a derived circle style so we can observe a style application transition. red := color.RGBA{R: 255, A: 255} styleID := w.AddStyleCircle(StyleOverride{FillColor: red}) _, err := w.AddCircle(5, 5, 1, CircleWithStyleID(styleID), CircleWithPriority(100)) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() iSetFill := indexOfFirstName(cmds, "SetFillColor") iAddCircle := indexOfFirstName(cmds, "AddCircle") require.NotEqual(t, -1, iSetFill) require.NotEqual(t, -1, iAddCircle) require.Less(t, iSetFill, iAddCircle, "style must be applied before AddCircle") } // TestRender_DoesNotReapplySameStyleAcrossMultipleObjects verifies render Does Not Reapply Same Style Across Multiple Objects. func TestRender_DoesNotReapplySameStyleAcrossMultipleObjects(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Two lines with the same default line style and same priority. _, err := w.AddLine(1, 5, 9, 5, LineWithPriority(100)) require.NoError(t, err) _, err = w.AddLine(1, 6, 9, 6, LineWithPriority(101)) // ensure deterministic order by priority require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) // We expect style application at least once. setWidth := d.CommandsByName("SetLineWidth") require.NotEmpty(t, setWidth) // The key batching assertion: style setters should not be called twice *between* two AddLine calls. cmds := d.Commands() line1 := indexOfFirstName(cmds, "AddLine") require.NotEqual(t, -1, line1) line2 := indexOfNextName(cmds, "AddLine", line1+1) require.NotEqual(t, -1, line2) // Between line1 and line2 there must be no SetLineWidth / SetStrokeColor / SetDash / SetDashOffset, // because StyleID is the same and the renderer caches lastStyleID. for i := line1 + 1; i < line2; i++ { switch cmds[i].Name { case "SetLineWidth", "SetStrokeColor", "SetDash", "SetDashOffset", "SetFillColor": t.Fatalf("unexpected style setter %q between two AddLine commands at index %d", cmds[i].Name, i) } } } // TestRender_ReappliesStyleWhenStyleIDChanges verifies render Reapplies Style When Style ID Changes. func TestRender_ReappliesStyleWhenStyleIDChanges(t *testing.T) { t.Parallel() w := NewWorld(10, 10) w.resetGrid(2 * SCALE) // Two circles, different derived fill colors => different StyleIDs. red := color.RGBA{R: 255, A: 255} green := color.RGBA{G: 255, A: 255} styleRed := w.AddStyleCircle(StyleOverride{FillColor: red}) styleGreen := w.AddStyleCircle(StyleOverride{FillColor: green}) _, err := w.AddCircle(4, 5, 1, CircleWithStyleID(styleRed), CircleWithPriority(100)) require.NoError(t, err) _, err = w.AddCircle(6, 5, 1, CircleWithStyleID(styleGreen), CircleWithPriority(101)) require.NoError(t, err) for _, obj := range w.objects { w.indexObject(obj) } params := RenderParams{ ViewportWidthPx: 10, ViewportHeightPx: 10, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, Options: &RenderOptions{ BackgroundColor: color.RGBA{A: 255}, }, } d := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d, params)) cmds := d.Commands() firstCircle := indexOfFirstName(cmds, "AddCircle") secondCircle := indexOfNextName(cmds, "AddCircle", firstCircle+1) require.NotEqual(t, -1, firstCircle) require.NotEqual(t, -1, secondCircle) // There must be at least one SetFillColor before each circle. // And importantly, we expect a SetFillColor BETWEEN the two circles due to style change. setBeforeFirst := lastIndexOfNameBefore(cmds, "SetFillColor", firstCircle) require.NotEqual(t, -1, setBeforeFirst) setBetween := indexOfFirstNameInRange(cmds, "SetFillColor", firstCircle+1, secondCircle) require.NotEqual(t, -1, setBetween, "expected style reapply (SetFillColor) between circles with different StyleIDs") } /* ---------- helper functions for fake command slices ---------- */ func indexOfFirstName(cmds []fakeDrawerCommand, name string) int { for i, c := range cmds { if c.Name == name { return i } } return -1 } func indexOfNextName(cmds []fakeDrawerCommand, name string, start int) int { for i := start; i < len(cmds); i++ { if cmds[i].Name == name { return i } } return -1 } func lastIndexOfNameBefore(cmds []fakeDrawerCommand, name string, before int) int { if before > len(cmds) { before = len(cmds) } for i := before - 1; i >= 0; i-- { if cmds[i].Name == name { return i } } return -1 } func indexOfFirstNameInRange(cmds []fakeDrawerCommand, name string, start, end int) int { if start < 0 { start = 0 } if end > len(cmds) { end = len(cmds) } for i := start; i < end; i++ { if cmds[i].Name == name { return i } } return -1 } // rendererTestEnv groups the common mutable inputs used by renderer tests. // The environment stores independent horizontal and vertical margins because // the expanded canvas geometry is derived separately on each axis. type rendererTestEnv struct { world *World drawer *fakePrimitiveDrawer // Viewport origin and size in canvas pixel coordinates. viewportX int viewportY int viewportW int viewportH int // Independent margins around the viewport in canvas pixels. marginXPx int marginYPx int // Final expanded canvas size in pixels. // In the default setup: // canvasW = viewportW + 2*marginXPx // canvasH = viewportH + 2*marginYPx canvasW int canvasH int // Camera center in fixed-point world coordinates. cameraX int cameraY int // Camera zoom in fixed-point representation, if needed by renderer internals. zoomFp int } // newRendererTestEnv returns a baseline renderer test environment. // The default margins are derived independently from viewport width and height. func newRendererTestEnv() *rendererTestEnv { viewportW := 100 viewportH := 80 marginXPx := viewportW / 4 marginYPx := viewportH / 4 return &rendererTestEnv{ world: NewWorld(10, 10), drawer: &fakePrimitiveDrawer{}, viewportX: marginXPx, viewportY: marginYPx, viewportW: viewportW, viewportH: viewportH, marginXPx: marginXPx, marginYPx: marginYPx, canvasW: viewportW + 2*marginXPx, canvasH: viewportH + 2*marginYPx, cameraX: 5 * SCALE, cameraY: 5 * SCALE, zoomFp: SCALE, } } // setViewport resets viewport-dependent fields and recomputes margins // using the default test formula: // // marginXPx = viewportW / 4 // marginYPx = viewportH / 4 func (env *rendererTestEnv) setViewport(viewportW, viewportH int) { env.viewportW = viewportW env.viewportH = viewportH env.marginXPx = viewportW / 4 env.marginYPx = viewportH / 4 env.viewportX = env.marginXPx env.viewportY = env.marginYPx env.canvasW = env.viewportW + 2*env.marginXPx env.canvasH = env.viewportH + 2*env.marginYPx } // setViewportAndMargins overrides viewport and margins explicitly. // This is useful for edge cases where the expanded canvas geometry // must be controlled exactly. func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) { env.viewportW = viewportW env.viewportH = viewportH env.marginXPx = marginXPx env.marginYPx = marginYPx env.viewportX = env.marginXPx env.viewportY = env.marginYPx env.canvasW = env.viewportW + 2*env.marginXPx env.canvasH = env.viewportH + 2*env.marginYPx } // viewportRect returns the viewport rectangle in canvas pixel coordinates. func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) { return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH) } // canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates. func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) { return 0, 0, float64(env.canvasW), float64(env.canvasH) } // worldMustAddPoint adds a point to the test world and fails the test on error. func worldMustAddPoint(t *testing.T, w *World, x, y float64) { t.Helper() _, err := w.AddPoint(x, y) require.NoError(t, err) } // worldMustAddCircle adds a circle to the test world and fails the test on error. func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) { t.Helper() _, err := w.AddCircle(x, y, r) require.NoError(t, err) } // worldMustAddLine adds a line to the test world and fails the test on error. func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) { t.Helper() _, err := w.AddLine(x1, y1, x2, y2) require.NoError(t, err) } // requireNoDrawerCommands asserts that the renderer produced no drawing commands. func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) { t.Helper() require.Empty(t, d.Commands()) } // requireStrokeCommandAt returns a command and asserts that it is Stroke. func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { t.Helper() cmd := requireDrawerCommandAt(t, d, index) requireCommandName(t, cmd, "Stroke") return cmd } // requireFillCommandAt returns a command and asserts that it is Fill. func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { t.Helper() cmd := requireDrawerCommandAt(t, d, index) requireCommandName(t, cmd, "Fill") return cmd } // requireAddPointCommandAt returns a command and asserts that it is AddPoint. func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { t.Helper() cmd := requireDrawerCommandAt(t, d, index) requireCommandName(t, cmd, "AddPoint") return cmd } // requireAddLineCommandAt returns a command and asserts that it is AddLine. func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { t.Helper() cmd := requireDrawerCommandAt(t, d, index) requireCommandName(t, cmd, "AddLine") return cmd } // requireAddCircleCommandAt returns a command and asserts that it is AddCircle. func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { t.Helper() cmd := requireDrawerCommandAt(t, d, index) requireCommandName(t, cmd, "AddCircle") return cmd } // requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect. func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) { t.Helper() requireCommandClipRects(t, cmd, fakeClipRect{ X: x, Y: y, W: w, H: h, }) } // rendererTestCase is a generic table-driven renderer test scaffold. // Replace invoke with the real renderer call once the renderer exists. type rendererTestCase struct { name string // setup prepares the world and optional environment overrides. setup func(t *testing.T, env *rendererTestEnv) // invoke calls the renderer under test. invoke func(t *testing.T, env *rendererTestEnv) // verify checks the produced fake drawer log. verify func(t *testing.T, env *rendererTestEnv) } func runRendererTestCases(t *testing.T, cases []rendererTestCase) { t.Helper() for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { env := newRendererTestEnv() if tc.setup != nil { tc.setup(t, env) } require.NotNil(t, tc.invoke, "renderer test case must define invoke") require.NotNil(t, tc.verify, "renderer test case must define verify") tc.invoke(t, env) tc.verify(t, env) }) } } // TestRenderer_Template_PointCases is a scaffold for future point renderer tests. func TestRenderer_Template_PointCases(t *testing.T) { t.Parallel() runRendererTestCases(t, []rendererTestCase{ { name: "point fully inside viewport", setup: func(t *testing.T, env *rendererTestEnv) { worldMustAddPoint(t, env.world, 5, 5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "point visible only in horizontal margin copy", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(160, 40) worldMustAddPoint(t, env.world, 0.1, 5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "point visible only in vertical margin copy", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(40, 160) worldMustAddPoint(t, env.world, 5, 0.1) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "point duplicated across torus corner with independent margins", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewportAndMargins(120, 60, 30, 10) worldMustAddPoint(t, env.world, 0.1, 0.1) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, }) } // TestRenderer_Template_LineCases is a scaffold for future line renderer tests. func TestRenderer_Template_LineCases(t *testing.T) { t.Parallel() runRendererTestCases(t, []rendererTestCase{ { name: "line fully inside viewport", setup: func(t *testing.T, env *rendererTestEnv) { worldMustAddLine(t, env.world, 2, 2, 8, 2) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "line wrap copy across x edge", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(160, 40) worldMustAddLine(t, env.world, 9, 5, 1, 5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "line wrap copy across y edge", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(40, 160) worldMustAddLine(t, env.world, 5, 9, 5, 1) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "line tie case uses deterministic wrapped representation", setup: func(t *testing.T, env *rendererTestEnv) { worldMustAddLine(t, env.world, 1, 5, 6, 5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, }) } // TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests. func TestRenderer_Template_CircleCases(t *testing.T) { t.Parallel() runRendererTestCases(t, []rendererTestCase{ { name: "circle fully inside viewport", setup: func(t *testing.T, env *rendererTestEnv) { worldMustAddCircle(t, env.world, 5, 5, 1) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "circle duplicated across horizontal edge", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(160, 40) worldMustAddCircle(t, env.world, 0.2, 5, 0.5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "circle duplicated across vertical edge", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewport(40, 160) worldMustAddCircle(t, env.world, 5, 0.2, 0.5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, { name: "circle duplicated across corner with asymmetric margins", setup: func(t *testing.T, env *rendererTestEnv) { env.setViewportAndMargins(120, 60, 30, 10) worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5) }, invoke: func(t *testing.T, env *rendererTestEnv) { t.Skip("replace with actual renderer call") }, verify: func(t *testing.T, env *rendererTestEnv) { requireNoDrawerCommands(t, env.drawer) }, }, }) } type pointRadiusTheme struct { id string radius float64 } func (t pointRadiusTheme) ID() string { return t.id } func (t pointRadiusTheme) Name() string { return t.id } func (t pointRadiusTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (t pointRadiusTheme) BackgroundImage() image.Image { return nil } func (t pointRadiusTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } func (t pointRadiusTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (t pointRadiusTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } func (t pointRadiusTheme) PointStyle() Style { return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: t.radius} } func (t pointRadiusTheme) LineStyle() Style { return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t pointRadiusTheme) CircleStyle() Style { return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1} } func (t pointRadiusTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t pointRadiusTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (t pointRadiusTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles verifies render Theme Change Applies Without Reindex Uses Latest Object Styles. func TestRender_ThemeChange_AppliesWithoutReindex_UsesLatestObjectStyles(t *testing.T) { t.Parallel() w := NewWorld(10, 10) // Build index once. w.IndexOnViewportChange(100, 100, 1.0) // Theme A: point radius 2 w.SetTheme(pointRadiusTheme{id: "A", radius: 2}) _, err := w.AddPoint(5, 5) require.NoError(t, err) // Ensure the point is actually present in grid (it will be, because Add triggers rebuild via index state). // Render once. params := RenderParams{ ViewportWidthPx: 100, ViewportHeightPx: 100, MarginXPx: 0, MarginYPx: 0, CameraXWorldFp: 5 * SCALE, CameraYWorldFp: 5 * SCALE, CameraZoom: 1.0, } d1 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d1, params)) p1 := d1.CommandsByName("AddPoint") require.NotEmpty(t, p1) r1 := p1[0].Args[2] require.Equal(t, 2.0, r1) // Theme B: point radius 7. Change theme, but DO NOT reindex. w.SetTheme(pointRadiusTheme{id: "B", radius: 7}) d2 := &fakePrimitiveDrawer{} require.NoError(t, w.Render(d2, params)) p2 := d2.CommandsByName("AddPoint") require.NotEmpty(t, p2) r2 := p2[0].Args[2] require.Equal(t, 7.0, r2) }