A Picture You Can Diff
The 15,360-pixel frame
The renderer can fill a rectangle, draw a line between two points, and drop any pixel that falls outside its clip rectangle. Everything it has drawn so far has been judged by a terminal preview that knows four colors, or by a person opening the PPM dump and finding it plausible. That covers looking. It covers nothing else: no test asserts that any pixel is any color.
Looking does not scale. A frame here is 15,360 pixels, and an eye slides over one wrong pixel in fifteen thousand without a flicker of doubt. An eye also cannot be scheduled, so a renderer checked only when somebody remembers to look drifts one row at a time. Volume 1 answered the same problem with a log that replays byte for byte and a test that fails when a single byte moves. The renderer gets held to that standard starting here: a frame is a value. It reduces to one number, a test asserts that number, and an image file sits beside it so a person can see what a changed number means.
Two pieces get built: a real image format, because the text dump costs 173,281 bytes for a scene PNG stores in 578, and a hash over the pixels, which is what the test asserts. At the end a fill loop gets broken by one pixel of reach, to show which of the two catches it and what it takes for a person to see it afterwards.
The asImage adapter
Go ships PNG encoding in the standard library, and the whole interface to it is one
function: png.Encode(w io.Writer, m image.Image). Nothing to install,
no format to configure. The only question is what image.Image wants, and it
wants three methods: which color model the image speaks, what rectangle it covers, and
what color sits at a given coordinate.
The buffer can answer all three about pixels it already holds, and it cannot answer them
under those names. It has a Bounds returning the Rect the clip
is measured against, and an At returning this package's Color;
the interface wants both names back with an image.Rectangle and a
color.Color, and one type may not carry two methods under one name. Go's
answer to that collision is an adapter: a small struct holding a pointer to the buffer and
wearing the standard library's method names, so the interface is satisfied while the
buffer's own API stays as the drawing chapters left it.
// internal/render/snapshot.go
package render
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
)
// asImage adapts a Buffer to image.Image. The buffer cannot satisfy that
// interface itself: image.Image wants a Bounds returning an
// image.Rectangle and an At returning a color.Color, the buffer's own
// two methods of those names return a Rect and a Color, and one type may
// not have two methods with one name.
type asImage struct{ b *Buffer }
func (a asImage) ColorModel() color.Model { return color.RGBAModel }
// Bounds is the rectangle the image covers, in the standard library's
// type: origin included, far corner excluded, the same half-open
// convention Rect uses.
func (a asImage) Bounds() image.Rectangle { return image.Rect(0, 0, a.b.W, a.b.H) }
// At hands one pixel to the encoder in the four fields it expects.
func (a asImage) At(x, y int) color.Color {
c := a.b.At(x, y)
return color.RGBA{A: c.A(), R: c.R(), G: c.G(), B: c.B()}
}
// SavePNG writes the buffer to path as a PNG file: the artifact a person
// opens and looks at.
func (b *Buffer) SavePNG(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
if err := png.Encode(f, asImage{b}); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
// cmd/worldc/main.go — the client hands you a frame and its fingerprint
func main() {
shot := flag.String("shot", "", "write the frame to this PNG file")
flag.Parse()
b := render.NewBuffer(render.SceneW, render.SceneH)
render.Scene(b)
fmt.Printf("worldc %s scene %dx%d sha256 %s\n", version, b.W, b.H, b.Hash())
if *shot != "" {
if err := b.SavePNG(*shot); err != nil {
fmt.Fprintln(os.Stderr, "worldc:", err)
os.Exit(1)
}
fmt.Println("wrote", *shot)
}
}
$ go run ./cmd/worldc -shot a.png
worldc 0.0.1 scene 160x96 sha256 e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
wrote a.png
The download is the exact scene.png fixture, saved as a.png
for this example.
Open a.png in any viewer and there is a picture: sky, brown ground under a
thin grey horizon, a rectangular pond, a ridge in two straight lines, and a pale block
near the top left with a corner sliced off by a clip rectangle. Every pixel of it came
out of the drawing operations the client already had. The PNG retires chapter 12's PPM
dump from the render path; ppm.go can stay as a debug format you can read
with less, or leave the project once nothing calls WritePPM.
Follow what png.Encode did: it asked for the color model, asked for the
bounds, then called At once per pixel in reading order. Encoding is a walk
over pixels that already exist, in the order they already sit in memory, so the adapter
needs no buffer of its own. One detail deserves naming before it bites.
color.RGBA is alpha-premultiplied: its color channels are expected to be
scaled by alpha already. Every pixel here is opaque, alpha 0xFF, and an
opaque pixel has the same three numbers either way, so the conversion holds while the
buffer stays opaque.
A PNG is simple enough to read by hand for the first few dozen bytes, so read them.
$ wc -c a.png; xxd -l 48 a.png
578 a.png
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR
00000010: 0000 00a0 0000 0060 0802 0000 0015 45ee .......`......E.
00000020: ce00 0002 0949 4441 5478 9cec dcb1 4df4 .....IDATx....M.
The first eight bytes are the signature, 89 then PNG then a
mix of line endings, chosen so a file mangled by a transfer that rewrites newlines fails
loudly. Then come chunks, each a 4-byte length, a 4-byte name, a payload and a 4-byte
checksum. IHDR is 13 bytes: width 000000a0 is 160, height
00000060 is 96, then bit depth 8 and color type 2. IDAT
carries 0x209 bytes, 521 of them, holding the rows deflate-compressed, and
IEND closes the file. So does the arithmetic: 8 plus 25 plus 533 plus 12 is
578, every byte the file has.
Color type 2 is the interesting number, because nobody asked for it. It means three channels and no alpha: the encoder walked the buffer, found every alpha byte opaque, and dropped a quarter of the data as pointless. Hold onto that, along with the compression that turned 61,440 bytes of framebuffer into 578. Both are decisions about the file, made by code you did not write, and neither is a fact about the picture.
A 64-character frame hash
Now the checking half. A test needs to compare this frame against the frame already blessed in source, and keeping 61,440 bytes around for that is possible and unpleasant. A cryptographic hash is a better handle: SHA-256 accepts any number of bytes and returns exactly 32, written as 64 hexadecimal characters. The same bytes give the same 32 on any machine, forever, and any change gives 32 unrelated ones. Recoloring a single pixel of this scene flips 114 of the digest's 256 bits.
// internal/render/snapshot.go — continued; the imports grow by
// crypto/sha256, encoding/binary and encoding/hex
// Hash is SHA-256 over the pixels themselves: the size first, then every
// pixel in reading order, each written big-endian so the digest does not
// depend on the machine's byte order.
func (b *Buffer) Hash() string {
h := sha256.New()
var head [8]byte
binary.BigEndian.PutUint32(head[0:4], uint32(b.W))
binary.BigEndian.PutUint32(head[4:8], uint32(b.H))
h.Write(head[:])
var px [4]byte
for _, c := range b.Pix {
binary.BigEndian.PutUint32(px[:], uint32(c))
h.Write(px[:])
}
return hex.EncodeToString(h.Sum(nil))
}
// LoadPNG reads a PNG back into a buffer, packing each pixel the way the
// framebuffer stores it.
func LoadPNG(path string) (*Buffer, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
r := img.Bounds()
b := NewBuffer(r.Dx(), r.Dy())
for y := range b.H {
for x := range b.W {
cr, cg, cb, ca := img.At(r.Min.X+x, r.Min.Y+y).RGBA()
b.Set(x, y, RGBA(uint8(ca>>8), uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)))
}
}
return b, nil
}
Two decisions inside Hash are load-bearing, and each is one line. Feeding
the size in first makes it part of the claim: a 160-by-96 buffer of plain sky and a
96-by-160 buffer of the same sky hold identical values in identical order, so without
those eight bytes a resize that wrecked every layout would pass silently. Writing each
pixel big-endian makes the digest depend on the numbers instead of on how this processor
arranges them in memory; hashing raw memory would be quicker and would hand two machines
two answers about one picture.
LoadPNG is the return trip, needed for the failure path rather than
drawing. Decoding hands back an image.Image of whatever type the file
called for, so pixels come out through RGBA(), which always reports 16
bits per channel; shifting each one right by 8 gives back the 8-bit channel the buffer
stores.
A digest is shorter than what it summarizes, so two pictures must eventually share one. Count the pigeons: this frame has 15,360 pixels with 232 possible values each, while SHA-256 has 2256 possible outputs. Far more frames than digests exist, so collisions exist by counting alone, and no clever hash makes them stop.
What makes the test sound anyway is how hard those collisions are to find. Write 2256 out in full and it runs to 78 digits, roughly 1.2 × 1077. A hash earns the word cryptographic when nobody has found a way to steer an input toward a chosen output faster than trying inputs one at a time, and at 78 digits that is not a plan. So the odds of a renderer bug landing on the correct picture's 32 bytes are zero for every purpose a test has.
The digest and the PNG are two claims about the same pixels, so check cheaply that they agree: encode the scene, decode it back, hash what came home.
// internal/render/snapshot_test.go — first test in the file; the line
// numbers quoted on this page are this file's, so keep the order
func TestPNGKeepsEveryPixel(t *testing.T) {
b := NewBuffer(SceneW, SceneH)
Scene(b)
path := filepath.Join(t.TempDir(), "scene.png")
if err := b.SavePNG(path); err != nil {
t.Fatal(err)
}
back, err := LoadPNG(path)
if err != nil {
t.Fatal(err)
}
if back.Hash() != b.Hash() {
d := Diff(b, back)
t.Fatalf("the file changed %d pixels on the way to disk, first at (%d,%d)", d.Count, d.At.X, d.At.Y)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
t.Logf("%d pixels, %d bytes on disk, same pixel hash after the round trip", SceneW*SceneH, fi.Size())
}
$ go test -count=1 -v -run TestPNGKeeps ./internal/render/ (elapsed times measured on the author's machine; yours will differ) === RUN TestPNGKeepsEveryPixel snapshot_test.go:34: 15360 pixels, 578 bytes on disk, same pixel hash after the round trip --- PASS: TestPNGKeepsEveryPixel (0.00s) PASS ok theworld/internal/render 0.004s
PNG is lossless, so this passes by design; running it turns a fact about the format into a fact about this code, which is the more useful of the two to own. It fails if the buffer holds pixels the encoder cannot represent exactly, and it makes a saved file trustworthy enough for a snapshot test.
sceneHash in snapshot_test.go
One gap first. A digest answers whether the picture changed and says nothing about where, and a person staring at 64 characters has nowhere to go next. Two functions close that, and the client gets a mode that runs them on two files.
// internal/render/snapshot.go — continued
// Difference is what a hash mismatch owes a human: how many pixels
// moved, where the first one is, and what it used to be.
type Difference struct {
Count int
At image.Point
Want, Got Color
}
// Diff compares two buffers pixel by pixel.
func Diff(want, got *Buffer) Difference {
d := Difference{At: image.Pt(-1, -1)}
if want.W != got.W || want.H != got.H {
d.Count = -1
return d
}
for i, c := range want.Pix {
if got.Pix[i] == c {
continue
}
if d.Count == 0 {
d.At = image.Pt(i%want.W, i/want.W)
d.Want, d.Got = c, got.Pix[i]
}
d.Count++
}
return d
}
// Mask paints where two buffers disagree: matching pixels dimmed to a
// quarter of their brightness, differing pixels opaque red.
func Mask(want, got *Buffer) *Buffer {
m := NewBuffer(want.W, want.H)
for i, c := range want.Pix {
if i < len(got.Pix) && got.Pix[i] != c {
m.Pix[i] = RGBA(0xFF, 0xFF, 0x00, 0x00)
continue
}
m.Pix[i] = RGBA(0xFF, c.R()/4, c.G()/4, c.B()/4)
}
return m
}
// cmd/worldc/main.go — a second job, and the whole main now
func main() {
shot := flag.String("shot", "", "write the frame to this PNG file")
diff := flag.Bool("diff", false, "compare two PNG files pixel by pixel")
flag.Parse()
if *diff {
if flag.NArg() != 2 {
fmt.Fprintln(os.Stderr, "usage: worldc -diff first.png second.png")
os.Exit(2)
}
if err := compare(flag.Arg(0), flag.Arg(1)); err != nil {
fmt.Fprintln(os.Stderr, "worldc:", err)
os.Exit(1)
}
return
}
b := render.NewBuffer(render.SceneW, render.SceneH)
render.Scene(b)
fmt.Printf("worldc %s scene %dx%d sha256 %s\n", version, b.W, b.H, b.Hash())
if *shot != "" {
if err := b.SavePNG(*shot); err != nil {
fmt.Fprintln(os.Stderr, "worldc:", err)
os.Exit(1)
}
fmt.Println("wrote", *shot)
}
}
func compare(a, c string) error {
want, err := render.LoadPNG(a)
if err != nil {
return err
}
got, err := render.LoadPNG(c)
if err != nil {
return err
}
fmt.Printf("%s sha256 %s\n%s sha256 %s\n", a, want.Hash(), c, got.Hash())
d := render.Diff(want, got)
if d.Count == 0 {
fmt.Println("every pixel agrees")
return nil
}
fmt.Printf("%d of %d pixels differ, first at (%d,%d): %08x became %08x\n",
d.Count, want.W*want.H, d.At.X, d.At.Y, d.Want, d.Got)
return nil
}
$ go run ./cmd/worldc -shot b.png; go run ./cmd/worldc -diff a.png b.png
worldc 0.0.1 scene 160x96 sha256 e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
wrote b.png
a.png sha256 e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
b.png sha256 e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
every pixel agrees
Diff reports a count and the first disagreement in reading order, usually
enough to name the operation at fault. Mask answers the same question as a
picture: the frame dimmed to a background, every moved pixel burning red. Behind a
client flag, both work on the two PNGs attached to somebody's bug report without a test
being written first. The run above is that machinery confirming the dull case: two
processes, two files, every pixel agreeing.
A snapshot test needs a subject that never drifts, so the scene is code: fixed size, fixed colors, fixed calls in a fixed order, nothing that reads a clock or a random draw.
// internal/render/scene.go
package render
// The scene is fixed on purpose: same size, same colors, same calls in
// the same order, so its hash is a number a test can be written against.
const (
SceneW = 160
SceneH = 96
)
// Two colors the ground palette has no word for yet.
const (
sky Color = 0xFF6E8CA0
sun Color = 0xFFE8D8A0
)
// Scene draws the standing composition every snapshot test in this
// volume renders: ground, sky, a pond, a ridge in two lines, and one
// rectangle that runs off the edge of its clip.
func Scene(b *Buffer) {
b.SetClip(b.Bounds())
b.Fill(Soil)
b.FillRect(Rect{X0: 0, Y0: 0, X1: SceneW, Y1: 60}, sky)
b.FillRect(Rect{X0: 24, Y0: 68, X1: 72, Y1: 86}, Water)
b.Line(0, 60, SceneW-1, 60, Rock)
b.Line(96, 60, 128, 20, Rock)
b.Line(128, 20, 152, 60, Rock)
b.SetClip(Rect{X0: 8, Y0: 8, X1: 56, Y1: 40})
b.FillRect(Rect{X0: -20, Y0: -10, X1: 40, Y1: 24}, sun)
b.SetClip(b.Bounds())
}
// internal/render/snapshot_test.go — second in the file, and the
// volume's first render test
var update = flag.Bool("update", false, "rewrite the golden PNG from this run")
const goldenPath = "testdata/scene.png"
// sceneHash is the SHA-256 of the pixels Scene draws. Editing this line is
// deciding that the picture is allowed to have changed.
const sceneHash = "e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626"
func TestSceneMatchesItsHash(t *testing.T) {
b := NewBuffer(SceneW, SceneH)
Scene(b)
got := b.Hash()
if *update {
if err := b.SavePNG(goldenPath); err != nil {
t.Fatal(err)
}
t.Fatalf("golden rewritten from this run: set sceneHash to %s, then run again without -update", got)
}
if got == sceneHash {
return
}
// The hash says the picture moved. Everything below exists to tell a
// human where.
want, err := LoadPNG(goldenPath)
if err != nil {
t.Fatalf("pixels hash %s, want %s, and the golden will not load: %v", got, sceneHash, err)
}
d := Diff(want, b)
gotPath := filepath.Join("testdata", "scene.got.png")
diffPath := filepath.Join("testdata", "scene.diff.png")
if err := b.SavePNG(gotPath); err != nil {
t.Fatal(err)
}
if err := Mask(want, b).SavePNG(diffPath); err != nil {
t.Fatal(err)
}
t.Fatalf("pixels hash %s\n want %s\n %d of %d pixels differ, first at (%d,%d): %08x became %08x\n wrote %s and %s",
got, sceneHash, d.Count, SceneW*SceneH, d.At.X, d.At.Y, d.Want, d.Got, gotPath, diffPath)
}
$ go test -count=1 -run TestSceneMatches ./internal/render/ -update
--- FAIL: TestSceneMatchesItsHash (0.00s)
snapshot_test.go:54: golden rewritten from this run: set sceneHash to e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626, then run again without -update
FAIL
FAIL theworld/internal/render 0.00s
FAIL
$ go test -count=1 ./internal/render/
ok theworld/internal/render 0.014s
The passing path is three lines: draw the scene, hash it, compare against a constant. That constant is the entire assertion, and it lives in source because a hash in a source file is a promise a person made on a particular day; changing it is a changed line in a review with a name attached. A test that read its expectation out of a file it also writes would be agreeing with itself.
-update is how the constant gets its value the first time, and it fails
the test on purpose after rewriting the golden. An update is you telling the test what
the picture is now, so the failure makes sure the sentence gets read and the constant
pasted, instead of the flag living in a shell history to be reused without thinking.
Everything after the early return exists for the case nobody wants. The golden is
loaded, the two frames are compared pixel by pixel, and two files land in
testdata: scene.got.png, what the code drew this time, and
scene.diff.png, a mask with matching pixels dimmed and differing pixels
opaque red. A hash mismatch is a yes-or-no answer; these two files make it a place to
look.
FillRect intersects its rectangle with the clip rectangle and then writes
rows. Here is a change to the row loop anyone might make while thinking about whether
a rectangle ought to include its far edge:
// internal/render/draw.go — FillRect, one character heavier
r = r.Intersect(b.clip)
if r.Empty() {
return
}
for y := r.Y0; y <= r.Y1; y++ { // was: y < r.Y1
Build the client, take a shot, open it. Same picture. Sky, ground, pond, ridge, pale block, nothing an eye is going to catch. Now run the test:
$ go test -count=1 ./internal/render/
--- FAIL: TestSceneMatchesItsHash (0.00s)
snapshot_test.go:75: pixels hash bfced8f8fd4fdb43af876eae2c3da807f1640eaf93e4987d7e7521c9972ec433
want e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
80 of 15360 pixels differ, first at (8,24): ff6e8ca0 became ffe8d8a0
wrote testdata/scene.got.png and testdata/scene.diff.png
FAIL
FAIL theworld/internal/render 0.00s
FAIL
Eighty pixels out of 15,360, half of one percent of the frame. Work out where they are.
Every FillRect now paints one row past its rectangle and the scene makes
three calls, so the expectation is three extra rows; two arrived. The sky rectangle
stops at row 60 and the horizon line is drawn along row 60 straight afterwards, so its
stray row was painted over before anything could observe it. The pond grew a row along
its southern edge, 48 pixels of soil turned to water, and the pale block grew one of its
own, 32 pixels wide because the clip trims it at column 40. First in reading order is
(8,24), sky colored ff6e8ca0 under the block's new bottom row.
What a bug costs depends on what the next operation does to the same pixels, which is
the argument for checking the finished frame instead of any one call.
The same bug from the shell, comparing the two files instead:
$ wc -c a.png broken.png; sha256sum a.png broken.png
578 a.png
577 broken.png
1155 total
9ee78ce5d64eed6fe27bba21d5286a25234b4ab2df5f49afd3b544f8467b6690 a.png
bae2387b2c694067b0ad91bc4032b0d082cf36b5ef42d346daa5fcc90597bf0b broken.png
$ go run ./cmd/worldc -diff a.png broken.png
a.png sha256 e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
broken.png sha256 bfced8f8fd4fdb43af876eae2c3da807f1640eaf93e4987d7e7521c9972ec433
80 of 15360 pixels differ, first at (8,24): ff6e8ca0 became ffe8d8a0
The broken frame came out a byte smaller, since two extra rows of flat color compress
marginally better. Put the two PNGs side by side and they are the same picture; open
scene.diff.png and the answer arrives at once, two thin red rows on a dim
background, one under the pale block and one under the pond. That is this chapter in
three steps: the hash notices, the diff counts and locates, the mask shows. None of the
three is an eye, and the eye is the one that failed.
The pixel hash and the PNG file
There is a shorter version of this chapter that would have gone badly: write a PNG, keep a known-good one, have the test compare the new file's bytes against it. No hashing code needed, and the golden file becomes the assertion. One test shows why not.
// internal/render/snapshot_test.go — continued
// TestTheFileIsAnEncodersOpinion writes one buffer twice with two
// compression settings: two different files, one picture.
func TestTheFileIsAnEncodersOpinion(t *testing.T) {
b := NewBuffer(SceneW, SceneH)
Scene(b)
dir := t.TempDir()
small := encodeAt(t, b, filepath.Join(dir, "small.png"), png.BestCompression)
quick := encodeAt(t, b, filepath.Join(dir, "quick.png"), png.BestSpeed)
if bytes.Equal(small, quick) {
t.Fatal("both settings wrote the same file; this test proves nothing today")
}
t.Logf("small.png %4d bytes, file sha256 %s", len(small), fileSum(small))
t.Logf("quick.png %4d bytes, file sha256 %s", len(quick), fileSum(quick))
a, err := LoadPNG(filepath.Join(dir, "small.png"))
if err != nil {
t.Fatal(err)
}
c, err := LoadPNG(filepath.Join(dir, "quick.png"))
if err != nil {
t.Fatal(err)
}
if a.Hash() != c.Hash() {
t.Fatalf("the two files disagree about the picture: %s and %s", a.Hash(), c.Hash())
}
t.Logf("both decode to pixel hash %s", a.Hash())
}
// encodeAt writes b to path at one compression level and hands back the
// bytes that landed on disk.
func encodeAt(t *testing.T, b *Buffer, path string, lvl png.CompressionLevel) []byte {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := (&png.Encoder{CompressionLevel: lvl}).Encode(f, asImage{b}); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}
func fileSum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
$ go test -count=1 -v -run TestTheFile ./internal/render/
=== RUN TestTheFileIsAnEncodersOpinion
snapshot_test.go:91: small.png 459 bytes, file sha256 b95a6746696cbb2f24f82646bd70f11f4236c7992119fe53a1e4bea02bfd80cf
snapshot_test.go:92: quick.png 589 bytes, file sha256 ca9899f5b1f0adcb9179c7444055af195ba12d31ddd1d21987a66ea369c6ab47
snapshot_test.go:105: both decode to pixel hash e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
--- PASS: TestTheFileIsAnEncodersOpinion (0.01s)
PASS
ok theworld/internal/render 0.011s
Three files, three sizes: 459 bytes tuned for size, 589 tuned for speed, 578 at the default the client uses. Three checksums, one picture, identical in every pixel, as the third log line proves by decoding both files and hashing what came out. All that separates them is deflate deciding how hard to work, which says nothing about what the renderer drew.
A test comparing file bytes would therefore fail the first time the standard library retunes its compressor, on a Go release that changed nothing here, and the failure would name the renderer. Real, loud, and about something else: whoever answered it would learn that the test lies, and a test known to lie stops being read.
The rule underneath reaches past pixels. When a program hands data to a library that serializes it, two things exist afterward: the values the program decided, and the bytes the library chose for them. Assert the first, keep the second as an artifact for humans and for transport. Volume 1 followed the same rule without naming it, hashing a log whose every field the simulation picked. Here the pixels belong to the renderer and the compression is somebody else's business.
Figure 14.1: the pixel hash is the assertion and the PNG is the evidence. The file's own checksum is neither: it moves when a compression setting moves.
One working habit follows, since the failure path gets walked mostly by people who changed
the renderer deliberately. Change something, run the test, read the count and the
coordinate, open scene.got.png and scene.diff.png, confirm the
change is the one you meant. Only then run with -update and paste the new
constant. Reaching for the flag ahead of those steps turns a snapshot test into a machine
for blessing whatever the code did last, which is how a one-row bug becomes the golden
picture every test agrees with.
Checkpoint
- Explain why
Buffercannot satisfyimage.Imagedirectly, and write the adapter that letspng.Encoderead buffer pixels without a copy. - Read the first 48 bytes of a PNG and name the signature, the size in
IHDR, the color type the encoder chose, and whereIDATbegins. - Hash a frame over its dimensions and its pixels in big-endian order, and say which bug each of those two decisions catches.
- Write a snapshot test that asserts a hash constant and, on failure, leaves behind the rendered PNG and a mask of every pixel that moved.
- Show with two compression settings why the assertion is the pixel hash and never the file's bytes.
- Given a mismatch, read the count and first coordinate, look at the mask, and only
then decide whether
-updateis the right answer.
Exercise 1: move one pixel. Add
b.Set(80, 48, sun) as the last line of Scene, recoloring one
pixel of empty sky. Predict what the test says, including how much of the hash you
expect to change.
One pixel in 15,360 is 0.0065% of the frame, and the test finds it:
$ go test -count=1 ./internal/render/
--- FAIL: TestSceneMatchesItsHash (0.00s)
snapshot_test.go:75: pixels hash 808722b3971c4835e3ed70ee72bb73a2255d84f7d2a1598376ba1ffbbe48db06
want e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
1 of 15360 pixels differ, first at (80,48): ff6e8ca0 became ffe8d8a0
wrote testdata/scene.got.png and testdata/scene.diff.png
FAIL
The two digests have nothing in common; counted in bits, 114 of the 256 flipped.
That avalanche is what makes a digest a fair stand-in for a picture: a hash whose
similar inputs gave similar outputs would let small errors hide inside small
differences. Open scene.diff.png, find the single red pixel, then take
the line back out.
Exercise 2: hash without the header. Delete the two
head lines from Hash so it digests only pixels. Then hash a
160-by-96 buffer filled with sky and a 96-by-160 buffer filled the same
way. What do you expect, and what does the omission cost?
Both buffers hold 15,360 identical values in identical order, so with the size gone
they hash identically,
630d79cb5d587b51379d19af4c54e3e01ccdcad93bd8efa53fd1f7304fe28ab3 for
the wide one and the same 64 characters for the tall one. Put the header back and
they separate, 9c45f870… against 24798e51…. Leaving it out
buys a resize that passes its own test: a window of the wrong proportions still
draws a scene, every layout is wrong, and the test reports that all is well.
Exercise 3: delete the clip. Remove the
b.SetClip(Rect{X0: 8, Y0: 8, X1: 56, Y1: 40}) line from
Scene, so the pale rectangle draws with the whole buffer available.
Predict how many pixels will differ before running the test.
The rectangle asks for x from −20 to 40 and y from −10 to 24, so unclipped it covers 40 columns by 24 rows, 960 pixels; clipped it covered 32 by 16, or 512. The difference is 448:
$ go test -count=1 ./internal/render/
--- FAIL: TestSceneMatchesItsHash (0.00s)
snapshot_test.go:75: pixels hash 692da7a33de0a962f384988316c4e30a7f0d49b1af8288b67b020af19722f1cc
want e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
448 of 15360 pixels differ, first at (0,0): ff6e8ca0 became ffe8d8a0
wrote testdata/scene.got.png and testdata/scene.diff.png
FAIL
First at (0,0), the corner of the frame, because the block now reaches
the origin it was kept away from. Open the mask and the missing clip is drawn for
you as a red band wrapping the block's top and left. This is the failure a snapshot
test earns its keep on: a clipping regression reaches the screen as a picture that
is merely a bit different, and a bit different is what an eye forgives.