Tooling
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A legend with one of each kind of mark, written the way an author would.
|
||||
const legendJSON = `{
|
||||
"_comment": "commentary survives a parse",
|
||||
"image": "sheet.png",
|
||||
"marks": [
|
||||
{ "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
|
||||
{ "name": "wild_coast", "rgb": [255, 128, 0], "coast_jitter": 2.5 },
|
||||
{ "name": "forest", "rgb": [0, 128, 0] },
|
||||
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
|
||||
]
|
||||
}`
|
||||
|
||||
func mustLegend(t *testing.T) *Legend {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(legendJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseFillsDefaultsAndRefusesNonsense(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
if l.MatchDistance != DefaultMatchDistance || l.MinAreaPx != DefaultMinAreaPx {
|
||||
t.Fatalf("defaults not filled: %v %v", l.MatchDistance, l.MinAreaPx)
|
||||
}
|
||||
if l.Index("forest") != 3 || l.Index("nope") != Blank {
|
||||
t.Fatalf("marks are numbered from 1 in legend order, got %d", l.Index("forest"))
|
||||
}
|
||||
if !l.TouchesCoast() {
|
||||
t.Fatal("this legend has a coast mark, so the roughening has a scale field to build")
|
||||
}
|
||||
if j, set := l.Marks[0].Jitter(); !set || j != 0 {
|
||||
t.Fatalf("a zero coast_jitter is the whole reason the key is a pointer; got %v set=%v", j, set)
|
||||
}
|
||||
if j, set := l.Marks[2].Jitter(); set || j != 1 {
|
||||
t.Fatalf("a mark that says nothing about the coast leaves the amplitude alone; got %v set=%v", j, set)
|
||||
}
|
||||
|
||||
for _, bad := range []struct{ what, src string }{
|
||||
{"two marks one colour", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`},
|
||||
{"two marks one name", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`},
|
||||
{"a width on an area", `{"marks":[{"name":"a","rgb":[1,2,3],"width_m":4}]}`},
|
||||
{"a negative jitter", `{"marks":[{"name":"a","rgb":[1,2,3],"coast_jitter":-1}]}`},
|
||||
{"an unknown kind", `{"marks":[{"name":"a","rgb":[1,2,3],"kind":"blob"}]}`},
|
||||
{"a misspelt key", `{"marks":[{"name":"a","rgb":[1,2,3],"coastjitter":0}]}`},
|
||||
} {
|
||||
if _, err := Parse([]byte(bad.src)); err == nil {
|
||||
t.Errorf("%s should not parse", bad.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paint builds an RGBA sheet the size asked for, all transparent, and returns setters.
|
||||
func paint(w, h int) (px, alpha []uint8, set func(x, y int, rgb [3]int)) {
|
||||
px = make([]uint8, w*h*3)
|
||||
alpha = make([]uint8, w*h)
|
||||
return px, alpha, func(x, y int, rgb [3]int) {
|
||||
i := y*w + x
|
||||
px[i*3], px[i*3+1], px[i*3+2] = uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2])
|
||||
alpha[i] = 255
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlankIsAlphaAndTolerance is the rule the whole layer rests on: most of the sheet is nothing, and there
|
||||
// are two ways to be nothing. An opaque pixel near no mark is dropped rather than snapped to the nearest,
|
||||
// which is the opposite of what the class legend does and is why they are different code.
|
||||
func TestBlankIsAlphaAndTolerance(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 8, 4
|
||||
px, alpha, set := paint(w, h)
|
||||
set(1, 1, [3]int{0, 128, 0}) // forest, exactly
|
||||
set(2, 1, [3]int{6, 132, 4}) // forest, near enough
|
||||
set(3, 1, [3]int{0, 0, 255}) // a colour the legend has never heard of
|
||||
// A transparent pixel that happens to carry a mark's colour: alpha wins.
|
||||
i := 1*w + 4
|
||||
px[i*3], px[i*3+1], px[i*3+2] = 0, 128, 0
|
||||
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
if got := r.At(1, 1); got != 3 {
|
||||
t.Fatalf("an exact colour is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(2, 1); got != 3 {
|
||||
t.Fatalf("within the tolerance is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(3, 1); got != Blank {
|
||||
t.Fatalf("a colour no mark is near is blank, not the nearest mark; got %d", got)
|
||||
}
|
||||
if got := r.At(4, 1); got != Blank {
|
||||
t.Fatalf("transparent is blank whatever colour is under it; got %d", got)
|
||||
}
|
||||
if m.Far != 1 {
|
||||
t.Fatalf("the one unmatched opaque pixel should be reported; Far=%d", m.Far)
|
||||
}
|
||||
if m.Total != w*h || m.Blank != w*h-2 {
|
||||
t.Fatalf("counts: total %d blank %d", m.Total, m.Blank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoastScaleLeavesUnmarkedPixelsUninstructed is the contract template.Coast.Scale depends on. An
|
||||
// unmarked cell must come back negative rather than 1, or a stroke painted on the land would be overruled by
|
||||
// the water beside it and the coastline would move anyway.
|
||||
func TestCoastScaleLeavesUnmarkedPixelsUninstructed(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 6, 2
|
||||
px, alpha, set := paint(w, h)
|
||||
set(0, 0, [3]int{255, 0, 255}) // drawn_coast: pinned
|
||||
set(1, 0, [3]int{255, 128, 0}) // wild_coast: chewed harder
|
||||
set(2, 0, [3]int{0, 128, 0}) // forest: says nothing about the coast
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
|
||||
sc := l.CoastScale(r)
|
||||
if sc == nil {
|
||||
t.Fatal("this legend has coast marks, so there is a scale")
|
||||
}
|
||||
if sc[0] != 0 {
|
||||
t.Errorf("a pinned coast is exactly zero, got %v", sc[0])
|
||||
}
|
||||
if sc[1] != 2.5 {
|
||||
t.Errorf("wild_coast is 2.5, got %v", sc[1])
|
||||
}
|
||||
if sc[2] >= 0 {
|
||||
t.Errorf("a mark that says nothing about the coast is uninstructed, got %v", sc[2])
|
||||
}
|
||||
if sc[3] >= 0 {
|
||||
t.Errorf("blank is uninstructed, got %v", sc[3])
|
||||
}
|
||||
|
||||
// And a legend with no coast marks builds nothing at all, so the roughening pays nothing.
|
||||
plain, err := Parse([]byte(`{"marks":[{"name":"forest","rgb":[0,128,0]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pr, _ := plain.Classify(px, alpha, w, h)
|
||||
if plain.CoastScale(pr) != nil {
|
||||
t.Error("no mark asks about the coast, so there should be no scale field")
|
||||
}
|
||||
}
|
||||
|
||||
func testScale(w, h int) Scale {
|
||||
return Scale{MetresPerPxX: 10, MetresPerPxY: 10, CircumferenceM: float64(w) * 10}
|
||||
}
|
||||
|
||||
// TestFeaturesMeasureAreasInWorldMetres covers the ordinary case and the speck filter.
|
||||
func TestFeaturesMeasureAreasInWorldMetres(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A 6x6 block of forest, and a single speck of it far away.
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
set(30, 15, [3]int{0, 128, 0})
|
||||
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the 36 px block is a feature and the 1 px speck is below min_area_px; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Mark != "forest" || f.Kind != KindArea {
|
||||
t.Fatalf("wrong mark: %+v", f)
|
||||
}
|
||||
if f.Cells != 36 || math.Abs(f.AreaM2-3600) > 1 {
|
||||
t.Fatalf("36 px at 10x10 m is 3600 m2; got %d px %v m2", f.Cells, f.AreaM2)
|
||||
}
|
||||
if math.Abs(f.CentreM[0]-125) > 1 || math.Abs(f.CentreM[1]-65) > 1 {
|
||||
t.Fatalf("centre should be the middle of the block in metres; got %v", f.CentreM)
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-60) > 1 || math.Abs(f.ExtentM[1]-60) > 1 {
|
||||
t.Fatalf("a 6x6 block is 60x60 m; got %v", f.ExtentM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASeamCrossingFeatureIsOneThing is the failure a cylindrical map has and nobody notices: a plain mean of
|
||||
// the longitudes puts the centre of a blob straddling the seam on the opposite side of the world.
|
||||
func TestASeamCrossingFeatureIsOneThing(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 6; y < 14; y++ {
|
||||
for _, x := range []int{38, 39, 0, 1} {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the blob crosses the seam and is one thing; got %d features", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Cells != 32 {
|
||||
t.Fatalf("all 32 px belong to it; got %d", f.Cells)
|
||||
}
|
||||
// Columns 38, 39, 0, 1 have their circular centre at 39.5, which is 395 m.
|
||||
if d := math.Abs(f.CentreM[0] - 395); d > 6 && math.Abs(f.CentreM[0]-395+400) > 6 {
|
||||
t.Fatalf("the centre should sit on the blob, near 395 m; got %v", f.CentreM[0])
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-40) > 1 {
|
||||
t.Fatalf("the extent is measured the short way round: 4 px is 40 m; got %v", f.ExtentM[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPathBecomesACentrelineNotAnOutline is the difference between a road and a ribbon-shaped polygon.
|
||||
func TestAPathBecomesACentrelineNotAnOutline(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 60, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A horizontal stroke three pixels thick from x=5 to x=50.
|
||||
for x := 5; x <= 50; x++ {
|
||||
for y := 9; y <= 11; y++ {
|
||||
set(x, y, [3]int{90, 60, 30})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("one stroke is one path; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Kind != KindPath || f.WidthM != 8 {
|
||||
t.Fatalf("the path's width travels with it: %+v", f)
|
||||
}
|
||||
if len(f.PointsM) < 2 {
|
||||
t.Fatalf("a path needs at least two points; got %d", len(f.PointsM))
|
||||
}
|
||||
// Simplified, so a straight stroke is a handful of points and not one per pixel.
|
||||
if len(f.PointsM) > 8 {
|
||||
t.Errorf("a straight stroke should simplify to a few points; got %d", len(f.PointsM))
|
||||
}
|
||||
// It runs the length of the stroke, not round its outline: 45 px is 450 m, an outline would be ~960.
|
||||
if f.LengthM < 400 || f.LengthM > 500 {
|
||||
t.Errorf("a 45 px stroke at 10 m a pixel is about 450 m of centreline; got %v", f.LengthM)
|
||||
}
|
||||
for _, p := range f.PointsM {
|
||||
if p[1] < 85 || p[1] > 115 {
|
||||
t.Errorf("every point should sit on the stroke, y near 100 m; got %v", p)
|
||||
}
|
||||
}
|
||||
// An area mark never gets points, whatever shape it is drawn in.
|
||||
for _, g := range feats {
|
||||
if g.Kind == KindArea && len(g.PointsM) > 0 {
|
||||
t.Error("an area keeps its outline and is not thinned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleWorldIsIndependentOfTheWindow is rule 1 for a raster: a cell gets the same mark whichever tile
|
||||
// reaches it, because the lookup goes through world metres rather than through a tile-local index.
|
||||
func TestSampleWorldIsIndependentOfTheWindow(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
s := testScale(w, h)
|
||||
|
||||
// Two windows of a 2 m grid overlapping the same ground: one starting at 100 m, one at 60 m.
|
||||
a := r.SampleWorld(100, 40, 2, 40, 40, s)
|
||||
b := r.SampleWorld(60, 40, 2, 60, 40, s)
|
||||
for y := 0; y < 40; y++ {
|
||||
for x := 0; x < 40; x++ {
|
||||
if a[y*40+x] != b[y*60+x+20] {
|
||||
t.Fatalf("the same ground read two marks at (%d,%d): %d vs %d",
|
||||
x, y, a[y*40+x], b[y*60+x+20])
|
||||
}
|
||||
}
|
||||
}
|
||||
// And it wraps, rather than clamping, past the seam.
|
||||
past := r.SampleWorld(s.CircumferenceM+100, 40, 2, 40, 40, s)
|
||||
for i := range a {
|
||||
if a[i] != past[i] {
|
||||
t.Fatalf("a window a whole world to the east must read the same ground; differ at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentReportsEveryMarkPaintedOrNot(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
doc := l.Describe(r, m, testScale(w, h), "sheet.png", "sheet.json")
|
||||
if len(doc.Marks) != 4 {
|
||||
t.Fatalf("every mark is reported, painted or not; got %d", len(doc.Marks))
|
||||
}
|
||||
byName := map[string]MarkShare{}
|
||||
for _, mk := range doc.Marks {
|
||||
byName[mk.Name] = mk
|
||||
}
|
||||
if f := byName["forest"]; f.Cells != 36 || f.Pieces != 1 || math.Abs(f.AreaKm2-0.0036) > 1e-6 {
|
||||
t.Errorf("forest: %+v", f)
|
||||
}
|
||||
if c := byName["drawn_coast"]; !c.HasJitter || c.Jitter != 0 || c.Pieces != 0 {
|
||||
t.Errorf("an unpainted coast mark still reports what it would ask for: %+v", c)
|
||||
}
|
||||
if rd := byName["road"]; rd.Kind != KindPath || rd.WidthM != 8 {
|
||||
t.Errorf("road: %+v", rd)
|
||||
}
|
||||
if doc.CircumferenceM != 400 || doc.PaintW != w {
|
||||
t.Errorf("the frame is the overlay's own: %v x %d", doc.CircumferenceM, doc.PaintW)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user