diff --git a/internal/bsp/build_test.go b/internal/bsp/build_test.go new file mode 100644 index 0000000..f4e935f --- /dev/null +++ b/internal/bsp/build_test.go @@ -0,0 +1,97 @@ +package bsp + +import ( + "math" + "testing" + + "bspviz/internal/geom" +) + +func TestStopUsesDefaults(t *testing.T) { + segs := make([]geom.Seg, 13) + if stop(segs, 0, Params{}) { + t.Fatalf("stop should continue when using default LeafMax with 13 segs") + } + if !stop(segs, 33, Params{}) { + t.Fatalf("stop should trigger when depth exceeds default MaxDepth") + } +} + +func TestEvalCostSplitsAndBalance(t *testing.T) { + line := []geom.Seg{ + {A: geom.V(-1, 1), B: geom.V(1, 1)}, + {A: geom.V(-1, -1), B: geom.V(1, -1)}, + {A: geom.V(-1, -1), B: geom.V(1, 1)}, + } + p := Params{Alpha: 10, Beta: 2, Eps: geom.EPS} + splits, balance, cost := evalCost(line, geom.V(0, 0), geom.V(1, 0), p) + if splits != 1 { + t.Fatalf("splits=%d want 1", splits) + } + if balance != 0 { + t.Fatalf("balance=%d want 0", balance) + } + if cost != 10 { + t.Fatalf("cost=%v want 10", cost) + } +} + +func TestSplitAllProducesFrontAndBack(t *testing.T) { + segs := []geom.Seg{{A: geom.V(-1, -1), B: geom.V(1, 1)}} + left, right, splits := splitAll(segs, geom.V(0, 0), geom.V(1, 0), Params{Eps: geom.EPS}) + if splits != 1 { + t.Fatalf("splits=%d want 1", splits) + } + if len(left) != 1 || len(right) != 1 { + t.Fatalf("expected split into two segments, left=%d right=%d", len(left), len(right)) + } + share := func(a, b geom.Vec) bool { + return math.Abs(a.X-b.X) < geom.EPS && math.Abs(a.Y-b.Y) < geom.EPS + } + if !share(left[0].A, right[0].B) && !share(left[0].B, right[0].A) { + t.Fatalf("split point mismatch") + } +} + +func TestBuildRespectsLeafAndDepth(t *testing.T) { + segs := []geom.Seg{ + {A: geom.V(-2, -2), B: geom.V(2, -2)}, + {A: geom.V(2, -2), B: geom.V(2, 2)}, + {A: geom.V(2, 2), B: geom.V(-2, 2)}, + {A: geom.V(-2, 2), B: geom.V(-2, -2)}, + {A: geom.V(-2, -2), B: geom.V(2, 2)}, + {A: geom.V(-2, 2), B: geom.V(2, -2)}, + } + p := Params{LeafMax: 2, MaxDepth: 3, Seed: 1, Cands: 4, Alpha: 5, Beta: 1, Eps: geom.EPS} + root := Build(segs, p) + if root == nil { + t.Fatalf("Build returned nil") + } + + stats := Measure(root) + if stats.MaxDepth > p.MaxDepth { + t.Fatalf("MaxDepth=%d exceeds limit %d", stats.MaxDepth, p.MaxDepth) + } + if stats.Leaves == 0 { + t.Fatalf("expected some leaves in tree") + } + verifyLeaves(t, root, 0, p) +} + +func verifyLeaves(t *testing.T, n *Node, depth int, p Params) { + t.Helper() + if n == nil { + return + } + if n.Leaf != nil { + if depth < p.MaxDepth && len(n.Leaf.Segs) > p.LeafMax { + t.Fatalf("leaf at depth %d has %d segs (limit %d)", depth, len(n.Leaf.Segs), p.LeafMax) + } + return + } + if depth >= p.MaxDepth { + t.Fatalf("internal node at depth %d exceeds MaxDepth %d", depth, p.MaxDepth) + } + verifyLeaves(t, n.Left, depth+1, p) + verifyLeaves(t, n.Right, depth+1, p) +} diff --git a/internal/geom/geom_test.go b/internal/geom/geom_test.go new file mode 100644 index 0000000..64ecab3 --- /dev/null +++ b/internal/geom/geom_test.go @@ -0,0 +1,83 @@ +package geom + +import ( + "math" + "testing" +) + +func TestSide(t *testing.T) { + tests := []struct { + name string + P, O, D Vec + want float64 + }{ + {"point left of line", V(1, 1), V(0, 0), V(1, 0), 1}, + {"point right of line", V(1, -1), V(0, 0), V(1, 0), -1}, + {"point on line", V(2, 0), V(0, 0), V(1, 0), 0}, + } + for _, tc := range tests { + if got := Side(tc.P, tc.O, tc.D); got < tc.want-EPS || got > tc.want+EPS { + t.Fatalf("%s: Side()=%v want %v", tc.name, got, tc.want) + } + } +} + +func TestSegLineIntersect(t *testing.T) { + O := V(0, 0) + D := V(1, 0) + tests := []struct { + name string + A, B Vec + wantOK bool + wantT float64 + }{ + {"hits interior", V(0, -1), V(0, 1), true, 0.5}, + {"parallel", V(0, 1), V(1, 1), false, 0}, + {"touches endpoint", V(0, 0), V(0, 1), false, 0}, + } + for _, tc := range tests { + ok, tVal := SegLineIntersect(tc.A, tc.B, O, D) + if ok != tc.wantOK { + t.Fatalf("%s: ok=%v want %v", tc.name, ok, tc.wantOK) + } + if !ok { + continue + } + if diff := tVal - tc.wantT; diff > 1e-6 || diff < -1e-6 { + t.Fatalf("%s: t=%v want %v", tc.name, tVal, tc.wantT) + } + } +} + +func TestSplitSeg(t *testing.T) { + O := V(0, 0) + D := V(1, 0) + seg := Seg{A: V(0, -1), B: V(0, 1)} + front, back := SplitSeg(seg, O, D) + if len(front) != 1 || len(back) != 1 { + t.Fatalf("expected split into two pieces got front=%d back=%d", len(front), len(back)) + } + share := func(a, b Vec) bool { + return math.Abs(a.X-b.X) < EPS && math.Abs(a.Y-b.Y) < EPS + } + if !share(front[0].A, back[0].B) && !share(front[0].B, back[0].A) { + t.Fatalf("split point mismatch: front=%v back=%v", front[0], back[0]) + } + + sameSide := Seg{A: V(0, 1), B: V(1, 1)} + front, back = SplitSeg(sameSide, O, D) + if len(back) != 0 || len(front) != 1 { + t.Fatalf("expected no split for same side got front=%d back=%d", len(front), len(back)) + } +} + +func TestBounds(t *testing.T) { + pts := []Vec{V(-1, 2), V(3, -4), V(0, 0)} + box := Bounds(pts) + if box.Min != V(-1, -4) { + t.Fatalf("Min=%v want (-1,-4)", box.Min) + } + if box.Max != V(3, 2) { + t.Fatalf("Max=%v want (3,2)", box.Max) + } +} diff --git a/internal/mapfmt/map_test.go b/internal/mapfmt/map_test.go new file mode 100644 index 0000000..a2ddc23 --- /dev/null +++ b/internal/mapfmt/map_test.go @@ -0,0 +1,76 @@ +package mapfmt + +import ( + "testing" +) + +func TestParseVertices(t *testing.T) { + data := []byte{ + 0, 0, 0, 0, // (0,0) + 255, 255, 10, 0, // (-1,10) + } + verts, err := ParseVertices(data) + if err != nil { + t.Fatalf("ParseVertices error: %v", err) + } + if len(verts) != 2 { + t.Fatalf("got %d vertices", len(verts)) + } + if verts[1].X != -1 || verts[1].Y != 10 { + t.Fatalf("unexpected vertex %+v", verts[1]) + } +} + +func TestParseVerticesBadSize(t *testing.T) { + if _, err := ParseVertices([]byte{0}); err == nil { + t.Fatalf("expected error on odd sized vertex data") + } +} + +func TestParseLinedefs(t *testing.T) { + data := make([]byte, 14) + data[0] = 1 // V1 + data[2] = 2 // V2 + lines, err := ParseLinedefs(data) + if err != nil { + t.Fatalf("ParseLinedefs error: %v", err) + } + if len(lines) != 1 { + t.Fatalf("expected one linedef") + } + if lines[0].V1 != 1 || lines[0].V2 != 2 { + t.Fatalf("unexpected linedef %+v", lines[0]) + } +} + +func TestParseLinedefsBadSize(t *testing.T) { + if _, err := ParseLinedefs(make([]byte, 13)); err == nil { + t.Fatalf("expected error on odd sized linedef data") + } +} + +func TestLoadMap(t *testing.T) { + raw := map[string][]byte{ + "VERTEXES": {0, 0, 0, 0}, + "LINEDEFS": make([]byte, 14), + } + m, err := LoadMap(raw) + if err != nil { + t.Fatalf("LoadMap error: %v", err) + } + if len(m.Vertices) != 1 || len(m.Linedefs) != 1 { + t.Fatalf("unexpected map data %+v", m) + } +} + +func TestLinedefsToSegs(t *testing.T) { + verts := []Vertex{{X: 0, Y: 0}, {X: 10, Y: 0}, {X: 10, Y: 0}} + lines := []Linedef{{V1: 0, V2: 1}, {V1: 1, V2: 2}} // second is degenerate + segs := LinedefsToSegs(verts, lines) + if len(segs) != 1 { + t.Fatalf("expected one segment, got %d", len(segs)) + } + if segs[0].A.X != 0 || segs[0].B.X != 10 { + t.Fatalf("unexpected segment: %+v", segs[0]) + } +} diff --git a/internal/viz/viz_test.go b/internal/viz/viz_test.go new file mode 100644 index 0000000..924370c --- /dev/null +++ b/internal/viz/viz_test.go @@ -0,0 +1,73 @@ +package viz + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "bspviz/internal/bsp" + "bspviz/internal/geom" + "bspviz/internal/mapfmt" +) + +func TestEmitDOT(t *testing.T) { + root := &bsp.Node{ + O: geom.V(0, 0), + D: geom.V(1, 0), + Left: &bsp.Node{ + Leaf: &bsp.Leaf{Segs: []geom.Seg{{A: geom.V(0, 0), B: geom.V(1, 1)}}}, + }, + Right: &bsp.Node{ + Leaf: &bsp.Leaf{}, + }, + } + out := filepath.Join(t.TempDir(), "tree.dot") + if err := EmitDOT(root, out); err != nil { + t.Fatalf("EmitDOT error: %v", err) + } + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + expect := "digraph BSP {\n" + + " node [fontname=\"Helvetica\"];\n" + + " n0 [label=\"Split\\nO=(0,0) D=(1,0)\"];\n" + + " n1 [label=\"Leaf\\nSegs=1\", shape=ellipse, style=filled, fillcolor=lightgray];\n" + + " n2 [label=\"Leaf\\nSegs=0\", shape=ellipse, style=filled, fillcolor=lightgray];\n" + + " n0 -> n1 [label=\"L\"];\n" + + " n0 -> n2 [label=\"R\"];\n" + + "}\n" + if string(got) != expect { + t.Fatalf("unexpected DOT output:\n%s", got) + } +} + +func TestRenderPNGProducesFile(t *testing.T) { + m := &mapfmt.MapData{ + Vertices: []mapfmt.Vertex{{X: 0, Y: 0}, {X: 64, Y: 0}, {X: 64, Y: 64}}, + Linedefs: []mapfmt.Linedef{ + {V1: 0, V2: 1}, + {V1: 1, V2: 2}, + {V1: 2, V2: 0}, + }, + } + root := &bsp.Node{ + O: geom.V(0, 0), + D: geom.V(1, 0), + Left: &bsp.Node{Leaf: &bsp.Leaf{Segs: []geom.Seg{{A: geom.V(0, 0), B: geom.V(64, 64)}}}}, + Right: &bsp.Node{Leaf: &bsp.Leaf{}}, + } + out := filepath.Join(t.TempDir(), "render.png") + if err := RenderPNG(m, root, out); err != nil { + t.Fatalf("RenderPNG error: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + pngMagic := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} + if len(data) < len(pngMagic) || !bytes.Equal(data[:len(pngMagic)], pngMagic) { + t.Fatalf("output is not a PNG") + } +} diff --git a/internal/wad/wad_test.go b/internal/wad/wad_test.go new file mode 100644 index 0000000..e1590d2 --- /dev/null +++ b/internal/wad/wad_test.go @@ -0,0 +1,140 @@ +package wad + +import ( + "bytes" + "encoding/binary" + "io" + "os" + "testing" +) + +type lumpDef struct { + name string + data []byte +} + +func writeTestWAD(t *testing.T, lumps []lumpDef) string { + t.Helper() + tmp, err := os.CreateTemp(t.TempDir(), "wad-*.wad") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + + hdr := header{Ident: [4]byte{'P', 'W', 'A', 'D'}, NumLumps: int32(len(lumps))} + if err := binary.Write(tmp, binary.LittleEndian, hdr); err != nil { + t.Fatalf("write header: %v", err) + } + + dir := make([]DirEntry, 0, len(lumps)) + for _, l := range lumps { + pos, err := tmp.Seek(0, io.SeekCurrent) + if err != nil { + t.Fatalf("seek: %v", err) + } + if len(l.data) > 0 { + if _, err := tmp.Write(l.data); err != nil { + t.Fatalf("write lump %s: %v", l.name, err) + } + } + var name [8]byte + copy(name[:], []byte(l.name)) + dir = append(dir, DirEntry{ + FilePos: int32(pos), + Size: int32(len(l.data)), + Name8: name, + }) + } + dirOffset, err := tmp.Seek(0, io.SeekCurrent) + if err != nil { + t.Fatalf("seek dir: %v", err) + } + if err := binary.Write(tmp, binary.LittleEndian, dir); err != nil { + t.Fatalf("write dir: %v", err) + } + + hdr.DirOffset = int32(dirOffset) + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek hdr: %v", err) + } + if err := binary.Write(tmp, binary.LittleEndian, hdr); err != nil { + t.Fatalf("rewrite header: %v", err) + } + + if err := tmp.Close(); err != nil { + t.Fatalf("close temp wad: %v", err) + } + + return tmp.Name() +} + +func TestOpenAndReadLump(t *testing.T) { + name := writeTestWAD(t, []lumpDef{ + {name: "MAP01", data: nil}, + {name: "DATA", data: []byte{1, 2, 3}}, + }) + w, err := Open(name) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = w.Close() }) + + if len(w.Dir()) != 2 { + t.Fatalf("Dir entries=%d want 2", len(w.Dir())) + } + + data, _, err := w.ReadLumpByName("data") + if err != nil { + t.Fatalf("ReadLumpByName: %v", err) + } + if !bytes.Equal(data, []byte{1, 2, 3}) { + t.Fatalf("unexpected lump data: %v", data) + } +} + +func TestFindMapAndLoadMapLumps(t *testing.T) { + verts := make([]byte, 4) + lines := make([]byte, 14) + name := writeTestWAD(t, []lumpDef{ + {name: "MAP01", data: nil}, + {name: "VERTEXES", data: verts}, + {name: "LINEDEFS", data: lines}, + {name: "MAP02", data: nil}, + }) + w, err := Open(name) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = w.Close() }) + + start, end, err := w.FindMap("map01") + if err != nil { + t.Fatalf("FindMap: %v", err) + } + if start != 0 || end != 3 { + t.Fatalf("unexpected marker bounds start=%d end=%d", start, end) + } + + lumps, err := w.LoadMapLumps("map01", "VERTEXES", "LINEDEFS") + if err != nil { + t.Fatalf("LoadMapLumps: %v", err) + } + if len(lumps) != 2 { + t.Fatalf("expected 2 lumps, got %d", len(lumps)) + } + if _, ok := lumps["VERTEXES"]; !ok { + t.Fatalf("missing VERTEXES lump") + } +} + +func TestLoadMapLumpsMissing(t *testing.T) { + name := writeTestWAD(t, []lumpDef{{name: "MAP01", data: nil}}) + w, err := Open(name) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = w.Close() }) + + if _, err := w.LoadMapLumps("map01", "VERTEXES"); err == nil { + t.Fatalf("expected error for missing lump") + } +}