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
Move hosting from GitHub to private Gitea instance.
588 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|