1package basemap_test23import (4 "bytes"5 "image/color"6 "testing"78 "github.com/FelineStateMachine/atlas/internal/generate/tiles/basemap"9)1011// These tests say what the drawing is for. What it *is* -- byte for byte -- is12// settled elsewhere: the pipeline test in cmd/atlas derives a drawn level and13// holds every tile against the capture that witnessed it, which is a stronger14// statement than any assertion here could make. What that gate cannot say is15// why the renderer is shaped the way it is, so that is what these are.1617// The zoom every test draws at: the zoom the style table's widths are spelled18// at, so a width in the table is a width in pixels and a test can count them.19const zoom = basemap.ReferenceZoom2021// worldPerTile is how much of the world square one tile covers at that zoom,22// and pixels is how many tile pixels one world pixel spans.23const (24 worldPerTile = basemap.WorldSize >> zoom25 pixels = basemap.TileSize / worldPerTile26)2728// square is a ring around a rectangle of the world, wound whichever way; the29// renderer normalizes it.30func square(minX, minY, maxX, maxY float64) [][2]float64 {31 return [][2]float64{{minX, minY}, {maxX, minY}, {maxX, maxY}, {minX, maxY}}32}3334// whole is a ring around the entire world square.35func whole() [][2]float64 { return square(0, 0, basemap.WorldSize, basemap.WorldSize) }3637// middle is the tile's centre pixel, the one furthest from any edge effect.38const middle = basemap.TileSize / 23940func TestNothingDrawnIsTheFlatGround(t *testing.T) {41 // Every tile of a level exists, because the deriver needs the level42 // complete to fold it down. A tile no shape reaches is the background, and43 // the deriver goes on to omit the one they all share.44 img := basemap.NewRenderer(nil, zoom).Tile(0, 0)45 for y := range basemap.TileSize {46 for x := range basemap.TileSize {47 if got := img.NRGBAAt(x, y); got != basemap.Background {48 t.Fatalf("pixel %d,%d is %v, want the flat ground %v", x, y, got, basemap.Background)49 }50 }51 }52}5354func TestRolesLandInTheDrawingsOwnOrder(t *testing.T) {55 // Water draws over parks so a lake inside a park reads as a lake, and the56 // order is the drawing's, not the order the shapes arrived in. Both57 // orderings are asked for, because "the z-order wins" is the claim.58 for _, reversed := range []bool{false, true} {59 shapes := []basemap.Feature{60 {Role: basemap.RolePark, Rings: [][][2]float64{whole()}},61 {Role: basemap.RoleWater, Rings: [][][2]float64{whole()}},62 }63 if reversed {64 shapes[0], shapes[1] = shapes[1], shapes[0]65 }66 got := basemap.NewRenderer(shapes, zoom).Tile(0, 0).NRGBAAt(middle, middle)67 want := color.NRGBA{R: 0x15, G: 0x25, B: 0x39, A: 0xff}68 if got != want {69 t.Errorf("park and water overlapping draw %v, want the water %v (shapes reversed: %t)",70 got, want, reversed)71 }72 }73}7475func TestEveryRingAfterTheFirstIsAHole(t *testing.T) {76 // Publishers do not reliably wind their holes the way GeoJSON asks, so the77 // convention is positional rather than geometric: ring zero is ground and78 // everything after it cuts out, whichever way round it was given.79 for _, name := range []string{"as given", "reversed"} {80 hole := square(worldPerTile/4, worldPerTile/4, worldPerTile*3/4, worldPerTile*3/4)81 if name == "reversed" {82 for i, j := 0, len(hole)-1; i < j; i, j = i+1, j-1 {83 hole[i], hole[j] = hole[j], hole[i]84 }85 }86 shapes := []basemap.Feature{{87 Role: basemap.RolePark,88 Rings: [][][2]float64{square(0, 0, worldPerTile, worldPerTile), hole},89 }}90 img := basemap.NewRenderer(shapes, zoom).Tile(0, 0)91 if got := img.NRGBAAt(middle, middle); got != basemap.Background {92 t.Errorf("%s: the hole's middle is %v, want the ground showing through %v",93 name, got, basemap.Background)94 }95 // And the ring outside the hole is still park.96 if got := img.NRGBAAt(4, middle); got == basemap.Background {97 t.Errorf("%s: the ring around the hole was cut out too", name)98 }99 }100}101102func TestAnUnknownRoleDrawsNothing(t *testing.T) {103 // A role the style table has no entry for is a curation table running ahead104 // of the drawing. Silence is the honest answer: inventing a style would put105 // a layer on the map that nobody chose the look of.106 shapes := []basemap.Feature{{Role: "chartreuse", Rings: [][][2]float64{whole()}}}107 if got := basemap.NewRenderer(shapes, zoom).Tile(0, 0).NRGBAAt(middle, middle); got != basemap.Background {108 t.Errorf("an unstyled role drew %v", got)109 }110}111112func TestAStrokeReachesInFromOutsideTheTile(t *testing.T) {113 // The bleed is why neighbouring tiles continue each other. A line whose114 // centre falls outside a tile still lays its shoulder inside it, and a tile115 // that clipped at its own edge would cut that shoulder off and show a seam.116 //117 // The line here sits four tenths of a world pixel to the left of tile 1's118 // left edge -- outside the tile, inside the bleed.119 const justOutside = worldPerTile - 0.4120 shapes := []basemap.Feature{{121 Role: basemap.RoleBoundary,122 Lines: [][][2]float64{{{justOutside, 0}, {justOutside, basemap.WorldSize}}},123 }}124 img := basemap.NewRenderer(shapes, zoom).Tile(1, 0)125 if got := img.NRGBAAt(0, middle); got == basemap.Background {126 t.Error("a stroke just outside the tile left nothing in it, so the bleed is not being drawn")127 }128 // And it really is only a shoulder: the line's own width does not carry129 // far into the tile.130 if got := img.NRGBAAt(basemap.TileSize/4, middle); got != basemap.Background {131 t.Errorf("the stroke reached a quarter of the way across the tile: %v", got)132 }133}134135func TestEmphasisWidensOneShapesStroke(t *testing.T) {136 // An arterial is a street drawn wider, not a role of its own -- which is137 // what lets one curated table say both what a layer is and how much of it138 // matters, without a second table to drift from the first.139 line := [][][2]float64{{{0, worldPerTile / 2}, {worldPerTile, worldPerTile / 2}}}140 count := func(emphasis float64) int {141 shapes := []basemap.Feature{{Role: basemap.RoleStreet, Lines: line, Emphasis: emphasis}}142 img := basemap.NewRenderer(shapes, zoom).Tile(0, 0)143 drawn := 0144 for y := range basemap.TileSize {145 if img.NRGBAAt(middle, y) != basemap.Background {146 drawn++147 }148 }149 return drawn150 }151 plain, emphasized := count(0), count(1.8)152 if plain == 0 {153 t.Fatal("an unemphasized street drew nothing")154 }155 if emphasized <= plain {156 t.Errorf("emphasis 1.8 drew %d pixels across, plain drew %d", emphasized, plain)157 }158}159160func TestWidthsAreWorldTrue(t *testing.T) {161 // Widths are spelled at the reference zoom and scale with depth, so a162 // street is the same width on the ground however deep the pyramid goes --163 // which, folded back down, is the same width on the screen.164 line := func(at int) [][][2]float64 {165 across := float64(int(basemap.WorldSize) >> at)166 return [][][2]float64{{{0, across / 2}, {across, across / 2}}}167 }168 count := func(at int) int {169 shapes := []basemap.Feature{{Role: basemap.RoleStreet, Lines: line(at)}}170 img := basemap.NewRenderer(shapes, at).Tile(0, 0)171 drawn := 0172 for y := range basemap.TileSize {173 if img.NRGBAAt(middle, y) != basemap.Background {174 drawn++175 }176 }177 return drawn178 }179 shallow, deep := count(zoom), count(zoom+1)180 if deep <= shallow {181 t.Errorf("a level deeper drew the same street %d pixels wide, the reference level %d; "+182 "a world-true width doubles with the zoom", deep, shallow)183 }184}185186func TestTheSameShapesMakeTheSameBytes(t *testing.T) {187 // The claim the whole city pyramid rests on. Two renderers, built and drawn188 // independently, must not disagree by a bit -- there is no clock, no map189 // iteration and no concurrency inside a tile for them to disagree over.190 build := func() []byte {191 shapes := []basemap.Feature{192 {Role: basemap.RoleWater, Rings: [][][2]float64{whole()}},193 {Role: basemap.RoleTrail, Lines: [][][2]float64{{{0, 0}, {worldPerTile, worldPerTile}}}},194 {Role: basemap.RoleStreet, Lines: [][][2]float64{{{0, worldPerTile}, {worldPerTile, 0}}}, Emphasis: 1.4},195 }196 body, err := basemap.EncodePNG(basemap.NewRenderer(shapes, zoom).Tile(0, 0))197 if err != nil {198 t.Fatal(err)199 }200 return body201 }202 if first, second := build(), build(); !bytes.Equal(first, second) {203 t.Errorf("two renders of the same shapes wrote %d and %d bytes", len(first), len(second))204 }205}206207func TestATileIsOpaqueTruecolour(t *testing.T) {208 // A tile has no transparency to carry -- the ground is always painted --209 // and the encoder is left to notice that, because an alpha channel nothing210 // uses is a quarter of the pixels wasted in every tile of every city.211 body, err := basemap.EncodePNG(basemap.NewRenderer(nil, zoom).Tile(0, 0))212 if err != nil {213 t.Fatal(err)214 }215 // The colour type is the 26th byte of a PNG: 8-byte signature, then the216 // IHDR chunk's length, type, width, height and bit depth.217 const colourType = 8 + 4 + 4 + 4 + 4 + 1218 if len(body) <= colourType {219 t.Fatalf("a tile encoded to %d bytes", len(body))220 }221 if got := body[colourType]; got != 2 {222 t.Errorf("the tile's PNG colour type is %d, want 2 (truecolour, no alpha)", got)223 }224}225226func TestTheDrawingAndTheDeriverAgreeOnEmpty(t *testing.T) {227 // The deriver omits a level's background tile and asks a reader to paint228 // the colour behind the raster instead. That only works if the colour it229 // samples is the colour this package painted.230 if basemap.BackgroundColor() != basemap.Background {231 t.Error("the drawing reports a background it does not paint")232 }233 if pixels != 2 {234 t.Fatalf("the tests assume the reference zoom halves the world into %d-pixel tiles", pixels)235 }236}