1894 lines
44 KiB
Go
1894 lines
44 KiB
Go
package world
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"github.com/stretchr/testify/require"
|
|
"image/color"
|
|
"testing"
|
|
)
|
|
|
|
func newIndexedTestWorld() *World {
|
|
w := NewWorld(10, 10)
|
|
w.SetCircleRadiusScaleFp(SCALE)
|
|
w.resetGrid(2 * SCALE) // 5x5 grid.
|
|
return w
|
|
}
|
|
|
|
func cellHasOnlyID(t *testing.T, w *World, row, col int, want PrimitiveID) {
|
|
t.Helper()
|
|
|
|
cell := w.grid[row][col]
|
|
if len(cell) != 1 {
|
|
t.Fatalf("cell[%d][%d] len = %d, want 1", row, col, len(cell))
|
|
}
|
|
if got := cell[0].ID(); got != want {
|
|
t.Fatalf("cell[%d][%d] item id = %v, want %v", row, col, got, want)
|
|
}
|
|
}
|
|
|
|
func cellIsEmpty(t *testing.T, w *World, row, col int) {
|
|
t.Helper()
|
|
|
|
if got := len(w.grid[row][col]); got != 0 {
|
|
t.Fatalf("cell[%d][%d] len = %d, want 0", row, col, got)
|
|
}
|
|
}
|
|
|
|
func occupiedCellsByID(w *World, id PrimitiveID) map[[2]int]struct{} {
|
|
result := make(map[[2]int]struct{})
|
|
|
|
for row := range w.grid {
|
|
for col := range w.grid[row] {
|
|
for _, item := range w.grid[row][col] {
|
|
if item.ID() == id {
|
|
result[[2]int{row, col}] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func assertOccupiedCells(t *testing.T, w *World, id PrimitiveID, want ...[2]int) {
|
|
t.Helper()
|
|
|
|
got := occupiedCellsByID(w, id)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("occupied cell count = %d, want %d; got=%v want=%v", len(got), len(want), got, want)
|
|
}
|
|
|
|
for _, cell := range want {
|
|
if _, ok := got[cell]; !ok {
|
|
t.Fatalf("missing occupied cell row=%d col=%d; got=%v", cell[0], cell[1], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewWorld verifies new World.
|
|
func TestNewWorld(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(12, 7)
|
|
|
|
if w.W != 12*SCALE {
|
|
t.Fatalf("W = %d, want %d", w.W, 12*SCALE)
|
|
}
|
|
if w.H != 7*SCALE {
|
|
t.Fatalf("H = %d, want %d", w.H, 7*SCALE)
|
|
}
|
|
if w.cellSize != 1 {
|
|
t.Fatalf("cellSize = %d, want 1", w.cellSize)
|
|
}
|
|
if w.objects == nil {
|
|
t.Fatal("objects map is nil")
|
|
}
|
|
}
|
|
|
|
// TestNewWorldPanicsOnInvalidSize verifies new World Panics On Invalid Size.
|
|
func TestNewWorldPanicsOnInvalidSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
width int
|
|
height int
|
|
}{
|
|
{name: "zero width", width: 0, height: 1},
|
|
{name: "zero height", width: 1, height: 0},
|
|
{name: "negative width", width: -1, height: 1},
|
|
{name: "negative height", width: 1, height: -1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatalf("NewWorld(%d, %d) did not panic", tt.width, tt.height)
|
|
}
|
|
}()
|
|
|
|
_ = NewWorld(tt.width, tt.height)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckCoordinate verifies check Coordinate.
|
|
func TestCheckCoordinate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
tests := []struct {
|
|
name string
|
|
xf int
|
|
yf int
|
|
want bool
|
|
}{
|
|
{name: "origin", xf: 0, yf: 0, want: true},
|
|
{name: "inside", xf: 5000, yf: 5000, want: true},
|
|
{name: "last valid", xf: 9999, yf: 9999, want: true},
|
|
{name: "x below", xf: -1, yf: 0, want: false},
|
|
{name: "y below", xf: 0, yf: -1, want: false},
|
|
{name: "x equal width", xf: 10000, yf: 0, want: false},
|
|
{name: "y equal height", xf: 0, yf: 10000, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := w.checkCoordinate(tt.xf, tt.yf); got != tt.want {
|
|
t.Fatalf("checkCoordinate(%d, %d) = %v, want %v", tt.xf, tt.yf, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAddPoint verifies add Point.
|
|
func TestAddPoint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id, err := w.AddPoint(1.25, 2.75)
|
|
if err != nil {
|
|
t.Fatalf("AddPoint returned error: %v", err)
|
|
}
|
|
|
|
item, ok := w.objects[id]
|
|
if !ok {
|
|
t.Fatalf("point with id %v was not stored", id)
|
|
}
|
|
|
|
p, ok := item.(Point)
|
|
if !ok {
|
|
t.Fatalf("stored item type = %T, want Point", item)
|
|
}
|
|
if p.X != 1250 || p.Y != 2750 {
|
|
t.Fatalf("stored point = (%d, %d), want (1250, 2750)", p.X, p.Y)
|
|
}
|
|
}
|
|
|
|
// TestAddPointRejectsOutOfBounds verifies add Point Rejects Out Of Bounds.
|
|
func TestAddPointRejectsOutOfBounds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
tests := []struct {
|
|
name string
|
|
x float64
|
|
y float64
|
|
}{
|
|
{name: "negative x", x: -0.001, y: 1},
|
|
{name: "negative y", x: 1, y: -0.001},
|
|
{name: "x rounds to width", x: 9.9995, y: 1},
|
|
{name: "y rounds to height", x: 1, y: 9.9995},
|
|
{name: "x clearly outside", x: 10, y: 1},
|
|
{name: "y clearly outside", x: 1, y: 10},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
_, err := w.AddPoint(tt.x, tt.y)
|
|
if !errors.Is(err, errBadCoordinate) {
|
|
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAddPointAllowsLastRoundedInsideValue verifies add Point Allows Last Rounded Inside Value.
|
|
func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id, err := w.AddPoint(9.9994, 9.9994)
|
|
if err != nil {
|
|
t.Fatalf("AddPoint returned error: %v", err)
|
|
}
|
|
|
|
p := w.objects[id].(Point)
|
|
if p.X != 9999 || p.Y != 9999 {
|
|
t.Fatalf("stored point = (%d, %d), want (9999, 9999)", p.X, p.Y)
|
|
}
|
|
}
|
|
|
|
// TestAddCircle verifies add Circle.
|
|
func TestAddCircle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id, err := w.AddCircle(2.5, 3.5, 1.25)
|
|
if err != nil {
|
|
t.Fatalf("AddCircle returned error: %v", err)
|
|
}
|
|
|
|
item, ok := w.objects[id]
|
|
if !ok {
|
|
t.Fatalf("circle with id %v was not stored", id)
|
|
}
|
|
|
|
c, ok := item.(Circle)
|
|
if !ok {
|
|
t.Fatalf("stored item type = %T, want Circle", item)
|
|
}
|
|
if c.X != 2500 || c.Y != 3500 || c.Radius != 1250 {
|
|
t.Fatalf("stored circle = (%d, %d, %d), want (2500, 3500, 1250)", c.X, c.Y, c.Radius)
|
|
}
|
|
}
|
|
|
|
// TestAddCircleAllowsZeroRadius verifies add Circle Allows Zero Radius.
|
|
func TestAddCircleAllowsZeroRadius(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id, err := w.AddCircle(2, 3, 0)
|
|
if err != nil {
|
|
t.Fatalf("AddCircle returned error: %v", err)
|
|
}
|
|
|
|
c := w.objects[id].(Circle)
|
|
if c.Radius != 0 {
|
|
t.Fatalf("radius = %d, want 0", c.Radius)
|
|
}
|
|
}
|
|
|
|
// TestAddCircleRejectsInvalidInput verifies add Circle Rejects Invalid Input.
|
|
func TestAddCircleRejectsInvalidInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
if _, err := w.AddCircle(1, 1, -0.001); !errors.Is(err, errBadRadius) {
|
|
t.Fatalf("negative radius error = %v, want %v", err, errBadRadius)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
x float64
|
|
y float64
|
|
}{
|
|
{name: "negative x", x: -0.001, y: 1},
|
|
{name: "negative y", x: 1, y: -0.001},
|
|
{name: "x rounds to width", x: 9.9995, y: 1},
|
|
{name: "y rounds to height", x: 1, y: 9.9995},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
_, err := w.AddCircle(tt.x, tt.y, 1)
|
|
if !errors.Is(err, errBadCoordinate) {
|
|
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAddLine verifies add Line.
|
|
func TestAddLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id, err := w.AddLine(1.1, 2.2, 3.3, 4.4)
|
|
if err != nil {
|
|
t.Fatalf("AddLine returned error: %v", err)
|
|
}
|
|
|
|
item, ok := w.objects[id]
|
|
if !ok {
|
|
t.Fatalf("line with id %v was not stored", id)
|
|
}
|
|
|
|
l, ok := item.(Line)
|
|
if !ok {
|
|
t.Fatalf("stored item type = %T, want Line", item)
|
|
}
|
|
if l.X1 != 1100 || l.Y1 != 2200 || l.X2 != 3300 || l.Y2 != 4400 {
|
|
t.Fatalf("stored line = (%d, %d) -> (%d, %d), want (1100, 2200) -> (3300, 4400)",
|
|
l.X1, l.Y1, l.X2, l.Y2)
|
|
}
|
|
}
|
|
|
|
// TestAddLineRejectsInvalidInput verifies add Line Rejects Invalid Input.
|
|
func TestAddLineRejectsInvalidInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
tests := []struct {
|
|
name string
|
|
x1 float64
|
|
y1 float64
|
|
x2 float64
|
|
y2 float64
|
|
}{
|
|
{name: "first point x below", x1: -0.001, y1: 1, x2: 2, y2: 2},
|
|
{name: "first point y below", x1: 1, y1: -0.001, x2: 2, y2: 2},
|
|
{name: "second point x below", x1: 1, y1: 1, x2: -0.001, y2: 2},
|
|
{name: "second point y below", x1: 1, y1: 1, x2: 2, y2: -0.001},
|
|
{name: "first point x rounds to width", x1: 9.9995, y1: 1, x2: 2, y2: 2},
|
|
{name: "second point y rounds to height", x1: 1, y1: 1, x2: 2, y2: 9.9995},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
_, err := w.AddLine(tt.x1, tt.y1, tt.x2, tt.y2)
|
|
if !errors.Is(err, errBadCoordinate) {
|
|
t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResetGrid verifies reset Grid.
|
|
func TestResetGrid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 6)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
if w.cellSize != 2*SCALE {
|
|
t.Fatalf("cellSize = %d, want %d", w.cellSize, 2*SCALE)
|
|
}
|
|
if w.cols != 5 {
|
|
t.Fatalf("cols = %d, want 5", w.cols)
|
|
}
|
|
if w.rows != 3 {
|
|
t.Fatalf("rows = %d, want 3", w.rows)
|
|
}
|
|
if len(w.grid) != 3 {
|
|
t.Fatalf("len(grid) = %d, want 3", len(w.grid))
|
|
}
|
|
for row := range w.grid {
|
|
if len(w.grid[row]) != 5 {
|
|
t.Fatalf("len(grid[%d]) = %d, want 5", row, len(w.grid[row]))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWorldToCellXY verifies world To Cell XY.
|
|
func TestWorldToCellXY(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
|
|
if got := w.worldToCellX(2500); got != 1 {
|
|
t.Fatalf("worldToCellX(2500) = %d, want 1", got)
|
|
}
|
|
if got := w.worldToCellY(4500); got != 2 {
|
|
t.Fatalf("worldToCellY(4500) = %d, want 2", got)
|
|
}
|
|
if got := w.worldToCellX(-1); got != 4 {
|
|
t.Fatalf("worldToCellX(-1) = %d, want 4", got)
|
|
}
|
|
if got := w.worldToCellY(10000); got != 0 {
|
|
t.Fatalf("worldToCellY(10000) = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestIndexObjectPoint verifies index Object Point.
|
|
func TestIndexObjectPoint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
p := Point{Id: id, X: 2500, Y: 4500}
|
|
w.indexObject(p)
|
|
|
|
cellHasOnlyID(t, w, 2, 1, id)
|
|
cellIsEmpty(t, w, 0, 0)
|
|
cellIsEmpty(t, w, 4, 4)
|
|
}
|
|
|
|
// TestIndexObjectCircleWithoutWrap verifies index Object Circle Without Wrap.
|
|
func TestIndexObjectCircleWithoutWrap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900}
|
|
w.indexObject(c)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{0, 1},
|
|
[2]int{1, 1},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectCircleWrapsAcrossCorner verifies index Object Circle Wraps Across Corner.
|
|
func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
c := Circle{Id: id, X: 500, Y: 500, Radius: 900}
|
|
w.indexObject(c)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{0, 0},
|
|
[2]int{0, 4},
|
|
[2]int{4, 0},
|
|
[2]int{4, 4},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectCircleCoversWholeWorld verifies index Object Circle Covers Whole World.
|
|
func TestIndexObjectCircleCoversWholeWorld(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000}
|
|
w.indexObject(c)
|
|
|
|
want := make([][2]int, 0, 25)
|
|
for row := 0; row < 5; row++ {
|
|
for col := 0; col < 5; col++ {
|
|
want = append(want, [2]int{row, col})
|
|
}
|
|
}
|
|
|
|
assertOccupiedCells(t, w, id, want...)
|
|
}
|
|
|
|
// TestIndexObjectVerticalLineExpandsDegenerateX verifies index Object Vertical Line Expands Degenerate X.
|
|
func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000}
|
|
w.indexObject(l)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{0, 1},
|
|
[2]int{1, 1},
|
|
[2]int{2, 1},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectHorizontalLineExpandsDegenerateY verifies index Object Horizontal Line Expands Degenerate Y.
|
|
func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000}
|
|
w.indexObject(l)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{1, 0},
|
|
[2]int{1, 1},
|
|
[2]int{1, 2},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectLineWrapsAcrossX verifies index Object Line Wraps Across X.
|
|
func TestIndexObjectLineWrapsAcrossX(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000}
|
|
w.indexObject(l)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{1, 4},
|
|
[2]int{1, 0},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectLineWrapsAcrossY verifies index Object Line Wraps Across Y.
|
|
func TestIndexObjectLineWrapsAcrossY(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000}
|
|
w.indexObject(l)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{4, 1},
|
|
[2]int{0, 1},
|
|
)
|
|
}
|
|
|
|
// TestIndexObjectLineTieCaseUsesDeterministicWrap verifies index Object Line Tie Case Uses Deterministic Wrap.
|
|
func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
id := PrimitiveID(1)
|
|
|
|
l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000}
|
|
w.indexObject(l)
|
|
|
|
assertOccupiedCells(t, w, id,
|
|
[2]int{1, 3},
|
|
[2]int{1, 4},
|
|
[2]int{1, 0},
|
|
)
|
|
}
|
|
|
|
type unknown struct {
|
|
id PrimitiveID
|
|
}
|
|
|
|
func (u unknown) ID() PrimitiveID {
|
|
return u.id
|
|
}
|
|
|
|
// TestIndexBBoxPanicsOnUnknownItemType verifies index B Box Panics On Unknown Item Type.
|
|
func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := newIndexedTestWorld()
|
|
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("indexObject did not panic for unknown item type")
|
|
}
|
|
}()
|
|
|
|
w.indexObject(unknown{id: PrimitiveID(1)})
|
|
}
|
|
|
|
// TestAddPoint_DefaultsPriorityAndStyle verifies add Point Defaults Priority And Style.
|
|
func TestAddPoint_DefaultsPriorityAndStyle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
id, err := w.AddPoint(1, 1)
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Point)
|
|
require.Equal(t, DefaultPriorityPoint, obj.Priority)
|
|
require.Equal(t, StyleIDDefaultPoint, obj.StyleID)
|
|
}
|
|
|
|
// TestAddCircle_DefaultsPriorityAndStyle verifies add Circle Defaults Priority And Style.
|
|
func TestAddCircle_DefaultsPriorityAndStyle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
id, err := w.AddCircle(1, 1, 1)
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Circle)
|
|
require.Equal(t, DefaultPriorityCircle, obj.Priority)
|
|
require.Equal(t, StyleIDDefaultCircle, obj.StyleID)
|
|
}
|
|
|
|
// TestAddLine_DefaultsPriorityAndStyle verifies add Line Defaults Priority And Style.
|
|
func TestAddLine_DefaultsPriorityAndStyle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
id, err := w.AddLine(1, 1, 2, 2)
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Line)
|
|
require.Equal(t, DefaultPriorityLine, obj.Priority)
|
|
require.Equal(t, StyleIDDefaultLine, obj.StyleID)
|
|
}
|
|
|
|
// TestAddStyleLine_ThenUseStyleID verifies add Style Line Then Use Style ID.
|
|
func TestAddStyleLine_ThenUseStyleID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
width := 5.0
|
|
ov := StyleOverride{StrokeWidthPx: &width}
|
|
styleID := w.AddStyleLine(ov)
|
|
|
|
id, err := w.AddLine(1, 1, 2, 2, LineWithStyleID(styleID), LineWithPriority(777))
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Line)
|
|
require.Equal(t, 777, obj.Priority)
|
|
require.Equal(t, styleID, obj.StyleID)
|
|
|
|
s, ok := w.styles.Get(styleID)
|
|
require.True(t, ok)
|
|
require.Equal(t, 5.0, s.StrokeWidthPx)
|
|
}
|
|
|
|
// TestAddPoint_WithOverride_CreatesDerivedStyle verifies add Point With Override Creates Derived Style.
|
|
func TestAddPoint_WithOverride_CreatesDerivedStyle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
newRadius := 9.0
|
|
ov := StyleOverride{PointRadiusPx: &newRadius}
|
|
|
|
id, err := w.AddPoint(1, 1, PointWithStyleOverride(ov))
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Point)
|
|
require.NotEqual(t, StyleIDDefaultPoint, obj.StyleID)
|
|
|
|
s, ok := w.styles.Get(obj.StyleID)
|
|
require.True(t, ok)
|
|
require.Equal(t, 9.0, s.PointRadiusPx)
|
|
}
|
|
|
|
// TestExplicitStyleID_WinsOverOverride verifies explicit Style ID Wins Over Override.
|
|
func TestExplicitStyleID_WinsOverOverride(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
red := color.RGBA{R: 255, A: 255}
|
|
styleID := w.AddStyleCircle(StyleOverride{FillColor: red})
|
|
|
|
// Try to override radius in options too; StyleID must win, override must be ignored.
|
|
width := 123.0
|
|
id, err := w.AddCircle(2, 2, 1,
|
|
CircleWithStyleID(styleID),
|
|
CircleWithStyleOverride(StyleOverride{StrokeWidthPx: &width}),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
obj := w.objects[id].(Circle)
|
|
require.Equal(t, styleID, obj.StyleID)
|
|
|
|
s, ok := w.styles.Get(styleID)
|
|
require.True(t, ok)
|
|
require.Equal(t, red, s.FillColor)
|
|
// width override must not affect styleID.
|
|
require.NotEqual(t, 123.0, s.StrokeWidthPx)
|
|
}
|
|
|
|
// TestWorldPrimitiveID_ReusesFreedIDs verifies world Primitive ID Reuses Freed I Ds.
|
|
func TestWorldPrimitiveID_ReusesFreedIDs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
|
|
id1, err := w.AddPoint(1, 1)
|
|
require.NoError(t, err)
|
|
|
|
id2, err := w.AddPoint(2, 2)
|
|
require.NoError(t, err)
|
|
|
|
require.NotEqual(t, id1, id2)
|
|
|
|
require.NoError(t, w.Remove(id1))
|
|
|
|
id3, err := w.AddPoint(3, 3)
|
|
require.NoError(t, err)
|
|
|
|
// LIFO free-list: id1 should be reused.
|
|
require.Equal(t, id1, id3)
|
|
}
|
|
|
|
// TestWorldRemove_UnknownID verifies world Remove Unknown ID.
|
|
func TestWorldRemove_UnknownID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
err := w.Remove(12345)
|
|
require.ErrorIs(t, err, errNoSuchObject)
|
|
}
|
|
|
|
type gridCell struct {
|
|
Row int
|
|
Col int
|
|
}
|
|
|
|
func newTestWorld(wReal, hReal int) *World {
|
|
w := NewWorld(wReal, hReal)
|
|
w.SetCircleRadiusScaleFp(SCALE)
|
|
return w
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// TestViewportPxToWorldFixed verifies viewport Px To World Fixed.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSplitByWrap_ZeroOrNegativeSizeReturnsNil verifies split By Wrap Zero Or Negative Size Returns Nil.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSplitByWrap_XWrapUsesWorldWidth verifies split By Wrap X Wrap Uses World Width.
|
|
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])
|
|
}
|
|
|
|
// TestSplitByWrap_YWrapUsesWorldHeight verifies split By Wrap Y Wrap Uses World Height.
|
|
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])
|
|
}
|
|
|
|
// TestSplitByWrap_XAndYWrap verifies split By Wrap X And Y Wrap.
|
|
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)
|
|
}
|
|
|
|
// TestSplitByWrap_NoWrapInsideWorld verifies split By Wrap No Wrap Inside World.
|
|
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])
|
|
}
|
|
|
|
// TestSplitByWrap_FullWorldCoverageOnEqualWidth verifies split By Wrap Full World Coverage On Equal Width.
|
|
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])
|
|
}
|
|
|
|
// TestSplitByWrap_FullWorldCoverageOnEqualHeight verifies split By Wrap Full World Coverage On Equal Height.
|
|
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])
|
|
}
|
|
|
|
// TestSplitByWrap_FullWorldCoverageOnBothAxes verifies split By Wrap Full World Coverage On Both Axes.
|
|
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])
|
|
}
|
|
|
|
// TestWorldToCell verifies world To Cell.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResetGrid_UsesWidthForColsAndHeightForRows verifies reset Grid Uses Width For Cols And Height For Rows.
|
|
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)
|
|
}
|
|
|
|
// TestIndexPoint verifies index Point.
|
|
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))
|
|
}
|
|
|
|
// TestIndexPoint_WrapsNegativeCoordinates verifies index Point Wraps Negative Coordinates.
|
|
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))
|
|
}
|
|
|
|
// TestIndexCircle_WrapsAcrossLeftAndTopEdges verifies index Circle Wraps Across Left And Top Edges.
|
|
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))
|
|
}
|
|
|
|
// TestIndexCircle_NoWrap verifies index Circle No Wrap.
|
|
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))
|
|
}
|
|
|
|
// TestIndexCircle_CoversWholeWorldWhenLargerThanWorld verifies index Circle Covers Whole World When Larger Than World.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIndexLine_HorizontalWrap verifies index Line Horizontal Wrap.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_VerticalWrap verifies index Line Vertical Wrap.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_DiagonalWrapBothAxes verifies index Line Diagonal Wrap Both Axes.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes verifies index Line Horizontal No Wrap Degenerate B Box Still Indexes.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes verifies index Line Vertical No Wrap Degenerate B Box Still Indexes.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_ZeroLengthIndexesSingleCell verifies index Line Zero Length Indexes Single Cell.
|
|
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))
|
|
}
|
|
|
|
// TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval verifies index Line Exactly On Cell Boundary Uses Half Open Interval.
|
|
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,
|
|
)
|
|
}
|
|
|
|
// TestIndexObject_Point_TableDriven verifies index Object Point Table Driven.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIndexObject_Circle_TableDriven verifies index Object Circle Table Driven.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIndexObject_Line_TableDriven verifies index Object Line Table Driven.
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIndexOnViewportChange_RebuildsGridAndIndexesObjects verifies index On Viewport Change Rebuilds Grid And Indexes Objects.
|
|
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)
|
|
}
|
|
|
|
// TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld verifies index On Viewport Change Rebuilds Grid Shape For Non Square World.
|
|
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)
|
|
}
|
|
|
|
// TestIndexOnViewportChange_ReindexesAfterCellSizeChange verifies index On Viewport Change Reindexes After Cell Size Change.
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestPrimitiveIndexing_ErrorMessagesStayReadable verifies primitive Indexing Error Messages Stay Readable.
|
|
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))
|
|
}
|
|
|
|
// TestPrimitiveIDs verifies primitive I Ds.
|
|
func TestPrimitiveIDs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
id1 := PrimitiveID(1)
|
|
id2 := PrimitiveID(2)
|
|
id3 := PrimitiveID(3)
|
|
|
|
p := Point{Id: id1}
|
|
l := Line{Id: id2}
|
|
c := Circle{Id: id3}
|
|
|
|
if got := p.ID(); got != id1 {
|
|
t.Fatalf("Point.ID() = %v, want %v", got, id1)
|
|
}
|
|
if got := l.ID(); got != id2 {
|
|
t.Fatalf("Line.ID() = %v, want %v", got, id2)
|
|
}
|
|
if got := c.ID(); got != id3 {
|
|
t.Fatalf("Circle.ID() = %v, want %v", got, id3)
|
|
}
|
|
}
|
|
|
|
// TestLineMinMax verifies line Min Max.
|
|
func TestLineMinMax(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
l := Line{
|
|
X1: 7000, Y1: 2000,
|
|
X2: 1000, Y2: 9000,
|
|
}
|
|
|
|
if got := l.MinX(); got != 1000 {
|
|
t.Fatalf("Line.MinX() = %d, want 1000", got)
|
|
}
|
|
if got := l.MaxX(); got != 7000 {
|
|
t.Fatalf("Line.MaxX() = %d, want 7000", got)
|
|
}
|
|
if got := l.MinY(); got != 2000 {
|
|
t.Fatalf("Line.MinY() = %d, want 2000", got)
|
|
}
|
|
if got := l.MaxY(); got != 9000 {
|
|
t.Fatalf("Line.MaxY() = %d, want 9000", got)
|
|
}
|
|
}
|
|
|
|
// TestCircleBounds verifies circle Bounds.
|
|
func TestCircleBounds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := Circle{
|
|
X: 4000,
|
|
Y: 7000,
|
|
Radius: 1500,
|
|
}
|
|
|
|
if got := c.MinX(); got != 2500 {
|
|
t.Fatalf("Circle.MinX() = %d, want 2500", got)
|
|
}
|
|
if got := c.MaxX(); got != 5500 {
|
|
t.Fatalf("Circle.MaxX() = %d, want 5500", got)
|
|
}
|
|
if got := c.MinY(); got != 5500 {
|
|
t.Fatalf("Circle.MinY() = %d, want 5500", got)
|
|
}
|
|
if got := c.MaxY(); got != 8500 {
|
|
t.Fatalf("Circle.MaxY() = %d, want 8500", got)
|
|
}
|
|
}
|
|
|
|
// TestRender_CircleRadiusScale_AffectsRenderedRadiusPx verifies render Circle Radius Scale Affects Rendered Radius Px.
|
|
func TestRender_CircleRadiusScale_AffectsRenderedRadiusPx(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := NewWorld(10, 10)
|
|
w.resetGrid(2 * SCALE)
|
|
|
|
// Ensure index state is initialized so Add triggers rebuild if needed.
|
|
w.IndexOnViewportChange(10, 10, 1.0)
|
|
|
|
_, err := w.AddCircle(5, 5, 2) // raw radius = 2 units
|
|
require.NoError(t, err)
|
|
|
|
// scale = 2.0
|
|
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
|
|
|
|
// Reindex explicitly (safe).
|
|
w.Reindex()
|
|
|
|
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))
|
|
|
|
circles := d.CommandsByName("AddCircle")
|
|
require.NotEmpty(t, circles)
|
|
|
|
// AddCircle args: cx, cy, rPx
|
|
rPx := circles[0].Args[2]
|
|
require.Equal(t, float64(4), rPx, "raw radius=2 with scale=2 => eff radius=4 => rPx=4 at zoom=1")
|
|
}
|