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}