git/intake/main
internal/artwork/visual_test.go
152 lines4 KB last change raw · history
package artwork

import (
	"image"
	"image/color"
	"image/jpeg"
	"image/png"
	"os"
	"path/filepath"
	"testing"
)

// picture draws something with structure, so a difference hash has real
// gradients to work with rather than one flat colour.
func picture(n int, variant int) image.Image {
	img := image.NewRGBA(image.Rect(0, 0, n, n))
	for y := 0; y < n; y++ {
		for x := 0; x < n; x++ {
			r := uint8((x * 255 / n) % 256)
			g := uint8((y * 255 / n) % 256)
			b := uint8(0)
			switch variant {
			case 1:
				b = uint8((x + y) * 255 / (2 * n))
			case 2:
				// a visibly different picture: a checker pattern with the
				// gradients reversed, so it differs in both directions
				if (x/(n/8)+y/(n/8))%2 == 0 {
					r, g = 255-r, 255-g
				}
				b = 200
			}
			img.Set(x, y, color.RGBA{r, g, b, 255})
		}
	}
	return img
}

func savePNG(t *testing.T, path string, img image.Image) {
	t.Helper()
	f, err := os.Create(path)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	if err := png.Encode(f, img); err != nil {
		t.Fatal(err)
	}
}

func saveJPEG(t *testing.T, path string, img image.Image) {
	t.Helper()
	f, err := os.Create(path)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	if err := jpeg.Encode(f, img, nil); err != nil {
		t.Fatal(err)
	}
}

// The same artwork at two sizes or in another format is one picture, and the
// point of the change is that the picker should say so.
func TestVisualHashSeesTheSamePictureAtAnotherSize(t *testing.T) {
	dir := t.TempDir()
	small := filepath.Join(dir, "small.png")
	large := filepath.Join(dir, "large.png")
	reencoded := filepath.Join(dir, "reencoded.jpg")

	savePNG(t, small, picture(300, 1))
	savePNG(t, large, picture(1200, 1))
	saveJPEG(t, reencoded, picture(800, 1))

	hs, ok := visualHash(small)
	if !ok {
		t.Fatal("could not hash the small image")
	}
	hl, _ := visualHash(large)
	hr, _ := visualHash(reencoded)

	if !hs.near(hl) {
		t.Errorf("300px and 1200px versions of one picture read as different")
	}
	if !hs.near(hr) {
		t.Errorf("a jpeg re-encode read as a different picture")
	}
}

func TestVisualHashSeparatesDifferentPictures(t *testing.T) {
	dir := t.TempDir()
	a := filepath.Join(dir, "a.png")
	b := filepath.Join(dir, "b.png")
	savePNG(t, a, picture(400, 1))
	savePNG(t, b, picture(400, 2))

	ha, _ := visualHash(a)
	hb, _ := visualHash(b)
	if ha.near(hb) {
		t.Errorf("two different pictures were read as the same")
	}
}

func TestVisualHashOnSomethingThatIsNotAnImage(t *testing.T) {
	dir := t.TempDir()
	junk := filepath.Join(dir, "cover.jpg")
	if err := os.WriteFile(junk, []byte("not an image"), 0o600); err != nil {
		t.Fatal(err)
	}
	if _, ok := visualHash(junk); ok {
		t.Error("nonsense should not produce a hash")
	}
	if _, ok := visualHash(filepath.Join(dir, "missing.png")); ok {
		t.Error("a missing file should not produce a hash")
	}
}

// The picker offered the same sleeve several times at several sizes. Dedupe
// works on what the picture looks like, so those collapse to one entry.
func TestDedupeCollapsesTheSameArtworkAtDifferentSizes(t *testing.T) {
	dir := t.TempDir()
	var in []Candidate
	for i, n := range []int{600, 1200, 800} {
		p := filepath.Join(dir, string(rune('a'+i))+".png")
		savePNG(t, p, picture(n, 1))
		in = append(in, Candidate{
			Source: SourceCoverArt, Label: "coverartarchive",
			Path: p, Width: n, Height: n,
		})
	}
	// and a genuinely different cover, which must survive
	other := filepath.Join(dir, "other.png")
	savePNG(t, other, picture(1000, 2))
	in = append(in, Candidate{Source: SourceDeezer, Label: "deezer", Path: other, Width: 1000, Height: 1000})

	got := dedupe(in)
	if len(got) != 2 {
		t.Fatalf("deduped to %d candidates, want 2: %s", len(got), describe(got))
	}
	if got[0].Width != 600 {
		t.Errorf("kept the %dpx copy; the first one should win", got[0].Width)
	}
}

func describe(cs []Candidate) string {
	s := ""
	for _, c := range cs {
		s += c.Path + " "
	}
	return s
}