Files
galaxy-game/client/world/renderer_test.go
T
2026-03-07 19:28:22 +02:00

697 lines
17 KiB
Go

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