1package ign23import (4 "encoding/json"5 "log/slog"6 "os"7 "path/filepath"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/format/semconv"12 "github.com/FelineStateMachine/atlas/internal/generate/archive"13 "github.com/FelineStateMachine/atlas/internal/generate/doc"14)1516// The capture these tests read is testdata/wikimap.json: an invented wikimap,17// as small as one can be while still exercising the three judgements this18// reader owns -- the image-relative projection, the identities minted from19// stable names, and the flat type list arranged into a two-level legend. It is20// the whole input, so what the assertions below say is exactly what the21// translation of it is.2223// openWikimap stages the testdata capture as the smallest archive holding one24// volume of one world, plus whatever artwork the caller dropped in.25func openWikimap(t *testing.T, icons map[string]string) *archive.Archive {26 t.Helper()27 body, err := os.ReadFile(filepath.Join("testdata", "wikimap.json"))28 if err != nil {29 t.Fatal(err)30 }31 root := t.TempDir()32 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")33 mustWrite(t, filepath.Join(root, "archive.json"),34 `{"games":[{"directory":"games/g-1","id":1,"title":"Hollow Vale","source":"ign"}]}`)35 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),36 `{"id":1,"title":"Hollow Vale","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"surface","title":"The Surface"}]}`)37 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),38 `[{"capturedAt":"2026-02-01T00:00:00Z","contentHash":"h","kind":"ign-map","sourceId":1,"sourceUrl":"/x"}]`)39 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), string(body))40 for key, svg := range icons {41 mustWrite(t, filepath.Join(root, "games", "g-1", "icons", key+".svg"), svg)42 }43 store, err := archive.Open(root)44 if err != nil {45 t.Fatal(err)46 }47 return store48}4950func mustWrite(t *testing.T, path, body string) {51 t.Helper()52 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {53 t.Fatal(err)54 }55 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {56 t.Fatal(err)57 }58}5960func translated(t *testing.T, icons map[string]string) doc.Document {61 t.Helper()62 store := openWikimap(t, icons)63 document, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))64 if err != nil {65 t.Fatal(err)66 }67 return document68}6970// claim mints the number one stable name derives to, in a space of its own, so71// a test can say which identity a thing must carry without re-walking the72// reader's own claim order.73func claim(t *testing.T, name string) int64 {74 t.Helper()75 id, err := doc.NewIDSpace().Claim(name)76 if err != nil {77 t.Fatal(err)78 }79 return id80}8182// TestWikimapTranslatesExactly walks the whole invented capture into the83// document and asserts everything it becomes: identity, projection, legend84// order, and the frame handed to the deriver.85func TestWikimapTranslatesExactly(t *testing.T) {86 document := translated(t, nil)8788 if document.Volume.Slug != "hollow-vale" || document.Volume.Title != "Hollow Vale" {89 t.Errorf("volume %s/%s", document.Volume.Slug, document.Volume.Title)90 }91 if document.Source.IDSpace != doc.IDSpaceDerived {92 t.Errorf("IGN numbers markers with opaque strings, so the id space must be %q, not %q",93 doc.IDSpaceDerived, document.Source.IDSpace)94 }95 if len(document.Worlds) != 1 {96 t.Fatalf("%d worlds from one map", len(document.Worlds))97 }98 world := document.Worlds[0]99 if world.Slug != "surface" || world.Title != "The Surface" {100 t.Errorf("world %s/%s", world.Slug, world.Title)101 }102 if world.ID != claim(t, "ign:map:hollow-vale/surface") {103 t.Errorf("world id %d is not the number its stable name mints", world.ID)104 }105 // The opening view runs through the same projection every marker does.106 if world.Center != imagePosition(0.5, -0.25) {107 t.Errorf("center %+v, want the image position of the captured initial view", world.Center)108 }109110 // The lens: the publisher's abbreviation never travels, the tile set is the111 // canonical <object>/<map> path, and the frame is the pyramid the crawler112 // must fetch and the deriver must expect -- including the half-height top113 // level a landscape image leaves at zoom one.114 if len(world.Lenses) != 1 {115 t.Fatalf("%d lenses", len(world.Lenses))116 }117 lens := world.Lenses[0]118 if lens.Name != "IGN Wiki" || lens.TileSet != TileSetPath("hollow-vale", "surface") {119 t.Errorf("lens %s/%s", lens.Name, lens.TileSet)120 }121 if lens.Frame == nil {122 t.Fatal("a wikimap is not cut from the shared window, so its frame must be declared whole")123 }124 if lens.Frame.MinZoom != 0 || lens.Frame.MaxZoom != 1 || lens.Frame.Format != "png" {125 t.Errorf("frame %d..%d/%s; the encoding reads off the tile template, lowercased",126 lens.Frame.MinZoom, lens.Frame.MaxZoom, lens.Frame.Format)127 }128 if window := lens.Frame.Windows["0"]; window != (doc.TileWindow{MaxX: 0, MaxY: 0}) {129 t.Errorf("zoom 0 window %+v", window)130 }131 if window := lens.Frame.Windows["1"]; window != (doc.TileWindow{MaxX: 1, MaxY: 0}) {132 t.Errorf("zoom 1 window %+v; a half-height image fills only the top row", window)133 }134135 // The legend: types sort by slug, gather under the heading of the parent136 // each names, and a type no marker uses is left out rather than dimming the137 // legend. Bench and fountain share "Street Life"; the parentless gate files138 // under the plain "Markers"; the ghost type has no markers and no row.139 if len(world.Collections) != 3 {140 t.Fatalf("%d collections, want benches, fountains and gates", len(world.Collections))141 }142 benches, fountains, gates := world.Collections[0], world.Collections[1], world.Collections[2]143 for _, check := range []struct {144 got doc.Collection145 title, group, icon string146 features int147 }{148 {benches, "Benches", "Street Life", "bench", 2},149 {fountains, "Fountains", "Street Life", "fountain", 1},150 {gates, "Gates", "Markers", "gate", 1},151 } {152 if check.got.Title != check.title || check.got.Group != check.group ||153 check.got.Icon != check.icon || len(check.got.Features) != check.features {154 t.Errorf("collection %q under %q with icon %q and %d features, want %q/%q/%q/%d",155 check.got.Title, check.got.Group, check.got.Icon, len(check.got.Features),156 check.title, check.group, check.icon, check.features)157 }158 if check.got.Kind != doc.KindPoint || !check.got.Visible {159 t.Errorf("collection %q is %s/%t; a wikimap publishes visible markers",160 check.got.Title, check.got.Kind, check.got.Visible)161 }162 if check.got.Attrs[semconv.KeyRenderAs] != semconv.RenderAsPin {163 t.Errorf("collection %q renders as %q", check.got.Title, check.got.Attrs[semconv.KeyRenderAs])164 }165 if check.got.ID != claim(t, "ign:type:hollow-vale/surface:"+check.icon) {166 t.Errorf("collection %q carries id %d, not the number its type slug mints",167 check.got.Title, check.got.ID)168 }169 }170171 // Markers sort by id within their type, carry identities minted from IGN's172 // opaque strings, and land on the picture through the image projection: a173 // marker's longitude runs across the normalized image and its negative174 // latitude runs down it, both scaled by the world square's edge.175 north := benches.Features[0]176 if north.Title != "North Bench" || benches.Features[1].Title != "South Bench" {177 t.Errorf("benches read %q then %q; markers sort by id", north.Title, benches.Features[1].Title)178 }179 if north.ID != claim(t, "ign:marker:m-b1") {180 t.Errorf("marker id %d is not the number its stable name mints", north.ID)181 }182 for _, check := range []struct {183 got doc.Feature184 x, y float64185 }{186 {north, 0.25 * doc.SyntheticWorldSize, 0.25 * doc.SyntheticWorldSize},187 {benches.Features[1], 0.5 * doc.SyntheticWorldSize, 0.75 * doc.SyntheticWorldSize},188 {fountains.Features[0], 0.125 * doc.SyntheticWorldSize, 0.0625 * doc.SyntheticWorldSize},189 {gates.Features[0], 0.875 * doc.SyntheticWorldSize, 0.5 * doc.SyntheticWorldSize},190 } {191 want := doc.SyntheticPosition(check.x, check.y)192 if check.got.At == nil || *check.got.At != want {193 t.Errorf("%q stands at %+v, want the synthetic position of pixel %v,%v",194 check.got.Title, check.got.At, check.x, check.y)195 }196 }197198 // The same archived bytes give the same document, identities included.199 again := translated(t, nil)200 if again.Worlds[0].ID != world.ID || again.Worlds[0].Collections[0].Features[0].ID != north.ID {201 t.Error("two translations of one capture minted different identities")202 }203}204205// TestArchivedArtworkAttachesByTypeKey: the archive holds a sprite per marker206// type, keyed by the type slug, and the document carries the ones its207// collections name so composition never reaches back into the archive.208func TestArchivedArtworkAttachesByTypeKey(t *testing.T) {209 document := translated(t, map[string]string{"bench": "<svg/>"})210 if len(document.Icons) != 1 {211 t.Fatalf("the document carries %d icons; one type has archived artwork", len(document.Icons))212 }213 icon := document.Icons[0]214 if icon.Key != "bench" || icon.File != "bench.svg" || string(icon.Data) != "<svg/>" {215 t.Errorf("artwork travelled as %s/%s %q", icon.Key, icon.File, icon.Data)216 }217}218219// TestTranslateRefusesWhatItCannotStandFor states the reader's preconditions:220// a capture of another source's kind, a map with no size, a type declared221// twice, and a marker of a type nothing declares are refusals, because the222// alternative to each is a document that quietly lies.223func TestTranslateRefusesWhatItCannotStandFor(t *testing.T) {224 tests := []struct {225 name string226 spoil func(*capture)227 says string228 }{229 {"another source's bytes", func(c *capture) { c.Source = "piggyback" }, "not \"ign-wikimaps\""},230 {"a map with no size", func(c *capture) { c.Map.Width = 0 }, "no size"},231 {"a type declared twice", func(c *capture) { c.Types = append(c.Types, kind{TypeSlug: "bench"}) }, "declared twice"},232 {"a marker of an undeclared type", func(c *capture) {233 c.Markers = append(c.Markers, marker{ID: "m-x", TypeSlug: "mystery", MarkerName: "X"})234 }, "which nothing declares"},235 }236 for _, tt := range tests {237 t.Run(tt.name, func(t *testing.T) {238 raw := capture{239 Source: source, ObjectSlug: "hollow-vale", MapSlug: "surface",240 Map: sheet{Width: 1, Height: 1, MaxZoom: 1},241 Types: []kind{{TypeSlug: "bench", TypeName: "Benches"}},242 Markers: []marker{{ID: "m-b1", TypeSlug: "bench", MarkerName: "One"}},243 }244 tt.spoil(&raw)245 ids := doc.NewIDSpace()246 raw.normalize()247 // The shape gates live in translateWorld and the legend gates in248 // collectionsOf; both are exercised through the reader's own checks.249 var err error250 switch {251 case raw.Source != source:252 err = errFrom(t, raw)253 case raw.Map.Width <= 0:254 err = errFrom(t, raw)255 default:256 _, err = collectionsOf(&raw, ids, "hollow-vale/surface")257 }258 if err == nil || !strings.Contains(err.Error(), tt.says) {259 t.Fatalf("the capture was read anyway: %v", err)260 }261 })262 }263}264265// errFrom runs one spoiled capture through the whole reader and reports what it266// refused with.267func errFrom(t *testing.T, raw capture) error {268 t.Helper()269 root := t.TempDir()270 body, err := json.Marshal(raw)271 if err != nil {272 t.Fatal(err)273 }274 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")275 mustWrite(t, filepath.Join(root, "archive.json"),276 `{"games":[{"directory":"games/g-1","id":1,"title":"Hollow Vale","source":"ign"}]}`)277 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),278 `{"id":1,"title":"Hollow Vale","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"surface","title":"The Surface"}]}`)279 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),280 `[{"capturedAt":"2026-02-01T00:00:00Z","contentHash":"h","kind":"ign-map","sourceId":1,"sourceUrl":"/x"}]`)281 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), string(body))282 store, err := archive.Open(root)283 if err != nil {284 t.Fatal(err)285 }286 _, err = New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))287 return err288}