clone

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

main
atlas / format / bundle / codec_test.go
codec_test.go 7.7K
1package bundle_test23import (4 "bytes"5 "encoding/binary"6 "math"7 "reflect"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/format/bundle"12)1314// codecCases are the payloads the packing has to survive. Each is packed,15// unpacked, and packed again: the structs must come back equal and the bytes16// must come back identical, which together say the encoding is total and has17// no slack a producer could vary.18var codecCases = []struct {19 name string20 locations []bundle.Location21}{22 {"nothing at all", nil},23 {"an empty slice", []bundle.Location{}},24 {"one location", []bundle.Location{{ID: 1, Title: "Origin"}}},25 {"a title-less location", []bundle.Location{{ID: 7}}},26 {"several with owners", []bundle.Location{27 {ID: 1, Title: "Origin", Lat: 12.5, Lng: -3.25, Owner: 0},28 {ID: 2, Title: "Peak", Lat: 80, Lng: 170, Owner: 1},29 {ID: 3, Title: "Trench", Lat: -80, Lng: -170, Owner: 65535},30 }},31 {"members and shards", []bundle.Location{32 {ID: 10, Title: "Inside", Member: 900, Shard: 3},33 {ID: 11, Title: "Outside", Member: 0, Shard: 0},34 }},35 {"negative identities", []bundle.Location{36 {ID: -1, Title: "Below zero", Member: -5, Shard: -2},37 }},38 {"the signed extremes", []bundle.Location{39 {ID: math.MaxInt32, Member: math.MinInt32, Shard: math.MaxInt32, Title: "Edge"},40 }},41 {"titles that are not ascii", []bundle.Location{42 {ID: 1, Title: "Ölüdeniz"},43 {ID: 2, Title: "東京"},44 {ID: 3, Title: "Rio de la Plata \x00 with a null"},45 }},46 {"a long title", []bundle.Location{{ID: 1, Title: strings.Repeat("verylong ", 200)}}},47 {"empty titles between full ones", []bundle.Location{48 {ID: 1, Title: "First"}, {ID: 2, Title: ""}, {ID: 3, Title: "Third"},49 }},50}5152func TestCodecRoundTripsBytesAndStructs(t *testing.T) {53 for _, test := range codecCases {54 t.Run(test.name, func(t *testing.T) {55 packed := bundle.PackLocations(test.locations)56 decoded, err := bundle.UnpackLocations(packed)57 if err != nil {58 t.Fatalf("unpack: %v", err)59 }60 if len(decoded) != len(test.locations) {61 t.Fatalf("unpacked %d locations, packed %d", len(decoded), len(test.locations))62 }63 for index, want := range test.locations {64 // Coordinates are single precision on the wire, so the round65 // trip is through float32 and back rather than exact.66 want.Lat = float64(float32(want.Lat))67 want.Lng = float64(float32(want.Lng))68 if !reflect.DeepEqual(decoded[index], want) {69 t.Errorf("location %d came back as %+v, want %+v", index, decoded[index], want)70 }71 }72 again := bundle.PackLocations(decoded)73 if !bytes.Equal(again, packed) {74 t.Errorf("repacking changed %d bytes into %d", len(packed), len(again))75 }76 })77 }78}7980func TestPackedHeaderIsTheDocumentedLayout(t *testing.T) {81 locations := []bundle.Location{{ID: 1, Title: "ab"}, {ID: 2, Title: "cde"}}82 data := bundle.PackLocations(locations)8384 if got := string(data[:8]); got != bundle.LocationMagic {85 t.Errorf("magic = %q, want %q", got, bundle.LocationMagic)86 }87 if got := binary.LittleEndian.Uint16(data[8:]); got != bundle.LocationVersion {88 t.Errorf("version = %d, want %d", got, bundle.LocationVersion)89 }90 if got := binary.LittleEndian.Uint32(data[10:]); got != 2 {91 t.Errorf("count = %d, want 2", got)92 }93 if data[14] != 0 || data[15] != 0 {94 t.Errorf("the reserved bytes carry %v", data[14:16])95 }96 // Header, five uint32 columns, the offsets, the owners, then the titles:97 // 16 + 2*20 + 3*4 + 2*2 + 5.98 if want := 16 + 2*20 + 3*4 + 2*2 + 5; len(data) != want {99 t.Errorf("payload is %d bytes, want %d", len(data), want)100 }101}102103func TestPackedViewAgreesWithStructs(t *testing.T) {104 locations := []bundle.Location{105 {ID: 5, Title: "Origin", Lat: 12.5, Lng: -3.25, Member: 2, Shard: 1, Owner: 3},106 {ID: 6, Title: "", Lat: -80, Lng: 170, Owner: 0},107 }108 packed, err := bundle.OpenPacked(bundle.PackLocations(locations))109 if err != nil {110 t.Fatal(err)111 }112 if packed.Len() != len(locations) {113 t.Fatalf("Len = %d, want %d", packed.Len(), len(locations))114 }115 decoded := packed.All()116 for index := range decoded {117 if packed.ID(index) != decoded[index].ID ||118 packed.Lat(index) != decoded[index].Lat ||119 packed.Lng(index) != decoded[index].Lng ||120 packed.Member(index) != decoded[index].Member ||121 packed.Shard(index) != decoded[index].Shard ||122 packed.Owner(index) != decoded[index].Owner ||123 packed.Title(index) != decoded[index].Title {124 t.Errorf("column reads disagree with struct %d: %+v", index, decoded[index])125 }126 if string(packed.TitleBytes(index)) != decoded[index].Title {127 t.Errorf("TitleBytes %d = %q", index, packed.TitleBytes(index))128 }129 if !reflect.DeepEqual(packed.At(index), decoded[index]) {130 t.Errorf("At(%d) = %+v, want %+v", index, packed.At(index), decoded[index])131 }132 }133}134135// The view borrows: a title read out of it must be a slice of the payload,136// not a copy, which is the whole reason the raw accessor exists.137func TestPackedTitleBytesBorrow(t *testing.T) {138 data := bundle.PackLocations([]bundle.Location{{ID: 1, Title: "Origin"}})139 packed, err := bundle.OpenPacked(data)140 if err != nil {141 t.Fatal(err)142 }143 title := packed.TitleBytes(0)144 if len(title) == 0 {145 t.Fatal("the title read as empty")146 }147 data[len(data)-len(title)] = 'o'148 if string(title) != "origin" {149 t.Errorf("the title did not track the payload: %q", title)150 }151}152153func TestOpenPackedRefusesWhatItCannotRead(t *testing.T) {154 sound := bundle.PackLocations([]bundle.Location{{ID: 1, Title: "Origin"}, {ID: 2, Title: "Peak"}})155156 corrupt := func(mutate func([]byte) []byte) []byte {157 copied := append([]byte(nil), sound...)158 return mutate(copied)159 }160 cases := []struct {161 name string162 data []byte163 mention string164 }{165 {"nothing", nil, "expected form"},166 {"a short header", sound[:12], "expected form"},167 {"the wrong magic", corrupt(func(d []byte) []byte {168 copy(d, "NOTALOCS")169 return d170 }), "expected form"},171 {"a future version", corrupt(func(d []byte) []byte {172 binary.LittleEndian.PutUint16(d[8:], bundle.LocationVersion+1)173 return d174 }), "version"},175 {"a past version", corrupt(func(d []byte) []byte {176 binary.LittleEndian.PutUint16(d[8:], 2)177 return d178 }), "version"},179 {"a count beyond the bytes", corrupt(func(d []byte) []byte {180 binary.LittleEndian.PutUint32(d[10:], 1<<20)181 return d182 }), "truncated"},183 {"a truncated title region", sound[:len(sound)-3], "truncated"},184 {"offsets that run backwards", corrupt(func(d []byte) []byte {185 // The offsets column starts after the header and five uint32186 // columns of two entries each.187 binary.LittleEndian.PutUint32(d[16+2*20:], 99)188 return d189 }), "backwards"},190 }191 for _, test := range cases {192 t.Run(test.name, func(t *testing.T) {193 _, err := bundle.OpenPacked(test.data)194 if err == nil || !strings.Contains(err.Error(), test.mention) {195 t.Errorf("opened with %v, want a complaint about %q", err, test.mention)196 }197 })198 }199}200201// A payload written by one build must be readable by another byte for byte,202// so the layout is pinned here as literal bytes rather than as a description203// of itself. Two locations, one owner apart, with a title each.204func TestPackedLayoutIsPinned(t *testing.T) {205 data := bundle.PackLocations([]bundle.Location{206 {ID: 1, Title: "ab", Lat: 1, Lng: -1, Member: 2, Shard: 3, Owner: 0},207 {ID: 2, Title: "c", Lat: 0, Lng: 0, Member: 0, Shard: 0, Owner: 1},208 })209 want := []byte{210 'A', 'T', 'L', 'A', 'S', 'L', 'O', 'C', // magic211 3, 0, // version212 2, 0, 0, 0, // count213 0, 0, // reserved214 1, 0, 0, 0, 2, 0, 0, 0, // id215 0, 0, 128, 63, 0, 0, 0, 0, // lat: 1.0, 0.0216 0, 0, 128, 191, 0, 0, 0, 0, // lng: -1.0, 0.0217 2, 0, 0, 0, 0, 0, 0, 0, // member218 3, 0, 0, 0, 0, 0, 0, 0, // shard219 0, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, // title offsets, count+1 of them220 0, 0, 1, 0, // owner221 'a', 'b', 'c', // titles222 }223 if !bytes.Equal(data, want) {224 t.Errorf("packed as\n%v\nwant\n%v", data, want)225 }226}