The IGN translator carried its own private copy of the document the pipeline reads, along with the synthetic projection, the identifier space, and the world constants. A second source would have carried a second copy of each, and the two would drift. internal/mgdoc now owns the shape and the shared machinery; translators own only their capture and their policy. The emitted bytes are unchanged -- the one new field, a location's description, marshals away when empty -- so existing captures still translate to the same payloads and the same stamps.
- commit
- 35a3cd723e024ba85af672a141d1da64ac678cf7
- parent
- 8de3de73f137982d492018b6b1e370ad0a2bf6ef
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 214 | ++++++------------------------ |
| M | internal/ | 37 | ++---- |
| A | internal/ | 150 | +++++++++++++++++++++ |
| A | internal/ | 59 | ++++++++ |
@@ -16,11 +16,12 @@ package ignmap
1616 import (1717 "encoding/json"1818 "fmt"19- "hash/fnv"2019 "math"2120 "sort"2221 "strconv"2322 "strings"23+24+ "github.com/FelineStateMachine/atlas/internal/mgdoc"2425 )2526 2627 // Kind names IGN captures in a map's snapshot index, alongside the "map"@@ -96,19 +97,9 @@ func (c *Capture) Normalize() {
9697 sort.Slice(c.Markers, func(a, b int) bool { return c.Markers[a].ID < c.Markers[b].ID })9798 }9899 99-// The world every IGN map is translated into: a 32-tile square, 8192 pixels100-// across, whose tile pyramid starts at a single tile. Pins are stored as101-// latitude and longitude and projected by the viewer, so a marker's linear102-// position on the image becomes the synthetic coordinate that projects back103-// onto that exact pixel.104-const (105- sourceZoom = 5106- tileSize = 256107- worldSize = 8192108-)109-110-// MaybeTranslate hands MapGenie snapshots through untouched and translates IGN111-// captures, so the tools that read snapshots stay one call away from either.100+// MaybeTranslate hands other sources' snapshots through untouched and101+// translates IGN captures, so the tools that read snapshots stay one call102+// away from either.112103 func MaybeTranslate(kind string, doc []byte) ([]byte, error) {113104 if kind != Kind {114105 return doc, nil@@ -136,13 +127,13 @@ func Translate(doc []byte) ([]byte, error) {
136127 }137128 capture.Normalize()138129 139- ids := newIDSpace()130+ ids := mgdoc.NewIDSpace()140131 scope := capture.ObjectSlug + "/" + capture.MapSlug141- mapID, err := ids.claim("ign:map:" + scope)132+ mapID, err := ids.Claim("ign:map:" + scope)142133 if err != nil {143134 return nil, err144135 }145- gameID, err := ids.claim("ign:game:" + capture.ObjectSlug)136+ gameID, err := ids.Claim("ign:game:" + capture.ObjectSlug)146137 if err != nil {147138 return nil, err148139 }@@ -152,14 +143,14 @@ func Translate(doc []byte) ([]byte, error) {
152143 return nil, err153144 }154145 155- out := mgMap{146+ out := mgdoc.Map{156147 ID: mapID,157148 Title: mapTitle(&capture),158149 Slug: capture.MapSlug,159- InitialLatitude: syntheticLatitude(-capture.Map.InitialLat * worldSize),160- InitialLongitude: syntheticLongitude(capture.Map.InitialLng * worldSize),161- Config: mgConfig{TileSets: []mgTileSet{tileSet(&capture, scope)}},162- Game: mgGame{150+ InitialLatitude: mgdoc.SyntheticLatitude(-capture.Map.InitialLat * mgdoc.WorldSize),151+ InitialLongitude: mgdoc.SyntheticLongitude(capture.Map.InitialLng * mgdoc.WorldSize),152+ Config: mgdoc.Config{TileSets: []mgdoc.TileSet{tileSet(&capture, scope)}},153+ Game: mgdoc.Game{163154 ID: gameID,164155 // The game keeps its plain slug, so captures of the same game from165156 // different sources build bundles that answer for one library entry@@ -179,31 +170,39 @@ func mapTitle(capture *Capture) string {
179170 if capture.MapTitle != "" {180171 return capture.MapTitle181172 }182- return spellOut(capture.MapSlug)173+ return mgdoc.SpellOut(capture.MapSlug)183174 }184175 185176 // tileSet describes the pyramid the crawler captured in the shape tools/tiles186177 // expects. Bounds are declared for every level: a level without them would be187178 // measured against the MapGenie window this map does not sit in.188-func tileSet(capture *Capture, scope string) mgTileSet {189- set := mgTileSet{179+func tileSet(capture *Capture, scope string) mgdoc.TileSet {180+ set := mgdoc.TileSet{190181 Name: "Default",191182 Path: scope,192183 MinZoom: 0,193184 MaxZoom: capture.Map.MaxZoom,194- Extension: tileExtension(capture.Map.Tileset),195- Bounds: make(map[string]mgBound, capture.Map.MaxZoom+1),185+ Extension: TileExtension(capture.Map.Tileset),186+ Bounds: make(map[string]mgdoc.Bound, capture.Map.MaxZoom+1),196187 }197188 for zoom := 0; zoom <= capture.Map.MaxZoom; zoom++ {198- across := float64(int(1) << zoom)199- set.Bounds[strconv.Itoa(zoom)] = mgBound{200- X: mgRange{Min: 0, Max: lastTile(capture.Map.Width, across)},201- Y: mgRange{Min: 0, Max: lastTile(capture.Map.Height, across)},189+ maxX, maxY := LevelExtent(capture.Map.Width, capture.Map.Height, zoom)190+ set.Bounds[strconv.Itoa(zoom)] = mgdoc.Bound{191+ X: mgdoc.Range{Min: 0, Max: maxX},192+ Y: mgdoc.Range{Min: 0, Max: maxY},202193 }203194 }204195 return set205196 }206197 198+// LevelExtent reports the last tile column and row holding any of the image at199+// a zoom. The crawler asks for exactly these tiles, and Translate declares200+// exactly these bounds, because they are the same call.201+func LevelExtent(width, height float64, zoom int) (maxX, maxY int) {202+ across := float64(int(1) << zoom)203+ return lastTile(width, across), lastTile(height, across)204+}205+207206 func lastTile(extent, across float64) int {208207 last := int(math.Ceil(extent*across)) - 1209208 if last < 0 {@@ -212,7 +211,8 @@ func lastTile(extent, across float64) int {
212211 return last213212 }214213 215-func tileExtension(template string) string {214+// TileExtension reads the image format off IGN's tile URL template.215+func TileExtension(template string) string {216216 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {217217 return strings.ToLower(template[dot+1:])218218 }@@ -224,7 +224,7 @@ func tileExtension(template string) string {
224224 // rest share one group. A type no marker uses is left out -- an empty category225225 // would only dim the legend -- but it stays in the capture, so a marker226226 // appearing under it later revives the category without a policy change.227-func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error) {227+func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {228228 markersByType := make(map[string][]Marker, len(capture.Types))229229 known := make(map[string]bool, len(capture.Types))230230 for _, t := range capture.Types {@@ -240,35 +240,35 @@ func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error
240240 markersByType[marker.TypeSlug] = append(markersByType[marker.TypeSlug], marker)241241 }242242 243- grouped := make(map[string][]mgCategory)243+ grouped := make(map[string][]mgdoc.Category)244244 var order []string245245 for _, t := range capture.Types {246246 markers := markersByType[t.TypeSlug]247247 if len(markers) == 0 {248248 continue249249 }250- categoryID, err := ids.claim("ign:type:" + scope + ":" + t.TypeSlug)250+ categoryID, err := ids.Claim("ign:type:" + scope + ":" + t.TypeSlug)251251 if err != nil {252252 return nil, err253253 }254- category := mgCategory{254+ category := mgdoc.Category{255255 ID: categoryID,256256 Title: t.TypeName,257257 Icon: t.TypeSlug,258258 DisplayType: "markers",259259 Visible: true,260- Locations: make([]mgLocation, 0, len(markers)),260+ Locations: make([]mgdoc.Location, 0, len(markers)),261261 }262262 for _, marker := range markers {263- locationID, err := ids.claim("ign:marker:" + marker.ID)263+ locationID, err := ids.Claim("ign:marker:" + marker.ID)264264 if err != nil {265265 return nil, err266266 }267- category.Locations = append(category.Locations, mgLocation{267+ category.Locations = append(category.Locations, mgdoc.Location{268268 ID: locationID,269269 Title: marker.MarkerName,270- Latitude: syntheticLatitude(-marker.Lat * worldSize),271- Longitude: syntheticLongitude(marker.Lng * worldSize),270+ Latitude: mgdoc.SyntheticLatitude(-marker.Lat * mgdoc.WorldSize),271+ Longitude: mgdoc.SyntheticLongitude(marker.Lng * mgdoc.WorldSize),272272 })273273 }274274 key := t.ParentTypeSlug@@ -278,143 +278,19 @@ func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error
278278 grouped[key] = append(grouped[key], category)279279 }280280 281- groups := make([]mgGroup, 0, len(order))281+ groups := make([]mgdoc.Group, 0, len(order))282282 for _, key := range order {283283 title := "Markers"284284 seed := "ign:group:" + scope285285 if key != "" {286- title = spellOut(key)286+ title = mgdoc.SpellOut(key)287287 seed += ":" + key288288 }289- groupID, err := ids.claim(seed)289+ groupID, err := ids.Claim(seed)290290 if err != nil {291291 return nil, err292292 }293- groups = append(groups, mgGroup{ID: groupID, Title: title, Categories: grouped[key]})293+ groups = append(groups, mgdoc.Group{ID: groupID, Title: title, Categories: grouped[key]})294294 }295295 return groups, nil296296 }297-298-// spellOut turns a slug into a title, which is all the naming a group or an299-// unnamed map gets from IGN.300-func spellOut(slug string) string {301- words := strings.Split(slug, "-")302- for index, word := range words {303- if word != "" {304- words[index] = strings.ToUpper(word[:1]) + word[1:]305- }306- }307- return strings.Join(words, " ")308-}309-310-// Synthetic coordinates invert the viewer's projection, so a marker's linear311-// position on IGN's image, run through the same spherical Mercator every other312-// map uses, lands on the same pixel it was captured at. The world here is the313-// map's own window -- a 32-tile square starting at tile zero -- which the314-// bundle carries as this map's grid.315-func syntheticLongitude(x float64) float64 {316- worldTiles := math.Pow(2, sourceZoom)317- xTile := x / tileSize318- return (xTile/worldTiles)*360 - 180319-}320-321-func syntheticLatitude(y float64) float64 {322- worldTiles := math.Pow(2, sourceZoom)323- yTile := y / tileSize324- return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi325-}326-327-// idSpace hands out the numeric identifiers the pipeline expects, derived from328-// the stable names IGN publishes, so the same capture always numbers itself the329-// same way. Identifiers stay within a positive int31 -- the packed location330-// format and the viewer both read them as signed 32-bit -- and zero is avoided331-// because the viewer reads zero as absence. A collision between two names is332-// vanishingly unlikely at this scale and loudly fatal rather than silently333-// merging two pins.334-type idSpace struct {335- seen map[int64]string336-}337-338-func newIDSpace() *idSpace {339- return &idSpace{seen: make(map[int64]string)}340-}341-342-func (s *idSpace) claim(seed string) (int64, error) {343- hash := fnv.New32a()344- _, _ = hash.Write([]byte(seed))345- id := int64(hash.Sum32() & 0x7fffffff)346- if id == 0 {347- id = 1348- }349- if holder, taken := s.seen[id]; taken {350- return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)351- }352- s.seen[id] = seed353- return id, nil354-}355-356-// The MapGenie shapes Translate emits: only the fields tools/tiles and357-// tools/generate actually read, spelled with the snake_case tags their own358-// decoders name.359-type mgMap struct {360- ID int64 `json:"id"`361- Title string `json:"title"`362- Slug string `json:"slug"`363- InitialLatitude float64 `json:"initial_latitude"`364- InitialLongitude float64 `json:"initial_longitude"`365- Config mgConfig `json:"config"`366- Game mgGame `json:"game"`367- Groups []mgGroup `json:"groups"`368- Regions []struct{} `json:"regions"`369-}370-371-type mgGame struct {372- ID int64 `json:"id"`373- Title string `json:"title"`374- Slug string `json:"slug"`375-}376-377-type mgConfig struct {378- TileSets []mgTileSet `json:"tile_sets"`379-}380-381-type mgTileSet struct {382- Name string `json:"name"`383- Path string `json:"path"`384- MinZoom int `json:"min_zoom"`385- MaxZoom int `json:"max_zoom"`386- Extension string `json:"extension"`387- Bounds map[string]mgBound `json:"bounds"`388-}389-390-type mgBound struct {391- X mgRange `json:"x"`392- Y mgRange `json:"y"`393-}394-395-type mgRange struct {396- Min int `json:"min"`397- Max int `json:"max"`398-}399-400-type mgGroup struct {401- ID int64 `json:"id"`402- Title string `json:"title"`403- Categories []mgCategory `json:"categories"`404-}405-406-type mgCategory struct {407- ID int64 `json:"id"`408- Title string `json:"title"`409- Icon string `json:"icon"`410- DisplayType string `json:"display_type"`411- Visible bool `json:"visible"`412- Locations []mgLocation `json:"locations"`413-}414-415-type mgLocation struct {416- ID int64 `json:"id"`417- Title string `json:"title"`418- Latitude float64 `json:"latitude"`419- Longitude float64 `json:"longitude"`420-}@@ -3,10 +3,12 @@ package ignmap
33 import (44 "bytes"55 "encoding/json"6- "hash/fnv"76 "math"7+ "strconv"88 "strings"99 "testing"10+11+ "github.com/FelineStateMachine/atlas/internal/mgdoc"1012 )1113 1214 // fixture is a small capture exercising everything Translate decides: a type@@ -39,7 +41,7 @@ func fixture() Capture {
3941 }4042 }4143 42-func translate(t *testing.T, capture Capture) mgMap {44+func translate(t *testing.T, capture Capture) mgdoc.Map {4345 t.Helper()4446 doc, err := json.Marshal(capture)4547 if err != nil {@@ -49,7 +51,7 @@ func translate(t *testing.T, capture Capture) mgMap {
4951 if err != nil {5052 t.Fatal(err)5153 }52- var m mgMap54+ var m mgdoc.Map5355 if err := json.Unmarshal(out, &m); err != nil {5456 t.Fatal(err)5557 }@@ -99,7 +101,7 @@ func TestTranslateShape(t *testing.T) {
99101 t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)100102 }101103 for zoom := 0; zoom <= 5; zoom++ {102- if _, ok := set.Bounds[itoa(zoom)]; !ok {104+ if _, ok := set.Bounds[strconv.Itoa(zoom)]; !ok {103105 t.Fatalf("no bounds declared for zoom %d", zoom)104106 }105107 }@@ -140,16 +142,16 @@ func TestSyntheticCoordinatesRoundTrip(t *testing.T) {
140142 m := translate(t, fixture())141143 142144 project := func(latitude, longitude float64) (float64, float64) {143- worldTiles := math.Pow(2, sourceZoom)144- x := ((longitude + 180) / 360) * worldTiles * tileSize145- y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * tileSize145+ 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.TileSize146148 return x, y147149 }148150 149151 byTitle := map[string][2]float64{150152 "Origin": {0, 0},151- "Centre": {0.4411 * worldSize, 0.5 * worldSize},152- "Corner": {0.8822243 * worldSize, worldSize},153+ "Centre": {0.4411 * mgdoc.WorldSize, 0.5 * mgdoc.WorldSize},154+ "Corner": {0.8822243 * mgdoc.WorldSize, mgdoc.WorldSize},153155 }154156 seen := 0155157 for _, group := range m.Groups {@@ -191,7 +193,7 @@ func TestIdentifiersAreStable(t *testing.T) {
191193 }192194 }193195 194-func locationIDs(m mgMap) map[string]int64 {196+func locationIDs(m mgdoc.Map) map[string]int64 {195197 out := make(map[string]int64)196198 for _, group := range m.Groups {197199 for _, category := range group.Categories {@@ -203,17 +205,6 @@ func locationIDs(m mgMap) map[string]int64 {
203205 return out204206 }205207 206-func TestIDCollisionIsFatal(t *testing.T) {207- space := newIDSpace()208- hash := fnv.New32a()209- _, _ = hash.Write([]byte("some-seed"))210- occupied := int64(hash.Sum32() & 0x7fffffff)211- space.seen[occupied] = "already-here"212- if _, err := space.claim("some-seed"); err == nil {213- t.Fatal("claiming an occupied id did not fail")214- }215-}216-217208 func TestTranslateRejectsBadCaptures(t *testing.T) {218209 duplicate := fixture()219210 duplicate.Types = append(duplicate.Types, Type{TypeSlug: "fast-travel", TypeName: "Again"})@@ -252,7 +243,3 @@ func TestMaybeTranslatePassesMapGenieThrough(t *testing.T) {
252243 t.Fatal("a MapGenie snapshot was rewritten")253244 }254245 }255-256-func itoa(n int) string {257- return string(rune('0' + n))258-}@@ -0,0 +1,150 @@
1+// Package mgdoc is the MapGenie-shaped document the pipeline reads, spelled2+// as Go structs for the translators that emit it. tools/tiles and3+// tools/generate decode this shape from every snapshot; a capture from4+// another source becomes one of these on the way in, and this package is5+// where the translators agree on the fields, the world they project into,6+// and the way stable names become the numeric identifiers the pipeline7+// expects.8+package mgdoc9+10+import (11+ "fmt"12+ "hash/fnv"13+ "math"14+ "strings"15+)16+17+// The world every translated map lives in: a 32-tile square, 8192 pixels18+// across, whose tile pyramid starts at a single tile. Pins are stored as19+// latitude and longitude and projected by the viewer, so a marker's linear20+// position on a source's image becomes the synthetic coordinate that projects21+// back onto that exact pixel.22+const (23+ SourceZoom = 524+ TileSize = 25625+ WorldSize = 819226+)27+28+// Only the fields tools/tiles and tools/generate actually read, spelled with29+// the snake_case tags their own decoders name.30+type Map struct {31+ ID int64 `json:"id"`32+ Title string `json:"title"`33+ Slug string `json:"slug"`34+ InitialLatitude float64 `json:"initial_latitude"`35+ InitialLongitude float64 `json:"initial_longitude"`36+ Config Config `json:"config"`37+ Game Game `json:"game"`38+ Groups []Group `json:"groups"`39+ Regions []struct{} `json:"regions"`40+}41+42+type Game struct {43+ ID int64 `json:"id"`44+ Title string `json:"title"`45+ Slug string `json:"slug"`46+}47+48+type Config struct {49+ TileSets []TileSet `json:"tile_sets"`50+}51+52+type TileSet struct {53+ Name string `json:"name"`54+ Path string `json:"path"`55+ MinZoom int `json:"min_zoom"`56+ MaxZoom int `json:"max_zoom"`57+ Extension string `json:"extension"`58+ Bounds map[string]Bound `json:"bounds"`59+}60+61+type Bound struct {62+ X Range `json:"x"`63+ Y Range `json:"y"`64+}65+66+type Range struct {67+ Min int `json:"min"`68+ Max int `json:"max"`69+}70+71+type Group struct {72+ ID int64 `json:"id"`73+ Title string `json:"title"`74+ Categories []Category `json:"categories"`75+}76+77+type Category struct {78+ ID int64 `json:"id"`79+ Title string `json:"title"`80+ Icon string `json:"icon"`81+ DisplayType string `json:"display_type"`82+ Visible bool `json:"visible"`83+ Locations []Location `json:"locations"`84+}85+86+type Location struct {87+ ID int64 `json:"id"`88+ Title string `json:"title"`89+ Description string `json:"description,omitempty"`90+ Latitude float64 `json:"latitude"`91+ Longitude float64 `json:"longitude"`92+}93+94+// Synthetic coordinates invert the viewer's projection, so a pin's linear95+// position on a source's image, run through the same spherical Mercator every96+// other map uses, lands on the same pixel it was captured at. The world here97+// is the map's own window -- a 32-tile square starting at tile zero -- which98+// the bundle carries as this map's grid.99+func SyntheticLongitude(x float64) float64 {100+ worldTiles := math.Pow(2, SourceZoom)101+ xTile := x / TileSize102+ return (xTile/worldTiles)*360 - 180103+}104+105+func SyntheticLatitude(y float64) float64 {106+ worldTiles := math.Pow(2, SourceZoom)107+ yTile := y / TileSize108+ return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi109+}110+111+// IDSpace hands out the numeric identifiers the pipeline expects, derived112+// from the stable names a source publishes, so the same capture always113+// numbers itself the same way. Identifiers stay within a positive int31 --114+// the packed location format and the viewer both read them as signed 32-bit115+// -- and zero is avoided because the viewer reads zero as absence. A116+// collision between two names is vanishingly unlikely at this scale and117+// loudly fatal rather than silently merging two pins.118+type IDSpace struct {119+ seen map[int64]string120+}121+122+func NewIDSpace() *IDSpace {123+ return &IDSpace{seen: make(map[int64]string)}124+}125+126+func (s *IDSpace) Claim(seed string) (int64, error) {127+ hash := fnv.New32a()128+ _, _ = hash.Write([]byte(seed))129+ id := int64(hash.Sum32() & 0x7fffffff)130+ if id == 0 {131+ id = 1132+ }133+ if holder, taken := s.seen[id]; taken {134+ return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)135+ }136+ s.seen[id] = seed137+ return id, nil138+}139+140+// SpellOut turns a slug into a title, which is all the naming some sources141+// give a thing.142+func SpellOut(slug string) string {143+ words := strings.Split(slug, "-")144+ for index, word := range words {145+ if word != "" {146+ words[index] = strings.ToUpper(word[:1]) + word[1:]147+ }148+ }149+ return strings.Join(words, " ")150+}@@ -0,0 +1,59 @@
1+package mgdoc2+3+import (4+ "hash/fnv"5+ "math"6+ "testing"7+)8+9+func TestClaimRefusesAnOccupiedID(t *testing.T) {10+ space := NewIDSpace()11+ hash := fnv.New32a()12+ _, _ = hash.Write([]byte("some-seed"))13+ occupied := int64(hash.Sum32() & 0x7fffffff)14+ space.seen[occupied] = "already-here"15+ if _, err := space.Claim("some-seed"); err == nil {16+ t.Fatal("claiming an occupied id did not fail")17+ }18+}19+20+func TestClaimStaysWithinPositiveInt31(t *testing.T) {21+ space := NewIDSpace()22+ for _, seed := range []string{"a", "b", "some:longer:seed", "ign:marker:x"} {23+ id, err := space.Claim(seed)24+ if err != nil {25+ t.Fatal(err)26+ }27+ if id <= 0 || id > math.MaxInt32 {28+ t.Fatalf("Claim(%q) = %d, outside the positive int31 range", seed, id)29+ }30+ }31+}32+33+// TestSyntheticCoordinatesInvertTheViewer projects the synthetic coordinates34+// with the viewer's own formula and expects the original pixel back.35+func TestSyntheticCoordinatesInvertTheViewer(t *testing.T) {36+ for _, pixel := range [][2]float64{{0, 0}, {4096, 4096}, {8192, 8192}, {123.5, 7000.25}} {37+ latitude := SyntheticLatitude(pixel[1])38+ longitude := SyntheticLongitude(pixel[0])39+ worldTiles := math.Pow(2, SourceZoom)40+ x := ((longitude + 180) / 360) * worldTiles * TileSize41+ y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * TileSize42+ if math.Abs(x-pixel[0]) > 1e-6 || math.Abs(y-pixel[1]) > 1e-6 {43+ t.Fatalf("pixel %v round-tripped to %.8f,%.8f", pixel, x, y)44+ }45+ }46+}47+48+func TestSpellOut(t *testing.T) {49+ cases := map[string]string{50+ "night-city": "Night City",51+ "ncpd-scanner-hustles": "Ncpd Scanner Hustles",52+ "collectibles": "Collectibles",53+ }54+ for slug, want := range cases {55+ if got := SpellOut(slug); got != want {56+ t.Errorf("SpellOut(%q) = %q, want %q", slug, got, want)57+ }58+ }59+}