1package ignmap23import (4 "bytes"5 "encoding/json"6 "math"7 "strconv"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/internal/mgdoc"12)1314// fixture is a small capture exercising everything Translate decides: a type15// with markers, a type without, a parented type, and coordinates at the16// corners and centre of the image.17func fixture() Capture {18 return Capture{19 Source: Source,20 ObjectSlug: "cyberpunk-2077",21 MapSlug: "night-city",22 GameTitle: "Cyberpunk 2077",23 MapTitle: "Night City",24 Map: MapConfig{25 Width: 0.8822243, Height: 1,26 MinZoom: 2, MaxZoom: 5,27 InitialLat: -0.5, InitialLng: 0.44111216,28 BackgroundColor: "#110b0b",29 Tileset: "https://oyster.ignimgs.com/ignmedia/wikimaps/cyberpunk-2077/night-city/{z}/{x}-{y}.jpg",30 },31 Types: []Type{32 {TypeSlug: "fast-travel", TypeName: "Fast Travel"},33 {TypeSlug: "elevator", TypeName: "Elevator"},34 {TypeSlug: "tarot-card", TypeName: "Tarot Card", ParentTypeSlug: "collectibles"},35 },36 Markers: []Marker{37 {ID: "cp:nc:b", Lat: -1, Lng: 0.8822243, MarkerName: "Corner", TypeSlug: "fast-travel"},38 {ID: "cp:nc:a", Lat: -0.5, Lng: 0.4411, MarkerName: "Centre", TypeSlug: "fast-travel", WikiPage: "Centre_Page"},39 {ID: "cp:nc:c", Lat: 0, Lng: 0, MarkerName: "Origin", TypeSlug: "tarot-card"},40 },41 }42}4344func translate(t *testing.T, capture Capture) mgdoc.Map {45 t.Helper()46 doc, err := json.Marshal(capture)47 if err != nil {48 t.Fatal(err)49 }50 out, err := Translate(doc)51 if err != nil {52 t.Fatal(err)53 }54 var m mgdoc.Map55 if err := json.Unmarshal(out, &m); err != nil {56 t.Fatal(err)57 }58 return m59}6061func TestTranslateIsDeterministic(t *testing.T) {62 capture := fixture()63 doc, _ := json.Marshal(capture)64 first, err := Translate(doc)65 if err != nil {66 t.Fatal(err)67 }6869 // The same markers arriving in a different order are the same capture.70 shuffled := fixture()71 shuffled.Markers[0], shuffled.Markers[2] = shuffled.Markers[2], shuffled.Markers[0]72 shuffled.Types[0], shuffled.Types[1] = shuffled.Types[1], shuffled.Types[0]73 reordered, _ := json.Marshal(shuffled)74 second, err := Translate(reordered)75 if err != nil {76 t.Fatal(err)77 }78 if !bytes.Equal(first, second) {79 t.Fatal("the same capture translated to different bytes")80 }81}8283func TestTranslateShape(t *testing.T) {84 m := translate(t, fixture())8586 if m.Slug != "night-city" || m.Title != "Night City" {87 t.Fatalf("map identity: %q %q", m.Slug, m.Title)88 }89 if m.Game.Slug != "cyberpunk-2077" || m.Game.Title != "Cyberpunk 2077" {90 t.Fatalf("game identity: %q %q", m.Game.Slug, m.Game.Title)91 }9293 if len(m.Config.TileSets) != 1 {94 t.Fatalf("tile sets: %d", len(m.Config.TileSets))95 }96 set := m.Config.TileSets[0]97 if set.Path != "cyberpunk-2077/night-city" || set.Extension != "jpg" {98 t.Fatalf("tile set: %+v", set)99 }100 if set.MinZoom != 0 || set.MaxZoom != 5 {101 t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)102 }103 for zoom := 0; zoom <= 5; zoom++ {104 if _, ok := set.Bounds[strconv.Itoa(zoom)]; !ok {105 t.Fatalf("no bounds declared for zoom %d", zoom)106 }107 }108 // The image is narrower than tall: at zoom 5 the last column holding any109 // content is tile 28 of 32, while rows fill the square.110 deepest := set.Bounds["5"]111 if deepest.X.Max != 28 || deepest.Y.Max != 31 {112 t.Fatalf("deepest bounds: x..%d y..%d", deepest.X.Max, deepest.Y.Max)113 }114 top := set.Bounds["0"]115 if top.X.Max != 0 || top.Y.Max != 0 {116 t.Fatalf("top of the pyramid is not a single tile: %+v", top)117 }118119 // Two groups: the unparented types, and the collectibles parent. The120 // elevator type has no markers and no category.121 if len(m.Groups) != 2 {122 t.Fatalf("groups: %d", len(m.Groups))123 }124 if len(m.Groups[0].Categories) != 1 || m.Groups[0].Categories[0].Icon != "fast-travel" {125 t.Fatalf("first group: %+v", m.Groups[0])126 }127 if m.Groups[1].Title != "Collectibles" {128 t.Fatalf("parented group title: %q", m.Groups[1].Title)129 }130 for _, group := range m.Groups {131 for _, category := range group.Categories {132 if category.Icon == "elevator" {133 t.Fatal("a markerless type kept its category")134 }135 }136 }137}138139// TestSyntheticCoordinatesRoundTrip runs the synthetic latitude and longitude140// through the viewer's projection and expects the marker's pixel back.141func TestSyntheticCoordinatesRoundTrip(t *testing.T) {142 m := translate(t, fixture())143144 project := func(latitude, longitude float64) (float64, float64) {145 worldTiles := math.Pow(2, mgdoc.SourceZoom)146 x := ((longitude + 180) / 360) * worldTiles * mgdoc.TileSize147 y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * mgdoc.TileSize148 return x, y149 }150151 byTitle := map[string][2]float64{152 "Origin": {0, 0},153 "Centre": {0.4411 * mgdoc.WorldSize, 0.5 * mgdoc.WorldSize},154 "Corner": {0.8822243 * mgdoc.WorldSize, mgdoc.WorldSize},155 }156 seen := 0157 for _, group := range m.Groups {158 for _, category := range group.Categories {159 for _, location := range category.Locations {160 want, ok := byTitle[location.Title]161 if !ok {162 continue163 }164 seen++165 x, y := project(location.Latitude, location.Longitude)166 if math.Abs(x-want[0]) > 1e-6 || math.Abs(y-want[1]) > 1e-6 {167 t.Fatalf("%s projects to %.8f,%.8f, want %.1f,%.1f",168 location.Title, x, y, want[0], want[1])169 }170 }171 }172 }173 if seen != len(byTitle) {174 t.Fatalf("saw %d of %d markers", seen, len(byTitle))175 }176}177178func TestIdentifiersAreStable(t *testing.T) {179 first := translate(t, fixture())180 second := translate(t, fixture())181 if first.ID != second.ID || first.ID <= 0 || first.ID > math.MaxInt32 {182 t.Fatalf("map id: %d then %d", first.ID, second.ID)183 }184 firstLocations := locationIDs(first)185 secondLocations := locationIDs(second)186 for title, id := range firstLocations {187 if secondLocations[title] != id {188 t.Fatalf("%s renumbered: %d then %d", title, id, secondLocations[title])189 }190 if id <= 0 || id > math.MaxInt32 {191 t.Fatalf("%s id %d leaves the positive int31 range", title, id)192 }193 }194}195196func locationIDs(m mgdoc.Map) map[string]int64 {197 out := make(map[string]int64)198 for _, group := range m.Groups {199 for _, category := range group.Categories {200 for _, location := range category.Locations {201 out[location.Title] = location.ID202 }203 }204 }205 return out206}207208func TestTranslateRejectsBadCaptures(t *testing.T) {209 duplicate := fixture()210 duplicate.Types = append(duplicate.Types, Type{TypeSlug: "fast-travel", TypeName: "Again"})211 if _, err := translateErr(duplicate); err == nil || !strings.Contains(err.Error(), "twice") {212 t.Fatalf("duplicate type: %v", err)213 }214215 stray := fixture()216 stray.Markers[0].TypeSlug = "never-declared"217 if _, err := translateErr(stray); err == nil || !strings.Contains(err.Error(), "undeclared") {218 t.Fatalf("undeclared type: %v", err)219 }220221 foreign := fixture()222 foreign.Source = "someone-else"223 if _, err := translateErr(foreign); err == nil {224 t.Fatal("foreign source accepted")225 }226}227228func translateErr(capture Capture) ([]byte, error) {229 doc, err := json.Marshal(capture)230 if err != nil {231 return nil, err232 }233 return Translate(doc)234}235236func TestMaybeTranslatePassesMapGenieThrough(t *testing.T) {237 doc := []byte(`{"id": 5, "title": "As Captured"}`)238 out, err := MaybeTranslate("map", doc)239 if err != nil {240 t.Fatal(err)241 }242 if !bytes.Equal(out, doc) {243 t.Fatal("a MapGenie snapshot was rewritten")244 }245}