ui: basic map scroller

This commit is contained in:
Ilia Denisov
2026-03-06 23:29:06 +02:00
committed by GitHub
parent 29d188969b
commit 1de621c743
68 changed files with 9861 additions and 118 deletions
+537
View File
@@ -0,0 +1,537 @@
package world
import (
"errors"
"testing"
"github.com/google/uuid"
)
func newIndexedTestWorld() *World {
w := NewWorld(10, 10)
w.resetGrid(2 * SCALE) // 5x5 grid.
return w
}
func cellHasOnlyID(t *testing.T, w *World, row, col int, want uuid.UUID) {
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 uuid.UUID) 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 uuid.UUID, 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)
}
}
}
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")
}
}
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)
})
}
}
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)
}
}
}
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)
}
}
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)
}
}
}
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)
}
}
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)
}
}
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)
}
}
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)
}
}
}
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)
}
}
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)
}
}
}
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]))
}
}
}
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)
}
}
func TestIndexObjectPoint(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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)
}
func TestIndexObjectCircleWithoutWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectCircleCoversWholeWorld(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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...)
}
func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectLineWrapsAcrossX(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectLineWrapsAcrossY(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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},
)
}
func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) {
t.Parallel()
w := newIndexedTestWorld()
id := uuid.New()
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 uuid.UUID
}
func (u unknown) ID() uuid.UUID {
return u.id
}
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: uuid.New()})
}