326 lines
9.3 KiB
Go
326 lines
9.3 KiB
Go
package region
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"salty/terrain/internal/template"
|
|
"salty/terrain/internal/world"
|
|
)
|
|
|
|
const legendJSON = `{"classes":[
|
|
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":100},
|
|
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.5}
|
|
]}`
|
|
|
|
// testMap builds a planet from a picture of its painted rows. '#' is land, '.' is sea; the polar pad of
|
|
// synthetic ocean is added above and below.
|
|
func testMap(t *testing.T, rows []string, pad int) *template.Map {
|
|
t.Helper()
|
|
l, err := template.Parse([]byte(legendJSON))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := len(rows[0])
|
|
p := world.Planet{CellM: 1, W: w, H: len(rows) + 2*pad, PadY: pad, NoisePeriodM: float64(w)}
|
|
if err := p.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := &template.Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
|
|
for i := range m.Class {
|
|
m.Class[i], m.Sea[i] = 0, true
|
|
}
|
|
for y, row := range rows {
|
|
if len(row) != w {
|
|
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
|
|
}
|
|
for x, r := range row {
|
|
if r == '#' {
|
|
i := (y+pad)*p.W + x
|
|
m.Class[i], m.Sea[i] = 1, false
|
|
}
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// assertBordersAreWater is the invariant the whole solve depends on, and the direct analogue of
|
|
// TestBorderIsAlwaysOcean: a grid edge is an outlet, so land on one would freeze at its initial relief while
|
|
// the interior eroded out from under it.
|
|
func assertBordersAreWater(t *testing.T, part *Partition, m *template.Map) {
|
|
t.Helper()
|
|
for _, r := range part.Regions {
|
|
_, land := part.Cut(m, r)
|
|
for x := 0; x < r.Frame.W; x++ {
|
|
if land[x] {
|
|
t.Errorf("region %d: land on the top edge at column %d", r.ID, x)
|
|
}
|
|
if land[(r.Frame.H-1)*r.Frame.W+x] {
|
|
t.Errorf("region %d: land on the bottom edge at column %d", r.ID, x)
|
|
}
|
|
}
|
|
for y := 0; y < r.Frame.H; y++ {
|
|
if land[y*r.Frame.W] {
|
|
t.Errorf("region %d: land on the left edge at row %d", r.ID, y)
|
|
}
|
|
if land[y*r.Frame.W+r.Frame.W-1] {
|
|
t.Errorf("region %d: land on the right edge at row %d", r.ID, y)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The crater island in the template this was written for straddles x = 0. If the partitioner split it in
|
|
// two, half of it would be solved against a shore that does not exist.
|
|
func TestSeamStraddlingLandmassIsOneRegion(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"#.........#",
|
|
"#.........#",
|
|
"...........",
|
|
}, 2)
|
|
part, err := Build(m, 2, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) != 1 {
|
|
t.Fatalf("got %d regions, want 1: the landmass wraps", len(part.Regions))
|
|
}
|
|
r := part.Regions[0]
|
|
if !r.Seam {
|
|
t.Error("the region does not report that it straddles the seam")
|
|
}
|
|
if r.LandCells != 4 {
|
|
t.Errorf("LandCells = %d, want 4", r.LandCells)
|
|
}
|
|
// Land occupies columns 10 and 0, which are neighbours on an 11-column cylinder; a 2-cell margin on
|
|
// each side makes the frame six columns wide starting at column 8.
|
|
if r.Frame.W != 6 {
|
|
t.Errorf("frame width = %d, want 6", r.Frame.W)
|
|
}
|
|
if r.Frame.X0 != 8 {
|
|
t.Errorf("frame X0 = %d, want 8", r.Frame.X0)
|
|
}
|
|
assertBordersAreWater(t, part, m)
|
|
}
|
|
|
|
// Landmasses close enough to shelter each other are solved together; further apart they are not. The
|
|
// alternative that was rejected - overlapping dilated bounding boxes - is transitively closed and one long
|
|
// landmass has an enormous box, so on a real template it collapses most of the map into a single region.
|
|
func TestClusteringFollowsDistanceNotBoundingBoxes(t *testing.T) {
|
|
near := testMap(t, []string{
|
|
"..............................",
|
|
".####....#....................",
|
|
".####....#....................",
|
|
"..............................",
|
|
}, 3)
|
|
part, err := Build(near, 3, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) != 1 {
|
|
t.Fatalf("got %d regions, want 1: a four-cell gap closes under a three-cell margin on each side",
|
|
len(part.Regions))
|
|
}
|
|
|
|
far := testMap(t, []string{
|
|
"..............................",
|
|
".####.........#...............",
|
|
".####.........#...............",
|
|
"..............................",
|
|
}, 3)
|
|
part, err = Build(far, 3, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) != 2 {
|
|
t.Fatalf("got %d regions, want 2: a nine-cell gap does not", len(part.Regions))
|
|
}
|
|
assertBordersAreWater(t, part, far)
|
|
}
|
|
|
|
func TestEveryRegionBorderIsWater(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"..###...........................................................",
|
|
"..###.........####.............####.............##..............",
|
|
"..............####.............####.............##..............",
|
|
"..............####.............####.............................",
|
|
"................................................................",
|
|
"................................................................",
|
|
}, 3)
|
|
part, err := Build(m, 3, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) < 2 {
|
|
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
|
}
|
|
assertBordersAreWater(t, part, m)
|
|
}
|
|
|
|
// A polar cap touches the top row of the painted map. The synthetic ocean pad is what gives it a shore, so
|
|
// that fluvial.isOutlet - which treats every top-row cell as an outlet - is answering about water.
|
|
func TestAPolarCapGetsAMarginOfOcean(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"###############...............",
|
|
"########......................",
|
|
"..............................",
|
|
"..............................",
|
|
}, 3)
|
|
part, err := Build(m, 3, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) != 1 {
|
|
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
|
}
|
|
r := part.Regions[0]
|
|
if r.Frame.Y0 != 0 {
|
|
t.Errorf("frame Y0 = %d, want 0: the cap reaches into the pad", r.Frame.Y0)
|
|
}
|
|
assertBordersAreWater(t, part, m)
|
|
}
|
|
|
|
// Every painted land cell belongs to exactly one region, and a round trip through Cut and Composite
|
|
// reproduces it. If two regions owned the same cell, one would silently overwrite the other.
|
|
func TestCutAndCompositeCoverEveryLandCellOnce(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"#..####...................#",
|
|
"#..####...................#",
|
|
"...........................",
|
|
"..........##.....##........",
|
|
"..........##.....##........",
|
|
"...........................",
|
|
}, 3)
|
|
part, err := Build(m, 2, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) < 2 {
|
|
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
|
}
|
|
|
|
dst := make([]float32, m.P.W*m.P.H)
|
|
hits := make([]int, len(dst))
|
|
total := 0
|
|
for _, r := range part.Regions {
|
|
_, land := part.Cut(m, r)
|
|
src := make([]float32, r.Frame.Cells())
|
|
for i := range src {
|
|
if land[i] {
|
|
src[i] = float32(r.ID + 1)
|
|
}
|
|
}
|
|
total += part.Composite(dst, m, r, src)
|
|
for y := 0; y < r.Frame.H; y++ {
|
|
for x := 0; x < r.Frame.W; x++ {
|
|
pi := r.Frame.PlanetIdx(x, y)
|
|
if part.Owner[pi] == int32(r.ID) && !m.Sea[pi] {
|
|
hits[pi]++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
painted := 0
|
|
for i := range m.Sea {
|
|
if !m.Sea[i] {
|
|
painted++
|
|
}
|
|
}
|
|
if total != painted {
|
|
t.Errorf("composited %d land cells, but %d are painted", total, painted)
|
|
}
|
|
for i, n := range hits {
|
|
if m.Sea[i] {
|
|
if n != 0 {
|
|
t.Fatalf("water cell %d was written %d times", i, n)
|
|
}
|
|
continue
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("land cell %d was written %d times, want exactly 1", i, n)
|
|
}
|
|
if dst[i] == 0 {
|
|
t.Fatalf("land cell %d came back zero", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A landmass that rings the planet cannot be flattened into a rectangle with water on both sides, and the
|
|
// solve needs that. Better a clear refusal than a silently frozen coastline.
|
|
func TestALandmassRingingThePlanetIsRefused(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"##########",
|
|
"..........",
|
|
"..........",
|
|
"..........",
|
|
}, 2)
|
|
_, err := Build(m, 2, 1)
|
|
if err == nil {
|
|
t.Fatal("accepted a landmass that goes all the way round")
|
|
}
|
|
if !strings.Contains(err.Error(), "all the way round") {
|
|
t.Errorf("error %q does not say why", err)
|
|
}
|
|
}
|
|
|
|
// A stray paint pixel should not cost a whole region.
|
|
func TestSpecksAreDroppedAndCounted(t *testing.T) {
|
|
m := testMap(t, []string{
|
|
"####......................",
|
|
"####...............#......",
|
|
"####......................",
|
|
"..........................",
|
|
}, 2)
|
|
part, err := Build(m, 2, 4)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(part.Regions) != 1 {
|
|
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
|
}
|
|
if part.DroppedRegions != 1 || part.DroppedCells != 1 {
|
|
t.Errorf("dropped %d regions / %d cells, want 1 and 1", part.DroppedRegions, part.DroppedCells)
|
|
}
|
|
// And the speck is not owned by anything, so nothing solves it.
|
|
for i := range m.Sea {
|
|
if !m.Sea[i] && part.Owner[i] < 0 {
|
|
return
|
|
}
|
|
}
|
|
t.Error("the speck is still owned by a region")
|
|
}
|
|
|
|
func TestTheMarginMustFitInsideThePad(t *testing.T) {
|
|
m := testMap(t, []string{"####......", ".........."}, 1)
|
|
if _, err := Build(m, 4, 1); err == nil {
|
|
t.Fatal("accepted a margin wider than the polar pad")
|
|
}
|
|
}
|
|
|
|
func TestSpanWrapsTheShortWay(t *testing.T) {
|
|
mark := func(w int, on ...int) []bool {
|
|
c := make([]bool, w)
|
|
for _, x := range on {
|
|
c[x] = true
|
|
}
|
|
return c
|
|
}
|
|
cases := []struct {
|
|
cols []bool
|
|
w int
|
|
x0, wanted int
|
|
}{
|
|
{mark(10, 0, 1, 2), 10, 0, 3},
|
|
{mark(10, 8, 9, 0, 1), 10, 8, 4},
|
|
{mark(10, 5), 10, 5, 1},
|
|
{mark(10, 0, 5), 10, 5, 6},
|
|
}
|
|
for _, c := range cases {
|
|
x0, w := span(c.cols, c.w)
|
|
if x0 != c.x0 || w != c.wanted {
|
|
t.Errorf("span = %d+%d, want %d+%d", x0, w, c.x0, c.wanted)
|
|
}
|
|
}
|
|
}
|