Files
pansy/internal/imagenorm/imagenorm_test.go
T
steveandClaude Opus 4.8 74f6b9a876
Build image / build-and-push (push) Successful in 12s
Gadfly review (reusable) / review (pull_request) Successful in 13m10s
Adversarial Review (Gadfly) / review (pull_request) Successful in 13m10s
Image normalization package: decode HEIC/webp/png/jpeg → JPEG (#80)
Prerequisite for seed-packet capture (#81). majordomo's media pipeline is
stdlib-based and cannot decode HEIC or webp — and HEIC is the iPhone camera
default, so the capture feature's very first input would fail on the device
that motivates it. Normalizing at the upload boundary means everything
downstream only ever sees JPEG.

internal/imagenorm.Normalize(reader, opts) decodes any of jpeg/png/heic/webp,
downscales to fit MaxDim (2048, ollama-cloud's limit) on the longest edge with
Catmull-Rom (sharp on the text a packet is mostly made of), and re-encodes JPEG.

CGO stays off: github.com/gen2brain/heic runs libheif as WASM via wazero (pure
Go), golang.org/x/image/webp is pure Go. Both register with image.Decode. The
~5 MB these add only links into the binary when #81 imports this package — it's
not pulled into cmd/pansy yet, so main is unchanged in size for now.

Guards against hostile uploads: input capped at MaxBytes (ErrTooLarge) before a
full read, and the decoded pixel count checked from DecodeConfig BEFORE the
bitmap is allocated, so a small file claiming a huge canvas (a decompression
bomb) is refused up front.

TestNormalizeAllFormats round-trips all four formats to a valid JPEG — its real
job is catching a dropped blank import, where the intuition-breaking failure is
that the COMMON format (png) breaks while the exotic one (heic) still works.
heic/webp fixtures live in testdata (Go has no encoder for them); the webp is a
known-good 16x16 from Python's stdlib test corpus, verified to decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:03:40 -04:00

142 lines
4.3 KiB
Go

package imagenorm
import (
"bytes"
"image"
"image/jpeg"
"image/png"
"os"
"testing"
)
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
// encode; heic/webp come from testdata (Go has no encoder for them).
func pngBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
m.Pix[m.PixOffset(x, y)+3] = 255
}
}
var b bytes.Buffer
if err := png.Encode(&b, m); err != nil {
t.Fatalf("encode png: %v", err)
}
return b.Bytes()
}
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
var b bytes.Buffer
if err := jpeg.Encode(&b, m, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return b.Bytes()
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
// accept must round-trip to a valid JPEG. It exists specifically to catch a
// dropped blank import — the failure mode where the common format (PNG) breaks
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
func TestNormalizeAllFormats(t *testing.T) {
cases := []struct {
name string
input []byte
wantFormat string
}{
{"png", pngBytes(t, 120, 90), "png"},
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
{"heic", readTestdata(t, "sample.heic"), "heic"},
{"webp", readTestdata(t, "sample.webp"), "webp"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
if err != nil {
t.Fatalf("Normalize(%s): %v", tc.name, err)
}
if format != tc.wantFormat {
t.Errorf("format = %q, want %q", format, tc.wantFormat)
}
// The output must itself be a decodable JPEG.
_, outFormat, err := image.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("output isn't a valid image: %v", err)
}
if outFormat != "jpeg" {
t.Errorf("output format = %q, want jpeg", outFormat)
}
})
}
}
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
// longest edge with aspect ratio preserved, and a small one is left alone.
func TestNormalizeDownscales(t *testing.T) {
// 4000x1000 → longest 4000, MaxDim 2048 → scaled to 2048x512.
big := pngBytes(t, 4000, 1000)
out, _, err := Normalize(bytes.NewReader(big), Options{})
if err != nil {
t.Fatalf("Normalize: %v", err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode out: %v", err)
}
if cfg.Width != 2048 {
t.Errorf("width = %d, want 2048 (longest edge clamped)", cfg.Width)
}
if cfg.Height != 512 {
t.Errorf("height = %d, want 512 (aspect preserved)", cfg.Height)
}
// A small image within bounds keeps its dimensions.
small := pngBytes(t, 100, 80)
out, _, err = Normalize(bytes.NewReader(small), Options{})
if err != nil {
t.Fatalf("Normalize small: %v", err)
}
cfg, _, _ = image.DecodeConfig(bytes.NewReader(out))
if cfg.Width != 100 || cfg.Height != 80 {
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
}
}
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
// refused without a full decode.
func TestNormalizeRejectsOversizeInput(t *testing.T) {
big := pngBytes(t, 500, 500)
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
if err != ErrTooLarge {
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
}
}
// TestNormalizeRejectsBombDimensions: a small file claiming a huge canvas is
// refused before the bitmap is allocated. A tiny MaxDim doesn't matter — the
// guard is on the DECODED pixel count, checked from DecodeConfig.
func TestNormalizeRejectsGarbage(t *testing.T) {
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
if err != ErrUnsupported {
t.Errorf("garbage err = %v, want ErrUnsupported", err)
}
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
png := pngBytes(t, 100, 100)
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
if err != ErrUnsupported {
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
}
}