1package main23import (4 "encoding/json"5 "testing"6)78func TestDominantHashIgnoresTinySamples(t *testing.T) {9 // The top of a pyramid is a handful of tiles showing the whole map. Calling10 // the only tile there "background" would stop a crawl before it started.11 single := map[[2]int]string{{0, 0}: "a"}12 if got := dominantHash(single); got != "" {13 t.Fatalf("dominantHash(single tile) = %q, want %q", got, "")14 }1516 four := map[[2]int]string{{0, 0}: "a", {1, 0}: "a", {0, 1}: "a", {1, 1}: "b"}17 if got := dominantHash(four); got != "" {18 t.Fatalf("dominantHash(4 tiles) = %q, want %q", got, "")19 }20}2122func TestDominantHashFindsBackgroundBelowMajority(t *testing.T) {23 // A map whose content fills most of its bounds still has empty corners, so24 // the background need not be a majority to be the background.25 hashes := make(map[[2]int]string, 32)26 for index := range 32 {27 key := [2]int{index, 0}28 if index < 10 {29 hashes[key] = "empty"30 continue31 }32 hashes[key] = string(rune('a' + index))33 }34 if got := dominantHash(hashes); got != "empty" {35 t.Fatalf("dominantHash = %q, want %q", got, "empty")36 }37}3839func TestDominantHashRejectsUniformLevel(t *testing.T) {40 // Every tile identical gives nothing to contrast against; the question is41 // left to a deeper level rather than answered wrongly.42 hashes := make(map[[2]int]string, 20)43 for index := range 20 {44 hashes[[2]int{index, 0}] = "same"45 }46 if got := dominantHash(hashes); got != "" {47 t.Fatalf("dominantHash(uniform) = %q, want %q", got, "")48 }49}5051func TestDominantHashRejectsDistinctTiles(t *testing.T) {52 hashes := make(map[[2]int]string, 40)53 for index := range 40 {54 hashes[[2]int{index, 0}] = string(rune('a' + index))55 }56 if got := dominantHash(hashes); got != "" {57 t.Fatalf("dominantHash(all distinct) = %q, want %q", got, "")58 }59}6061func TestLiveTilesDropsBackground(t *testing.T) {62 hashes := map[[2]int]string{63 {0, 0}: "empty",64 {1, 0}: "content",65 {2, 0}: "empty",66 }67 live := liveTiles(hashes, "empty")68 if len(live) != 1 || !live[[2]int{1, 0}] {69 t.Fatalf("liveTiles = %v, want only {1,0}", live)70 }71 // With no background known, nothing is pruned.72 if all := liveTiles(hashes, ""); len(all) != 3 {73 t.Fatalf("liveTiles(no background) kept %d, want 3", len(all))74 }75}7677func TestChildrenOfStaysInsideWindow(t *testing.T) {78 live := map[[2]int]bool{{2, 2}: true}79 window := tileWindow{minX: 4, minY: 4, maxX: 5, maxY: 4}80 children := childrenOf(live, window)81 if len(children) != 2 {82 t.Fatalf("childrenOf = %v, want the two children inside the window", children)83 }84 for _, child := range children {85 if !window.holds(child) {86 t.Errorf("child %v falls outside the window", child)87 }88 }89}9091func TestChildrenOfPrunesEmptySpace(t *testing.T) {92 // One live parent out of a 4x4 level yields 4 requests at the next zoom93 // rather than the 64 a whole level would need.94 live := map[[2]int]bool{{0, 0}: true}95 window := tileWindow{minX: 0, minY: 0, maxX: 7, maxY: 7}96 if children := childrenOf(live, window); len(children) != 4 {97 t.Fatalf("childrenOf = %d tiles, want 4", len(children))98 }99}100101func TestWindowTiles(t *testing.T) {102 window := tileWindow{minX: 3, minY: 7, maxX: 4, maxY: 8}103 tiles := window.tiles()104 if len(tiles) != 4 {105 t.Fatalf("tiles = %v, want 4", tiles)106 }107 if tiles[0] != [2]int{3, 7} || tiles[3] != [2]int{4, 8} {108 t.Fatalf("tiles = %v, want row-major from the minimum corner", tiles)109 }110}111112func TestTileSetWindowMissingZoom(t *testing.T) {113 set := apiTileSet{Bounds: map[string]apiBound{114 "13": {X: apiRange{Min: 4064, Max: 4095}, Y: apiRange{Min: 4064, Max: 4095}},115 }}116 if _, ok := set.window(14); ok {117 t.Fatal("window(14) reported bounds the layer does not publish")118 }119 window, ok := set.window(13)120 if !ok || window.minX != 4064 || window.maxY != 4095 {121 t.Fatalf("window(13) = %+v, %v", window, ok)122 }123}124125func TestDirectoryName(t *testing.T) {126 cases := map[string]string{127 "L.A. Noire": "l-a-noire-59",128 "Pokémon Red/Blue/Yellow": "pokemon-red-blue-yellow-59",129 "Assassin's Creed (Resynced)": "assassin-s-creed-resynced-59",130 "Skyrim": "skyrim-59",131 }132 for title, want := range cases {133 if got := directoryName(title, 59); got != want {134 t.Errorf("directoryName(%q) = %q, want %q", title, got, want)135 }136 }137}138139func TestTileSetPathOf(t *testing.T) {140 url := "https://tiles.mapgenie.io/games/la-noire/los-angeles/default-v2/10/508/508.png"141 if got := tileSetPathOf(url, 10); got != "la-noire/los-angeles/default-v2" {142 t.Fatalf("tileSetPathOf = %q", got)143 }144 if got := tileSetPathOf(url, 13); got != "" {145 t.Fatalf("tileSetPathOf with the wrong zoom = %q, want empty", got)146 }147 // IGN serves its pyramids under /wikimaps/, and a re-crawl has to recover148 // the same layer path from those URLs or it orphans every captured tile.149 ign := "https://oyster.ignimgs.com/ignmedia/wikimaps/cyberpunk-2077/night-city/5/12-3.jpg"150 if got := tileSetPathOf(ign, 5); got != "cyberpunk-2077/night-city" {151 t.Fatalf("tileSetPathOf(ign) = %q", got)152 }153}154155func TestIGNArchiveIDStaysApartFromMapGenie(t *testing.T) {156 id := ignArchiveID("ign:cyberpunk-2077")157 if id <= 1<<32 {158 t.Fatalf("id %d could collide with a MapGenie id", id)159 }160 if id >= 1<<53 {161 t.Fatalf("id %d would lose integers read back through a float64", id)162 }163 if again := ignArchiveID("ign:cyberpunk-2077"); again != id {164 t.Fatalf("id is unstable: %d then %d", id, again)165 }166}167168func TestArcgisArchiveIDStaysApartFromEveryOtherSource(t *testing.T) {169 id := arcgisArchiveID("arcgis:bend-or")170 if id <= 1<<35 {171 t.Fatalf("id %d could collide with another source's ids", id)172 }173 if id >= 1<<53 {174 t.Fatalf("id %d would lose integers read back through a float64", id)175 }176 if again := arcgisArchiveID("arcgis:bend-or"); again != id {177 t.Fatalf("id is unstable: %d then %d", id, again)178 }179}180181func TestArcgisTileTemplateRecoversItsTileSetPath(t *testing.T) {182 // The rendered basemap's record URLs must group under the same layer183 // path the translator declares, through the same marker every archive184 // reader cuts on.185 url := "arcgis-basemap://tiles/bend-or/2026-08-01/basemap/6/12/40.png"186 if got := tileSetPathOf(url, 6); got != "bend-or/2026-08-01/basemap" {187 t.Fatalf("tileSetPathOf = %q", got)188 }189}190191func TestTileIndexReusesExistingTileSetDirectory(t *testing.T) {192 // Re-crawling an archive the extension captured must not orphan its files.193 index := newTileIndex(203)194 index.put(tileRecord{195 URL: "https://tiles.mapgenie.io/games/a/b/c-v1/10/1/2.png",196 Zoom: 10,197 TileSetID: 206,198 Status: "cached",199 })200 index.setIDs["a/b/c-v1"] = 206201 index.usedIDs[206] = true202203 if got := index.tileSetID("a/b/c-v1"); got != 206 {204 t.Fatalf("tileSetID(known layer) = %d, want 206", got)205 }206 fresh := index.tileSetID("x/y/z-v1")207 if fresh == 206 {208 t.Fatal("a new layer reused the existing directory")209 }210 if again := index.tileSetID("x/y/z-v1"); again != fresh {211 t.Fatalf("tileSetID is unstable: %d then %d", fresh, again)212 }213}214215func TestUpsertByIDReplacesInPlace(t *testing.T) {216 existing := []any{217 map[string]any{"id": float64(1), "directory": "games/one", "extra": "kept"},218 map[string]any{"id": float64(2), "directory": "games/two"},219 }220 result := upsertByID(existing, map[string]any{"directory": "games/one-moved"}, 1)221 if len(result) != 2 {222 t.Fatalf("upsertByID changed the entry count to %d", len(result))223 }224 first := result[0].(map[string]any)225 if first["directory"] != "games/one-moved" {226 t.Errorf("directory = %v, want the replacement", first["directory"])227 }228 if first["extra"] != "kept" {229 t.Errorf("upsertByID dropped a field the archive already had")230 }231232 appended := upsertByID(existing, map[string]any{"id": float64(3)}, 3)233 if len(appended) != 3 {234 t.Fatalf("upsertByID did not append an unknown id")235 }236}237238func TestChildWindowCoversEveryChild(t *testing.T) {239 live := map[[2]int]bool{{2, 3}: true, {5, 4}: true}240 window, ok := childWindow(live)241 if !ok {242 t.Fatal("two live tiles yielded no window")243 }244 want := tileWindow{minX: 4, minY: 6, maxX: 11, maxY: 9}245 if window != want {246 t.Fatalf("childWindow() = %+v, want %+v", window, want)247 }248 for parent := range live {249 for _, child := range [][2]int{250 {parent[0] * 2, parent[1] * 2},251 {parent[0]*2 + 1, parent[1]*2 + 1},252 } {253 if !window.holds(child) {254 t.Errorf("window drops child %v of %v", child, parent)255 }256 }257 }258 if _, ok := childWindow(nil); ok {259 t.Error("an empty level yielded a window")260 }261}262263// The search for an unpublished window starts at the tile the map opens on, so264// that tile has to be the one the map is actually drawn in. Appalachia opens on265// the single tile its shallowest level holds.266func TestStartTileFindsTheOpeningTile(t *testing.T) {267 full := &apiMapFull{268 InitialLatitude: json.RawMessage("0.50662148513874"),269 InitialLongitude: json.RawMessage("-0.62060295831731"),270 }271 at, ok := startTile(full, 8)272 if !ok {273 t.Fatal("a map with a start coordinate gave no tile")274 }275 if at != [2]int{127, 127} {276 t.Fatalf("startTile(8) = %v, want [127 127]", at)277 }278 if at, ok := startTile(full, 9); !ok || at != [2]int{255, 255} {279 t.Fatalf("startTile(9) = %v %v, want [255 255] true", at, ok)280 }281 if _, ok := startTile(&apiMapFull{}, 8); ok {282 t.Error("a map with no start coordinate gave a tile")283 }284}285286func TestNumberValueReadsQuotedAndPlain(t *testing.T) {287 for _, tt := range []struct {288 raw string289 want float64290 ok bool291 }{292 {raw: "1.5", want: 1.5, ok: true},293 {raw: `"-0.25"`, want: -0.25, ok: true},294 {raw: "null"},295 {raw: `""`},296 {raw: `"east"`},297 } {298 got, ok := numberValue(json.RawMessage(tt.raw))299 if ok != tt.ok || (ok && got != tt.want) {300 t.Errorf("numberValue(%s) = %v %v, want %v %v", tt.raw, got, ok, tt.want, tt.ok)301 }302 }303}