Files
2026-09-25 17:02:24 +03:00

292 lines
9.1 KiB
Go

package field
import (
"image"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
// valleys cut into it read as a white cut-out.
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
// managed to pass for the wrong reason.
const w, h = 96, 64
f := New(w, h, 8)
sea := make([]bool, w*h)
snow := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
v := 40 - d2/60 // a low ice dome on the left
if x > 56 {
// and a 700 m range on the right, which is what sets the top of the ramp
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
}
if v < 0 {
v = 0
sea[i] = true
}
f.Data[i] = float32(v)
snow[i] = !sea[i] && x <= 56
}
}
dir := t.TempDir()
plain := filepath.Join(dir, "plain.png")
iced := filepath.Join(dir, "iced.png")
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
if _, err := WritePreview(plain, f, opt); err != nil {
t.Fatal(err)
}
opt.Snow = snow
if _, err := WritePreview(iced, f, opt); err != nil {
t.Fatal(err)
}
a, b := readRGBA(t, plain), readRGBA(t, iced)
cx, cy := 24, 32 // the ice dome's summit
pr, pg, pb, _ := a.At(cx, cy).RGBA()
sr, sg, sb, _ := b.At(cx, cy).RGBA()
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
if sr <= pr || sb <= pb {
t.Errorf("the iced summit is not lighter than the plain one")
}
if sb < sg {
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
}
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
if lr <= dr {
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
lr>>8, dr>>8)
}
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
sx, sy := -1, -1
for i, isSea := range sea {
if isSea {
sx, sy = i%w, i/w
break
}
}
if sx < 0 {
t.Fatal("the test terrain has no sea in it")
}
wr, wg, wb, _ := a.At(sx, sy).RGBA()
xr, xg, xb, _ := b.At(sx, sy).RGBA()
if wr != xr || wg != xg || wb != xb {
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
}
}
func readRGBA(t *testing.T, path string) image.Image {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
return img
}
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
// on its own output.
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
want := DefaultPalette()
if err := want.Write(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "_comment") {
t.Error("the written palette carries no commentary")
}
got, err := LoadPalette(path)
if err != nil {
t.Fatalf("reading back what Write produced: %v", err)
}
if len(got.LandStops) != len(want.LandStops) {
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
}
for i := range want.LandStops {
if got.LandStops[i] != want.LandStops[i] {
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
}
}
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
}
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
}
}
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadPalette(path)
if err != nil {
t.Fatal(err)
}
if got.SeaDeep != (RGB{1, 2, 3}) {
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
}
if got.Ice != DefaultPalette().Ice {
t.Errorf("ice = %v, want the default", got.Ice)
}
}
// And a misspelt key is an error rather than a setting that silently does nothing.
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPalette(path); err == nil {
t.Fatal("accepted a misspelt key")
}
}
// The palette actually reaches the picture: swapping the sea colour changes the sea.
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
const w, h = 32, 32
f := New(w, h, 8)
sea := make([]bool, w*h)
for i := range sea {
sea[i] = i%w < w/2
if !sea[i] {
f.Data[i] = 50
}
}
dir := t.TempDir()
a := filepath.Join(dir, "a.png")
b := filepath.Join(dir, "b.png")
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
t.Fatal(err)
}
pal := DefaultPalette()
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
t.Fatal(err)
}
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
}
if ar == br && ag == bg && ab == bb {
t.Error("the palette changed nothing")
}
}
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
const w = 96
f := New(w, w, 10)
sea := make([]bool, w*w)
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
i := y*w + x
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
d := dx*dx + dy*dy
if d > 0.8 {
sea[i] = true
f.Data[i] = -50
continue
}
// A 40 m hill on a continent, which is a plain by any reading.
f.Data[i] = float32(40 * (1 - d/0.8))
}
}
dir := t.TempDir()
rel := filepath.Join(dir, "relative.png")
abs := filepath.Join(dir, "absolute.png")
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
if err != nil {
t.Fatal(err)
}
if top > 45 {
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
}
pal := DefaultPalette()
pal.LandTopM = 2000
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
if err != nil {
t.Fatal(err)
}
if top != 2000 {
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
}
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
relTop := brightestLand(t, rel, sea, w)
absTop := brightestLand(t, abs, sea, w)
if relTop <= absTop {
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
relTop, absTop)
}
}
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
// summit got: the ramp ends in near-white snow and starts in dark green.
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
best := 0
b := img.Bounds()
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
if sea[y*w+x] {
continue
}
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
if v := int(r+g+bl) >> 8; v > best {
best = v
}
}
}
return best
}