Skip to content

Commit

Permalink
resources/images: Refactor golden image tests to locate them closer t…
Browse files Browse the repository at this point in the history
…o the implementation
  • Loading branch information
bep committed Jan 11, 2025
1 parent 06cc867 commit 2501de7
Show file tree
Hide file tree
Showing 59 changed files with 306 additions and 240 deletions.
289 changes: 49 additions & 240 deletions resources/images/images_golden_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,63 +14,22 @@
package images_test

import (
"image"
"image/gif"
_ "image/jpeg"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/disintegration/gift"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/hashing"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib"
"github.com/google/go-cmp/cmp"
"github.com/gohugoio/hugo/resources/images/imagetesting"
)

var eq = qt.CmpEquals(
cmp.Comparer(func(p1, p2 os.FileInfo) bool {
return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir()
}),
cmp.Comparer(func(d1, d2 fs.DirEntry) bool {
p1, err1 := d1.Info()
p2, err2 := d2.Info()
if err1 != nil || err2 != nil {
return false
}
return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir()
}),
)

var goldenOpts = struct {
// Toggle this to write golden files to disk.
// Note: Remember to set this to false before committing.
writeGoldenFiles bool

// This will skip any assertions. Useful when adding new golden variants to a test.
devMode bool
}{
writeGoldenFiles: false,
devMode: false,
}

// Note, if you're enabling writeGoldenFiles on a MacOS ARM 64 you need to run the test with GOARCH=amd64, e.g.
// GOARCH=amd64 go test -count 1 -timeout 30s -run "^TestGolden" ./resources/images
func TestGoldenFiltersMisc(t *testing.T) {
func TestImagesGoldenFiltersMisc(t *testing.T) {
t.Parallel()

if skipGolden {
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}

// Will be used to generate golden files.
name := "filters_misc"
// Will be used as the base folder for generated images.
name := "filters/misc"

files := `
-- hugo.toml --
Expand All @@ -82,9 +41,9 @@ sourcefilename: ../testdata/sunset.jpg
sourcefilename: ../testdata/gopher-hero8.png
-- layouts/index.html --
Home.
{{ $sunset := resources.Get "sunset.jpg" }}
{{ $sunset := (resources.Get "sunset.jpg").Resize "x300" }}
{{ $sunsetGrayscale := $sunset.Filter (images.Grayscale) }}
{{ $gopher := resources.Get "gopher.png" }}
{{ $gopher := (resources.Get "gopher.png").Resize "x80" }}
{{ $overlayFilter := images.Overlay $gopher 20 20 }}
{{ $textOpts := dict
Expand Down Expand Up @@ -130,18 +89,23 @@ Home.
{{ end }}
`

runGolden(t, name, files)
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files

imagetesting.RunGolden(opts)
}

func TestGoldenFiltersMask(t *testing.T) {
func TestImagesGoldenFiltersMask(t *testing.T) {
t.Parallel()

if skipGolden {
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}

// Will be used to generate golden files.
name := "filters_mask"
// Will be used as the base folder for generated images.
name := "filters/mask"

files := `
-- hugo.toml --
Expand All @@ -163,15 +127,20 @@ Home.
{{ template "mask" (dict "name" "transparant.png" "base" $sunset "mask" $mask) }}
{{ template "mask" (dict "name" "yellow.jpg" "base" $sunset "mask" $mask) }}
{{ template "mask" (dict "name" "wide.jpg" "base" $sunset "mask" $mask "spec" "resize 600x200") }}
{{/* This looks a little odd, but is correct and the recommended way to do this.
This will 1. Scale the image to x300, 2. Apply the mask, 3. Create the final image with background color #323ea.
It's possible to have multiple images.Process filters in the chain, but for the options for the final image (target format, bgGolor etc.),
the last entry will win.
*/}}
{{ template "mask" (dict "name" "blue.jpg" "base" $sunset "mask" $mask "spec" "resize x300 #323ea8") }}
{{ define "mask"}}
{{ $ext := path.Ext .name }}
{{ if lt (len (path.Ext .name)) 4 }}
{{ errorf "No extension in %q" .name }}
{{ end }}
{{ $format := strings.TrimPrefix "." $ext }}
{{ $spec := .spec | default (printf "resize 300x300 %s" $format) }}
{{ $spec := .spec | default (printf "resize x300 %s" $format) }}
{{ $filters := slice (images.Process $spec) (images.Mask .mask) }}
{{ $name := printf "images/%s" .name }}
{{ $img := .base.Filter $filters }}
Expand All @@ -181,18 +150,23 @@ Home.
{{ end }}
`

runGolden(t, name, files)
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files

imagetesting.RunGolden(opts)
}

func TestGoldenFiltersText(t *testing.T) {
func TestImagesGoldenFiltersText(t *testing.T) {
t.Parallel()

if skipGolden {
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}

// Will be used to generate golden files.
name := "filters_text"
// Will be used as the base folder for generated images.
name := "filters/text"

files := `
-- hugo.toml --
Expand Down Expand Up @@ -230,18 +204,23 @@ Home.
{{ end }}
`

runGolden(t, name, files)
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files

imagetesting.RunGolden(opts)
}

func TestGoldenProcessMisc(t *testing.T) {
func TestImagesGoldenProcessMisc(t *testing.T) {
t.Parallel()

if skipGolden {
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}

// Will be used to generate golden files.
name := "process_misc"
// Will be used as the base folder for generated images.
name := "process/misc"

files := `
-- hugo.toml --
Expand Down Expand Up @@ -277,180 +256,10 @@ Home.
{{ end }}
`

runGolden(t, name, files)
}

func TestGoldenFuncs(t *testing.T) {
t.Parallel()

if skipGolden {
t.Skip("Skip golden test on this architecture")
}

// Will be used to generate golden files.
name := "funcs"

files := `
-- hugo.toml --
-- assets/sunset.jpg --
sourcefilename: ../testdata/sunset.jpg
-- layouts/index.html --
Home.
{{ template "copy" (dict "name" "qr-default.png" "img" (images.QR "https://gohugo.io")) }}
{{ template "copy" (dict "name" "qr-level-high_scale-6.png" "img" (images.QR "https://gohugo.io" (dict "level" "high" "scale" 6))) }}
{{ define "copy"}}
{{ if lt (len (path.Ext .name)) 4 }}
{{ errorf "No extension in %q" .name }}
{{ end }}
{{ $img := .img }}
{{ $name := printf "images/%s" .name }}
{{ with $img | resources.Copy $name }}
{{ .Publish }}
{{ end }}
{{ end }}
`

runGolden(t, name, files)
}

func runGolden(t testing.TB, name, files string) *hugolib.IntegrationTestBuilder {
t.Helper()

c := hugolib.Test(t, files, hugolib.TestOptWithOSFs()) // hugolib.TestOptWithPrintAndKeepTempDir(true))
c.AssertFileContent("public/index.html", "Home.")

outputDir := filepath.Join(c.H.Conf.WorkingDir(), "public", "images")
goldenBaseDir := filepath.Join("testdata", "images_golden")
goldenDir := filepath.Join(goldenBaseDir, name)
if goldenOpts.writeGoldenFiles {
c.Assert(htesting.IsRealCI(), qt.IsFalse)
c.Assert(os.MkdirAll(goldenBaseDir, 0o777), qt.IsNil)
c.Assert(os.RemoveAll(goldenDir), qt.IsNil)
c.Assert(hugio.CopyDir(hugofs.Os, outputDir, goldenDir, nil), qt.IsNil)
return c
}
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files

if goldenOpts.devMode {
c.Assert(htesting.IsRealCI(), qt.IsFalse)
return c
}

decodeAll := func(f *os.File) []image.Image {
c.Helper()

var images []image.Image

if strings.HasSuffix(f.Name(), ".gif") {
gif, err := gif.DecodeAll(f)
c.Assert(err, qt.IsNil, qt.Commentf(f.Name()))
images = make([]image.Image, len(gif.Image))
for i, img := range gif.Image {
images[i] = img
}
} else {
img, _, err := image.Decode(f)
c.Assert(err, qt.IsNil, qt.Commentf(f.Name()))
images = append(images, img)
}
return images
}

entries1, err := os.ReadDir(outputDir)
c.Assert(err, qt.IsNil)
entries2, err := os.ReadDir(goldenDir)
c.Assert(err, qt.IsNil)
c.Assert(len(entries1), qt.Equals, len(entries2))
for i, e1 := range entries1 {
c.Assert(filepath.Ext(e1.Name()), qt.Not(qt.Equals), "")
func() {
e2 := entries2[i]

f1, err := os.Open(filepath.Join(outputDir, e1.Name()))
c.Assert(err, qt.IsNil)
defer f1.Close()

f2, err := os.Open(filepath.Join(goldenDir, e2.Name()))
c.Assert(err, qt.IsNil)
defer f2.Close()

imgs2 := decodeAll(f2)
imgs1 := decodeAll(f1)
c.Assert(len(imgs1), qt.Equals, len(imgs2))

if !usesFMA {
c.Assert(e1, eq, e2)
_, err = f1.Seek(0, 0)
c.Assert(err, qt.IsNil)
_, err = f2.Seek(0, 0)
c.Assert(err, qt.IsNil)

hash1, _, err := hashing.XXHashFromReader(f1)
c.Assert(err, qt.IsNil)
hash2, _, err := hashing.XXHashFromReader(f2)
c.Assert(err, qt.IsNil)

c.Assert(hash1, qt.Equals, hash2)
}

for i, img1 := range imgs1 {
img2 := imgs2[i]
nrgba1 := image.NewNRGBA(img1.Bounds())
gift.New().Draw(nrgba1, img1)
nrgba2 := image.NewNRGBA(img2.Bounds())
gift.New().Draw(nrgba2, img2)
c.Assert(goldenEqual(nrgba1, nrgba2), qt.Equals, true, qt.Commentf(e1.Name()))
}
}()
}
return c
imagetesting.RunGolden(opts)
}

// goldenEqual compares two NRGBA images. It is used in golden tests only.
// A small tolerance is allowed on architectures using "fused multiply and add"
// (FMA) instruction to accommodate for floating-point rounding differences
// with control golden images that were generated on amd64 architecture.
// See https://golang.org/ref/spec#Floating_point_operators
// and https://github.com/gohugoio/hugo/issues/6387 for more information.
//
// Based on https://github.com/disintegration/gift/blob/a999ff8d5226e5ab14b64a94fca07c4ac3f357cf/gift_test.go#L598-L625
// Copyright (c) 2014-2019 Grigory Dryapak
// Licensed under the MIT License.
func goldenEqual(img1, img2 *image.NRGBA) bool {
maxDiff := 0
if runtime.GOARCH != "amd64" {
// The golden files are created using the AMD64 architecture.
// Be lenient on other platforms due to floaging point and dithering differences.
maxDiff = 15
}
if !img1.Rect.Eq(img2.Rect) {
return false
}
if len(img1.Pix) != len(img2.Pix) {
return false
}
for i := 0; i < len(img1.Pix); i++ {
diff := int(img1.Pix[i]) - int(img2.Pix[i])
if diff < 0 {
diff = -diff
}
if diff > maxDiff {
return false
}
}
return true
}

// We don't have a CI test environment for these, and there are known dithering issues that makes these time consuming to maintain.
var skipGolden = runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" || runtime.GOARCH == "s390x"

// usesFMA indicates whether "fused multiply and add" (FMA) instruction is
// used. The command "grep FMADD go/test/codegen/floats.go" can help keep
// the FMA-using architecture list updated.
var usesFMA = runtime.GOARCH == "s390x" ||
runtime.GOARCH == "ppc64" ||
runtime.GOARCH == "ppc64le" ||
runtime.GOARCH == "arm64" ||
runtime.GOARCH == "riscv64"
Loading

0 comments on commit 2501de7

Please sign in to comment.