Vellum/internal/modules/photos/photos_test.go
mithral 0212d089ec Phase 6c: photo thumbnails and viewer
- photos: generates a downscaled JPEG thumbnail per image, because serving a
  folder of 12-megapixel originals to a grid is unusable on a TV box. Uses
  CatmullRom scaling via golang.org/x/image; decodes jpeg/png/gif/webp.
- an undecodable format returns no thumbnail rather than an error, so the grid
  falls back to a placeholder.
- images already smaller than the 512px ceiling are passed through untouched.
- library items endpoint now returns library_id, which the viewer needs to
  find its siblings for navigation.
- web: PhotoViewer with keyboard and button navigation within the library.

Verified: 900x1200 PNG thumbnailed to a 512-long-edge JPEG and served;
unit tests assert exact dimensions, aspect preservation, and that non-images
produce no thumbnail without erroring.
2026-10-09 21:22:30 -04:00

112 lines
2.5 KiB
Go

package photos
import (
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/media"
)
func writePNG(t *testing.T, w, h int) string {
t.Helper()
p := filepath.Join(t.TempDir(), "photo.png")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return p
}
func decode(t *testing.T, path string) image.Image {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatal(err)
}
return img
}
func TestExtractCoverDownscales(t *testing.T) {
src := writePNG(t, 1000, 1500)
cacheDir := t.TempDir()
item := media.Item{ID: 7, Kind: Kind}
out, err := ExtractCover(context.Background(), nil, item, src, cacheDir)
if err != nil {
t.Fatalf("ExtractCover: %v", err)
}
if out == "" {
t.Fatal("no thumbnail produced")
}
img := decode(t, out)
b := img.Bounds()
if b.Dx() > thumbMax || b.Dy() > thumbMax {
t.Errorf("thumbnail is %dx%d, exceeds %d", b.Dx(), b.Dy(), thumbMax)
}
// Aspect ratio must be preserved: 1000x1500 -> 341x512.
if b.Dy() != thumbMax {
t.Errorf("long edge = %d, want %d", b.Dy(), thumbMax)
}
if b.Dx() != 341 {
t.Errorf("short edge = %d, want 341", b.Dx())
}
// It must actually be a JPEG.
f, err := os.Open(out)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := jpeg.Decode(f); err != nil {
t.Errorf("output is not a JPEG: %v", err)
}
}
func TestExtractCoverLeavesSmallImagesAlone(t *testing.T) {
src := writePNG(t, 100, 80)
out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, src, t.TempDir())
if err != nil {
t.Fatalf("ExtractCover: %v", err)
}
img := decode(t, out)
if img.Bounds().Dx() != 100 || img.Bounds().Dy() != 80 {
t.Errorf("small image was resized to %v", img.Bounds())
}
}
func TestExtractCoverIgnoresNonImage(t *testing.T) {
p := filepath.Join(t.TempDir(), "not-an-image.jpg")
if err := os.WriteFile(p, []byte("definitely not an image"), 0o644); err != nil {
t.Fatal(err)
}
out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, p, t.TempDir())
if err != nil {
t.Fatalf("a non-image must not error: %v", err)
}
if out != "" {
t.Errorf("expected no thumbnail, got %q", out)
}
}