internal/library/scan_test.go
287 lines · 8492 bytes
1package library
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/cmc/gallery/internal/format"
13)
14
15type fakeFormat struct {
16 meta map[string]format.Meta // by base name
17 fail map[string]bool
18 calls int
19}
20
21func (f *fakeFormat) Name() string { return "fake" }
22func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") }
23func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
24func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil }
25func (f *fakeFormat) Metadata(p string) (format.Meta, error) {
26 f.calls++
27 name := filepath.Base(p)
28 if f.fail[name] {
29 return format.Meta{}, errors.New("bad file")
30 }
31 if m, ok := f.meta[name]; ok {
32 return m, nil
33 }
34 return format.Meta{Width: 300, Height: 200}, nil
35}
36
37func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) }
38
39func writeTree(t *testing.T, files map[string]string) string {
40 t.Helper()
41 root := t.TempDir()
42 for rel, body := range files {
43 p := filepath.Join(root, rel)
44 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
45 t.Fatal(err)
46 }
47 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
48 t.Fatal(err)
49 }
50 }
51 return root
52}
53
54func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library {
55 t.Helper()
56 if cache == nil {
57 var err error
58 if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil {
59 t.Fatal(err)
60 }
61 }
62 reg := &format.Registry{}
63 reg.Register(f)
64 lib, err := Scan(root, reg, cache)
65 if err != nil {
66 t.Fatal(err)
67 }
68 return lib
69}
70
71func names(items []*Item) string {
72 var s []string
73 for _, it := range items {
74 s = append(s, it.Name)
75 }
76 return strings.Join(s, ",")
77}
78
79func slugs(as []*Album) string {
80 var s []string
81 for _, a := range as {
82 s = append(s, a.Slug)
83 }
84 return strings.Join(s, ",")
85}
86
87func TestScanSkipsAndSorts(t *testing.T) {
88 root := writeTree(t, map[string]string{
89 "tokyo/b.jpg": "x",
90 "tokyo/a.jpg": "x",
91 "tokyo/.hidden.jpg": "x",
92 "tokyo/notes.txt": "x",
93 "tokyo/sub/c.jpg": "x",
94 ".git/x.jpg": "x",
95 "img/x.jpg": "x",
96 "info/x.jpg": "x",
97 "_/x.jpg": "x",
98 "timeline/x.jpg": "x",
99 "empty/notes.txt": "x",
100 "loose.jpg": "x",
101 })
102 f := &fakeFormat{meta: map[string]format.Meta{
103 "a.jpg": {Width: 300, Height: 200, Taken: day(2)},
104 "b.jpg": {Width: 300, Height: 200, Taken: day(1)},
105 }}
106 lib := scanWith(t, root, f, nil)
107
108 if got := slugs(lib.Albums); got != "tokyo" {
109 t.Fatalf("albums = %s", got)
110 }
111 a := lib.Album("tokyo")
112 if got := names(a.Items); got != "b.jpg,a.jpg" {
113 t.Fatalf("items = %s", got)
114 }
115 if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" {
116 t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year())
117 }
118 if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") {
119 t.Fatalf("Item(a.jpg) = %d, %+v", i, it)
120 }
121 if i, it := a.Item("nope.jpg"); i != -1 || it != nil {
122 t.Fatal("Item(nope.jpg) should be absent")
123 }
124 if lib.Album("nope") != nil {
125 t.Fatal("Album(nope) should be nil")
126 }
127}
128
129func TestScanAlbumOrg(t *testing.T) {
130 root := writeTree(t, map[string]string{
131 "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n",
132 "tokyo/b.jpg": "x",
133 "tokyo/a.jpg": "x",
134 })
135 f := &fakeFormat{meta: map[string]format.Meta{
136 "a.jpg": {Width: 1, Height: 1, Taken: day(2)},
137 "b.jpg": {Width: 1, Height: 1, Taken: day(1)},
138 }}
139 a := scanWith(t, root, f, nil).Album("tokyo")
140 if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" {
141 t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year())
142 }
143 if got := names(a.Items); got != "a.jpg,b.jpg" {
144 t.Fatalf("sort name: items = %s", got)
145 }
146 if a.Cover.Name != "b.jpg" {
147 t.Fatalf("cover = %s", a.Cover.Name)
148 }
149 if !strings.Contains(string(a.Description), "<em>quiet</em>") {
150 t.Fatalf("description = %s", a.Description)
151 }
152}
153
154func TestScanAlbumOrdering(t *testing.T) {
155 root := writeTree(t, map[string]string{
156 "a/album.org": "#+order: 2\n",
157 "a/x.jpg": "x",
158 "b/album.org": "#+order: 1\n",
159 "b/x.jpg": "x",
160 "c/album.org": "#+date: 2024\n",
161 "c/x.jpg": "x",
162 "d/album.org": "#+date: 2025-06\n",
163 "d/x.jpg": "x",
164 "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03
165 })
166 f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}}
167 if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" {
168 t.Fatalf("albums = %s", got)
169 }
170}
171
172func TestScanTakenFallsBackToModTime(t *testing.T) {
173 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
174 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
175 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
176 a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo")
177 if !a.Items[0].Taken.Equal(mt) {
178 t.Fatalf("Taken = %v, want %v", a.Items[0].Taken, mt)
179 }
180 if !a.Items[0].Meta.Taken.IsZero() {
181 t.Fatalf("Meta.Taken = %v, want zero", a.Items[0].Meta.Taken)
182 }
183}
184
185func TestScanSkipsFailedMetadata(t *testing.T) {
186 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"})
187 a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo")
188 if got := names(a.Items); got != "a.jpg" {
189 t.Fatalf("items = %s", got)
190 }
191}
192
193func TestScanUsesMetaCache(t *testing.T) {
194 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"})
195 cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
196 f := &fakeFormat{}
197 scanWith(t, root, f, cache)
198 scanWith(t, root, f, cache)
199 if f.calls != 2 {
200 t.Fatalf("calls after two scans = %d, want 2", f.calls)
201 }
202 later := time.Now().Add(time.Hour)
203 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later)
204 scanWith(t, root, f, cache)
205 if f.calls != 3 {
206 t.Fatalf("calls after touch = %d, want 3", f.calls)
207 }
208}
209
210func TestScanAbout(t *testing.T) {
211 root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"})
212 lib := scanWith(t, root, &fakeFormat{}, nil)
213 if !strings.Contains(string(lib.About), "<em>there</em>") {
214 t.Fatalf("About = %s", lib.About)
215 }
216 root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
217 if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" {
218 t.Fatalf("About without file = %q", lib.About)
219 }
220}
221
222func TestScanIncomplete(t *testing.T) {
223 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
224 cachePath := filepath.Join(t.TempDir(), "meta.json")
225 cache, err := OpenMetaCache(cachePath)
226 if err != nil {
227 t.Fatal(err)
228 }
229 f := &fakeFormat{}
230
231 lib := scanWith(t, root, f, cache)
232 if lib.Incomplete {
233 t.Fatal("normal scan should not be Incomplete")
234 }
235 if f.calls != 1 {
236 t.Fatalf("calls = %d, want 1", f.calls)
237 }
238
239 empty := t.TempDir()
240 lib2 := scanWith(t, empty, f, cache)
241 if !lib2.Incomplete {
242 t.Fatal("empty root scan should be Incomplete")
243 }
244
245 // The cache entry from the first scan must have survived the incomplete scan.
246 lib3 := scanWith(t, root, f, cache)
247 if lib3.Incomplete {
248 t.Fatal("rescan of the normal root should not be Incomplete")
249 }
250 if f.calls != 1 {
251 t.Fatalf("calls after rescan = %d, want 1 (cache entry should have survived the incomplete scan)", f.calls)
252 }
253}
254
255func TestStore(t *testing.T) {
256 var s Store
257 if s.Load() != nil {
258 t.Fatal("empty store should load nil")
259 }
260 lib := &Library{}
261 s.Set(lib)
262 if s.Load() != lib {
263 t.Fatal("Load after Set")
264 }
265}
266
267func TestTimelineGroupsByMonthNewestFirst(t *testing.T) {
268 root := writeTree(t, map[string]string{
269 "a/x1.jpg": "x", "a/x2.jpg": "x",
270 "b/y1.jpg": "x", "b/y2.jpg": "x",
271 })
272 f := &fakeFormat{meta: map[string]format.Meta{
273 "x1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)},
274 "x2.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 10, 12, 0, 0, 0, time.UTC)},
275 "y1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 20, 12, 0, 0, 0, time.UTC)},
276 "y2.jpg": {Width: 1, Height: 1, Taken: time.Date(2024, 12, 31, 12, 0, 0, 0, time.UTC)},
277 }}
278 lib := scanWith(t, root, f, nil)
279 var got []string
280 for _, m := range lib.Timeline {
281 got = append(got, m.Time.Format("2006-01")+":"+names(m.Items))
282 }
283 want := "2025-05:y1.jpg,x2.jpg 2025-03:x1.jpg 2024-12:y2.jpg"
284 if strings.Join(got, " ") != want {
285 t.Fatalf("timeline = %s\nwant %s", strings.Join(got, " "), want)
286 }
287}