1055 lines
22 KiB
Go
1055 lines
22 KiB
Go
package world
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type gridCell struct {
|
|
Row int
|
|
Col int
|
|
}
|
|
|
|
func newTestWorld(wReal, hReal int) *World {
|
|
return NewWorld(wReal, hReal)
|
|
}
|
|
|
|
func countObjectInGrid(g *World, id PrimitiveID) int {
|
|
count := 0
|
|
for row := range g.grid {
|
|
for col := range g.grid[row] {
|
|
for _, item := range g.grid[row][col] {
|
|
if item.ID() == id {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool {
|
|
for _, item := range g.grid[row][col] {
|
|
if item.ID() == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestViewportPxToWorldFixed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
viewportWidthPx int
|
|
viewportHeightPx int
|
|
cameraZoom int
|
|
wantWidth int
|
|
wantHeight int
|
|
}{
|
|
{
|
|
name: "zoom 1.0",
|
|
viewportWidthPx: 500,
|
|
viewportHeightPx: 400,
|
|
cameraZoom: SCALE,
|
|
wantWidth: 500 * SCALE,
|
|
wantHeight: 400 * SCALE,
|
|
},
|
|
{
|
|
name: "zoom 2.0",
|
|
viewportWidthPx: 500,
|
|
viewportHeightPx: 400,
|
|
cameraZoom: 2 * SCALE,
|
|
wantWidth: 250 * SCALE,
|
|
wantHeight: 200 * SCALE,
|
|
},
|
|
{
|
|
name: "zoom below 1.0",
|
|
viewportWidthPx: 550,
|
|
viewportHeightPx: 550,
|
|
cameraZoom: 917,
|
|
wantWidth: 599781,
|
|
wantHeight: 599781,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotW, gotH := viewportPxToWorldFixed(tt.viewportWidthPx, tt.viewportHeightPx, tt.cameraZoom)
|
|
require.Equal(t, tt.wantWidth, gotW)
|
|
require.Equal(t, tt.wantHeight, gotH)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitByWrap_ZeroOrNegativeSizeReturnsNil(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
minX, maxX int
|
|
minY, maxY int
|
|
}{
|
|
{
|
|
name: "zero width",
|
|
minX: 100, maxX: 100,
|
|
minY: 50, maxY: 100,
|
|
},
|
|
{
|
|
name: "zero height",
|
|
minX: 100, maxX: 200,
|
|
minY: 50, maxY: 50,
|
|
},
|
|
{
|
|
name: "negative width",
|
|
minX: 200, maxX: 100,
|
|
minY: 50, maxY: 100,
|
|
},
|
|
{
|
|
name: "negative height",
|
|
minX: 100, maxX: 200,
|
|
minY: 100, maxY: 50,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rects := splitByWrap(600, 400, tt.minX, tt.maxX, tt.minY, tt.maxY)
|
|
require.Nil(t, rects)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitByWrap_XWrapUsesWorldWidth(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
500, 650,
|
|
50, 100,
|
|
)
|
|
|
|
require.Len(t, rects, 2)
|
|
require.Equal(t, Rect{minX: 500, maxX: 600, minY: 50, maxY: 100}, rects[0])
|
|
require.Equal(t, Rect{minX: 0, maxX: 50, minY: 50, maxY: 100}, rects[1])
|
|
}
|
|
|
|
func TestSplitByWrap_YWrapUsesWorldHeight(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
50, 100,
|
|
350, 450,
|
|
)
|
|
|
|
require.Len(t, rects, 2)
|
|
require.Equal(t, Rect{minX: 50, maxX: 100, minY: 350, maxY: 400}, rects[0])
|
|
require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 50}, rects[1])
|
|
}
|
|
|
|
func TestSplitByWrap_XAndYWrap(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
550, 650,
|
|
350, 450,
|
|
)
|
|
|
|
require.Len(t, rects, 4)
|
|
require.ElementsMatch(t, []Rect{
|
|
{minX: 550, maxX: 600, minY: 350, maxY: 400},
|
|
{minX: 550, maxX: 600, minY: 0, maxY: 50},
|
|
{minX: 0, maxX: 50, minY: 350, maxY: 400},
|
|
{minX: 0, maxX: 50, minY: 0, maxY: 50},
|
|
}, rects)
|
|
}
|
|
|
|
func TestSplitByWrap_NoWrapInsideWorld(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
100, 200,
|
|
50, 100,
|
|
)
|
|
|
|
require.Len(t, rects, 1)
|
|
require.Equal(t, Rect{minX: 100, maxX: 200, minY: 50, maxY: 100}, rects[0])
|
|
}
|
|
|
|
func TestSplitByWrap_FullWorldCoverageOnEqualWidth(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
0, 600,
|
|
50, 100,
|
|
)
|
|
|
|
require.Len(t, rects, 1)
|
|
require.Equal(t, Rect{minX: 0, maxX: 600, minY: 50, maxY: 100}, rects[0])
|
|
}
|
|
|
|
func TestSplitByWrap_FullWorldCoverageOnEqualHeight(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
50, 100,
|
|
0, 400,
|
|
)
|
|
|
|
require.Len(t, rects, 1)
|
|
require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 400}, rects[0])
|
|
}
|
|
|
|
func TestSplitByWrap_FullWorldCoverageOnBothAxes(t *testing.T) {
|
|
rects := splitByWrap(
|
|
600, 400,
|
|
0, 600,
|
|
0, 400,
|
|
)
|
|
|
|
require.Len(t, rects, 1)
|
|
require.Equal(t, Rect{minX: 0, maxX: 600, minY: 0, maxY: 400}, rects[0])
|
|
}
|
|
|
|
func TestWorldToCell(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int
|
|
worldSize int
|
|
cells int
|
|
cellSize int
|
|
want int
|
|
}{
|
|
{
|
|
name: "simple inside world",
|
|
value: 150,
|
|
worldSize: 600,
|
|
cells: 6,
|
|
cellSize: 100,
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "negative wraps to last cell",
|
|
value: -1,
|
|
worldSize: 600,
|
|
cells: 6,
|
|
cellSize: 100,
|
|
want: 5,
|
|
},
|
|
{
|
|
name: "exact world size wraps to zero",
|
|
value: 600,
|
|
worldSize: 600,
|
|
cells: 6,
|
|
cellSize: 100,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "large positive wraps correctly",
|
|
value: 650,
|
|
worldSize: 600,
|
|
cells: 6,
|
|
cellSize: 100,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "last in-range value lands in last cell",
|
|
value: 599,
|
|
worldSize: 600,
|
|
cells: 6,
|
|
cellSize: 100,
|
|
want: 5,
|
|
},
|
|
{name: "first cell", value: 0, worldSize: 10000, cells: 5, cellSize: 2000, want: 0},
|
|
{name: "middle cell", value: 2500, worldSize: 10000, cells: 5, cellSize: 2000, want: 1},
|
|
{name: "last exact world point wraps to zero", value: 10000, worldSize: 10000, cells: 5, cellSize: 2000, want: 0},
|
|
{name: "negative wraps to last", value: -1, worldSize: 10000, cells: 5, cellSize: 2000, want: 4},
|
|
{name: "partial last cell is clamped", value: 9999, worldSize: 10000, cells: 4, cellSize: 3000, want: 3},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := worldToCell(tt.value, tt.worldSize, tt.cells, tt.cellSize)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResetGrid_UsesWidthForColsAndHeightForRows(t *testing.T) {
|
|
g := newTestWorld(600, 400)
|
|
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
require.Equal(t, 6, g.cols)
|
|
require.Equal(t, 4, g.rows)
|
|
require.Len(t, g.grid, 4)
|
|
require.Len(t, g.grid[0], 6)
|
|
}
|
|
|
|
func TestIndexPoint(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
p := Point{
|
|
Id: id,
|
|
X: 150 * SCALE,
|
|
Y: 250 * SCALE,
|
|
}
|
|
|
|
g.indexObject(p)
|
|
|
|
require.True(t, hasObjectInCell(g, 2, 1, id))
|
|
require.Equal(t, 1, countObjectInGrid(g, id))
|
|
}
|
|
|
|
func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
p := Point{
|
|
Id: id,
|
|
X: -1,
|
|
Y: -1,
|
|
}
|
|
|
|
g.indexObject(p)
|
|
|
|
require.True(t, hasObjectInCell(g, 5, 5, id))
|
|
require.Equal(t, 1, countObjectInGrid(g, id))
|
|
}
|
|
|
|
func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
c := Circle{
|
|
Id: id,
|
|
X: 50 * SCALE,
|
|
Y: 50 * SCALE,
|
|
Radius: 75 * SCALE,
|
|
}
|
|
|
|
g.indexObject(c)
|
|
|
|
// The circle spans [-25..125] on both axes.
|
|
// It must appear both near zero and near the wrapped end.
|
|
require.True(t, hasObjectInCell(g, 0, 0, id))
|
|
require.True(t, hasObjectInCell(g, 0, 5, id))
|
|
require.True(t, hasObjectInCell(g, 5, 0, id))
|
|
require.True(t, hasObjectInCell(g, 5, 5, id))
|
|
|
|
// It also extends into the next cells near the origin.
|
|
require.True(t, hasObjectInCell(g, 0, 1, id))
|
|
require.True(t, hasObjectInCell(g, 1, 0, id))
|
|
require.True(t, hasObjectInCell(g, 1, 1, id))
|
|
}
|
|
|
|
func TestIndexCircle_NoWrap(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
c := Circle{
|
|
Id: id,
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
}
|
|
|
|
g.indexObject(c)
|
|
|
|
require.True(t, hasObjectInCell(g, 2, 2, id))
|
|
require.True(t, hasObjectInCell(g, 2, 3, id))
|
|
require.True(t, hasObjectInCell(g, 3, 2, id))
|
|
require.True(t, hasObjectInCell(g, 3, 3, id))
|
|
}
|
|
|
|
func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
c := Circle{
|
|
Id: id,
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 400 * SCALE,
|
|
}
|
|
|
|
g.indexObject(c)
|
|
|
|
for row := 0; row < g.rows; row++ {
|
|
for col := 0; col < g.cols; col++ {
|
|
require.Truef(t, hasObjectInCell(g, row, col, id), "missing object in row=%d col=%d", row, col)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIndexLine_HorizontalWrap(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 590 * SCALE,
|
|
Y1: 200 * SCALE,
|
|
X2: 10 * SCALE,
|
|
Y2: 200 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
// The shortest torus representation crosses the right/left border.
|
|
require.True(t, hasObjectInCell(g, 2, 5, id))
|
|
require.True(t, hasObjectInCell(g, 2, 0, id))
|
|
}
|
|
|
|
func TestIndexLine_VerticalWrap(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 200 * SCALE,
|
|
Y1: 590 * SCALE,
|
|
X2: 200 * SCALE,
|
|
Y2: 10 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
require.True(t, hasObjectInCell(g, 5, 2, id))
|
|
require.True(t, hasObjectInCell(g, 0, 2, id))
|
|
}
|
|
|
|
func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 590 * SCALE,
|
|
Y1: 590 * SCALE,
|
|
X2: 10 * SCALE,
|
|
Y2: 10 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
require.True(t, hasObjectInCell(g, 5, 5, id))
|
|
require.True(t, hasObjectInCell(g, 0, 0, id))
|
|
}
|
|
|
|
func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 100 * SCALE,
|
|
Y1: 200 * SCALE,
|
|
X2: 300 * SCALE,
|
|
Y2: 200 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
// The indexed interval is half-open: [100,300).
|
|
// Therefore it occupies columns 1 and 2, but not column 3.
|
|
require.True(t, hasObjectInCell(g, 2, 1, id))
|
|
require.True(t, hasObjectInCell(g, 2, 2, id))
|
|
require.False(t, hasObjectInCell(g, 2, 3, id))
|
|
}
|
|
|
|
func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 200 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 200 * SCALE,
|
|
Y2: 300 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
// The indexed interval is half-open: [100,300).
|
|
// Therefore it occupies rows 1 and 2, but not row 3.
|
|
require.True(t, hasObjectInCell(g, 1, 2, id))
|
|
require.True(t, hasObjectInCell(g, 2, 2, id))
|
|
require.False(t, hasObjectInCell(g, 3, 2, id))
|
|
}
|
|
|
|
func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 250 * SCALE,
|
|
Y1: 350 * SCALE,
|
|
X2: 250 * SCALE,
|
|
Y2: 350 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
require.True(t, hasObjectInCell(g, 3, 2, id))
|
|
require.Equal(t, 1, countObjectInGrid(g, id))
|
|
}
|
|
|
|
func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
l := Line{
|
|
Id: id,
|
|
X1: 200 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 400 * SCALE,
|
|
Y2: 100 * SCALE,
|
|
}
|
|
|
|
g.indexObject(l)
|
|
|
|
// The indexed interval is [200,400), so it must occupy columns 2 and 3 only.
|
|
require.True(t, hasObjectInCell(g, 1, 2, id))
|
|
require.True(t, hasObjectInCell(g, 1, 3, id))
|
|
require.False(t, hasObjectInCell(g, 1, 4, id))
|
|
}
|
|
|
|
func collectOccupiedCells(g *World, id PrimitiveID) []gridCell {
|
|
var cells []gridCell
|
|
for row := range g.grid {
|
|
for col := range g.grid[row] {
|
|
for _, item := range g.grid[row][col] {
|
|
if item.ID() == id {
|
|
cells = append(cells, gridCell{Row: row, Col: col})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return cells
|
|
}
|
|
|
|
func allGridCells(rows, cols int) []gridCell {
|
|
cells := make([]gridCell, 0, rows*cols)
|
|
for row := 0; row < rows; row++ {
|
|
for col := 0; col < cols; col++ {
|
|
cells = append(cells, gridCell{Row: row, Col: col})
|
|
}
|
|
}
|
|
return cells
|
|
}
|
|
|
|
func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) {
|
|
t.Helper()
|
|
|
|
got := collectOccupiedCells(g, id)
|
|
|
|
require.ElementsMatchf(
|
|
t,
|
|
want,
|
|
got,
|
|
"unexpected indexed cells for object %d",
|
|
id,
|
|
)
|
|
}
|
|
|
|
func TestIndexObject_Point_TableDriven(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
worldW int
|
|
worldH int
|
|
cellSize int
|
|
item Point
|
|
wantCells []gridCell
|
|
}{
|
|
{
|
|
name: "point inside world",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Point{
|
|
Id: PrimitiveID(1),
|
|
X: 150 * SCALE,
|
|
Y: 250 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 1},
|
|
},
|
|
},
|
|
{
|
|
name: "point wraps from negative coordinates to last cell",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Point{
|
|
Id: PrimitiveID(1),
|
|
X: -1,
|
|
Y: -1,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 5, Col: 5},
|
|
},
|
|
},
|
|
{
|
|
name: "point exactly at world boundary wraps to zero cell",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Point{
|
|
Id: PrimitiveID(1),
|
|
X: 600 * SCALE,
|
|
Y: 600 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 0, Col: 0},
|
|
},
|
|
},
|
|
{
|
|
name: "point on cell boundary belongs to that cell",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Point{
|
|
Id: PrimitiveID(1),
|
|
X: 200 * SCALE,
|
|
Y: 300 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 3, Col: 2},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := newTestWorld(tt.worldW, tt.worldH)
|
|
g.resetGrid(tt.cellSize)
|
|
|
|
g.indexObject(tt.item)
|
|
|
|
requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIndexObject_Circle_TableDriven(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
worldW int
|
|
worldH int
|
|
cellSize int
|
|
item Circle
|
|
wantCells []gridCell
|
|
}{
|
|
{
|
|
name: "circle without wrap",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 2},
|
|
{Row: 2, Col: 3},
|
|
{Row: 3, Col: 2},
|
|
{Row: 3, Col: 3},
|
|
},
|
|
},
|
|
{
|
|
name: "circle wraps across left and top edges",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 50 * SCALE,
|
|
Y: 50 * SCALE,
|
|
Radius: 75 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 5, Col: 5},
|
|
{Row: 5, Col: 0},
|
|
{Row: 5, Col: 1},
|
|
{Row: 0, Col: 5},
|
|
{Row: 0, Col: 0},
|
|
{Row: 0, Col: 1},
|
|
{Row: 1, Col: 5},
|
|
{Row: 1, Col: 0},
|
|
{Row: 1, Col: 1},
|
|
},
|
|
},
|
|
{
|
|
name: "circle wraps across right edge only",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 575 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 5},
|
|
{Row: 2, Col: 0},
|
|
{Row: 3, Col: 5},
|
|
{Row: 3, Col: 0},
|
|
},
|
|
},
|
|
{
|
|
name: "circle wraps across bottom edge only",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 300 * SCALE,
|
|
Y: 575 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 5, Col: 2},
|
|
{Row: 5, Col: 3},
|
|
{Row: 0, Col: 2},
|
|
{Row: 0, Col: 3},
|
|
},
|
|
},
|
|
{
|
|
name: "circle larger than world covers the whole grid",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 400 * SCALE,
|
|
},
|
|
wantCells: allGridCells(6, 6),
|
|
},
|
|
{
|
|
name: "circle touching boundaries exactly uses half-open indexing",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Circle{
|
|
Id: PrimitiveID(1),
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 100 * SCALE, // bbox [200, 400) x [200, 400)
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 2},
|
|
{Row: 2, Col: 3},
|
|
{Row: 3, Col: 2},
|
|
{Row: 3, Col: 3},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := newTestWorld(tt.worldW, tt.worldH)
|
|
g.resetGrid(tt.cellSize)
|
|
|
|
g.indexObject(tt.item)
|
|
|
|
requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIndexObject_Line_TableDriven(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
worldW int
|
|
worldH int
|
|
cellSize int
|
|
item Line
|
|
wantCells []gridCell
|
|
}{
|
|
{
|
|
name: "horizontal line without wrap",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 100 * SCALE,
|
|
Y1: 200 * SCALE,
|
|
X2: 300 * SCALE,
|
|
Y2: 200 * SCALE,
|
|
},
|
|
// Half-open interval [100,300), so only cols 1 and 2.
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 1},
|
|
{Row: 2, Col: 2},
|
|
},
|
|
},
|
|
{
|
|
name: "vertical line without wrap",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 200 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 200 * SCALE,
|
|
Y2: 300 * SCALE,
|
|
},
|
|
// Half-open interval [100,300), so only rows 1 and 2.
|
|
wantCells: []gridCell{
|
|
{Row: 1, Col: 2},
|
|
{Row: 2, Col: 2},
|
|
},
|
|
},
|
|
{
|
|
name: "horizontal line wraps across left right border",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 590 * SCALE,
|
|
Y1: 200 * SCALE,
|
|
X2: 10 * SCALE,
|
|
Y2: 200 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 2, Col: 5},
|
|
{Row: 2, Col: 0},
|
|
},
|
|
},
|
|
{
|
|
name: "vertical line wraps across top bottom border",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 200 * SCALE,
|
|
Y1: 590 * SCALE,
|
|
X2: 200 * SCALE,
|
|
Y2: 10 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 5, Col: 2},
|
|
{Row: 0, Col: 2},
|
|
},
|
|
},
|
|
{
|
|
name: "diagonal line wraps across both axes",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 590 * SCALE,
|
|
Y1: 590 * SCALE,
|
|
X2: 10 * SCALE,
|
|
Y2: 10 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 5, Col: 5},
|
|
{Row: 5, Col: 0},
|
|
{Row: 0, Col: 5},
|
|
{Row: 0, Col: 0},
|
|
},
|
|
},
|
|
{
|
|
name: "zero length line indexes a single cell",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 250 * SCALE,
|
|
Y1: 350 * SCALE,
|
|
X2: 250 * SCALE,
|
|
Y2: 350 * SCALE,
|
|
},
|
|
wantCells: []gridCell{
|
|
{Row: 3, Col: 2},
|
|
},
|
|
},
|
|
{
|
|
name: "line exactly on cell boundaries follows half-open interval",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 200 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 400 * SCALE,
|
|
Y2: 100 * SCALE,
|
|
},
|
|
// [200,400) => cols 2 and 3 only.
|
|
wantCells: []gridCell{
|
|
{Row: 1, Col: 2},
|
|
{Row: 1, Col: 3},
|
|
},
|
|
},
|
|
{
|
|
name: "diagonal line without wrap indexes its full bbox footprint",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 100 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 300 * SCALE,
|
|
Y2: 300 * SCALE,
|
|
},
|
|
// Indexing is bbox-based, not raster-based.
|
|
// The bbox is [100,300) x [100,300), so four cells.
|
|
wantCells: []gridCell{
|
|
{Row: 1, Col: 1},
|
|
{Row: 1, Col: 2},
|
|
{Row: 2, Col: 1},
|
|
{Row: 2, Col: 2},
|
|
},
|
|
},
|
|
{
|
|
name: "horizontal wrap exactly on borders still indexes both edge cells",
|
|
worldW: 600,
|
|
worldH: 600,
|
|
cellSize: 100 * SCALE,
|
|
item: Line{
|
|
Id: PrimitiveID(1),
|
|
X1: 600 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 0,
|
|
Y2: 100 * SCALE,
|
|
},
|
|
// After wrapping both endpoints are equivalent to zero-width on the edge.
|
|
// The degenerate bbox expansion should still index the first cell only.
|
|
wantCells: []gridCell{
|
|
{Row: 1, Col: 0},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := newTestWorld(tt.worldW, tt.worldH)
|
|
g.resetGrid(tt.cellSize)
|
|
|
|
g.indexObject(tt.item)
|
|
|
|
requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) {
|
|
g := newTestWorld(600, 400)
|
|
|
|
pID := PrimitiveID(1)
|
|
cID := PrimitiveID(2)
|
|
lID := PrimitiveID(3)
|
|
|
|
g.objects[pID] = Point{
|
|
Id: pID,
|
|
X: 50 * SCALE,
|
|
Y: 50 * SCALE,
|
|
}
|
|
g.objects[cID] = Circle{
|
|
Id: cID,
|
|
X: 300 * SCALE,
|
|
Y: 200 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
}
|
|
g.objects[lID] = Line{
|
|
Id: lID,
|
|
X1: 590 * SCALE,
|
|
Y1: 100 * SCALE,
|
|
X2: 10 * SCALE,
|
|
Y2: 100 * SCALE,
|
|
}
|
|
|
|
g.IndexOnViewportChange(500, 300, 1.)
|
|
|
|
require.Greater(t, g.cellSize, 0)
|
|
require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols)
|
|
require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows)
|
|
|
|
require.Greaterf(t, countObjectInGrid(g, pID), 0, "point %s was not indexed", pID)
|
|
require.Greaterf(t, countObjectInGrid(g, cID), 0, "circle %s was not indexed", cID)
|
|
require.Greaterf(t, countObjectInGrid(g, lID), 0, "line %s was not indexed", lID)
|
|
}
|
|
|
|
func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) {
|
|
g := newTestWorld(600, 400)
|
|
g.IndexOnViewportChange(500, 300, 1.)
|
|
|
|
require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols)
|
|
require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows)
|
|
require.Len(t, g.grid, g.rows)
|
|
require.Len(t, g.grid[0], g.cols)
|
|
}
|
|
|
|
func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
|
|
id := PrimitiveID(1)
|
|
g.objects[id] = Circle{
|
|
Id: id,
|
|
X: 300 * SCALE,
|
|
Y: 300 * SCALE,
|
|
Radius: 50 * SCALE,
|
|
}
|
|
|
|
g.IndexOnViewportChange(500, 500, 1.)
|
|
firstCellSize := g.cellSize
|
|
firstCount := countObjectInGrid(g, id)
|
|
|
|
g.IndexOnViewportChange(200, 200, 1.)
|
|
secondCellSize := g.cellSize
|
|
secondCount := countObjectInGrid(g, id)
|
|
|
|
require.NotEqual(t, firstCellSize, secondCellSize)
|
|
require.Greater(t, firstCount, 0)
|
|
require.Greater(t, secondCount, 0)
|
|
|
|
if firstCellSize != secondCellSize && firstCount == secondCount {
|
|
t.Logf(
|
|
"cell size changed from %d to %d, but the indexed cell count happened to stay equal (%d)",
|
|
firstCellSize,
|
|
secondCellSize,
|
|
firstCount,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) {
|
|
g := newTestWorld(600, 600)
|
|
g.resetGrid(100 * SCALE)
|
|
|
|
id := PrimitiveID(1)
|
|
p := Point{
|
|
Id: id,
|
|
X: 100 * SCALE,
|
|
Y: 100 * SCALE,
|
|
}
|
|
|
|
g.indexObject(p)
|
|
|
|
got := collectOccupiedCells(g, id)
|
|
require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id))
|
|
}
|