package library import ( "errors" "io" "os" "path/filepath" "strings" "testing" "time" "gitbay.org/cmc/gallery/internal/format" ) type fakeFormat struct { meta map[string]format.Meta // by base name fail map[string]bool calls int } func (f *fakeFormat) Name() string { return "fake" } func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") } func (f *fakeFormat) Kind() format.Kind { return format.KindImage } func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil } func (f *fakeFormat) Metadata(p string) (format.Meta, error) { f.calls++ name := filepath.Base(p) if f.fail[name] { return format.Meta{}, errors.New("bad file") } if m, ok := f.meta[name]; ok { return m, nil } return format.Meta{Width: 300, Height: 200}, nil } func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) } func writeTree(t *testing.T, files map[string]string) string { t.Helper() root := t.TempDir() for rel, body := range files { p := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } return root } func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library { t.Helper() if cache == nil { var err error if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil { t.Fatal(err) } } reg := &format.Registry{} reg.Register(f) lib, err := Scan(root, reg, cache) if err != nil { t.Fatal(err) } return lib } func names(items []*Item) string { var s []string for _, it := range items { s = append(s, it.Name) } return strings.Join(s, ",") } func slugs(as []*Album) string { var s []string for _, a := range as { s = append(s, a.Slug) } return strings.Join(s, ",") } func TestScanSkipsAndSorts(t *testing.T) { root := writeTree(t, map[string]string{ "tokyo/b.jpg": "x", "tokyo/a.jpg": "x", "tokyo/.hidden.jpg": "x", "tokyo/notes.txt": "x", "tokyo/sub/c.jpg": "x", ".git/x.jpg": "x", "img/x.jpg": "x", "info/x.jpg": "x", "_/x.jpg": "x", "timeline/x.jpg": "x", "empty/notes.txt": "x", "loose.jpg": "x", }) f := &fakeFormat{meta: map[string]format.Meta{ "a.jpg": {Width: 300, Height: 200, Taken: day(2)}, "b.jpg": {Width: 300, Height: 200, Taken: day(1)}, }} lib := scanWith(t, root, f, nil) if got := slugs(lib.Albums); got != "tokyo" { t.Fatalf("albums = %s", got) } a := lib.Album("tokyo") if got := names(a.Items); got != "b.jpg,a.jpg" { t.Fatalf("items = %s", got) } if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" { t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year()) } if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") { t.Fatalf("Item(a.jpg) = %d, %+v", i, it) } if i, it := a.Item("nope.jpg"); i != -1 || it != nil { t.Fatal("Item(nope.jpg) should be absent") } if lib.Album("nope") != nil { t.Fatal("Album(nope) should be nil") } } func TestScanAlbumOrg(t *testing.T) { root := writeTree(t, map[string]string{ "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n", "tokyo/b.jpg": "x", "tokyo/a.jpg": "x", }) f := &fakeFormat{meta: map[string]format.Meta{ "a.jpg": {Width: 1, Height: 1, Taken: day(2)}, "b.jpg": {Width: 1, Height: 1, Taken: day(1)}, }} a := scanWith(t, root, f, nil).Album("tokyo") if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" { t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year()) } if got := names(a.Items); got != "a.jpg,b.jpg" { t.Fatalf("sort name: items = %s", got) } if a.Cover.Name != "b.jpg" { t.Fatalf("cover = %s", a.Cover.Name) } if !strings.Contains(string(a.Description), "quiet") { t.Fatalf("description = %s", a.Description) } } func TestScanAlbumOrdering(t *testing.T) { root := writeTree(t, map[string]string{ "a/album.org": "#+order: 2\n", "a/x.jpg": "x", "b/album.org": "#+order: 1\n", "b/x.jpg": "x", "c/album.org": "#+date: 2024\n", "c/x.jpg": "x", "d/album.org": "#+date: 2025-06\n", "d/x.jpg": "x", "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03 }) f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}} if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" { t.Fatalf("albums = %s", got) } } func TestScanTakenFallsBackToModTime(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC) os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt) a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo") if !a.Items[0].Taken.Equal(mt) { t.Fatalf("Taken = %v, want %v", a.Items[0].Taken, mt) } if !a.Items[0].Meta.Taken.IsZero() { t.Fatalf("Meta.Taken = %v, want zero", a.Items[0].Meta.Taken) } } func TestScanSkipsFailedMetadata(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"}) a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo") if got := names(a.Items); got != "a.jpg" { t.Fatalf("items = %s", got) } } func TestScanUsesMetaCache(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"}) cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) f := &fakeFormat{} scanWith(t, root, f, cache) scanWith(t, root, f, cache) if f.calls != 2 { t.Fatalf("calls after two scans = %d, want 2", f.calls) } later := time.Now().Add(time.Hour) os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later) scanWith(t, root, f, cache) if f.calls != 3 { t.Fatalf("calls after touch = %d, want 3", f.calls) } } func TestScanAbout(t *testing.T) { root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"}) lib := scanWith(t, root, &fakeFormat{}, nil) if !strings.Contains(string(lib.About), "there") { t.Fatalf("About = %s", lib.About) } root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" { t.Fatalf("About without file = %q", lib.About) } } func TestScanIncomplete(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) cachePath := filepath.Join(t.TempDir(), "meta.json") cache, err := OpenMetaCache(cachePath) if err != nil { t.Fatal(err) } f := &fakeFormat{} lib := scanWith(t, root, f, cache) if lib.Incomplete { t.Fatal("normal scan should not be Incomplete") } if f.calls != 1 { t.Fatalf("calls = %d, want 1", f.calls) } empty := t.TempDir() lib2 := scanWith(t, empty, f, cache) if !lib2.Incomplete { t.Fatal("empty root scan should be Incomplete") } // The cache entry from the first scan must have survived the incomplete scan. lib3 := scanWith(t, root, f, cache) if lib3.Incomplete { t.Fatal("rescan of the normal root should not be Incomplete") } if f.calls != 1 { t.Fatalf("calls after rescan = %d, want 1 (cache entry should have survived the incomplete scan)", f.calls) } } func TestStore(t *testing.T) { var s Store if s.Load() != nil { t.Fatal("empty store should load nil") } lib := &Library{} s.Set(lib) if s.Load() != lib { t.Fatal("Load after Set") } } func TestTimelineGroupsByMonthNewestFirst(t *testing.T) { root := writeTree(t, map[string]string{ "a/x1.jpg": "x", "a/x2.jpg": "x", "b/y1.jpg": "x", "b/y2.jpg": "x", }) f := &fakeFormat{meta: map[string]format.Meta{ "x1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)}, "x2.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 10, 12, 0, 0, 0, time.UTC)}, "y1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 20, 12, 0, 0, 0, time.UTC)}, "y2.jpg": {Width: 1, Height: 1, Taken: time.Date(2024, 12, 31, 12, 0, 0, 0, time.UTC)}, }} lib := scanWith(t, root, f, nil) var got []string for _, m := range lib.Timeline { got = append(got, m.Time.Format("2006-01")+":"+names(m.Items)) } want := "2025-05:y1.jpg,x2.jpg 2025-03:x1.jpg 2024-12:y2.jpg" if strings.Join(got, " ") != want { t.Fatalf("timeline = %s\nwant %s", strings.Join(got, " "), want) } }