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)
}
}