Files
go-jdenticon/internal/renderer/integration_test.go
Kevin McIntyre f1544ef49c
Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
chore: update module path to gitea.dockr.co/kev/go-jdenticon
Move hosting from GitHub to private Gitea instance.
2026-02-10 10:07:57 -05:00

588 lines
15 KiB
Go

package renderer
import (
"bytes"
"crypto/sha1"
"fmt"
"image/png"
"testing"
"gitea.dockr.co/kev/go-jdenticon/internal/engine"
)
// TestPNGRenderer_VisualRegression tests that PNG output matches expected characteristics
func TestPNGRenderer_VisualRegression(t *testing.T) {
testCases := []struct {
name string
size int
bg string
bgOp float64
shapes []testShape
checksum string // Expected checksum of PNG data
}{
{
name: "simple_red_square",
size: 50,
bg: "#ffffff",
bgOp: 1.0,
shapes: []testShape{
{
color: "#ff0000",
polygons: [][]engine.Point{
{
{X: 10, Y: 10},
{X: 40, Y: 10},
{X: 40, Y: 40},
{X: 10, Y: 40},
},
},
},
},
},
{
name: "blue_circle",
size: 60,
bg: "#f0f0f0",
bgOp: 1.0,
shapes: []testShape{
{
color: "#0000ff",
circles: []testCircle{
{center: engine.Point{X: 30, Y: 30}, radius: 20, invert: false},
},
},
},
},
{
name: "transparent_background",
size: 40,
bg: "#000000",
bgOp: 0.0,
shapes: []testShape{
{
color: "#00ff00",
polygons: [][]engine.Point{
{
{X: 5, Y: 5},
{X: 35, Y: 5},
{X: 20, Y: 35},
},
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
renderer := NewPNGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
for _, shape := range tc.shapes {
renderer.BeginShape(shape.color)
for _, points := range shape.polygons {
renderer.AddPolygon(points)
}
for _, circle := range shape.circles {
renderer.AddCircle(circle.center, circle.radius, circle.invert)
}
renderer.EndShape()
}
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify PNG is valid
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("Failed to decode PNG: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
t.Errorf("Image size = %dx%d, want %dx%d",
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
}
// Calculate checksum for regression testing
checksum := fmt.Sprintf("%x", sha1.Sum(pngData))
t.Logf("PNG checksum for %s: %s", tc.name, checksum)
// Basic size validation
if len(pngData) < 100 {
t.Errorf("PNG data too small: %d bytes", len(pngData))
}
})
}
}
// testShape represents a shape to be drawn for testing
type testShape struct {
color string
polygons [][]engine.Point
circles []testCircle
}
type testCircle struct {
center engine.Point
radius float64
invert bool
}
// TestPNGRenderer_ComplexIcon tests rendering a more complex icon pattern
func TestPNGRenderer_ComplexIcon(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.SetBackground("#f8f8f8", 1.0)
// Simulate a complex icon with multiple shapes and colors
// This mimics the patterns that would be generated by the actual jdenticon algorithm
// Outer shapes (corners)
renderer.BeginShape("#3f7cac")
// Top-left triangle
renderer.AddPolygon([]engine.Point{
{X: 0, Y: 0}, {X: 25, Y: 0}, {X: 0, Y: 25},
})
// Top-right triangle
renderer.AddPolygon([]engine.Point{
{X: 75, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 25},
})
// Bottom-left triangle
renderer.AddPolygon([]engine.Point{
{X: 0, Y: 75}, {X: 0, Y: 100}, {X: 25, Y: 100},
})
// Bottom-right triangle
renderer.AddPolygon([]engine.Point{
{X: 75, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 75},
})
renderer.EndShape()
// Middle shapes
renderer.BeginShape("#95b3d0")
// Left rhombus
renderer.AddPolygon([]engine.Point{
{X: 12.5, Y: 37.5}, {X: 25, Y: 50}, {X: 12.5, Y: 62.5}, {X: 0, Y: 50},
})
// Right rhombus
renderer.AddPolygon([]engine.Point{
{X: 87.5, Y: 37.5}, {X: 100, Y: 50}, {X: 87.5, Y: 62.5}, {X: 75, Y: 50},
})
// Top rhombus
renderer.AddPolygon([]engine.Point{
{X: 37.5, Y: 12.5}, {X: 50, Y: 0}, {X: 62.5, Y: 12.5}, {X: 50, Y: 25},
})
// Bottom rhombus
renderer.AddPolygon([]engine.Point{
{X: 37.5, Y: 87.5}, {X: 50, Y: 75}, {X: 62.5, Y: 87.5}, {X: 50, Y: 100},
})
renderer.EndShape()
// Center shape
renderer.BeginShape("#2f5f8f")
renderer.AddCircle(engine.Point{X: 50, Y: 50}, 15, false)
renderer.EndShape()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify the complex icon renders successfully
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("Failed to decode complex PNG: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("Complex icon size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
}
// Ensure PNG is reasonable size (not too large, not too small)
if len(pngData) < 500 || len(pngData) > 50000 {
t.Errorf("Complex PNG size %d bytes seems unreasonable", len(pngData))
}
t.Logf("Complex icon PNG size: %d bytes", len(pngData))
}
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// implement the Renderer interface consistently
func TestRendererInterface_Consistency(t *testing.T) {
testCases := []struct {
name string
size int
bg string
bgOp float64
testFunc func(Renderer)
}{
{
name: "basic_shapes",
size: 100,
bg: "#ffffff",
bgOp: 1.0,
testFunc: func(r Renderer) {
r.BeginShape("#ff0000")
r.AddRectangle(10, 10, 30, 30)
r.EndShape()
r.BeginShape("#00ff00")
r.AddCircle(engine.Point{X: 70, Y: 70}, 15, false)
r.EndShape()
r.BeginShape("#0000ff")
r.AddTriangle(
engine.Point{X: 20, Y: 80},
engine.Point{X: 40, Y: 80},
engine.Point{X: 30, Y: 60},
)
r.EndShape()
},
},
{
name: "complex_polygon",
size: 80,
bg: "#f8f8f8",
bgOp: 0.8,
testFunc: func(r Renderer) {
r.BeginShape("#8B4513")
// Star shape
points := []engine.Point{
{X: 40, Y: 10},
{X: 45, Y: 25},
{X: 60, Y: 25},
{X: 50, Y: 35},
{X: 55, Y: 50},
{X: 40, Y: 40},
{X: 25, Y: 50},
{X: 30, Y: 35},
{X: 20, Y: 25},
{X: 35, Y: 25},
}
r.AddPolygon(points)
r.EndShape()
},
},
{
name: "primitive_drawing",
size: 60,
bg: "",
bgOp: 0,
testFunc: func(r Renderer) {
r.BeginShape("#FF6B35")
r.MoveTo(10, 10)
r.LineTo(50, 10)
r.LineTo(50, 50)
r.CurveTo(45, 55, 35, 55, 30, 50)
r.LineTo(10, 50)
r.ClosePath()
r.Fill("#FF6B35")
r.EndShape()
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test with PNG renderer
t.Run("png", func(t *testing.T) {
renderer := NewPNGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify PNG output
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("PNG renderer produced no data")
}
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("PNG decode failed: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
t.Errorf("PNG size = %dx%d, want %dx%d",
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
}
})
// Test with SVG renderer
t.Run("svg", func(t *testing.T) {
renderer := NewSVGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify SVG output
svgData := renderer.ToSVG()
if len(svgData) == 0 {
t.Error("SVG renderer produced no data")
}
// Basic SVG validation
if !bytes.Contains([]byte(svgData), []byte("<svg")) {
t.Error("SVG output missing opening tag")
}
if !bytes.Contains([]byte(svgData), []byte("</svg>")) {
t.Error("SVG output missing closing tag")
}
// Check size attributes
expectedWidth := fmt.Sprintf(`width="%d"`, tc.size)
expectedHeight := fmt.Sprintf(`height="%d"`, tc.size)
if !bytes.Contains([]byte(svgData), []byte(expectedWidth)) {
t.Errorf("SVG missing width attribute: %s", expectedWidth)
}
if !bytes.Contains([]byte(svgData), []byte(expectedHeight)) {
t.Errorf("SVG missing height attribute: %s", expectedHeight)
}
})
})
}
}
// TestRendererInterface_BaseRendererMethods tests that renderers properly use BaseRenderer methods
func TestRendererInterface_BaseRendererMethods(t *testing.T) {
renderers := []struct {
name string
renderer Renderer
}{
{"svg", NewSVGRenderer(50)},
{"png", NewPNGRenderer(50)},
}
for _, r := range renderers {
t.Run(r.name, func(t *testing.T) {
renderer := r.renderer
// Test size getter
if renderer.GetSize() != 50 {
t.Errorf("GetSize() = %d, want 50", renderer.GetSize())
}
// Test background setting
renderer.SetBackground("#123456", 0.75)
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if bg, op := svgRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
t.Errorf("SVG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if bg, op := pngRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
t.Errorf("PNG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
// Test shape management
renderer.BeginShape("#ff0000")
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if color := svgRenderer.GetCurrentColor(); color != "#ff0000" {
t.Errorf("SVG GetCurrentColor() = %s, want #ff0000", color)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if color := pngRenderer.GetCurrentColor(); color != "#ff0000" {
t.Errorf("PNG GetCurrentColor() = %s, want #ff0000", color)
}
}
// Test clearing
renderer.Clear()
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if color := svgRenderer.GetCurrentColor(); color != "" {
t.Errorf("SVG GetCurrentColor() after Clear() = %s, want empty", color)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if color := pngRenderer.GetCurrentColor(); color != "" {
t.Errorf("PNG GetCurrentColor() after Clear() = %s, want empty", color)
}
}
})
}
}
// TestRendererInterface_CompatibilityWithJavaScript tests patterns from JavaScript reference
func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
// This test replicates patterns that would be used by the JavaScript jdenticon library
// to ensure our Go implementation is compatible
testJavaScriptPattern := func(r Renderer) {
// Simulate the JavaScript renderer usage pattern
r.SetBackground("#f0f0f0", 1.0)
// Pattern similar to what iconGenerator.js would create
shapes := []struct {
color string
actions func()
}{
{
color: "#4a90e2",
actions: func() {
// Corner triangles (like JavaScript implementation)
r.AddPolygon([]engine.Point{
{X: 0, Y: 0}, {X: 20, Y: 0}, {X: 0, Y: 20},
})
r.AddPolygon([]engine.Point{
{X: 80, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 20},
})
r.AddPolygon([]engine.Point{
{X: 0, Y: 80}, {X: 0, Y: 100}, {X: 20, Y: 100},
})
r.AddPolygon([]engine.Point{
{X: 80, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 80},
})
},
},
{
color: "#7fc383",
actions: func() {
// Center circle
r.AddCircle(engine.Point{X: 50, Y: 50}, 25, false)
},
},
{
color: "#e94b3c",
actions: func() {
// Side rhombs
r.AddPolygon([]engine.Point{
{X: 25, Y: 37.5}, {X: 37.5, Y: 50}, {X: 25, Y: 62.5}, {X: 12.5, Y: 50},
})
r.AddPolygon([]engine.Point{
{X: 75, Y: 37.5}, {X: 87.5, Y: 50}, {X: 75, Y: 62.5}, {X: 62.5, Y: 50},
})
},
},
}
for _, shape := range shapes {
r.BeginShape(shape.color)
shape.actions()
r.EndShape()
}
}
t.Run("svg_javascript_pattern", func(t *testing.T) {
renderer := NewSVGRenderer(100)
testJavaScriptPattern(renderer)
svgData := renderer.ToSVG()
// Should contain multiple paths with different colors
for _, color := range []string{"#4a90e2", "#7fc383", "#e94b3c"} {
expected := fmt.Sprintf(`fill="%s"`, color)
if !bytes.Contains([]byte(svgData), []byte(expected)) {
t.Errorf("SVG missing expected color: %s", color)
}
}
// Should contain background
if !bytes.Contains([]byte(svgData), []byte("#f0f0f0")) {
t.Error("SVG missing background color")
}
})
t.Run("png_javascript_pattern", func(t *testing.T) {
renderer := NewPNGRenderer(100)
testJavaScriptPattern(renderer)
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify valid PNG
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("PNG decode failed: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("PNG size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
}
})
}
// TestPNGRenderer_EdgeCases tests various edge cases
func TestPNGRenderer_EdgeCases(t *testing.T) {
t.Run("very_small_icon", func(t *testing.T) {
renderer := NewPNGRenderer(1)
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}})
renderer.EndShape()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("1x1 PNG should generate data")
}
})
t.Run("large_icon", func(t *testing.T) {
renderer := NewPNGRenderer(512)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#000000")
renderer.AddCircle(engine.Point{X: 256, Y: 256}, 200, false)
renderer.EndShape()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("512x512 PNG should generate data")
}
// Large images should compress well due to simple content
t.Logf("512x512 PNG size: %d bytes", len(pngData))
})
t.Run("shapes_outside_bounds", func(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.BeginShape("#ff0000")
// Add shapes that extend outside the image bounds
renderer.AddPolygon([]engine.Point{
{X: -10, Y: -10}, {X: 60, Y: -10}, {X: 60, Y: 60}, {X: -10, Y: 60},
})
renderer.AddCircle(engine.Point{X: 25, Y: 25}, 50, false)
renderer.EndShape()
// Should not panic and should produce valid PNG
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
reader := bytes.NewReader(pngData)
_, err = png.Decode(reader)
if err != nil {
t.Errorf("Failed to decode PNG with out-of-bounds shapes: %v", err)
}
})
}