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 }