1package crawl23import (4 "encoding/json"5 "os"6 "path/filepath"7 "testing"8)910func TestDirectoryName(t *testing.T) {11 tests := []struct {12 title string13 id int6414 want string15 }{16 {"Skyrim", 59, "skyrim-59"},17 {"L.A. Noire", 59, "l-a-noire-59"},18 {"Pokémon Red/Blue/Yellow", 59, "pokemon-red-blue-yellow-59"},19 {"Assassin's Creed (Resynced)", 59, "assassin-s-creed-resynced-59"},20 {"IGN Cyberpunk 2077", 4295212345, "ign-cyberpunk-2077-4295212345"},21 }22 for _, test := range tests {23 if got := DirectoryName(test.title, test.id); got != test.want {24 t.Errorf("%q/%d named %q, want %q", test.title, test.id, got, test.want)25 }26 }27}2829// The property everything replayable downstream stands on: a re-crawl of30// unchanged bytes appends nothing, so capturedAt is first-seen and a rebuild31// computes the same stamp.32func TestAnUnchangedCaptureRecordsNothing(t *testing.T) {33 root := t.TempDir()34 store, err := Open(root)35 if err != nil {36 t.Fatal(err)37 }38 world := filepath.Join(root, "games", "thing-1", "maps", "world-2")39 body := []byte(`{"source":"test","hello":"world"}`)4041 first, fresh, err := store.WriteCapture(world, Capture{Kind: "test", Body: body})42 if err != nil {43 t.Fatal(err)44 }45 if !fresh {46 t.Fatal("the first sight of a capture recorded nothing")47 }48 index := filepath.Join(world, "snapshots", "index.json")49 before, err := os.ReadFile(index)50 if err != nil {51 t.Fatal(err)52 }5354 again, fresh, err := store.WriteCapture(world, Capture{Kind: "test", Body: body})55 if err != nil {56 t.Fatal(err)57 }58 if fresh {59 t.Error("re-crawling unchanged bytes recorded a second capture")60 }61 if again != first {62 t.Errorf("the same bytes addressed as %s then %s", first, again)63 }64 after, err := os.ReadFile(index)65 if err != nil {66 t.Fatal(err)67 }68 if string(after) != string(before) {69 t.Errorf("the index moved:\n%s\n%s", before, after)70 }7172 // Different bytes do record.73 if _, fresh, err = store.WriteCapture(world, Capture{74 Kind: "test", Body: []byte(`{"source":"test","hello":"elsewhere"}`),75 }); err != nil || !fresh {76 t.Errorf("changed bytes recorded nothing (fresh=%t, err=%v)", fresh, err)77 }78}7980// A register entry is found by identity, merged in place, and never renamed --81// which is what keeps a directory named once named forever, and what lets a82// field this package has never heard of survive a run.83func TestRegisteringAVolumeTwiceKeepsItsDirectory(t *testing.T) {84 root := t.TempDir()85 store, err := Open(root)86 if err != nil {87 t.Fatal(err)88 }89 volume := Volume{ID: 7, Title: "Cyberpunk 2077", Source: "ign", DirectoryTitle: "IGN Cyberpunk 2077"}90 first, err := store.RegisterVolume(volume)91 if err != nil {92 t.Fatal(err)93 }94 if filepath.Base(first) != "ign-cyberpunk-2077-7" {95 t.Errorf("first sight opened %s", first)96 }9798 // A stranger's field, and a run that would have named the directory99 // differently.100 register := filepath.Join(root, "archive.json")101 var held map[string]any102 data, err := os.ReadFile(register)103 if err != nil {104 t.Fatal(err)105 }106 if err := json.Unmarshal(data, &held); err != nil {107 t.Fatal(err)108 }109 held["games"].([]any)[0].(map[string]any)["curatedBy"] = "somebody"110 data, _ = json.MarshalIndent(held, "", " ")111 if err := os.WriteFile(register, data, 0o644); err != nil {112 t.Fatal(err)113 }114115 volume.DirectoryTitle = "Something Else Entirely"116 again, err := store.RegisterVolume(volume)117 if err != nil {118 t.Fatal(err)119 }120 if again != first {121 t.Errorf("a second run renamed the directory to %s", again)122 }123 data, _ = os.ReadFile(register)124 if err := json.Unmarshal(data, &held); err != nil {125 t.Fatal(err)126 }127 if held["games"].([]any)[0].(map[string]any)["curatedBy"] != "somebody" {128 t.Error("a field the crawler has never heard of was dropped")129 }130}131132// The tile index resumes a run: a numbered directory a previous run assigned is133// recovered rather than reassigned, or every tile already on disk is orphaned.134func TestTileIndexRecoversItsSetIDs(t *testing.T) {135 root := t.TempDir()136 store, err := Open(root)137 if err != nil {138 t.Fatal(err)139 }140 world := filepath.Join(root, "w")141 index, err := store.OpenTileIndex(world, 42)142 if err != nil {143 t.Fatal(err)144 }145 const scope = "cyberpunk-2077/night-city"146 id := index.SetID(scope)147 index.Put(TileRecord{148 URL: "https://x/wikimaps/" + scope + "/3/1/2.jpg",149 Zoom: 3,150 X: 1,151 Y: 2,152 Status: StatusCached,153 TileSetID: id,154 })155 if err := index.Close(world); err != nil {156 t.Fatal(err)157 }158159 resumed, err := store.OpenTileIndex(world, 42)160 if err != nil {161 t.Fatal(err)162 }163 if got := resumed.SetID(scope); got != id {164 t.Errorf("a resumed run numbered %s %d, the first run numbered it %d", scope, got, id)165 }166 if _, known := resumed.Held("https://x/wikimaps/" + scope + "/3/1/2.jpg"); !known {167 t.Error("a resumed run did not find the tile the first run cached")168 }169 if want := "42:" + itoa(id) + ":3:1:2"; resumed.CoverageKey(id, 3, 1, 2) != want {170 t.Errorf("coverage key %q, want %q", resumed.CoverageKey(id, 3, 1, 2), want)171 }172}173174func itoa(v int64) string {175 if v == 0 {176 return "0"177 }178 var digits []byte179 for v > 0 {180 digits = append([]byte{byte('0' + v%10)}, digits...)181 v /= 10182 }183 return string(digits)184}