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 / corpus_test.go
corpus_test.go 24.5K
1package bundle_test23import (4 "bytes"5 "crypto/sha256"6 "encoding/hex"7 "encoding/json"8 "fmt"9 "os"10 "path/filepath"11 "reflect"12 "strings"13 "testing"1415 "github.com/FelineStateMachine/atlas/format/bundle"16)1718// The corpus: committed extractions of two real builds -- the proof city and19// the planet -- taken from .atlas files the reference implementation actually20// wrote. Every byte compared here was produced by the implementation this one21// replaces, which is what holds the reader and writer to real bytes without a22// library anywhere in sight. A corpus file that is missing is a failure, never23// a skip: the corpus is committed, so its absence means the checkout is24// broken, not that there is nothing to judge.25//26// What the corpus cannot carry is the archives themselves -- rasters and all,27// they are hundreds of megabytes -- so the checks that needed a real .atlas in28// hand (part hashes against the wire, entry order, tiles stored uncompressed)29// live in a separate non-gating command that points at an installed library.3031const corpusRoot = "../../testdata/corpus/bundles"3233// corpusSlugs are the two volumes the corpus holds, by name rather than by34// listing, so a corpus directory that lost one fails loudly.35var corpusSlugs = []string{"bend-or", "mars"}3637func corpusDir(slug string) string {38 return filepath.Join(corpusRoot, slug)39}4041// corpusHeader is the part of a volume.json extraction these tests read: the42// identity the build carried, the counts, and the pyramids the tile43// inventories are inventories of.44type corpusHeader struct {45 Slug string `json:"slug"`46 Title string `json:"title"`47 File string `json:"file"`48 FileBytes int64 `json:"fileBytes"`49 FormatVersion int `json:"formatVersion"`50 Conventions int `json:"conventions"`51 Stamp string `json:"stamp"`52 Stamp12 string `json:"stamp12"`53 CreatedAt string `json:"createdAt"`54 Revision int `json:"revision"`55 Counts corpusCounts `json:"counts"`56 Worlds []corpusWorld `json:"worlds"`57 Pyramids []corpusPyramid `json:"pyramids"`58 PartHashes map[string]string `json:"partHashes"`59}6061type corpusCounts struct {62 Entries int `json:"entries"`63 Worlds int `json:"worlds"`64 Tiles int `json:"tiles"`65 Icons int `json:"icons"`66 Points int `json:"points"`67 Paths int `json:"paths"`68 Areas int `json:"areas"`69}7071type corpusWorld struct {72 Slug string `json:"slug"`73 Points int `json:"points"`74 Paths int `json:"paths"`75 Areas int `json:"areas"`76 Collections int `json:"collections"`77 TextEntries int `json:"textEntries"`78}7980type corpusPyramid struct {81 Pyramid string `json:"pyramid"`82 Tiles int `json:"tiles"`83 Bytes int64 `json:"bytes"`84 Content string `json:"contentRollup"`85 Pixels string `json:"pixelRollup"`86 Inventory string `json:"inventory"`87}8889// locationsHeader is what a locations extraction says about the payload it90// was unpacked from: the real archive's packed bytes, by size and by digest.91type locationsHeader struct {92 World string `json:"world"`93 Packed string `json:"packed"`94 PackedSHA256 string `json:"packedSha256"`95 PackedBytes int `json:"packedBytes"`96 Count int `json:"count"`97}9899// locationRow is one packed location as the extraction spells it.100type locationRow struct {101 ID int64 `json:"id"`102 Owner uint16 `json:"owner"`103 Lat float64 `json:"lat"`104 Lng float64 `json:"lng"`105 Member int64 `json:"member"`106 Shard int64 `json:"shard"`107 Title string `json:"title"`108}109110type iconRow struct {111 Name string `json:"name"`112 Bytes int64 `json:"bytes"`113 SHA256 string `json:"sha256"`114}115116type tileRow struct {117 Name string `json:"name"`118 Bytes int64 `json:"bytes"`119 SHA256 string `json:"sha256"`120}121122func readCorpusFile(t *testing.T, path string) []byte {123 t.Helper()124 data, err := os.ReadFile(path)125 if err != nil {126 t.Fatalf("the corpus is incomplete: %v", err)127 }128 return data129}130131func readCorpusJSON(t *testing.T, path string, into any) {132 t.Helper()133 if err := json.Unmarshal(readCorpusFile(t, path), into); err != nil {134 t.Fatalf("%s: %v", path, err)135 }136}137138func readCorpusManifest(t *testing.T, slug string) (bundle.Manifest, []byte) {139 t.Helper()140 committed := readCorpusFile(t, filepath.Join(corpusDir(slug), "manifest.json"))141 var manifest bundle.Manifest142 if err := json.Unmarshal(committed, &manifest); err != nil {143 t.Fatalf("%s manifest: %v", slug, err)144 }145 return manifest, committed146}147148func readCorpusHeader(t *testing.T, slug string) corpusHeader {149 t.Helper()150 var header corpusHeader151 readCorpusJSON(t, filepath.Join(corpusDir(slug), "volume.json"), &header)152 return header153}154155// corpusRows reads the row array of an inventory-shaped extraction.156func corpusRows(t *testing.T, path, key string, into any) {157 t.Helper()158 var document map[string]json.RawMessage159 readCorpusJSON(t, path, &document)160 body, held := document[key]161 if !held {162 t.Fatalf("%s carries no %q array", path, key)163 }164 if err := json.Unmarshal(body, into); err != nil {165 t.Fatalf("%s %s: %v", path, key, err)166 }167}168169// canonicalJSON writes raw JSON in the one shape the corpus is committed in:170// keys sorted, numbers carried as the source wrote them, no HTML escaping,171// two-space indentation, one trailing newline. It mirrors the canon the172// capture used, so a re-encoding that matches a corpus file matches the173// extraction and not merely something equivalent to it.174func canonicalJSON(raw []byte) ([]byte, error) {175 decoder := json.NewDecoder(bytes.NewReader(raw))176 decoder.UseNumber()177 var value any178 if err := decoder.Decode(&value); err != nil {179 return nil, err180 }181 var out bytes.Buffer182 encoder := json.NewEncoder(&out)183 encoder.SetEscapeHTML(false)184 encoder.SetIndent("", " ")185 if err := encoder.Encode(value); err != nil {186 return nil, err187 }188 return out.Bytes(), nil189}190191// rollup is the digest an inventory summarizes itself with: one "name hash"192// line per row, in the inventory's own order.193func rollup(lines []string) string {194 digest := sha256.New()195 for _, line := range lines {196 digest.Write([]byte(line + "\n"))197 }198 return hex.EncodeToString(digest.Sum(nil))199}200201// firstDiff reports where two documents first differ, in a form a reader can202// act on; a corpus manifest is small but a payload is not.203func firstDiff(got, want []byte) string {204 limit := min(len(got), len(want))205 for at := range limit {206 if got[at] != want[at] {207 return fmt.Sprintf("byte %d differs (%d bytes against %d):\n got %s\nwant %s",208 at, len(got), len(want), excerpt(got, at), excerpt(want, at))209 }210 }211 if len(got) != len(want) {212 return fmt.Sprintf("one is a prefix of the other: %d bytes against %d, first difference at %d",213 len(got), len(want), limit)214 }215 return "identical"216}217218func excerpt(data []byte, at int) string {219 start := max(at-60, 0)220 end := min(at+60, len(data))221 return strings.ReplaceAll(string(data[start:end]), "\n", "\\n")222}223224func locationsOf(rows []locationRow) []bundle.Location {225 locations := make([]bundle.Location, len(rows))226 for index, row := range rows {227 locations[index] = bundle.Location{228 ID: row.ID,229 Title: row.Title,230 Lat: row.Lat,231 Lng: row.Lng,232 Member: row.Member,233 Shard: row.Shard,234 Owner: row.Owner,235 }236 }237 return locations238}239240// A corpus manifest must survive the trip through this package's types: every241// key read, every key written again, nothing dropped, nothing reordered, no242// number reformatted. The manifest's encoded bytes feed the stamp of every243// bundle ever built, so a schema that has quietly moved is the single most244// expensive thing that could be wrong in this package, and this is the check245// that catches it without an archive in hand.246func TestCorpusManifestsReEncodeExactly(t *testing.T) {247 for _, slug := range corpusSlugs {248 t.Run(slug, func(t *testing.T) {249 manifest, committed := readCorpusManifest(t, slug)250 if err := manifest.Validate(); err != nil {251 t.Fatalf("the corpus manifest does not validate: %v", err)252 }253 encoded, err := bundle.MarshalManifest(manifest)254 if err != nil {255 t.Fatal(err)256 }257 again, err := canonicalJSON(encoded)258 if err != nil {259 t.Fatal(err)260 }261 if !bytes.Equal(again, committed) {262 t.Errorf("re-encoding the manifest does not reproduce the extraction:\n%s",263 firstDiff(again, committed))264 }265 })266 }267}268269// The file a build is written under is derived from its manifest alone, and270// volume.json records the name the real build actually carried. This is the271// stamp's short form, the capture-day rule and the naming order checked end272// to end, with no bundle in hand.273func TestCorpusManifestsDeriveTheirIdentity(t *testing.T) {274 for _, slug := range corpusSlugs {275 t.Run(slug, func(t *testing.T) {276 manifest, _ := readCorpusManifest(t, slug)277 header := readCorpusHeader(t, slug)278279 if derived := bundle.VersionedFileName(manifest); derived != header.File {280 t.Errorf("this build would be named %s, and it is named %s", derived, header.File)281 }282 if short := bundle.ShortStamp(manifest.Version.Stamp); short != header.Stamp12 {283 t.Errorf("short stamp is %s, and volume.json says %s", short, header.Stamp12)284 }285 if day := bundle.CaptureDay(manifest.Version.CreatedAt); !strings.Contains(header.File, day) {286 t.Errorf("capture day %q does not appear in %s", day, header.File)287 }288289 for _, field := range []struct {290 name string291 got, wants any292 }{293 {"slug", manifest.Volume.Slug, header.Slug},294 {"title", manifest.Volume.Title, header.Title},295 {"stamp", manifest.Version.Stamp, header.Stamp},296 {"createdAt", manifest.Version.CreatedAt, header.CreatedAt},297 {"revision", manifest.Version.Revision, header.Revision},298 {"formatVersion", manifest.FormatVersion, header.FormatVersion},299 {"conventions", manifest.Conventions, header.Conventions},300 {"worlds", len(manifest.Worlds), header.Counts.Worlds},301 } {302 if field.got != field.wants {303 t.Errorf("manifest %s is %v, and volume.json says %v", field.name, field.got, field.wants)304 }305 }306307 var points, paths, areas int308 for _, world := range manifest.Worlds {309 points += world.Points310 paths += world.Paths311 areas += world.Areas312 }313 if points != header.Counts.Points || paths != header.Counts.Paths || areas != header.Counts.Areas {314 t.Errorf("the manifest promises %d points, %d paths and %d areas; volume.json counts %d, %d and %d",315 points, paths, areas, header.Counts.Points, header.Counts.Paths, header.Counts.Areas)316 }317 })318 }319}320321// Every world's unpacked locations are packed again by this package, and the322// result must be the real payload: the extraction records the packed bytes323// the archive carried by size and by SHA-256, so a packing that matches324// reproduces bytes written by another implementation. And back again, so the325// codec is checked in both directions rather than only in the one the capture326// ran.327func TestCorpusLocationsRepackToTheRecordedPayload(t *testing.T) {328 for _, slug := range corpusSlugs {329 t.Run(slug, func(t *testing.T) {330 manifest, _ := readCorpusManifest(t, slug)331 header := readCorpusHeader(t, slug)332 for _, entry := range manifest.Worlds {333 path := filepath.Join(corpusDir(slug), "worlds", entry.Slug+".locations.json")334 var recorded locationsHeader335 readCorpusJSON(t, path, &recorded)336 var rows []locationRow337 corpusRows(t, path, "locations", &rows)338339 if want := fmt.Sprintf("%s v%d", bundle.LocationMagic, bundle.LocationVersion); recorded.Packed != want {340 t.Errorf("world %s: the extraction is packed as %q and this package writes %q",341 entry.Slug, recorded.Packed, want)342 }343 if len(rows) != entry.Points || len(rows) != recorded.Count {344 t.Errorf("world %s: the extraction holds %d locations, its header counts %d, and the manifest promises %d points",345 entry.Slug, len(rows), recorded.Count, entry.Points)346 }347348 packed := bundle.PackLocations(locationsOf(rows))349 if len(packed) != recorded.PackedBytes {350 t.Errorf("world %s: repacking weighs %d bytes and the extraction recorded %d",351 entry.Slug, len(packed), recorded.PackedBytes)352 }353 if hash := bundle.HashBytes(packed); hash != recorded.PackedSHA256 {354 t.Errorf("world %s: repacking hashes to %s and the extraction recorded %s",355 entry.Slug, hash, recorded.PackedSHA256)356 }357 // The packed payload is a stamped part, and volume.json carries358 // the part hash the capture took off the archive itself. The359 // three records must agree, or the corpus is not an oracle.360 part := bundle.WorldEntryName(entry.Slug, bundle.PackedSuffix)361 if header.PartHashes[part] != recorded.PackedSHA256 {362 t.Errorf("world %s: volume.json hashes %s as %s and the extraction says %s",363 entry.Slug, part, header.PartHashes[part], recorded.PackedSHA256)364 }365366 back, err := bundle.UnpackLocations(packed)367 if err != nil {368 t.Errorf("world %s: %v", entry.Slug, err)369 continue370 }371 if !reflect.DeepEqual(back, locationsOf(rows)) {372 t.Errorf("world %s: unpacking the repacked payload does not reproduce the extraction's rows",373 entry.Slug)374 }375 if again := bundle.PackLocations(back); !bytes.Equal(again, packed) {376 t.Errorf("world %s: packing what was unpacked writes %d bytes from %d",377 entry.Slug, len(again), len(packed))378 }379 }380 })381 }382}383384// The extractions are assembled back into an archive -- the corpus manifest,385// the corpus payloads, the locations packed from the corpus rows, an entry386// per icon and one tile per level of every pyramid the inventories name --387// and the result is opened and validated.388//389// This is the one corpus check that reaches the parts of this package a390// manifest alone cannot: the writer, the reader, the offline scan, the391// per-kind counts against the payload, the geometry rules, and every392// conventions rule in format/semconv, run against real captured payloads. The393// icon and tile bodies are stand-ins, because nothing validation asks about394// them concerns their bytes; their names are the real build's own.395func TestCorpusExtractionsReassembleIntoAValidBundle(t *testing.T) {396 for _, slug := range corpusSlugs {397 t.Run(slug, func(t *testing.T) {398 header := readCorpusHeader(t, slug)399 path := reassembleCorpus(t, slug, header)400 reader, err := bundle.Open(path)401 if err != nil {402 t.Fatalf("the reassembled bundle does not open: %v", err)403 }404 defer reader.Close()405406 if err := reader.Validate(); err != nil {407 t.Errorf("the reassembled bundle does not validate: %v", err)408 }409 if name := bundle.VersionedFileName(reader.Manifest); name != header.File {410 t.Errorf("reopened, this build would be named %s rather than %s", name, header.File)411 }412413 // The manifest went through the writer and came back through the414 // reader; it must still canonicalize to the extraction.415 encoded, err := bundle.MarshalManifest(reader.Manifest)416 if err != nil {417 t.Fatal(err)418 }419 again, err := canonicalJSON(encoded)420 if err != nil {421 t.Fatal(err)422 }423 committed := readCorpusFile(t, filepath.Join(corpusDir(slug), "manifest.json"))424 if !bytes.Equal(again, committed) {425 t.Errorf("the manifest read back out of the archive does not match the extraction:\n%s",426 firstDiff(again, committed))427 }428 })429 }430}431432// reassembleCorpus writes an archive out of one volume's committed433// extractions and returns its path.434func reassembleCorpus(t *testing.T, slug string, header corpusHeader) string {435 t.Helper()436 manifest, _ := readCorpusManifest(t, slug)437438 path := filepath.Join(t.TempDir(), slug+bundle.Extension)439 file, err := os.Create(path)440 if err != nil {441 t.Fatal(err)442 }443 defer file.Close()444 writer, err := bundle.NewWriter(file, manifest)445 if err != nil {446 t.Fatalf("the corpus manifest is not one a writer accepts: %v", err)447 }448449 for _, entry := range manifest.Worlds {450 worlds := filepath.Join(corpusDir(slug), "worlds")451 payload := readCorpusFile(t, filepath.Join(worlds, entry.Slug+".payload.json"))452 text := readCorpusFile(t, filepath.Join(worlds, entry.Slug+".text.json"))453454 var rows []locationRow455 corpusRows(t, filepath.Join(worlds, entry.Slug+".locations.json"), "locations", &rows)456 packed := bundle.PackLocations(locationsOf(rows))457458 must(t, writer.AddDeflated(bundle.WorldEntryName(entry.Slug, bundle.WorldSuffix), payload))459 must(t, writer.AddStored(bundle.WorldEntryName(entry.Slug, bundle.PackedSuffix), bytes.NewReader(packed)))460 must(t, writer.AddDeflated(bundle.WorldEntryName(entry.Slug, bundle.TextSuffix), text))461 }462463 var icons []iconRow464 corpusRows(t, filepath.Join(corpusDir(slug), "icons.json"), "icons", &icons)465 for _, icon := range icons {466 must(t, writer.AddDeflated(icon.Name, []byte("<svg/>")))467 }468469 // One tile per level of every pyramid. Validation asks whether a level470 // holds anything, never what; a whole pyramid would be thousands of471 // entries to answer a yes-or-no question.472 written := map[string]bool{}473 for _, pyramid := range header.Pyramids {474 var tiles []tileRow475 corpusRows(t, filepath.Join(corpusDir(slug), filepath.FromSlash(pyramid.Inventory)), "tiles", &tiles)476 levels := map[string]bool{}477 for _, tile := range tiles {478 level, _, held := strings.Cut(tile.Name, "/")479 if !held || levels[level] {480 continue481 }482 levels[level] = true483 name := bundle.TilesPrefix + pyramid.Pyramid + "/" + tile.Name484 if written[name] {485 continue486 }487 written[name] = true488 must(t, writer.AddStored(name, strings.NewReader("raster")))489 }490 }491492 must(t, writer.Close())493 must(t, file.Close())494 return path495}496497// The corpus manifests, folded as a library: each build serves its own498// volume, the derived index lists them all, and the index still speaks the499// legacy wire keys. The last one is not cosmetic -- an index that renamed500// "games" to "volumes" would be read as empty by everything that consumes it.501func TestCorpusManifestsFoldAsALibrary(t *testing.T) {502 headers := make(map[string]corpusHeader, len(corpusSlugs))503 descriptors := make([]bundle.Descriptor, 0, len(corpusSlugs))504 for _, slug := range corpusSlugs {505 manifest, _ := readCorpusManifest(t, slug)506 header := readCorpusHeader(t, slug)507 headers[slug] = header508 descriptors = append(descriptors, bundle.DescriptorOf(header.File, manifest, header.FileBytes))509 }510511 winners := bundle.Fold(descriptors)512 if len(winners) != len(corpusSlugs) {513 t.Errorf("%d corpus builds folded to %d volumes", len(corpusSlugs), len(winners))514 }515 for _, slug := range corpusSlugs {516 winner, held := winners[slug]517 if !held {518 t.Errorf("%s did not survive the fold", slug)519 continue520 }521 if winner.Stamp != headers[slug].Stamp {522 t.Errorf("%s is served by %s, not by the corpus build", slug, winner.Stamp)523 }524 }525 if shadowed := bundle.Shadowed(descriptors); len(shadowed) != 0 {526 t.Errorf("%d corpus builds were shadowed, and no two are builds of one volume", len(shadowed))527 }528529 index, err := bundle.MarshalIndex(bundle.BuildIndex(descriptors))530 if err != nil {531 t.Fatal(err)532 }533 for _, key := range []string{`"games":`, `"maps":`} {534 if !bytes.Contains(index, []byte(key)) {535 t.Errorf("the derived index carries no %s; the listing's wire keys predate the volume vocabulary and were never renamed", key)536 }537 }538 var listing struct {539 Volumes []struct {540 Slug string `json:"slug"`541 Versions []struct {542 File string `json:"file"`543 Stamp string `json:"stamp"`544 Worlds int `json:"maps"`545 } `json:"versions"`546 } `json:"games"`547 }548 if err := json.Unmarshal(index, &listing); err != nil {549 t.Fatal(err)550 }551 if len(listing.Volumes) != len(corpusSlugs) {552 t.Errorf("the index lists %d volumes of %d", len(listing.Volumes), len(corpusSlugs))553 }554 for _, listed := range listing.Volumes {555 header := headers[listed.Slug]556 if len(listed.Versions) != 1 {557 t.Errorf("%s is listed with %d builds", listed.Slug, len(listed.Versions))558 continue559 }560 build := listed.Versions[0]561 if build.File != header.File || build.Stamp != header.Stamp || build.Worlds != header.Counts.Worlds {562 t.Errorf("%s is listed as %s/%s with %d worlds", listed.Slug, build.File, build.Stamp, build.Worlds)563 }564 }565}566567// The extractions are read together and held to each other: the icon and tile568// inventories must sum to the rollups they carry, every header must count569// what its rows hold, and volume.json must agree with the manifest world for570// world. A corpus that disagrees with itself is not an oracle, and this is571// the check that says it still is one.572func TestCorpusInventoriesAgreeWithTheirHeaders(t *testing.T) {573 for _, slug := range corpusSlugs {574 t.Run(slug, func(t *testing.T) {575 header := readCorpusHeader(t, slug)576577 iconsPath := filepath.Join(corpusDir(slug), "icons.json")578 var iconsHeader struct {579 Volume string `json:"volume"`580 Count int `json:"count"`581 Rollup string `json:"rollup"`582 }583 readCorpusJSON(t, iconsPath, &iconsHeader)584 var icons []iconRow585 corpusRows(t, iconsPath, "icons", &icons)586 if iconsHeader.Volume != slug || iconsHeader.Count != len(icons) || len(icons) != header.Counts.Icons {587 t.Errorf("the icon inventory holds %d rows, counts %d, and volume.json counts %d",588 len(icons), iconsHeader.Count, header.Counts.Icons)589 }590 lines := make([]string, 0, len(icons))591 for _, icon := range icons {592 lines = append(lines, icon.Name+" "+icon.SHA256)593 }594 if got := rollup(lines); got != iconsHeader.Rollup {595 t.Errorf("the icon inventory sums to %s and its header says %s", got, iconsHeader.Rollup)596 }597598 var tiles int599 for _, pyramid := range header.Pyramids {600 path := filepath.Join(corpusDir(slug), filepath.FromSlash(pyramid.Inventory))601 var inventory struct {602 Pyramid string `json:"pyramid"`603 Count int `json:"count"`604 Bytes int64 `json:"bytes"`605 Content string `json:"contentRollup"`606 Pixels string `json:"pixelRollup"`607 }608 readCorpusJSON(t, path, &inventory)609 if inventory.Pyramid != pyramid.Pyramid || inventory.Count != pyramid.Tiles ||610 inventory.Bytes != pyramid.Bytes || inventory.Content != pyramid.Content ||611 inventory.Pixels != pyramid.Pixels {612 t.Errorf("pyramid %s describes itself as %d tiles weighing %d, and volume.json says %d weighing %d",613 pyramid.Pyramid, inventory.Count, inventory.Bytes, pyramid.Tiles, pyramid.Bytes)614 }615616 var rows []struct {617 Name string `json:"name"`618 Bytes int64 `json:"bytes"`619 SHA256 string `json:"sha256"`620 Pixels string `json:"px"`621 }622 corpusRows(t, path, "tiles", &rows)623 tiles += len(rows)624625 var weight int64626 content := make([]string, 0, len(rows))627 pixels := make([]string, 0, len(rows))628 for _, tile := range rows {629 weight += tile.Bytes630 content = append(content, tile.Name+" "+tile.SHA256)631 pixels = append(pixels, tile.Name+" "+tile.Pixels)632 }633 if len(rows) != pyramid.Tiles || weight != pyramid.Bytes {634 t.Errorf("pyramid %s inventories %d tiles weighing %d, and its header says %d weighing %d",635 pyramid.Pyramid, len(rows), weight, pyramid.Tiles, pyramid.Bytes)636 }637 if got := rollup(content); got != pyramid.Content {638 t.Errorf("pyramid %s sums to %s and its header says %s", pyramid.Pyramid, got, pyramid.Content)639 }640 if got := rollup(pixels); got != pyramid.Pixels {641 t.Errorf("pyramid %s pixel-sums to %s and its header says %s", pyramid.Pyramid, got, pyramid.Pixels)642 }643 }644 if tiles != header.Counts.Tiles {645 t.Errorf("the tile inventories hold %d rows and volume.json counts %d", tiles, header.Counts.Tiles)646 }647648 manifest, _ := readCorpusManifest(t, slug)649 if len(header.Worlds) != len(manifest.Worlds) {650 t.Fatalf("volume.json describes %d worlds and the manifest lists %d",651 len(header.Worlds), len(manifest.Worlds))652 }653 for index, world := range header.Worlds {654 entry := manifest.Worlds[index]655 if world.Slug != entry.Slug || world.Points != entry.Points ||656 world.Paths != entry.Paths || world.Areas != entry.Areas {657 t.Errorf("volume.json describes world %s as %d/%d/%d and the manifest says %s as %d/%d/%d",658 world.Slug, world.Points, world.Paths, world.Areas,659 entry.Slug, entry.Points, entry.Paths, entry.Areas)660 continue661 }662 var payload struct {663 Collections []json.RawMessage `json:"collections"`664 }665 readCorpusJSON(t, filepath.Join(corpusDir(slug), "worlds", world.Slug+".payload.json"), &payload)666 if len(payload.Collections) != world.Collections {667 t.Errorf("world %s inlines %d collections and volume.json counts %d",668 world.Slug, len(payload.Collections), world.Collections)669 }670 var text map[string]json.RawMessage671 readCorpusJSON(t, filepath.Join(corpusDir(slug), "worlds", world.Slug+".text.json"), &text)672 if len(text) != world.TextEntries {673 t.Errorf("world %s carries %d text entries and volume.json counts %d",674 world.Slug, len(text), world.TextEntries)675 }676 }677 })678 }679}