clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

archive/golden-reference
atlas / routes_test.go
routes_test.go 21.8K
1package main23import (4 "encoding/json"5 "io/fs"6 "math"7 "net/http"8 "net/http/httptest"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/atlas/internal/bundle"13 "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"14)1516func fixtureRegistry(t *testing.T, specs ...bundletest.Spec) *bundle.Registry {17 t.Helper()18 dir := t.TempDir()19 for _, spec := range specs {20 bundletest.Build(t, dir, spec)21 }22 registry := bundle.NewRegistry(dir)23 if err := registry.Rescan(); err != nil {24 t.Fatal(err)25 }26 return registry27}2829func get(t *testing.T, handler http.Handler, path string) *httptest.ResponseRecorder {30 t.Helper()31 rec := httptest.NewRecorder()32 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))33 return rec34}3536func TestRoutesServeEmbeddedExplorer(t *testing.T) {37 rec := get(t, routes(assets, fixtureRegistry(t)), "/")3839 if rec.Code != http.StatusOK {40 t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)41 }42 if body := rec.Body.String(); !strings.Contains(body, `id="map"`) {43 t.Fatal("root response does not contain the map explorer")44 }45 if body := rec.Body.String(); strings.Contains(body, "OFFLINE") {46 t.Fatal("root response contains the redundant offline status chip")47 }48 if body := rec.Body.String(); strings.Contains(body, "Z13 EMBEDDED ASSETS") {49 t.Fatal("root response contains the redundant asset credit")50 }51 // Text-overlay categories are no longer promoted into their own row; they52 // sit in their own group alongside the other categories, and zones are a53 // layer section like any other rather than a separate always-open panel.54 if body := rec.Body.String(); strings.Contains(body, `id="text-overlay-controls"`) {55 t.Fatal("root response still promotes text-overlay controls out of their group")56 }57 if body := rec.Body.String(); !strings.Contains(body, `id="layers"`) {58 t.Fatal("root response does not contain the layers list")59 }60 // Zones stopped being a hard-coded switch when the legend became one61 // tree: every collection, shape or point, renders into the same list.62 if body := rec.Body.String(); !strings.Contains(body, `id="legend"`) {63 t.Fatal("root response does not contain the legend tree")64 }65}6667func TestRoutesReturnNotFoundForUnknownPage(t *testing.T) {68 if rec := get(t, routes(assets, fixtureRegistry(t)), "/missing"); rec.Code != http.StatusNotFound {69 t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)70 }71}7273// The shell must stand on its own: it is what greets a reader before any74// bundle is installed, and nothing in it may lean on a network that Atlas75// promises not to need.76func TestEmbeddedShellUsesNoRuntimeCDNs(t *testing.T) {77 index, err := fs.ReadFile(assets, "assets/index.html")78 if err != nil {79 t.Fatal(err)80 }81 if body := string(index); strings.Contains(body, "http://") || strings.Contains(body, "https://") {82 t.Fatal("application shell references a runtime network dependency")83 }84 for _, path := range []string{"assets/app.js", "assets/app.css"} {85 if _, err := fs.Stat(assets, path); err != nil {86 t.Errorf("embedded frontend asset %q: %v", path, err)87 }88 }89}9091func TestCatalogAnswersForInstalledBundles(t *testing.T) {92 handler := routes(assets, fixtureRegistry(t,93 bundletest.Spec{Slug: "zebra-quest", Title: "Zebra Quest"},94 bundletest.Spec{Slug: "aardvark-saga", Title: "Aardvark Saga"},95 ))9697 rec := get(t, handler, "/data/catalog.json")98 if rec.Code != http.StatusOK {99 t.Fatalf("status = %d", rec.Code)100 }101 // Installing a bundle must show up on the very next fetch, so the catalog102 // is the one response nothing may hold on to.103 if cache := rec.Header().Get("Cache-Control"); cache != "no-store" {104 t.Errorf("catalog Cache-Control = %q, want no-store", cache)105 }106 var catalog struct {107 Volumes []struct {108 Slug string `json:"slug"`109 Title string `json:"title"`110 Base string `json:"base"`111 Maps []struct {112 Slug string `json:"slug"`113 } `json:"worlds"`114 } `json:"volumes"`115 }116 if err := json.Unmarshal(rec.Body.Bytes(), &catalog); err != nil {117 t.Fatal(err)118 }119 if len(catalog.Volumes) != 2 ||120 catalog.Volumes[0].Title != "Aardvark Saga" || catalog.Volumes[1].Title != "Zebra Quest" {121 t.Fatalf("games = %+v, want Aardvark Saga then Zebra Quest", catalog.Volumes)122 }123 if base := catalog.Volumes[0].Base; !strings.HasPrefix(base, "/data/v/aardvark-saga/") {124 t.Errorf("base = %q, want a stamped /data/v/aardvark-saga/ prefix", base)125 }126}127128func TestCatalogIsEmptyRatherThanAbsentWithNoBundles(t *testing.T) {129 rec := get(t, routes(assets, fixtureRegistry(t)), "/data/catalog.json")130 if rec.Code != http.StatusOK {131 t.Fatalf("status = %d", rec.Code)132 }133 var catalog struct {134 Volumes []any `json:"volumes"`135 BundlesDir string `json:"bundlesDir"`136 }137 if err := json.Unmarshal(rec.Body.Bytes(), &catalog); err != nil {138 t.Fatal(err)139 }140 if catalog.Volumes == nil || len(catalog.Volumes) != 0 {141 t.Errorf("volumes = %v, want a present, empty list", catalog.Volumes)142 }143 // The empty state tells the reader where bundles go, in the words of144 // their own machine.145 if catalog.BundlesDir == "" {146 t.Error("catalog names no bundles directory")147 }148}149150// Without a window there is no native picker to raise, and the request says151// so rather than hanging or pretending.152func TestImportWithoutAWindowIsRefused(t *testing.T) {153 rec := httptest.NewRecorder()154 routes(assets, fixtureRegistry(t)).ServeHTTP(rec,155 httptest.NewRequest(http.MethodPost, "/data/bundles/import", nil))156 if rec.Code != http.StatusServiceUnavailable {157 t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)158 }159}160161func TestDataServesEveryKindOfBundleEntry(t *testing.T) {162 registry := fixtureRegistry(t, bundletest.Spec{163 Slug: "game", Title: "Game",164 Worlds: []bundletest.WorldSpec{{Slug: "overworld", Pins: []bundletest.Pin{{Title: "Origin"}}}},165 })166 handler := routes(assets, registry)167 base := gameBase(t, handler, "game")168169 served := []struct{ path, kind string }{170 {"/worlds/overworld.json", "application/json"},171 {"/worlds/overworld.text", "application/json"},172 {"/worlds/overworld.bin", "application/octet-stream"},173 {"/tiles/overworld/0/0/0.jpg", "image/jpeg"},174 {"/icons/marker.svg", "image/svg+xml"},175 }176 for _, entry := range served {177 rec := get(t, handler, base+entry.path)178 if rec.Code != http.StatusOK {179 t.Errorf("%s: status = %d", entry.path, rec.Code)180 continue181 }182 if kind := rec.Header().Get("Content-Type"); kind != entry.kind {183 t.Errorf("%s: Content-Type = %q, want %q", entry.path, kind, entry.kind)184 }185 // Stamped URLs name one build of one game forever, so everything186 // under them may be kept for as long as anyone likes.187 if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "immutable") {188 t.Errorf("%s: Cache-Control = %q, want immutable", entry.path, cache)189 }190 }191192 packed := get(t, handler, base+"/worlds/overworld.bin").Body.Bytes()193 locations, err := bundle.UnpackLocations(packed)194 if err != nil {195 t.Fatal(err)196 }197 if len(locations) != 1 || locations[0].Title != "Origin" {198 t.Fatalf("served locations = %+v", locations)199 }200}201202func TestDataRefusesWhatItDoesNotHold(t *testing.T) {203 registry := fixtureRegistry(t, bundletest.Spec{Slug: "game", Title: "Game"})204 handler := routes(assets, registry)205 base := gameBase(t, handler, "game")206207 refused := []string{208 "/data/v/other-volume/000000000000/worlds/overworld.json",209 // A stale stamp is a build that has been replaced: the frontend takes210 // the 404 as its cue to refetch the catalog.211 "/data/v/game/000000000000/worlds/overworld.json",212 // The manifest and anything outside the content trees stay private.213 base + "/atlas.json",214 base + "/maps/missing.json",215 base + "/worlds/overworld",216 }217 for _, path := range refused {218 if rec := get(t, handler, path); rec.Code != http.StatusNotFound {219 t.Errorf("%s: status = %d, want %d", path, rec.Code, http.StatusNotFound)220 }221 }222223 // A climbing path never reaches a bundle: the mux redirects it to its224 // cleaned form, and the cleaned form names nothing.225 climb := get(t, handler, base+"/tiles/../../atlas.json")226 if climb.Code != http.StatusTemporaryRedirect && climb.Code != http.StatusNotFound {227 t.Errorf("climbing path: status = %d, want a redirect or refusal", climb.Code)228 }229 if location := climb.Header().Get("Location"); location != "" {230 if rec := get(t, handler, location); rec.Code != http.StatusNotFound {231 t.Errorf("climbing path lands on %s with status %d, want %d",232 location, rec.Code, http.StatusNotFound)233 }234 }235}236237func gameBase(t *testing.T, handler http.Handler, slug string) string {238 t.Helper()239 var catalog struct {240 Volumes []struct {241 Slug string `json:"slug"`242 Base string `json:"base"`243 } `json:"volumes"`244 }245 if err := json.Unmarshal(get(t, handler, "/data/catalog.json").Body.Bytes(), &catalog); err != nil {246 t.Fatal(err)247 }248 for _, game := range catalog.Volumes {249 if game.Slug == slug {250 return game.Base251 }252 }253 t.Fatalf("game %s is not in the catalog", slug)254 return ""255}256257// ---------------------------------------------------------------------------258// Everything below runs against the real bundles when they have been built259// into the central library, and is skipped when they have not: the corpus260// lives outside the repository, so a checkout without ../gamemap still tests261// everything above.262263func builtRegistry(t *testing.T) *bundle.Registry {264 t.Helper()265 library, err := bundle.DefaultDir()266 if err != nil {267 t.Fatal(err)268 }269 registry := bundle.NewRegistry(library)270 if err := registry.Rescan(); err != nil {271 t.Fatal(err)272 }273 return registry274}275276func builtBundles(t *testing.T) map[string]*bundle.Bundle {277 t.Helper()278 games := builtRegistry(t).Snapshot().Volumes279 if len(games) == 0 {280 t.Skip("no built bundles in the library; run go generate first")281 }282 return games283}284285func builtGame(t *testing.T, games map[string]*bundle.Bundle, slug string) *bundle.Bundle {286 t.Helper()287 held, ok := games[slug]288 if !ok {289 t.Fatalf("game %s is not among the built bundles", slug)290 }291 return held292}293294func builtMapSlug(t *testing.T, held *bundle.Bundle, title string) string {295 t.Helper()296 for _, entry := range held.Manifest.Worlds {297 if entry.Title == title {298 return entry.Slug299 }300 }301 t.Fatalf("%s has no map titled %q", held.Manifest.Volume.Slug, title)302 return ""303}304305type mapPayload struct {306 // Grid is carried only by a map cut from a window of its own; the rest are307 // placed against the game's.308 Grid *struct {309 SourceZoom int `json:"sourceZoom"`310 FirstTile int `json:"firstTile"`311 } `json:"grid"`312 Lenses []struct {313 Name string `json:"name"`314 Tiles string `json:"tiles"`315 Formats []string `json:"formats"`316 Shard int64 `json:"shard"`317 MinZoom int `json:"minZoom"`318 MaxZoom int `json:"maxZoom"`319 Bounds *struct {320 X, Y, Width, Height int321 } `json:"bounds"`322 Surface *struct {323 X, Y, Width, Height int324 } `json:"surface"`325 } `json:"lenses"`326 Collections []struct {327 Title string `json:"title"`328 Kind string `json:"kind"`329 Color string `json:"color"`330 IconAsset string `json:"iconAsset"`331 Attrs map[string]string `json:"attrs"`332 Features []any `json:"features"`333 } `json:"collections"`334}335336func readBuiltPayload(t *testing.T, held *bundle.Bundle, slug string) mapPayload {337 t.Helper()338 raw, err := held.ReadEntry("worlds/" + slug + ".json")339 if err != nil {340 t.Fatal(err)341 }342 var payload mapPayload343 if err := json.Unmarshal(raw, &payload); err != nil {344 t.Fatal(err)345 }346 return payload347}348349func readBuiltLocations(t *testing.T, held *bundle.Bundle, slug string) []bundle.Location {350 t.Helper()351 raw, err := held.ReadEntry("worlds/" + slug + ".bin")352 if err != nil {353 t.Fatal(err)354 }355 locations, err := bundle.UnpackLocations(raw)356 if err != nil {357 t.Fatal(err)358 }359 return locations360}361362// Every built bundle keeps every promise its manifest makes: payloads363// present, pin counts agreed, tile levels filled, icons resolvable, and no364// live URLs anywhere. Validate is the same check the bundler runs, repeated365// here so a bundle edited or corrupted after generation still fails loudly.366func TestBuiltBundlesKeepTheirPromises(t *testing.T) {367 games := builtBundles(t)368 var maps int369 for _, held := range games {370 if err := held.Validate(); err != nil {371 t.Errorf("%s: %v", held.Manifest.Volume.Slug, err)372 }373 maps += len(held.Manifest.Worlds)374 }375 if maps < 20 {376 t.Errorf("built bundles list %d maps, want at least 20", maps)377 }378 for _, name := range []string{379 "skyrim", "temtem", "zelda-breath-of-the-wild", "pokemon-red-blue-yellow", "gta5",380 } {381 builtGame(t, games, name)382 }383}384385// The catalog is fetched every time Atlas opens, so its size is the cost of386// starting up. It must not grow with what the bundles hold.387func TestComposedCatalogStaysSmall(t *testing.T) {388 registry := builtRegistry(t)389 if len(registry.Snapshot().Volumes) == 0 {390 t.Skip("no built bundles in the library; run go generate first")391 }392 if size := len(registry.Snapshot().Catalog); size > 64*1024 {393 t.Errorf("composed catalog = %d bytes, want under 64 KiB", size)394 }395}396397// A map split into layers offers one at a time, and a location that does not398// name its layer is drawn on all of them -- over the empty space beside a399// layer it does not belong to. The packed shard is what prevents that, so400// every location of a layered map must carry one its variants know.401func TestLayeredMapLocationsNameTheirLayer(t *testing.T) {402 games := builtBundles(t)403 totk := builtGame(t, games, "zelda-tears-of-the-kingdom")404 hyrule := builtMapSlug(t, totk, "Hyrule")405 payload := readBuiltPayload(t, totk, hyrule)406 layers := make(map[int64]string, len(payload.Lenses))407 for _, variant := range payload.Lenses {408 if variant.Shard == 0 {409 t.Fatalf("Tears of the Kingdom layer %q names no shard", variant.Name)410 }411 layers[variant.Shard] = variant.Name412 }413 if len(layers) < 2 {414 t.Fatalf("Tears of the Kingdom has %d layers, want the Sky, Surface and Depths", len(layers))415 }416417 counts := make(map[int64]int, len(layers))418 for _, location := range readBuiltLocations(t, totk, hyrule) {419 if _, ok := layers[location.Shard]; !ok {420 t.Fatalf("%q is on shard %d, which is no layer of this map", location.Title, location.Shard)421 }422 counts[location.Shard]++423 }424 for shard, name := range layers {425 if counts[shard] == 0 {426 t.Errorf("the %s layer holds no locations", name)427 }428 }429}430431// A piece cut from a sheet keeps a margin in its raster so the title drawn432// beside it is not sliced in half. The geohash grid divides the ground instead,433// or a small map spends whole cells on blank sheet -- so the two rectangles434// travel separately, with the ground inside the window it is drawn through.435func TestSheetPiecesCarryTheirGroundSeparately(t *testing.T) {436 games := builtBundles(t)437 vegas := builtGame(t, games, "fallout-new-vegas")438 mccarran := builtMapSlug(t, vegas, "Mojave Wasteland — Camp McCarran")439 for _, variant := range readBuiltPayload(t, vegas, mccarran).Lenses {440 if variant.Bounds == nil || variant.Surface == nil {441 t.Fatalf("Camp McCarran layer %q has bounds %v and surface %v, wanting both",442 variant.Name, variant.Bounds, variant.Surface)443 }444 if variant.Surface.Width <= 0 || variant.Surface.Height <= 0 {445 t.Fatalf("Camp McCarran surface is %dx%d", variant.Surface.Width, variant.Surface.Height)446 }447 if variant.Surface.X < variant.Bounds.X || variant.Surface.Y < variant.Bounds.Y ||448 variant.Surface.X+variant.Surface.Width > variant.Bounds.X+variant.Bounds.Width ||449 variant.Surface.Y+variant.Surface.Height > variant.Bounds.Y+variant.Bounds.Height {450 t.Fatalf("Camp McCarran ground %v reaches outside the window %v drawn for it",451 *variant.Surface, *variant.Bounds)452 }453 if variant.Surface.Width >= variant.Bounds.Width &&454 variant.Surface.Height >= variant.Bounds.Height {455 t.Error("Camp McCarran keeps no margin, so the split no longer grows its pieces")456 }457 }458459 // Every map names its ground, split or not: the sheets that were never cut460 // up are the ones with a printed border or a title panel around them, and461 // the grid has to know the difference there too.462 for _, place := range []struct{ game, title string }{463 {"skyrim", "Skyrim"},464 {"tunic", "World"},465 {"sonic-frontiers", "Kronos Island"},466 {"la-noire", "Los Angeles"},467 } {468 held := builtGame(t, games, place.game)469 slug := builtMapSlug(t, held, place.title)470 for _, variant := range readBuiltPayload(t, held, slug).Lenses {471 if variant.Surface == nil || variant.Surface.Width <= 0 || variant.Surface.Height <= 0 {472 t.Errorf("%s / %s layer %q measures no ground", place.game, place.title, variant.Name)473 continue474 }475 if variant.Bounds == nil {476 continue477 }478 if variant.Surface.X < variant.Bounds.X || variant.Surface.Y < variant.Bounds.Y ||479 variant.Surface.X+variant.Surface.Width > variant.Bounds.X+variant.Bounds.Width ||480 variant.Surface.Y+variant.Surface.Height > variant.Bounds.Y+variant.Bounds.Height {481 t.Errorf("%s / %s ground %v reaches outside its window %v",482 place.game, place.title, *variant.Surface, *variant.Bounds)483 }484 }485 }486487 // Tunic is drawn inside a solid border filling a full-world window, so its488 // ground has to be a fraction of what it is cut from.489 tunic := builtGame(t, games, "tunic")490 for _, variant := range readBuiltPayload(t, tunic, builtMapSlug(t, tunic, "World")).Lenses {491 if variant.Surface.Width > 6000 || variant.Surface.Height > 6000 {492 t.Errorf("Tunic ground is %dx%d, so the border is still being divided up",493 variant.Surface.Width, variant.Surface.Height)494 }495 }496}497498func TestBuiltBundlesCarryTextLabelsAndZones(t *testing.T) {499 games := builtBundles(t)500501 marathon := builtGame(t, games, "marathon")502 cryo := builtMapSlug(t, marathon, "Cryo Archive")503 payload := readBuiltPayload(t, marathon, cryo)504 areaOrdinal := -1505 for index, collection := range payload.Collections {506 if collection.Title == "Area" && collection.Attrs["atlas.render.as"] == "text" {507 areaOrdinal = index508 }509 }510 if areaOrdinal < 0 {511 t.Fatal("Cryo Archive has no text-rendered Area collection")512 }513 var foundPanopticon bool514 for _, location := range readBuiltLocations(t, marathon, cryo) {515 if location.Title == "PANOPTICON" && int(location.Owner) == areaOrdinal {516 foundPanopticon = true517 }518 }519 if !foundPanopticon {520 t.Error("PANOPTICON is not preserved as a text-rendered location")521 }522523 forza := builtGame(t, games, "forza-horizon-6")524 japan := readBuiltPayload(t, forza, builtMapSlug(t, forza, "Japan"))525 var zones int526 for _, collection := range japan.Collections {527 if collection.Kind != "point" {528 zones += len(collection.Features)529 }530 }531 if zones != 10 {532 t.Errorf("Forza Japan zones = %d, want 10", zones)533 }534 var bounded int535 for _, variant := range japan.Lenses {536 if variant.Bounds != nil && variant.Bounds.Width == 4096 && variant.Bounds.Height == 4096 {537 bounded++538 }539 }540 if bounded != 4 {541 t.Errorf("bounded Forza Japan variants = %d, want 4", bounded)542 }543}544545// Los Santos is cut from a window of its own: five levels shallower than the546// one the other maps share, and anchored at the origin rather than at tile547// 4064. A pin arrives as a latitude and longitude in that window, so the map548// has to carry it -- placed against the shared numbers instead, every pin would549// land in a corner of the world with no map beneath it. This is the frontend's550// own projection, kept honest against the numbers actually shipped.551func TestAMapCutFromItsOwnWindowPlacesItsPins(t *testing.T) {552 games := builtBundles(t)553 gta := builtGame(t, games, "gta5")554 slug := builtMapSlug(t, gta, "Los Santos")555 payload := readBuiltPayload(t, gta, slug)556 if payload.Grid == nil {557 t.Fatal("Los Santos carries no window of its own")558 }559 if payload.Grid.SourceZoom != 6 || payload.Grid.FirstTile != 0 {560 t.Errorf("window = zoom %d at tile %d, want zoom 6 at tile 0",561 payload.Grid.SourceZoom, payload.Grid.FirstTile)562 }563 bounds := payload.Lenses[0].Bounds564 if bounds == nil {565 t.Fatal("the first layer has no bounds")566 }567568 tileSize := float64(gta.Manifest.TileGrid.TileSize)569 worldTiles := math.Pow(2, float64(payload.Grid.SourceZoom))570 first := float64(payload.Grid.FirstTile)571 var placed int572 locations := readBuiltLocations(t, gta, slug)573 for _, location := range locations {574 x := (((location.Lng+180)/360)*worldTiles - first) * tileSize575 radians := location.Lat * math.Pi / 180576 down := (1 - math.Asinh(math.Tan(radians))/math.Pi) / 2 * worldTiles577 y := -(down - first) * tileSize578 if x >= float64(bounds.X) && x <= float64(bounds.X+bounds.Width) &&579 y <= -float64(bounds.Y) && y >= -float64(bounds.Y+bounds.Height) {580 placed++581 }582 }583 // A couple of the captured records are jokes standing at the pole -- "Tommy584 // Vercetti" among them -- and belong nowhere on this map. Everything real585 // is on the raster.586 if ratio := float64(placed) / float64(len(locations)); ratio < 0.99 {587 t.Errorf("%d of %d pins land on the raster (%.1f%%), want at least 99%%",588 placed, len(locations), ratio*100)589 }590}591592func TestBuiltBundlesCarryCategoryIconsAndColors(t *testing.T) {593 games := builtBundles(t)594 var iconAssets int595 var foundPokemonCenter bool596 for _, held := range games {597 for _, entry := range held.Manifest.Worlds {598 for _, collection := range readBuiltPayload(t, held, entry.Slug).Collections {599 if collection.IconAsset != "" {600 iconAssets++601 if strings.Contains(collection.IconAsset, "/") {602 t.Errorf("%s names icon %q, which is not bundle-relative",603 held.Manifest.Volume.Slug, collection.IconAsset)604 }605 if !held.Has("icons/" + collection.IconAsset) {606 t.Errorf("%s icon %q is missing", held.Manifest.Volume.Slug, collection.IconAsset)607 }608 }609 if held.Manifest.Volume.Slug != "pokemon-red-blue-yellow" ||610 entry.Title != "Yellow" || collection.Title != "Pokémon Center" {611 continue612 }613 foundPokemonCenter = true614 if collection.Color != "#38344C" {615 t.Errorf("Pokémon Center color = %q, want #38344C", collection.Color)616 }617 if collection.IconAsset == "" {618 t.Error("Pokémon Center has no icon asset")619 }620 }621 }622 }623 if !foundPokemonCenter {624 t.Error("Yellow Pokémon Center category not found")625 }626 if iconAssets < 200 {627 t.Errorf("catalog icon assets = %d, want at least 200", iconAssets)628 }629}