Files
Ilia Denisov 5029857fe4 world refactor
2026-03-17 12:48:05 +03:00

3326 lines
94 KiB
Go

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)
}