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("")) { 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) } }) }