Initial release: Go Jdenticon library v0.1.0

- Core library with SVG and PNG generation
- CLI tool with generate and batch commands
- Cross-platform path handling for Windows compatibility
- Comprehensive test suite with integration tests
This commit is contained in:
Kevin McIntyre
2026-01-02 23:56:48 -05:00
parent f84b511895
commit d9e84812ff
292 changed files with 19725 additions and 38884 deletions

View File

@@ -0,0 +1,22 @@
package constants
// Default security limits for DoS protection.
// These constants define safe default values for user inputs to prevent
// denial of service attacks through resource exhaustion while remaining configurable.
// DefaultMaxIconSize is the default maximum dimension (width or height) for a generated icon.
// A 4096x4096 RGBA image requires ~64MB of memory, which is generous for legitimate
// use while preventing unbounded memory allocation attacks.
// This limit is stricter than the JavaScript reference implementation for enhanced security.
const DefaultMaxIconSize = 4096
// DefaultMaxInputLength is the default maximum number of bytes for the input string to be hashed.
// 1MB is sufficient for any reasonable identifier and prevents hash computation DoS attacks.
// Input strings longer than this are rejected before hashing begins.
const DefaultMaxInputLength = 1 * 1024 * 1024 // 1 MB
// DefaultMaxComplexity is the default maximum geometric complexity score for an identicon.
// This score is calculated as the sum of complexity points for all shapes in an identicon.
// A complexity score of 100 allows for diverse identicons while preventing resource exhaustion.
// This value may be adjusted based on empirical analysis of typical identicon complexity.
const DefaultMaxComplexity = 100

131
internal/engine/cache.go Normal file
View File

@@ -0,0 +1,131 @@
package engine
import (
"fmt"
"strconv"
"sync/atomic"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
)
// CacheMetrics holds cache performance metrics using atomic operations for efficiency
type CacheMetrics struct {
hits int64 // Use atomic operations, no mutex needed
misses int64 // Use atomic operations, no mutex needed
}
// GetHits returns the number of cache hits
func (m *CacheMetrics) GetHits() int64 {
return atomic.LoadInt64(&m.hits)
}
// GetMisses returns the number of cache misses
func (m *CacheMetrics) GetMisses() int64 {
return atomic.LoadInt64(&m.misses)
}
// recordHit records a cache hit atomically
func (m *CacheMetrics) recordHit() {
atomic.AddInt64(&m.hits, 1)
}
// recordMiss records a cache miss atomically
func (m *CacheMetrics) recordMiss() {
atomic.AddInt64(&m.misses, 1)
}
// cacheKey generates a cache key from hash and size
func (g *Generator) cacheKey(hash string, size float64) string {
// Use a simple concatenation approach for better performance
// Convert float64 size to string with appropriate precision
return hash + ":" + strconv.FormatFloat(size, 'f', 1, 64)
}
// ClearCache removes all entries from the cache and resets metrics
func (g *Generator) ClearCache() {
g.mu.Lock()
defer g.mu.Unlock()
g.cache.Purge()
// Reset metrics
atomic.StoreInt64(&g.metrics.hits, 0)
atomic.StoreInt64(&g.metrics.misses, 0)
}
// GetCacheSize returns the number of items currently in the cache
func (g *Generator) GetCacheSize() int {
g.mu.RLock()
defer g.mu.RUnlock()
return g.cache.Len()
}
// GetCacheCapacity returns the maximum number of items the cache can hold
func (g *Generator) GetCacheCapacity() int {
g.mu.RLock()
defer g.mu.RUnlock()
// LRU cache doesn't expose capacity, return the configured capacity from config
return g.config.CacheSize
}
// GetCacheMetrics returns the cache hit and miss counts
func (g *Generator) GetCacheMetrics() (hits int64, misses int64) {
return g.metrics.GetHits(), g.metrics.GetMisses()
}
// SetConfig updates the generator's color configuration and clears the cache
func (g *Generator) SetConfig(colorConfig ColorConfig) {
g.mu.Lock()
defer g.mu.Unlock()
g.config.ColorConfig = colorConfig
g.cache.Purge() // Clear cache since config changed
}
// SetGeneratorConfig updates the generator's configuration, including cache size
func (g *Generator) SetGeneratorConfig(config GeneratorConfig) error {
g.mu.Lock()
defer g.mu.Unlock()
// Validate cache size
if config.CacheSize <= 0 {
return fmt.Errorf("jdenticon: engine: invalid cache size: %d", config.CacheSize)
}
// Create new cache with updated size if necessary
if config.CacheSize != g.config.CacheSize {
newCache, err := lru.New[string, *Icon](config.CacheSize)
if err != nil {
return fmt.Errorf("jdenticon: engine: failed to create new cache: %w", err)
}
g.cache = newCache
} else {
// Same cache size, just clear existing cache
g.cache.Purge()
}
g.config = config
// Update resolved max icon size
if config.MaxIconSize > 0 {
g.maxIconSize = config.MaxIconSize
} else {
g.maxIconSize = constants.DefaultMaxIconSize
}
return nil
}
// GetConfig returns the current color configuration
func (g *Generator) GetConfig() ColorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config.ColorConfig
}
// GetGeneratorConfig returns the current generator configuration
func (g *Generator) GetGeneratorConfig() GeneratorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config
}

View File

@@ -0,0 +1,449 @@
package engine
import (
"context"
"fmt"
"sync"
"testing"
)
func TestGenerateCaching(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef123456789"
size := 64.0
// Generate icon first time
icon1, err := generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
// Check cache size
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
}
// Generate same icon again
icon2, err := generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// Should be the same instance from cache
if icon1 != icon2 {
t.Error("Second generate did not return cached instance")
}
// Cache size should still be 1
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
}
}
func TestClearCache(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Clear cache
generator.ClearCache()
// Verify cache is empty
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
}
}
func TestSetConfig(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Set new config
newConfig := DefaultColorConfig()
newConfig.IconPadding = 0.1
generator.SetConfig(newConfig)
// Verify config was updated
if generator.GetConfig().IconPadding != 0.1 {
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
}
// Verify cache was cleared
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
}
func TestLRUCacheEviction(t *testing.T) {
// Create generator with small cache for testing eviction
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 2, // Small cache to test eviction
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
// Generate 3 different icons to trigger eviction
hashes := []string{
"abcdef1234567890abcdef1234567890abcdef12",
"123456789abcdef0123456789abcdef0123456789",
"fedcba0987654321fedcba0987654321fedcba09",
}
size := 64.0
icons := make([]*Icon, len(hashes))
for i, hash := range hashes {
var icon *Icon
icon, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Generate failed for hash %s: %v", hash, err)
}
icons[i] = icon
}
// Cache should only contain 2 items (the last 2)
if generator.GetCacheSize() != 2 {
t.Errorf("Expected cache size 2 after eviction, got %d", generator.GetCacheSize())
}
// The first icon should have been evicted, so generating it again should create a new instance
icon1Again, err := generator.Generate(context.Background(), hashes[0], size)
if err != nil {
t.Fatalf("Generate failed for evicted hash: %v", err)
}
// This should be a different instance since the first was evicted
if icons[0] == icon1Again {
t.Error("First icon was not evicted from cache as expected")
}
// The last icon should still be cached
icon3Again, err := generator.Generate(context.Background(), hashes[2], size)
if err != nil {
t.Fatalf("Generate failed for cached hash: %v", err)
}
// This should be the same instance
if icons[2] != icon3Again {
t.Error("Last icon was evicted from cache unexpectedly")
}
}
func TestCacheMetrics(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
// Initially, metrics should be zero
hits, misses := generator.GetCacheMetrics()
if hits != 0 || misses != 0 {
t.Errorf("Expected initial metrics (0, 0), got (%d, %d)", hits, misses)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// First generation should be a cache miss
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
hits, misses = generator.GetCacheMetrics()
if hits != 0 || misses != 1 {
t.Errorf("Expected metrics (0, 1) after first generate, got (%d, %d)", hits, misses)
}
// Second generation should be a cache hit
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
hits, misses = generator.GetCacheMetrics()
if hits != 1 || misses != 1 {
t.Errorf("Expected metrics (1, 1) after cache hit, got (%d, %d)", hits, misses)
}
// Generate different icon should be another miss
_, err = generator.Generate(context.Background(), "1234567890abcdef1234567890abcdef12345678", size)
if err != nil {
t.Fatalf("Generate with different hash failed: %v", err)
}
hits, misses = generator.GetCacheMetrics()
if hits != 1 || misses != 2 {
t.Errorf("Expected metrics (1, 2) after different hash, got (%d, %d)", hits, misses)
}
// Clear cache should reset metrics
generator.ClearCache()
hits, misses = generator.GetCacheMetrics()
if hits != 0 || misses != 0 {
t.Errorf("Expected metrics (0, 0) after cache clear, got (%d, %d)", hits, misses)
}
}
func TestSetGeneratorConfig(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
// Generate an icon to populate cache
hash := "abcdef1234567890abcdef1234567890abcdef12"
_, err = generator.Generate(context.Background(), hash, 64.0)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Update configuration with different cache size
newConfig := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 500,
}
newConfig.ColorConfig.IconPadding = 0.15
err = generator.SetGeneratorConfig(newConfig)
if err != nil {
t.Fatalf("SetGeneratorConfig failed: %v", err)
}
// Verify configuration was updated
currentConfig := generator.GetGeneratorConfig()
if currentConfig.CacheSize != 500 {
t.Errorf("Expected cache size 500, got %d", currentConfig.CacheSize)
}
if currentConfig.ColorConfig.IconPadding != 0.15 {
t.Errorf("Expected icon padding 0.15, got %f", currentConfig.ColorConfig.IconPadding)
}
// Verify cache was cleared due to config change
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
// Verify cache capacity is updated
if generator.GetCacheCapacity() != 500 {
t.Errorf("Expected cache capacity 500, got %d", generator.GetCacheCapacity())
}
}
func TestSetGeneratorConfigSameSize(t *testing.T) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1000, // Same as default
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
// Generate an icon to populate cache
hash := "abcdef1234567890abcdef1234567890abcdef12"
_, err = generator.Generate(context.Background(), hash, 64.0)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Update configuration with same cache size but different color config
newConfig := config
newConfig.ColorConfig.IconPadding = 0.2
err = generator.SetGeneratorConfig(newConfig)
if err != nil {
t.Fatalf("SetGeneratorConfig failed: %v", err)
}
// Cache should be cleared even with same cache size
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
}
func TestSetGeneratorConfigInvalidCacheSize(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
tests := []struct {
name string
cacheSize int
}{
{"Zero cache size", 0},
{"Negative cache size", -1},
{"Very negative cache size", -100},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: test.cacheSize,
}
err := generator.SetGeneratorConfig(config)
if err == nil {
t.Errorf("Expected error for cache size %d, but got none", test.cacheSize)
}
})
}
}
func TestConcurrentCacheAccess(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
const numGoroutines = 10
const numGenerations = 5
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Launch multiple goroutines that generate the same icon concurrently
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < numGenerations; j++ {
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
t.Errorf("Concurrent generate failed: %v", err)
return
}
}
}()
}
wg.Wait()
// Cache should only contain one item since all goroutines generated the same icon
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1 after concurrent access, got %d", generator.GetCacheSize())
}
// Total cache operations should be recorded correctly (allow tolerance for singleflight deduplication)
hits, misses := generator.GetCacheMetrics()
totalOperations := hits + misses
expectedOperations := int64(numGoroutines * numGenerations)
// Singleflight can significantly reduce counted operations when many goroutines
// request the same key simultaneously - they share the result from one generation.
// Allow for up to 50% reduction due to deduplication in highly concurrent scenarios.
minExpected := expectedOperations / 2
if totalOperations < minExpected || totalOperations > expectedOperations {
t.Errorf("Expected %d-%d total cache operations, got %d (hits: %d, misses: %d)",
minExpected, expectedOperations, totalOperations, hits, misses)
}
// There should be at least one miss (the first generation)
if misses < 1 {
t.Errorf("Expected at least 1 cache miss, got %d", misses)
}
}
func BenchmarkCacheKey(b *testing.B) {
generator, err := NewDefaultGenerator()
if err != nil {
b.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = generator.cacheKey(hash, size)
}
}
func BenchmarkLRUCacheHit(b *testing.B) {
generator, err := NewDefaultGenerator()
if err != nil {
b.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// Pre-populate cache
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Pre-populate failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func BenchmarkLRUCacheMiss(b *testing.B) {
generator, err := NewDefaultGenerator()
if err != nil {
b.Fatalf("NewDefaultGenerator failed: %v", err)
}
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Use different hash each time to ensure cache miss
hash := fmt.Sprintf("%040x", i)
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}

View File

@@ -3,36 +3,85 @@ package engine
import (
"fmt"
"math"
"strconv"
"strings"
)
// Color-related constants
const (
// Alpha channel constants
defaultAlphaValue = 255 // Default alpha value for opaque colors
// RGB/HSL conversion constants
rgbComponentMax = 255.0 // Maximum RGB component value
rgbMaxValue = 255 // Maximum RGB value as integer
hueCycle = 6.0 // Hue cycle length for HSL conversion
hslMidpoint = 0.5 // HSL lightness midpoint
// Grayscale detection threshold
grayscaleToleranceThreshold = 0.01 // Threshold for detecting grayscale colors
// Hue calculation constants
hueSegmentCount = 6 // Number of hue segments for correction
hueRounding = 0.5 // Rounding offset for hue indexing
// Color theme lightness values (matches JavaScript implementation)
colorThemeDarkLightness = 0.0 // Dark color lightness value
colorThemeMidLightness = 0.5 // Mid color lightness value
colorThemeFullLightness = 1.0 // Full lightness value
// Hex color string buffer sizes
hexColorLength = 7 // #rrggbb = 7 characters
hexColorAlphaLength = 9 // #rrggbbaa = 9 characters
)
// Lightness correctors for each hue segment (based on JavaScript implementation)
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
// These values are carefully tuned to match the JavaScript reference implementation
var correctors = []float64{
0.55, // Red hues (0°-60°)
0.5, // Yellow hues (60°-120°)
0.5, // Green hues (120°-180°)
0.46, // Cyan hues (180°-240°)
0.6, // Blue hues (240°-300°)
0.55, // Magenta hues (300°-360°)
0.55, // Wrap-around for edge cases
}
// Color represents a color with both HSL and RGB representations
// Color represents a color with HSL representation and on-demand RGB conversion
type Color struct {
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
R, G, B uint8 // RGB values: [0,255]
A uint8 // Alpha channel: [0,255]
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
A uint8 // Alpha channel: [0,255]
corrected bool // Whether to use corrected HSL to RGB conversion
}
// ToRGB returns the RGB values computed from HSL using appropriate conversion
func (c Color) ToRGB() (r, g, b uint8, err error) {
if c.corrected {
return CorrectedHSLToRGB(c.H, c.S, c.L)
}
return HSLToRGB(c.H, c.S, c.L)
}
// ToRGBA returns the RGBA values computed from HSL using appropriate conversion
func (c Color) ToRGBA() (r, g, b, a uint8, err error) {
r, g, b, err = c.ToRGB()
return r, g, b, c.A, err
}
// NewColorHSL creates a new Color from HSL values
func NewColorHSL(h, s, l float64) Color {
r, g, b := HSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
A: defaultAlphaValue,
corrected: false,
}
}
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
func NewColorCorrectedHSL(h, s, l float64) Color {
r, g, b := CorrectedHSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
A: defaultAlphaValue,
corrected: true,
}
}
@@ -41,8 +90,8 @@ func NewColorRGB(r, g, b uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
A: defaultAlphaValue,
corrected: false,
}
}
@@ -51,102 +100,134 @@ func NewColorRGBA(r, g, b, a uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: a,
A: a,
corrected: false,
}
}
// String returns the hex representation of the color
func (c Color) String() string {
if c.A == 255 {
return RGBToHex(c.R, c.G, c.B)
r, g, b, err := c.ToRGB()
if err != nil {
// Return a fallback color (black) if conversion fails
// This maintains the string contract while indicating an error state
r, g, b = 0, 0, 0
}
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
if c.A == defaultAlphaValue {
return RGBToHex(r, g, b)
}
// Use strings.Builder for RGBA format
var buf strings.Builder
buf.Grow(hexColorAlphaLength)
buf.WriteByte('#')
writeHexByte(&buf, r)
writeHexByte(&buf, g)
writeHexByte(&buf, b)
writeHexByte(&buf, c.A)
return buf.String()
}
// Equals compares two colors for equality
func (c Color) Equals(other Color) bool {
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
r1, g1, b1, err1 := c.ToRGB()
r2, g2, b2, err2 := other.ToRGB()
// If either color has a conversion error, they are not equal
if err1 != nil || err2 != nil {
return false
}
return r1 == r2 && g1 == g2 && b1 == b2 && c.A == other.A
}
// WithAlpha returns a new color with the specified alpha value
func (c Color) WithAlpha(alpha uint8) Color {
return Color{
H: c.H, S: c.S, L: c.L,
R: c.R, G: c.G, B: c.B,
A: alpha,
A: alpha,
corrected: c.corrected,
}
}
// IsGrayscale returns true if the color is grayscale (saturation near zero)
func (c Color) IsGrayscale() bool {
return c.S < 0.01 // Small tolerance for floating point comparison
return c.S < grayscaleToleranceThreshold
}
// Darken returns a new color with reduced lightness
func (c Color) Darken(amount float64) Color {
newL := clamp(c.L-amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
return Color{
H: c.H, S: c.S, L: newL,
A: c.A,
corrected: c.corrected,
}
}
// Lighten returns a new color with increased lightness
func (c Color) Lighten(amount float64) Color {
newL := clamp(c.L+amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
return Color{
H: c.H, S: c.S, L: newL,
A: c.A,
corrected: c.corrected,
}
}
// RGBToHSL converts RGB values to HSL
// Returns H=[0,1], S=[0,1], L=[0,1]
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
rf := float64(r) / 255.0
gf := float64(g) / 255.0
bf := float64(b) / 255.0
rf := float64(r) / rgbComponentMax
gf := float64(g) / rgbComponentMax
bf := float64(b) / rgbComponentMax
max := math.Max(rf, math.Max(gf, bf))
min := math.Min(rf, math.Min(gf, bf))
// Calculate lightness
l = (max + min) / 2.0
if max == min {
// Achromatic (gray)
h, s = 0, 0
} else {
delta := max - min
// Calculate saturation
if l > 0.5 {
if l > hslMidpoint {
s = delta / (2.0 - max - min)
} else {
s = delta / (max + min)
}
// Calculate hue
switch max {
case rf:
h = (gf-bf)/delta + (func() float64 {
if gf < bf {
return 6
}
return 0
})()
h = (gf - bf) / delta
if gf < bf {
h += 6
}
case gf:
h = (bf-rf)/delta + 2
case bf:
h = (rf-gf)/delta + 4
}
h /= 6.0
h /= hueCycle
}
return h, s, l
}
// HSLToRGB converts HSL color values to RGB.
// h: hue in range [0, 1]
// s: saturation in range [0, 1]
// s: saturation in range [0, 1]
// l: lightness in range [0, 1]
// Returns RGB values in range [0, 255]
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
// Returns RGB values in range [0, 255] and an error if conversion fails
func HSLToRGB(h, s, l float64) (r, g, b uint8, err error) {
// Clamp input values to valid ranges
h = math.Mod(h, 1.0)
if h < 0 {
@@ -154,50 +235,72 @@ func HSLToRGB(h, s, l float64) (r, g, b uint8) {
}
s = clamp(s, 0, 1)
l = clamp(l, 0, 1)
// Handle grayscale case (saturation = 0)
if s == 0 {
// All RGB components are equal for grayscale
gray := uint8(clamp(l*255, 0, 255))
return gray, gray, gray
gray := uint8(clamp(l*rgbComponentMax, 0, rgbComponentMax))
return gray, gray, gray, nil
}
// Calculate intermediate values for HSL to RGB conversion
var m2 float64
if l <= 0.5 {
if l <= hslMidpoint {
m2 = l * (s + 1)
} else {
m2 = l + s - l*s
}
m1 := l*2 - m2
// Convert each RGB component
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
return r, g, b
rf := hueToRGB(m1, m2, h*hueCycle+2) * rgbComponentMax
gf := hueToRGB(m1, m2, h*hueCycle) * rgbComponentMax
bf := hueToRGB(m1, m2, h*hueCycle-2) * rgbComponentMax
// Validate floating point results before conversion to uint8
if math.IsNaN(rf) || math.IsInf(rf, 0) ||
math.IsNaN(gf) || math.IsInf(gf, 0) ||
math.IsNaN(bf) || math.IsInf(bf, 0) {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: HSL to RGB conversion failed: non-finite value produced during conversion")
}
r = uint8(clamp(rf, 0, rgbComponentMax))
g = uint8(clamp(gf, 0, rgbComponentMax))
b = uint8(clamp(bf, 0, rgbComponentMax))
return r, g, b, nil
}
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
// This function adjusts the lightness based on the hue to compensate for the human eye's
// different sensitivity to different colors.
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8, err error) {
// Defensive check: ensure correctors table is properly initialized
if len(correctors) == 0 {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: color correctors table is empty or not initialized")
}
// Get the corrector for the current hue
hueIndex := int((h*6 + 0.5)) % len(correctors)
hueIndex := int((h*hueSegmentCount + hueRounding)) % len(correctors)
corrector := correctors[hueIndex]
// Adjust lightness relative to the corrector
if l < 0.5 {
if l < hslMidpoint {
l = l * corrector * 2
} else {
l = corrector + (l-0.5)*(1-corrector)*2
l = corrector + (l-hslMidpoint)*(1-corrector)*2
}
// Clamp the corrected lightness
l = clamp(l, 0, 1)
return HSLToRGB(h, s, l)
// Call HSLToRGB and propagate any error
r, g, b, err = HSLToRGB(h, s, l)
if err != nil {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: %w", err)
}
return r, g, b, nil
}
// hueToRGB converts a hue value to an RGB component value
@@ -205,11 +308,11 @@ func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
func hueToRGB(m1, m2, h float64) float64 {
// Normalize hue to [0, 6) range
if h < 0 {
h += 6
} else if h > 6 {
h -= 6
h += hueCycle
} else if h > hueCycle {
h -= hueCycle
}
// Calculate RGB component based on hue position
if h < 1 {
return m1 + (m2-m1)*h
@@ -233,77 +336,35 @@ func clamp(value, min, max float64) float64 {
return value
}
// writeHexByte writes a single byte as two hex characters to the builder
func writeHexByte(buf *strings.Builder, b uint8) {
const hexChars = "0123456789abcdef"
buf.WriteByte(hexChars[b>>4])
buf.WriteByte(hexChars[b&0x0f])
}
// RGBToHex converts RGB values to a hexadecimal color string
func RGBToHex(r, g, b uint8) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// Use a strings.Builder for more efficient hex formatting
var buf strings.Builder
buf.Grow(hexColorLength)
// ParseHexColor parses a hexadecimal color string and returns RGB values
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
// Returns error if the format is invalid
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
if len(color) == 0 || color[0] != '#' {
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
}
hex := color[1:] // Remove '#' prefix
a = 255 // Default alpha
// Helper to parse a component and chain errors
parse := func(target *uint8, hexStr string) {
if err != nil {
return // Don't parse if a previous component failed
}
*target, err = hexToByte(hexStr)
}
switch len(hex) {
case 3: // #RGB
parse(&r, hex[0:1]+hex[0:1])
parse(&g, hex[1:2]+hex[1:2])
parse(&b, hex[2:3]+hex[2:3])
case 6: // #RRGGBB
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
case 8: // #RRGGBBAA
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
parse(&a, hex[6:8])
default:
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
}
if err != nil {
// Return zero values for color components on error, but keep default alpha
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
}
return r, g, b, a, nil
}
buf.WriteByte('#')
writeHexByte(&buf, r)
writeHexByte(&buf, g)
writeHexByte(&buf, b)
// hexToByte converts a 2-character hex string to a byte value
func hexToByte(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
}
n, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
}
return uint8(n), nil
return buf.String()
}
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
// Get lightness from configuration range
lightness := config.ColorLightness.GetLightness(lightnessValue)
// Use corrected HSL to RGB conversion
return NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, lightness)
}
@@ -311,11 +372,11 @@ func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Colo
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
// For grayscale, hue doesn't matter, but we'll use 0
hue := 0.0
hue := colorThemeDarkLightness
// Get lightness from grayscale configuration range
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
// Use grayscale saturation (typically 0)
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
}
@@ -326,21 +387,21 @@ func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
func GenerateColorTheme(hue float64, config ColorConfig) []Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
return []Color{
// Dark gray (grayscale with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeDarkLightness)),
// Mid color (normal color with lightness 0.5)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeMidLightness)),
// Light gray (grayscale with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeFullLightness)),
// Light color (normal color with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeFullLightness)),
// Dark color (normal color with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeDarkLightness)),
}
}
}

View File

@@ -1,17 +1,18 @@
package engine
import (
"fmt"
"testing"
)
var benchmarkCases = []struct {
h, s, l float64
}{
{0.0, 0.5, 0.5}, // Red
{0.33, 0.5, 0.5}, // Green
{0.66, 0.5, 0.5}, // Blue
{0.5, 1.0, 0.3}, // Cyan dark
{0.8, 0.8, 0.7}, // Purple light
{0.0, 0.5, 0.5}, // Red
{0.33, 0.5, 0.5}, // Green
{0.66, 0.5, 0.5}, // Blue
{0.5, 1.0, 0.3}, // Cyan dark
{0.8, 0.8, 0.7}, // Purple light
}
func BenchmarkCorrectedHSLToRGB(b *testing.B) {
@@ -32,4 +33,69 @@ func BenchmarkNewColorCorrectedHSL(b *testing.B) {
tc := benchmarkCases[i%len(benchmarkCases)]
NewColorCorrectedHSL(tc.h, tc.s, tc.l)
}
}
}
// Benchmark hex color formatting optimization
func BenchmarkRGBToHex(b *testing.B) {
colors := []struct {
r, g, b uint8
}{
{255, 0, 0},
{0, 255, 0},
{0, 0, 255},
{128, 128, 128},
{255, 255, 255},
{0, 0, 0},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, c := range colors {
_ = RGBToHex(c.r, c.g, c.b)
}
}
}
// Benchmark against the old fmt.Sprintf approach
func BenchmarkRGBToHex_OldSprintf(b *testing.B) {
colors := []struct {
r, g, b uint8
}{
{255, 0, 0},
{0, 255, 0},
{0, 0, 255},
{128, 128, 128},
{255, 255, 255},
{0, 0, 0},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, c := range colors {
_ = fmt.Sprintf("#%02x%02x%02x", c.r, c.g, c.b)
}
}
}
// Benchmark Color.String() method
func BenchmarkColorString(b *testing.B) {
colors := []Color{
NewColorRGB(255, 0, 0), // Red, no alpha
NewColorRGBA(0, 255, 0, 128), // Green with alpha
NewColorRGB(0, 0, 255), // Blue, no alpha
NewColorRGBA(128, 128, 128, 200), // Gray with alpha
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, c := range colors {
_ = c.String()
}
}
}

View File

@@ -0,0 +1,197 @@
package engine
import (
"testing"
)
// TestCorrectedHSLToRGB_EmptyCorrectors tests the defensive bounds checking
// for the correctors array in CorrectedHSLToRGB
func TestCorrectedHSLToRGB_EmptyCorrectors(t *testing.T) {
// Save original correctors
originalCorrectors := correctors
defer func() { correctors = originalCorrectors }()
// Temporarily modify the unexported variable for this test
correctors = []float64{}
// Call the function and assert that it returns the expected error
//nolint:dogsled // We only care about the error in this test
_, _, _, err := CorrectedHSLToRGB(0.5, 0.5, 0.5)
if err == nil {
t.Fatal("expected an error for empty correctors, got nil")
}
// Check if error message contains expected content
expectedMsg := "color correctors table is empty"
if !contains(err.Error(), expectedMsg) {
t.Errorf("expected error message to contain %q, got %q", expectedMsg, err.Error())
}
t.Logf("Got expected error: %v", err)
}
// TestHSLToRGB_FloatingPointValidation tests that HSLToRGB validates
// floating point results and catches NaN/Inf values
func TestHSLToRGB_FloatingPointValidation(t *testing.T) {
// Test normal cases first
testCases := []struct {
name string
h, s, l float64
expectError bool
}{
{"normal_color", 0.5, 0.5, 0.5, false},
{"pure_red", 0.0, 1.0, 0.5, false},
{"white", 0.0, 0.0, 1.0, false},
{"black", 0.0, 0.0, 0.0, false},
{"grayscale", 0.0, 0.0, 0.5, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r, g, b, err := HSLToRGB(tc.h, tc.s, tc.l)
if tc.expectError {
if err == nil {
t.Errorf("expected error for %s, got none", tc.name)
}
} else {
if err != nil {
t.Errorf("unexpected error for %s: %v", tc.name, err)
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_ = r
_ = g
_ = b
}
})
}
}
// TestColor_ToRGB_ErrorHandling tests that Color.ToRGB properly handles
// errors from the underlying conversion functions
func TestColor_ToRGB_ErrorHandling(t *testing.T) {
// Test with normal values
color := NewColorHSL(0.5, 0.5, 0.5)
r, g, b, err := color.ToRGB()
if err != nil {
t.Errorf("ToRGB failed for normal color: %v", err)
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_ = r
_ = g
_ = b
// Test corrected color conversion
correctedColor := NewColorCorrectedHSL(0.5, 0.5, 0.5)
r2, g2, b2, err2 := correctedColor.ToRGB()
if err2 != nil {
t.Errorf("ToRGB failed for corrected color: %v", err2)
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_ = r2
_ = g2
_ = b2
}
// TestColor_String_ErrorFallback tests that Color.String() properly handles
// conversion errors by falling back to black
func TestColor_String_ErrorFallback(t *testing.T) {
// Save original correctors
originalCorrectors := correctors
defer func() { correctors = originalCorrectors }()
// Create a corrected color that will fail conversion
color := NewColorCorrectedHSL(0.5, 0.5, 0.5)
// Temporarily break correctors to force an error
correctors = []float64{}
// String() should not panic and should return a fallback color
result := color.String()
// Should return black (#000000) as fallback
expected := "#000000"
if result != expected {
t.Errorf("expected fallback color %s, got %s", expected, result)
}
t.Logf("String() properly fell back to: %s", result)
}
// TestColor_Equals_ErrorHandling tests that Color.Equals properly handles
// conversion errors by returning false
func TestColor_Equals_ErrorHandling(t *testing.T) {
// Save original correctors
originalCorrectors := correctors
defer func() { correctors = originalCorrectors }()
color1 := NewColorCorrectedHSL(0.5, 0.5, 0.5)
color2 := NewColorCorrectedHSL(0.5, 0.5, 0.5)
// First test normal comparison
if !color1.Equals(color2) {
t.Error("identical colors should be equal")
}
// Now break correctors to force conversion errors
correctors = []float64{}
// Colors with conversion errors should not be equal
if color1.Equals(color2) {
t.Error("colors with conversion errors should not be equal")
}
t.Log("Equals properly handled conversion errors")
}
// TestGenerateColorTheme_Robustness tests that GenerateColorTheme always
// returns exactly 5 colors and handles edge cases
func TestGenerateColorTheme_Robustness(t *testing.T) {
config := DefaultColorConfig()
testCases := []struct {
name string
hue float64
}{
{"zero_hue", 0.0},
{"mid_hue", 0.5},
{"max_hue", 1.0},
{"negative_hue", -0.5}, // Should be handled by hue normalization
{"large_hue", 2.5}, // Should be handled by hue normalization
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
colors := GenerateColorTheme(tc.hue, config)
// Must always return exactly 5 colors
if len(colors) != 5 {
t.Errorf("GenerateColorTheme returned %d colors, expected 5", len(colors))
}
// Each color should be valid (convertible to RGB)
for i, color := range colors {
_, _, _, err := color.ToRGB()
if err != nil {
t.Errorf("color %d in theme failed RGB conversion: %v", i, err)
}
}
})
}
}
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr || (len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
func() bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}())))
}

View File

@@ -7,61 +7,64 @@ import (
func TestHSLToRGB(t *testing.T) {
tests := []struct {
name string
name string
h, s, l float64
r, g, b uint8
}{
{
name: "pure red",
h: 0.0, s: 1.0, l: 0.5,
h: 0.0, s: 1.0, l: 0.5,
r: 255, g: 0, b: 0,
},
{
name: "pure green",
h: 1.0/3.0, s: 1.0, l: 0.5,
name: "pure green",
h: 1.0 / 3.0, s: 1.0, l: 0.5,
r: 0, g: 255, b: 0,
},
{
name: "pure blue",
h: 2.0/3.0, s: 1.0, l: 0.5,
h: 2.0 / 3.0, s: 1.0, l: 0.5,
r: 0, g: 0, b: 255,
},
{
name: "white",
h: 0.0, s: 0.0, l: 1.0,
h: 0.0, s: 0.0, l: 1.0,
r: 255, g: 255, b: 255,
},
{
name: "black",
h: 0.0, s: 0.0, l: 0.0,
h: 0.0, s: 0.0, l: 0.0,
r: 0, g: 0, b: 0,
},
{
name: "gray",
h: 0.0, s: 0.0, l: 0.5,
h: 0.0, s: 0.0, l: 0.5,
r: 127, g: 127, b: 127,
},
{
name: "dark red",
h: 0.0, s: 1.0, l: 0.25,
h: 0.0, s: 1.0, l: 0.25,
r: 127, g: 0, b: 0,
},
{
name: "light blue",
h: 2.0/3.0, s: 1.0, l: 0.75,
h: 2.0 / 3.0, s: 1.0, l: 0.75,
r: 127, g: 127, b: 255,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b := HSLToRGB(tt.h, tt.s, tt.l)
r, g, b, err := HSLToRGB(tt.h, tt.s, tt.l)
if err != nil {
t.Fatalf("HSLToRGB failed: %v", err)
}
// Allow small tolerance due to floating point arithmetic
tolerance := uint8(2)
if abs(int(r), int(tt.r)) > int(tolerance) ||
abs(int(g), int(tt.g)) > int(tolerance) ||
abs(int(b), int(tt.b)) > int(tolerance) {
abs(int(g), int(tt.g)) > int(tolerance) ||
abs(int(b), int(tt.b)) > int(tolerance) {
t.Errorf("HSLToRGB(%f, %f, %f) = (%d, %d, %d), want (%d, %d, %d)",
tt.h, tt.s, tt.l, r, g, b, tt.r, tt.g, tt.b)
}
@@ -82,16 +85,17 @@ func TestCorrectedHSLToRGB(t *testing.T) {
{"DarkCyan", 0.5, 0.7, 0.3},
{"LightMagenta", 0.8, 0.8, 0.8},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r, g, b := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
// Verify RGB values are in valid range
if r > 255 || g > 255 || b > 255 {
t.Errorf("CorrectedHSLToRGB(%f, %f, %f) = (%d, %d, %d), RGB values should be <= 255",
tc.h, tc.s, tc.l, r, g, b)
r, g, b, err := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
if err != nil {
t.Fatalf("CorrectedHSLToRGB failed: %v", err)
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_ = r
_ = g
_ = b
})
}
}
@@ -120,83 +124,6 @@ func TestRGBToHex(t *testing.T) {
}
}
func TestHexToByte(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
expectError bool
}{
{
name: "valid hex 00",
input: "00",
expected: 0,
},
{
name: "valid hex ff",
input: "ff",
expected: 255,
},
{
name: "valid hex a5",
input: "a5",
expected: 165,
},
{
name: "valid hex A5 uppercase",
input: "A5",
expected: 165,
},
{
name: "invalid length - too short",
input: "f",
expectError: true,
},
{
name: "invalid length - too long",
input: "fff",
expectError: true,
},
{
name: "invalid character x",
input: "fx",
expectError: true,
},
{
name: "invalid character z",
input: "zz",
expectError: true,
},
{
name: "empty string",
input: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := hexToByte(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("hexToByte(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("hexToByte(%s) unexpected error: %v", tt.input, err)
return
}
if result != tt.expected {
t.Errorf("hexToByte(%s) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestParseHexColor(t *testing.T) {
tests := []struct {
name string
@@ -205,78 +132,79 @@ func TestParseHexColor(t *testing.T) {
r, g, b, a uint8
}{
{
name: "3-char hex",
name: "3-char hex",
input: "#f0a",
r: 255, g: 0, b: 170, a: 255,
r: 255, g: 0, b: 170, a: 255,
},
{
name: "6-char hex",
name: "6-char hex",
input: "#ff00aa",
r: 255, g: 0, b: 170, a: 255,
r: 255, g: 0, b: 170, a: 255,
},
{
name: "8-char hex with alpha",
name: "8-char hex with alpha",
input: "#ff00aa80",
r: 255, g: 0, b: 170, a: 128,
r: 255, g: 0, b: 170, a: 128,
},
{
name: "black",
name: "black",
input: "#000",
r: 0, g: 0, b: 0, a: 255,
r: 0, g: 0, b: 0, a: 255,
},
{
name: "white",
name: "white",
input: "#fff",
r: 255, g: 255, b: 255, a: 255,
r: 255, g: 255, b: 255, a: 255,
},
{
name: "invalid format - no hash",
input: "ff0000",
name: "invalid format - no hash",
input: "ff0000",
expectError: true,
},
{
name: "invalid format - too short",
input: "#f",
name: "invalid format - too short",
input: "#f",
expectError: true,
},
{
name: "invalid format - too long",
input: "#ff00aa12345",
name: "invalid format - too long",
input: "#ff00aa12345",
expectError: true,
},
{
name: "invalid hex character in 3-char",
input: "#fxz",
name: "invalid hex character in 3-char",
input: "#fxz",
expectError: true,
},
{
name: "invalid hex character in 6-char",
input: "#ff00xz",
name: "invalid hex character in 6-char",
input: "#ff00xz",
expectError: true,
},
{
name: "invalid hex character in 8-char",
input: "#ff00aaxz",
name: "invalid hex character in 8-char",
input: "#ff00aaxz",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b, a, err := ParseHexColor(tt.input)
rgba, err := ParseHexColorToRGBA(tt.input)
r, g, b, a := rgba.R, rgba.G, rgba.B, rgba.A
if tt.expectError {
if err == nil {
t.Errorf("ParseHexColor(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseHexColor(%s) unexpected error: %v", tt.input, err)
return
}
if r != tt.r || g != tt.g || b != tt.b || a != tt.a {
t.Errorf("ParseHexColor(%s) = (%d, %d, %d, %d), want (%d, %d, %d, %d)",
tt.input, r, g, b, a, tt.r, tt.g, tt.b, tt.a)
@@ -289,11 +217,11 @@ func TestClamp(t *testing.T) {
tests := []struct {
value, min, max, expected float64
}{
{0.5, 0.0, 1.0, 0.5}, // within range
{-0.5, 0.0, 1.0, 0.0}, // below min
{1.5, 0.0, 1.0, 1.0}, // above max
{0.0, 0.0, 1.0, 0.0}, // at min
{1.0, 0.0, 1.0, 1.0}, // at max
{0.5, 0.0, 1.0, 0.5}, // within range
{-0.5, 0.0, 1.0, 0.0}, // below min
{1.5, 0.0, 1.0, 1.0}, // above max
{0.0, 0.0, 1.0, 0.0}, // at min
{1.0, 0.0, 1.0, 1.0}, // at max
}
for _, tt := range tests {
@@ -306,17 +234,21 @@ func TestClamp(t *testing.T) {
func TestNewColorHSL(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
if color.H != 0.0 || color.S != 1.0 || color.L != 0.5 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) HSL = (%f, %f, %f), want (0.0, 1.0, 0.5)",
color.H, color.S, color.L)
}
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
r, g, b, err := color.ToRGB()
if err != nil {
t.Fatalf("ToRGB failed: %v", err)
}
if r != 255 || g != 0 || b != 0 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
r, g, b)
}
if color.A != 255 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) A = %d, want 255", color.A)
}
@@ -324,12 +256,16 @@ func TestNewColorHSL(t *testing.T) {
func TestNewColorRGB(t *testing.T) {
color := NewColorRGB(255, 0, 0) // Pure red
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
r, g, b, err := color.ToRGB()
if err != nil {
t.Fatalf("ToRGB failed: %v", err)
}
if r != 255 || g != 0 || b != 0 {
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
r, g, b)
}
// HSL values should be approximately (0, 1, 0.5) for pure red
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance || math.Abs(color.S-1.0) > tolerance || math.Abs(color.L-0.5) > tolerance {
@@ -393,16 +329,24 @@ func TestColorEquals(t *testing.T) {
func TestColorWithAlpha(t *testing.T) {
color := NewColorRGB(255, 0, 0)
newColor := color.WithAlpha(128)
if newColor.A != 128 {
t.Errorf("WithAlpha(128) A = %d, want 128", newColor.A)
}
// RGB and HSL should remain the same
if newColor.R != color.R || newColor.G != color.G || newColor.B != color.B {
newColorR, newColorG, newColorB, err1 := newColor.ToRGB()
if err1 != nil {
t.Fatalf("newColor.ToRGB failed: %v", err1)
}
colorR, colorG, colorB, err2 := color.ToRGB()
if err2 != nil {
t.Fatalf("color.ToRGB failed: %v", err2)
}
if newColorR != colorR || newColorG != colorG || newColorB != colorB {
t.Error("WithAlpha should not change RGB values")
}
if newColor.H != color.H || newColor.S != color.S || newColor.L != color.L {
t.Error("WithAlpha should not change HSL values")
}
@@ -411,11 +355,11 @@ func TestColorWithAlpha(t *testing.T) {
func TestColorIsGrayscale(t *testing.T) {
grayColor := NewColorRGB(128, 128, 128)
redColor := NewColorRGB(255, 0, 0)
if !grayColor.IsGrayscale() {
t.Error("Expected gray color to be identified as grayscale")
}
if redColor.IsGrayscale() {
t.Error("Expected red color to not be identified as grayscale")
}
@@ -423,23 +367,23 @@ func TestColorIsGrayscale(t *testing.T) {
func TestColorDarkenLighten(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
darker := color.Darken(0.2)
if darker.L >= color.L {
t.Error("Darken should reduce lightness")
}
lighter := color.Lighten(0.2)
if lighter.L <= color.L {
t.Error("Lighten should increase lightness")
}
// Test clamping
veryDark := color.Darken(1.0)
if veryDark.L != 0.0 {
t.Errorf("Darken with large amount should clamp to 0, got %f", veryDark.L)
}
veryLight := color.Lighten(1.0)
if veryLight.L != 1.0 {
t.Errorf("Lighten with large amount should clamp to 1, got %f", veryLight.L)
@@ -454,32 +398,32 @@ func TestRGBToHSL(t *testing.T) {
}{
{
name: "red",
r: 255, g: 0, b: 0,
r: 255, g: 0, b: 0,
h: 0.0, s: 1.0, l: 0.5,
},
{
name: "green",
r: 0, g: 255, b: 0,
h: 1.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 255, b: 0,
h: 1.0 / 3.0, s: 1.0, l: 0.5,
},
{
name: "blue",
r: 0, g: 0, b: 255,
h: 2.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 0, b: 255,
h: 2.0 / 3.0, s: 1.0, l: 0.5,
},
{
name: "white",
r: 255, g: 255, b: 255,
r: 255, g: 255, b: 255,
h: 0.0, s: 0.0, l: 1.0,
},
{
name: "black",
r: 0, g: 0, b: 0,
r: 0, g: 0, b: 0,
h: 0.0, s: 0.0, l: 0.0,
},
{
name: "gray",
r: 128, g: 128, b: 128,
r: 128, g: 128, b: 128,
h: 0.0, s: 0.0, l: 0.502, // approximately 0.5
},
}
@@ -487,7 +431,7 @@ func TestRGBToHSL(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, s, l := RGBToHSL(tt.r, tt.g, tt.b)
tolerance := 0.01
if math.Abs(h-tt.h) > tolerance || math.Abs(s-tt.s) > tolerance || math.Abs(l-tt.l) > tolerance {
t.Errorf("RGBToHSL(%d, %d, %d) = (%f, %f, %f), want approximately (%f, %f, %f)",
@@ -499,22 +443,22 @@ func TestRGBToHSL(t *testing.T) {
func TestGenerateColor(t *testing.T) {
config := DefaultColorConfig()
// Test color generation with mid-range lightness
color := GenerateColor(0.0, config, 0.5) // Red hue, mid lightness
// Should be approximately red with default saturation (0.5) and mid lightness (0.6)
expectedLightness := config.ColorLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance {
t.Errorf("GenerateColor hue = %f, want approximately 0.0", color.H)
}
if math.Abs(color.S-config.ColorSaturation) > tolerance {
t.Errorf("GenerateColor saturation = %f, want %f", color.S, config.ColorSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateColor lightness = %f, want approximately %f", color.L, expectedLightness)
}
@@ -522,22 +466,22 @@ func TestGenerateColor(t *testing.T) {
func TestGenerateGrayscale(t *testing.T) {
config := DefaultColorConfig()
// Test grayscale generation
color := GenerateGrayscale(config, 0.5)
// Should be grayscale (saturation 0) with mid lightness
expectedLightness := config.GrayscaleLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.S-config.GrayscaleSaturation) > tolerance {
t.Errorf("GenerateGrayscale saturation = %f, want %f", color.S, config.GrayscaleSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateGrayscale lightness = %f, want approximately %f", color.L, expectedLightness)
}
if !color.IsGrayscale() {
t.Error("GenerateGrayscale should produce a grayscale color")
}
@@ -546,17 +490,17 @@ func TestGenerateGrayscale(t *testing.T) {
func TestGenerateColorTheme(t *testing.T) {
config := DefaultColorConfig()
hue := 0.25 // Green-ish hue
theme := GenerateColorTheme(hue, config)
// Should have exactly 5 colors
if len(theme) != 5 {
t.Errorf("GenerateColorTheme returned %d colors, want 5", len(theme))
}
// Test color indices according to JavaScript implementation:
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
// Index 0: Dark gray (grayscale with lightness 0)
darkGray := theme[0]
if !darkGray.IsGrayscale() {
@@ -566,7 +510,7 @@ func TestGenerateColorTheme(t *testing.T) {
if math.Abs(darkGray.L-expectedLightness) > 0.01 {
t.Errorf("Dark gray lightness = %f, want %f", darkGray.L, expectedLightness)
}
// Index 1: Mid color (normal color with lightness 0.5)
midColor := theme[1]
if midColor.IsGrayscale() {
@@ -576,7 +520,7 @@ func TestGenerateColorTheme(t *testing.T) {
if math.Abs(midColor.L-expectedLightness) > 0.01 {
t.Errorf("Mid color lightness = %f, want %f", midColor.L, expectedLightness)
}
// Index 2: Light gray (grayscale with lightness 1)
lightGray := theme[2]
if !lightGray.IsGrayscale() {
@@ -586,7 +530,7 @@ func TestGenerateColorTheme(t *testing.T) {
if math.Abs(lightGray.L-expectedLightness) > 0.01 {
t.Errorf("Light gray lightness = %f, want %f", lightGray.L, expectedLightness)
}
// Index 3: Light color (normal color with lightness 1)
lightColor := theme[3]
if lightColor.IsGrayscale() {
@@ -596,7 +540,7 @@ func TestGenerateColorTheme(t *testing.T) {
if math.Abs(lightColor.L-expectedLightness) > 0.01 {
t.Errorf("Light color lightness = %f, want %f", lightColor.L, expectedLightness)
}
// Index 4: Dark color (normal color with lightness 0)
darkColor := theme[4]
if darkColor.IsGrayscale() {
@@ -606,7 +550,7 @@ func TestGenerateColorTheme(t *testing.T) {
if math.Abs(darkColor.L-expectedLightness) > 0.01 {
t.Errorf("Dark color lightness = %f, want %f", darkColor.L, expectedLightness)
}
// All colors should have the same hue (or close to it for grayscale)
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
@@ -619,12 +563,11 @@ func TestGenerateColorTheme(t *testing.T) {
func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
// Test with hue restriction
config := NewColorConfigBuilder().
WithHues(180). // Only allow cyan (180 degrees = 0.5 turns)
Build()
config := DefaultColorConfig()
config.Hues = []float64{180} // Only allow cyan (180 degrees = 0.5 turns)
theme := GenerateColorTheme(0.25, config) // Request green, should get cyan
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
if math.Abs(color.H-0.5) > 0.01 {
@@ -636,18 +579,17 @@ func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
func TestGenerateColorWithConfiguration(t *testing.T) {
// Test with custom configuration
config := NewColorConfigBuilder().
WithColorSaturation(0.8).
WithColorLightness(0.2, 0.6).
Build()
config := DefaultColorConfig()
config.ColorSaturation = 0.8
config.ColorLightness = LightnessRange{Min: 0.2, Max: 0.6}
color := GenerateColor(0.33, config, 1.0) // Green hue, max lightness
tolerance := 0.01
if math.Abs(color.S-0.8) > tolerance {
t.Errorf("Custom config saturation = %f, want 0.8", color.S)
}
expectedLightness := config.ColorLightness.GetLightness(1.0) // Should be 0.6
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("Custom config lightness = %f, want %f", color.L, expectedLightness)
@@ -660,4 +602,4 @@ func abs(a, b int) int {
return a - b
}
return b - a
}
}

View File

@@ -0,0 +1,178 @@
package engine
import (
"fmt"
"image/color"
"regexp"
"strconv"
"sync"
)
var (
// Compiled regex pattern for hex color validation
hexColorRegex *regexp.Regexp
// Initialization guard for hex color regex
hexColorRegexOnce sync.Once
)
// getHexColorRegex returns the compiled hex color regex pattern, compiling it only once.
// Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
func getHexColorRegex() *regexp.Regexp {
hexColorRegexOnce.Do(func() {
hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
})
return hexColorRegex
}
// ParseHexColorToRGBA is the consolidated hex color parsing function for the entire codebase.
// It parses a hexadecimal color string and returns color.RGBA and an error.
// Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
// Returns error if the format is invalid.
//
// This function replaces all other hex color parsing implementations and provides
// consistent error handling for all color operations, following REQ-1.3.
func ParseHexColorToRGBA(hexStr string) (color.RGBA, error) {
if len(hexStr) == 0 || hexStr[0] != '#' {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: invalid color format: %s", hexStr)
}
// Validate the hex color format using regex
if !getHexColorRegex().MatchString(hexStr) {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: invalid hex color format: %s", hexStr)
}
hex := hexStr[1:] // Remove '#' prefix
var r, g, b, a uint8 = 0, 0, 0, 255 // Default alpha is fully opaque
// Helper to parse a 2-character hex component
parse := func(target *uint8, hexStr string) error {
val, err := hexToByte(hexStr)
if err != nil {
return err
}
*target = val
return nil
}
// Helper to parse a single hex digit and expand it (e.g., 'F' -> 'FF' = 255)
parseShort := func(target *uint8, hexChar byte) error {
var val uint8
if hexChar >= '0' && hexChar <= '9' {
val = hexChar - '0'
} else if hexChar >= 'a' && hexChar <= 'f' {
val = hexChar - 'a' + 10
} else if hexChar >= 'A' && hexChar <= 'F' {
val = hexChar - 'A' + 10
} else {
return fmt.Errorf("jdenticon: engine: hex digit parsing failed: invalid hex character: %c", hexChar)
}
*target = val * 17 // Expand single digit: 0xF * 17 = 0xFF
return nil
}
switch len(hex) {
case 3: // #RGB -> expand to #RRGGBB
if err := parseShort(&r, hex[0]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
}
if err := parseShort(&g, hex[1]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
}
if err := parseShort(&b, hex[2]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
}
case 4: // #RGBA -> expand to #RRGGBBAA
if err := parseShort(&r, hex[0]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
}
if err := parseShort(&g, hex[1]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
}
if err := parseShort(&b, hex[2]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
}
if err := parseShort(&a, hex[3]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse alpha component: %w", err)
}
case 6: // #RRGGBB
if err := parse(&r, hex[0:2]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
}
if err := parse(&g, hex[2:4]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
}
if err := parse(&b, hex[4:6]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
}
case 8: // #RRGGBBAA
if err := parse(&r, hex[0:2]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse red component: %w", err)
}
if err := parse(&g, hex[2:4]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse green component: %w", err)
}
if err := parse(&b, hex[4:6]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse blue component: %w", err)
}
if err := parse(&a, hex[6:8]); err != nil {
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: failed to parse alpha component: %w", err)
}
default:
// This case should be unreachable due to the regex validation above.
// Return an error instead of panicking to ensure library never panics.
return color.RGBA{}, fmt.Errorf("jdenticon: engine: hex color parsing failed: unsupported color format with length %d", len(hex))
}
return color.RGBA{R: r, G: g, B: b, A: a}, nil
}
// ValidateHexColor validates that a color string is a valid hex color format.
// Returns nil if valid, error if invalid.
func ValidateHexColor(hexStr string) error {
if !getHexColorRegex().MatchString(hexStr) {
return fmt.Errorf("jdenticon: engine: hex color validation failed: color must be a hex color like #fff, #ffffff, or #ffffff80")
}
return nil
}
// ParseHexColorToEngine parses a hex color string and returns an engine.Color.
// This is a convenience function for converting hex colors to the engine's internal Color type.
func ParseHexColorToEngine(hexStr string) (Color, error) {
rgba, err := ParseHexColorToRGBA(hexStr)
if err != nil {
return Color{}, err
}
return NewColorRGBA(rgba.R, rgba.G, rgba.B, rgba.A), nil
}
// ParseHexColorForRenderer parses a hex color for use in renderers.
// Returns color.RGBA with the specified opacity applied.
// This function provides compatibility with the fast PNG renderer's parseColor function.
func ParseHexColorForRenderer(hexStr string, opacity float64) (color.RGBA, error) {
rgba, err := ParseHexColorToRGBA(hexStr)
if err != nil {
return color.RGBA{}, err
}
// Apply opacity to the alpha channel
rgba.A = uint8(float64(rgba.A) * opacity)
return rgba, nil
}
// hexToByte converts a 2-character hex string to a byte value.
// This is a helper function used by ParseHexColor.
func hexToByte(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("jdenticon: engine: hex byte parsing failed: invalid hex string length: expected 2 characters, got %d", len(hex))
}
n, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("jdenticon: engine: hex byte parsing failed: invalid hex value '%s': %w", hex, err)
}
return uint8(n), nil
}

View File

@@ -0,0 +1,229 @@
package engine
import (
"image/color"
"testing"
)
func TestParseHexColorToRGBA(t *testing.T) {
tests := []struct {
name string
input string
expected color.RGBA
hasError bool
}{
// Valid cases
{"RGB short form", "#000", color.RGBA{0, 0, 0, 255}, false},
{"RGB short form white", "#fff", color.RGBA{255, 255, 255, 255}, false},
{"RGB short form mixed", "#f0a", color.RGBA{255, 0, 170, 255}, false},
{"RGBA short form", "#f0a8", color.RGBA{255, 0, 170, 136}, false},
{"RRGGBB full form", "#000000", color.RGBA{0, 0, 0, 255}, false},
{"RRGGBB full form white", "#ffffff", color.RGBA{255, 255, 255, 255}, false},
{"RRGGBB full form mixed", "#ff00aa", color.RGBA{255, 0, 170, 255}, false},
{"RRGGBBAA full form", "#ff00aa80", color.RGBA{255, 0, 170, 128}, false},
{"RRGGBBAA full form transparent", "#ff00aa00", color.RGBA{255, 0, 170, 0}, false},
{"RRGGBBAA full form opaque", "#ff00aaff", color.RGBA{255, 0, 170, 255}, false},
// Invalid cases
{"Empty string", "", color.RGBA{}, true},
{"No hash prefix", "ffffff", color.RGBA{}, true},
{"Invalid length", "#12", color.RGBA{}, true},
{"Invalid length", "#12345", color.RGBA{}, true},
{"Invalid length", "#1234567", color.RGBA{}, true},
{"Invalid hex character", "#gggggg", color.RGBA{}, true},
{"Invalid hex character short", "#ggg", color.RGBA{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHexColorToRGBA(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("ParseHexColorToRGBA(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseHexColorToRGBA(%q) unexpected error: %v", tt.input, err)
return
}
if result != tt.expected {
t.Errorf("ParseHexColorToRGBA(%q) = %+v, expected %+v", tt.input, result, tt.expected)
}
})
}
}
func TestValidateHexColor(t *testing.T) {
tests := []struct {
name string
input string
hasError bool
}{
// Valid cases
{"RGB short", "#000", false},
{"RGB short uppercase", "#FFF", false},
{"RGB short mixed case", "#f0A", false},
{"RGBA short", "#f0a8", false},
{"RRGGBB", "#000000", false},
{"RRGGBB uppercase", "#FFFFFF", false},
{"RRGGBB mixed case", "#Ff00Aa", false},
{"RRGGBBAA", "#ff00aa80", false},
{"RRGGBBAA uppercase", "#FF00AA80", false},
// Invalid cases
{"Empty string", "", true},
{"No hash", "ffffff", true},
{"Too short", "#12", true},
{"Invalid length", "#12345", true},
{"Too long", "#123456789", true},
{"Invalid character", "#gggggg", true},
{"Invalid character short", "#ggg", true},
{"Space", "#fff fff", true},
{"Special character", "#fff@ff", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHexColor(tt.input)
if tt.hasError && err == nil {
t.Errorf("ValidateHexColor(%q) expected error, got nil", tt.input)
} else if !tt.hasError && err != nil {
t.Errorf("ValidateHexColor(%q) unexpected error: %v", tt.input, err)
}
})
}
}
func TestParseHexColorToEngine(t *testing.T) {
tests := []struct {
name string
input string
expected Color
hasError bool
}{
{"RGB short", "#000", NewColorRGBA(0, 0, 0, 255), false},
{"RGB short white", "#fff", NewColorRGBA(255, 255, 255, 255), false},
{"RRGGBB", "#ff00aa", NewColorRGBA(255, 0, 170, 255), false},
{"RRGGBBAA", "#ff00aa80", NewColorRGBA(255, 0, 170, 128), false},
{"Invalid", "#invalid", Color{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHexColorToEngine(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("ParseHexColorToEngine(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseHexColorToEngine(%q) unexpected error: %v", tt.input, err)
return
}
resultR, resultG, resultB, err1 := result.ToRGB()
if err1 != nil {
t.Fatalf("result.ToRGB failed: %v", err1)
}
expectedR, expectedG, expectedB, err2 := tt.expected.ToRGB()
if err2 != nil {
t.Fatalf("expected.ToRGB failed: %v", err2)
}
if resultR != expectedR || resultG != expectedG ||
resultB != expectedB || result.A != tt.expected.A {
t.Errorf("ParseHexColorToEngine(%q) = R:%d G:%d B:%d A:%d, expected R:%d G:%d B:%d A:%d",
tt.input, resultR, resultG, resultB, result.A,
expectedR, expectedG, expectedB, tt.expected.A)
}
})
}
}
func TestParseHexColorForRenderer(t *testing.T) {
tests := []struct {
name string
input string
opacity float64
expected color.RGBA
hasError bool
}{
{"RGB with opacity 1.0", "#ff0000", 1.0, color.RGBA{255, 0, 0, 255}, false},
{"RGB with opacity 0.5", "#ff0000", 0.5, color.RGBA{255, 0, 0, 127}, false},
{"RGB with opacity 0.0", "#ff0000", 0.0, color.RGBA{255, 0, 0, 0}, false},
{"RGBA with opacity 1.0", "#ff000080", 1.0, color.RGBA{255, 0, 0, 128}, false},
{"RGBA with opacity 0.5", "#ff000080", 0.5, color.RGBA{255, 0, 0, 64}, false},
{"Invalid color", "#invalid", 1.0, color.RGBA{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHexColorForRenderer(tt.input, tt.opacity)
if tt.hasError {
if err == nil {
t.Errorf("ParseHexColorForRenderer(%q, %f) expected error, got nil", tt.input, tt.opacity)
}
return
}
if err != nil {
t.Errorf("ParseHexColorForRenderer(%q, %f) unexpected error: %v", tt.input, tt.opacity, err)
return
}
if result != tt.expected {
t.Errorf("ParseHexColorForRenderer(%q, %f) = %+v, expected %+v", tt.input, tt.opacity, result, tt.expected)
}
})
}
}
func TestHexToByte(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
hasError bool
}{
{"Zero", "00", 0, false},
{"Max", "ff", 255, false},
{"Max uppercase", "FF", 255, false},
{"Mixed case", "Ff", 255, false},
{"Middle value", "80", 128, false},
{"Small value", "0a", 10, false},
{"Invalid length short", "f", 0, true},
{"Invalid length long", "fff", 0, true},
{"Invalid character", "gg", 0, true},
{"Empty string", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := hexToByte(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("hexToByte(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("hexToByte(%q) unexpected error: %v", tt.input, err)
return
}
if result != tt.expected {
t.Errorf("hexToByte(%q) = %d, expected %d", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -1,23 +1,47 @@
package engine
import "math"
import (
"fmt"
"math"
"strings"
)
// Default configuration constants matching JavaScript implementation
const (
// Default saturation values
defaultColorSaturation = 0.5 // Default saturation for colored shapes
defaultGrayscaleSaturation = 0.0 // Default saturation for grayscale shapes
// Default lightness range boundaries
defaultColorLightnessMin = 0.4 // Default minimum lightness for colors
defaultColorLightnessMax = 0.8 // Default maximum lightness for colors
defaultGrayscaleLightnessMin = 0.3 // Default minimum lightness for grayscale
defaultGrayscaleLightnessMax = 0.9 // Default maximum lightness for grayscale
// Default padding
defaultIconPadding = 0.08 // Default padding as percentage of icon size
// Hue calculation constants
hueIndexNormalizationFactor = 0.999 // Factor to normalize hue to [0,1) range for indexing
degreesToTurns = 360.0 // Conversion factor from degrees to turns
)
// ColorConfig represents the configuration for color generation
type ColorConfig struct {
// Saturation settings
ColorSaturation float64 // Saturation for normal colors [0, 1]
GrayscaleSaturation float64 // Saturation for grayscale colors [0, 1]
// Lightness ranges
ColorLightness LightnessRange // Lightness range for normal colors
GrayscaleLightness LightnessRange // Lightness range for grayscale colors
// Hue restrictions
Hues []float64 // Allowed hues in degrees [0, 360] or range [0, 1]. Empty means no restriction
// Background color
BackColor *Color // Background color (nil for transparent)
// Icon padding
IconPadding float64 // Padding as percentage of icon size [0, 1]
}
@@ -33,10 +57,10 @@ type LightnessRange struct {
func (lr LightnessRange) GetLightness(value float64) float64 {
// Clamp value to [0, 1] range
value = clamp(value, 0, 1)
// Linear interpolation between min and max
result := lr.Min + value*(lr.Max-lr.Min)
// Clamp result to valid lightness range
return clamp(result, 0, 1)
}
@@ -44,13 +68,13 @@ func (lr LightnessRange) GetLightness(value float64) float64 {
// DefaultColorConfig returns the default configuration matching the JavaScript implementation
func DefaultColorConfig() ColorConfig {
return ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
Hues: nil, // No hue restriction
BackColor: nil, // Transparent background
IconPadding: 0.08,
ColorSaturation: defaultColorSaturation,
GrayscaleSaturation: defaultGrayscaleSaturation,
ColorLightness: LightnessRange{Min: defaultColorLightnessMin, Max: defaultColorLightnessMax},
GrayscaleLightness: LightnessRange{Min: defaultGrayscaleLightnessMin, Max: defaultGrayscaleLightnessMax},
Hues: nil, // No hue restriction
BackColor: nil, // Transparent background
IconPadding: defaultIconPadding,
}
}
@@ -62,114 +86,99 @@ func (c ColorConfig) RestrictHue(originalHue float64) float64 {
if hue < 0 {
hue += 1.0
}
// If no hue restrictions, return original
if len(c.Hues) == 0 {
return hue
}
// Find the closest allowed hue
// originalHue is in range [0, 1], multiply by 0.999 to get range [0, 1)
// then truncate to get index
index := int((0.999 * hue * float64(len(c.Hues))))
index := int((hueIndexNormalizationFactor * hue * float64(len(c.Hues))))
if index >= len(c.Hues) {
index = len(c.Hues) - 1
}
restrictedHue := c.Hues[index]
// Convert from degrees to turns in range [0, 1)
// Handle any turn - e.g. 746° is valid
result := math.Mod(restrictedHue/360.0, 1.0)
result := math.Mod(restrictedHue/degreesToTurns, 1.0)
if result < 0 {
result += 1.0
}
return result
}
// ValidateConfig validates and corrects a ColorConfig to ensure all values are within valid ranges
func (c *ColorConfig) Validate() {
// Validate validates a ColorConfig to ensure all values are within valid ranges
// Returns an error if any validation issues are found without correcting the values
func (c *ColorConfig) Validate() error {
var validationErrors []string
// Validate saturation values
if c.ColorSaturation < 0 || c.ColorSaturation > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color saturation out of range: value %f not in [0, 1]", c.ColorSaturation))
}
if c.GrayscaleSaturation < 0 || c.GrayscaleSaturation > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale saturation out of range: value %f not in [0, 1]", c.GrayscaleSaturation))
}
// Validate lightness ranges
if c.ColorLightness.Min < 0 || c.ColorLightness.Min > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness minimum out of range: value %f not in [0, 1]", c.ColorLightness.Min))
}
if c.ColorLightness.Max < 0 || c.ColorLightness.Max > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness maximum out of range: value %f not in [0, 1]", c.ColorLightness.Max))
}
if c.ColorLightness.Min > c.ColorLightness.Max {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: color lightness range invalid: minimum %f greater than maximum %f", c.ColorLightness.Min, c.ColorLightness.Max))
}
if c.GrayscaleLightness.Min < 0 || c.GrayscaleLightness.Min > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness minimum out of range: value %f not in [0, 1]", c.GrayscaleLightness.Min))
}
if c.GrayscaleLightness.Max < 0 || c.GrayscaleLightness.Max > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness maximum out of range: value %f not in [0, 1]", c.GrayscaleLightness.Max))
}
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: grayscale lightness range invalid: minimum %f greater than maximum %f", c.GrayscaleLightness.Min, c.GrayscaleLightness.Max))
}
// Validate icon padding
if c.IconPadding < 0 || c.IconPadding > 1 {
validationErrors = append(validationErrors, fmt.Sprintf("jdenticon: engine: validation failed: icon padding out of range: value %f not in [0, 1]", c.IconPadding))
}
if len(validationErrors) > 0 {
return fmt.Errorf("jdenticon: engine: validation failed: configuration invalid: %s", strings.Join(validationErrors, "; "))
}
return nil
}
// Normalize validates and corrects a ColorConfig to ensure all values are within valid ranges
// This method provides backward compatibility by applying corrections for invalid values
func (c *ColorConfig) Normalize() {
// Clamp saturation values
c.ColorSaturation = clamp(c.ColorSaturation, 0, 1)
c.GrayscaleSaturation = clamp(c.GrayscaleSaturation, 0, 1)
// Validate lightness ranges
// Validate and fix lightness ranges
c.ColorLightness.Min = clamp(c.ColorLightness.Min, 0, 1)
c.ColorLightness.Max = clamp(c.ColorLightness.Max, 0, 1)
if c.ColorLightness.Min > c.ColorLightness.Max {
c.ColorLightness.Min, c.ColorLightness.Max = c.ColorLightness.Max, c.ColorLightness.Min
}
c.GrayscaleLightness.Min = clamp(c.GrayscaleLightness.Min, 0, 1)
c.GrayscaleLightness.Max = clamp(c.GrayscaleLightness.Max, 0, 1)
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
c.GrayscaleLightness.Min, c.GrayscaleLightness.Max = c.GrayscaleLightness.Max, c.GrayscaleLightness.Min
}
// Clamp icon padding
c.IconPadding = clamp(c.IconPadding, 0, 1)
// Validate hues (no need to clamp as RestrictHue handles normalization)
}
// ColorConfigBuilder provides a fluent interface for building ColorConfig
type ColorConfigBuilder struct {
config ColorConfig
}
// NewColorConfigBuilder creates a new builder with default values
func NewColorConfigBuilder() *ColorConfigBuilder {
return &ColorConfigBuilder{
config: DefaultColorConfig(),
}
}
// WithColorSaturation sets the color saturation
func (b *ColorConfigBuilder) WithColorSaturation(saturation float64) *ColorConfigBuilder {
b.config.ColorSaturation = saturation
return b
}
// WithGrayscaleSaturation sets the grayscale saturation
func (b *ColorConfigBuilder) WithGrayscaleSaturation(saturation float64) *ColorConfigBuilder {
b.config.GrayscaleSaturation = saturation
return b
}
// WithColorLightness sets the color lightness range
func (b *ColorConfigBuilder) WithColorLightness(min, max float64) *ColorConfigBuilder {
b.config.ColorLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithGrayscaleLightness sets the grayscale lightness range
func (b *ColorConfigBuilder) WithGrayscaleLightness(min, max float64) *ColorConfigBuilder {
b.config.GrayscaleLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithHues sets the allowed hues in degrees
func (b *ColorConfigBuilder) WithHues(hues ...float64) *ColorConfigBuilder {
b.config.Hues = make([]float64, len(hues))
copy(b.config.Hues, hues)
return b
}
// WithBackColor sets the background color
func (b *ColorConfigBuilder) WithBackColor(color Color) *ColorConfigBuilder {
b.config.BackColor = &color
return b
}
// WithIconPadding sets the icon padding
func (b *ColorConfigBuilder) WithIconPadding(padding float64) *ColorConfigBuilder {
b.config.IconPadding = padding
return b
}
// Build returns the configured ColorConfig after validation
func (b *ColorConfigBuilder) Build() ColorConfig {
b.config.Validate()
return b.config
}

View File

@@ -5,36 +5,131 @@ import (
"testing"
)
func TestColorConfigValidate(t *testing.T) {
tests := []struct {
name string
config ColorConfig
wantErr bool
errMsg string
}{
{
name: "valid default config",
config: DefaultColorConfig(),
wantErr: false,
},
{
name: "invalid color saturation < 0",
config: ColorConfig{
ColorSaturation: -0.1,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
IconPadding: 0.08,
},
wantErr: true,
errMsg: "color saturation out of range",
},
{
name: "invalid grayscale saturation > 1",
config: ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 1.5,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
IconPadding: 0.08,
},
wantErr: true,
errMsg: "grayscale saturation out of range",
},
{
name: "invalid color lightness min > max",
config: ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.8, Max: 0.4},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
IconPadding: 0.08,
},
wantErr: true,
errMsg: "color lightness range invalid",
},
{
name: "invalid icon padding > 1",
config: ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
IconPadding: 1.5,
},
wantErr: true,
errMsg: "icon padding out of range",
},
{
name: "multiple validation errors",
config: ColorConfig{
ColorSaturation: -0.1, // Invalid
GrayscaleSaturation: 1.5, // Invalid
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
IconPadding: 0.08,
},
wantErr: true,
errMsg: "color saturation out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for config validation, got none")
return
}
if tt.errMsg != "" && !containsString(err.Error(), tt.errMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestDefaultColorConfig(t *testing.T) {
config := DefaultColorConfig()
// Test default values match JavaScript implementation
if config.ColorSaturation != 0.5 {
t.Errorf("ColorSaturation = %f, want 0.5", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.0 {
t.Errorf("GrayscaleSaturation = %f, want 0.0", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.4 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.4, 0.8}",
t.Errorf("ColorLightness = {%f, %f}, want {0.4, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.3 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.3, 0.9}",
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.3, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 0 {
t.Errorf("Hues should be empty by default, got %v", config.Hues)
}
if config.BackColor != nil {
t.Error("BackColor should be nil by default")
}
if config.IconPadding != 0.08 {
t.Errorf("IconPadding = %f, want 0.08", config.IconPadding)
}
@@ -42,18 +137,18 @@ func TestDefaultColorConfig(t *testing.T) {
func TestLightnessRangeGetLightness(t *testing.T) {
lr := LightnessRange{Min: 0.3, Max: 0.9}
tests := []struct {
value float64
expected float64
}{
{0.0, 0.3}, // Min value
{1.0, 0.9}, // Max value
{0.5, 0.6}, // Middle value: 0.3 + 0.5 * (0.9 - 0.3) = 0.6
{-0.5, 0.3}, // Below range, should clamp to min
{1.5, 0.9}, // Above range, should clamp to max
{0.0, 0.3}, // Min value
{1.0, 0.9}, // Max value
{0.5, 0.6}, // Middle value: 0.3 + 0.5 * (0.9 - 0.3) = 0.6
{-0.5, 0.3}, // Below range, should clamp to min
{1.5, 0.9}, // Above range, should clamp to max
}
for _, tt := range tests {
result := lr.GetLightness(tt.value)
if math.Abs(result-tt.expected) > 0.001 {
@@ -64,54 +159,54 @@ func TestLightnessRangeGetLightness(t *testing.T) {
func TestConfigRestrictHue(t *testing.T) {
tests := []struct {
name string
hues []float64
originalHue float64
expectedHue float64
name string
hues []float64
originalHue float64
expectedHue float64
}{
{
name: "no restriction",
hues: nil,
originalHue: 0.25,
expectedHue: 0.25,
name: "no restriction",
hues: nil,
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "empty restriction",
hues: []float64{},
originalHue: 0.25,
expectedHue: 0.25,
name: "empty restriction",
hues: []float64{},
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "single hue restriction",
hues: []float64{180}, // 180 degrees = 0.5 turns
originalHue: 0.25,
expectedHue: 0.5,
name: "single hue restriction",
hues: []float64{180}, // 180 degrees = 0.5 turns
originalHue: 0.25,
expectedHue: 0.5,
},
{
name: "multiple hue restriction",
hues: []float64{0, 120, 240}, // Red, Green, Blue
originalHue: 0.1, // Should map to first hue (0 degrees)
expectedHue: 0.0,
name: "multiple hue restriction",
hues: []float64{0, 120, 240}, // Red, Green, Blue
originalHue: 0.1, // Should map to first hue (0 degrees)
expectedHue: 0.0,
},
{
name: "hue normalization - negative",
hues: []float64{90}, // 90 degrees = 0.25 turns
originalHue: -0.5,
expectedHue: 0.25,
name: "hue normalization - negative",
hues: []float64{90}, // 90 degrees = 0.25 turns
originalHue: -0.5,
expectedHue: 0.25,
},
{
name: "hue normalization - over 1",
hues: []float64{270}, // 270 degrees = 0.75 turns
originalHue: 1.5,
expectedHue: 0.75,
name: "hue normalization - over 1",
hues: []float64{270}, // 270 degrees = 0.75 turns
originalHue: 1.5,
expectedHue: 0.75,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := ColorConfig{Hues: tt.hues}
result := config.RestrictHue(tt.originalHue)
if math.Abs(result-tt.expectedHue) > 0.001 {
t.Errorf("RestrictHue(%f) = %f, want %f", tt.originalHue, result, tt.expectedHue)
}
@@ -119,38 +214,38 @@ func TestConfigRestrictHue(t *testing.T) {
}
}
func TestConfigValidate(t *testing.T) {
// Test that validation corrects invalid values
func TestConfigNormalize(t *testing.T) {
// Test that Normalize corrects invalid values
config := ColorConfig{
ColorSaturation: -0.5, // Invalid: below 0
GrayscaleSaturation: 1.5, // Invalid: above 1
ColorLightness: LightnessRange{Min: 0.8, Max: 0.2}, // Invalid: min > max
ColorSaturation: -0.5, // Invalid: below 0
GrayscaleSaturation: 1.5, // Invalid: above 1
ColorLightness: LightnessRange{Min: 0.8, Max: 0.2}, // Invalid: min > max
GrayscaleLightness: LightnessRange{Min: -0.1, Max: 1.1}, // Invalid: out of range
IconPadding: 2.0, // Invalid: above 1
IconPadding: 2.0, // Invalid: above 1
}
config.Validate()
config.Normalize()
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation after validation = %f, want 0.0", config.ColorSaturation)
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation after validation = %f, want 1.0", config.GrayscaleSaturation)
}
// Min and max should be swapped
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness after validation = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
// Values should be clamped
if config.GrayscaleLightness.Min != 0.0 || config.GrayscaleLightness.Max != 1.0 {
t.Errorf("GrayscaleLightness after validation = {%f, %f}, want {0.0, 1.0}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if config.IconPadding != 1.0 {
t.Errorf("IconPadding after validation = %f, want 1.0", config.IconPadding)
}
@@ -158,61 +253,71 @@ func TestConfigValidate(t *testing.T) {
func TestColorConfigBuilder(t *testing.T) {
redColor := NewColorRGB(255, 0, 0)
config := NewColorConfigBuilder().
WithColorSaturation(0.7).
WithGrayscaleSaturation(0.1).
WithColorLightness(0.2, 0.8).
WithGrayscaleLightness(0.1, 0.9).
WithHues(0, 120, 240).
WithBackColor(redColor).
WithIconPadding(0.1).
Build()
config := DefaultColorConfig()
config.ColorSaturation = 0.7
config.GrayscaleSaturation = 0.1
config.ColorLightness = LightnessRange{Min: 0.2, Max: 0.8}
config.GrayscaleLightness = LightnessRange{Min: 0.1, Max: 0.9}
config.Hues = []float64{0, 120, 240}
config.BackColor = &redColor
config.IconPadding = 0.1
if config.ColorSaturation != 0.7 {
t.Errorf("ColorSaturation = %f, want 0.7", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.1 {
t.Errorf("GrayscaleSaturation = %f, want 0.1", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.1 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.1, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 3 || config.Hues[0] != 0 || config.Hues[1] != 120 || config.Hues[2] != 240 {
t.Errorf("Hues = %v, want [0, 120, 240]", config.Hues)
}
if config.BackColor == nil || !config.BackColor.Equals(redColor) {
t.Error("BackColor should be set to red")
}
if config.IconPadding != 0.1 {
t.Errorf("IconPadding = %f, want 0.1", config.IconPadding)
}
}
func TestColorConfigBuilderValidation(t *testing.T) {
// Test that builder validates configuration
config := NewColorConfigBuilder().
WithColorSaturation(-0.5). // Invalid
WithGrayscaleSaturation(1.5). // Invalid
Build()
// Should be corrected by validation
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation = %f, want 0.0 (corrected)", config.ColorSaturation)
func TestColorConfigValidation(t *testing.T) {
// Test direct config validation
config := DefaultColorConfig()
config.ColorSaturation = -0.5 // Invalid
config.GrayscaleSaturation = 1.5 // Invalid
err := config.Validate()
// Should return validation error for invalid values
if err == nil {
t.Error("Expected validation error for invalid configuration, got nil")
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation = %f, want 1.0 (corrected)", config.GrayscaleSaturation)
if !containsString(err.Error(), "color saturation out of range") {
t.Errorf("Expected error to mention color saturation validation, got: %s", err.Error())
}
}
}
// containsString checks if a string contains a substring
func containsString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

51
internal/engine/doc.go Normal file
View File

@@ -0,0 +1,51 @@
/*
Package engine contains the core, format-agnostic logic for generating Jdenticon
identicons. It is responsible for translating an input hash into a structured,
intermediate representation of the final image.
This package is internal to the jdenticon library and its API is not guaranteed
to be stable. Do not use it directly.
# Architectural Overview
The generation process follows a clear pipeline:
1. Hashing: An input value (e.g., a username) is hashed into a byte slice. This
is handled by the public `jdenticon` package.
2. Generator: The `Generator` struct is the heart of the engine. It consumes the
hash to deterministically select shapes, colors, and their transformations
(rotation, position).
3. Shape Selection: Based on bytes from the hash, specific shapes are chosen from
the predefined shape catalog in `shapes.go`.
4. Transform & Positioning: The `transform.go` file defines how shapes are
positioned and rotated within the icon's grid. The center shape is
handled separately from the outer shapes.
5. Colorization: `color.go` uses the hash and the `Config` to determine the
final hue, saturation, and lightness of the icon's foreground color.
The output of this engine is a `[]RenderedElement`, which is a list of
geometries and their associated colors. This intermediate representation is then
passed to a renderer (see the `internal/renderer` package) to produce the final
output (e.g., SVG or PNG). This separation of concerns allows the core generation
logic to remain independent of the output format.
# Key Components
- generator.go: Main generation algorithm and core deterministic logic
- shapes.go: Shape definitions and rendering with coordinate transformations
- color.go: Color theme generation using HSL color space
- config.go: Internal configuration structures and validation
- transform.go: Coordinate transformation utilities
# Hash-Based Determinism
The engine ensures deterministic output by using specific positions within the
input hash to drive shape selection, color generation, and transformations.
This guarantees that identical inputs always produce identical identicons while
maintaining visual variety across different inputs.
*/
package engine

View File

@@ -0,0 +1,412 @@
package engine
import (
"context"
"math"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/util"
)
// FuzzGeneratorGenerate tests the internal engine generator with arbitrary inputs
func FuzzGeneratorGenerate(f *testing.F) {
// Seed with known hash patterns and sizes
f.Add("abcdef1234567890", 64.0)
f.Add("", 32.0)
f.Add("0123456789abcdef", 128.0)
f.Add("ffffffffffffffff", 256.0)
f.Add("0000000000000000", 1.0)
f.Fuzz(func(t *testing.T, hash string, size float64) {
// Test invalid sizes for proper error handling
if size <= 0 || math.IsNaN(size) || math.IsInf(size, 0) {
// Create a generator with default config
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 100,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
return
}
_, err = generator.Generate(context.Background(), hash, size)
if err == nil {
t.Errorf("Generate with invalid size %f should have returned an error", size)
}
return // Stop further processing for invalid inputs
}
if size > 10000 {
return // Avoid resource exhaustion
}
// Create a generator with default config
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 100,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
return
}
// Generate should never panic, regardless of hash input
icon, err := generator.Generate(context.Background(), hash, size)
// We don't require success for all inputs, but we require no crashes
if err != nil {
// Check that error is reasonable
_ = err
return
}
if icon == nil {
t.Errorf("Generate(%q, %f) returned nil icon without error", hash, size)
return
}
// Verify icon has reasonable properties
if icon.Size != size {
t.Errorf("Generated icon size %f does not match requested size %f", icon.Size, size)
}
if len(icon.Shapes) == 0 {
t.Errorf("Generated icon has no shapes")
}
})
}
// FuzzColorConfigValidation tests color configuration validation
func FuzzColorConfigValidation(f *testing.F) {
// Seed with various color configuration patterns
f.Add(0.5, 0.5, 0.4, 0.8, 0.3, 0.9, 0.08)
f.Add(-1.0, 2.0, -0.5, 1.5, -0.1, 1.1, -0.1)
f.Add(0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0)
f.Fuzz(func(t *testing.T, colorSat, grayscaleSat, colorLightMin, colorLightMax,
grayscaleLightMin, grayscaleLightMax, padding float64) {
config := ColorConfig{
ColorSaturation: colorSat,
GrayscaleSaturation: grayscaleSat,
ColorLightness: LightnessRange{
Min: colorLightMin,
Max: colorLightMax,
},
GrayscaleLightness: LightnessRange{
Min: grayscaleLightMin,
Max: grayscaleLightMax,
},
IconPadding: padding,
}
// Validation should never panic
err := config.Validate()
_ = err
// If validation passes, test that we can create a generator
if err == nil {
genConfig := GeneratorConfig{
ColorConfig: config,
CacheSize: 10,
}
generator, genErr := NewGeneratorWithConfig(genConfig)
if genErr == nil && generator != nil {
// Try to generate an icon
icon, iconErr := generator.Generate(context.Background(), "test-hash", 64.0)
if iconErr == nil && icon != nil {
// Verify the icon has valid properties
if icon.Size != 64.0 {
t.Errorf("Icon size mismatch: expected 64.0, got %f", icon.Size)
}
}
}
}
})
}
// FuzzParseHex tests the hex parsing function with arbitrary inputs
func FuzzParseHex(f *testing.F) {
// Seed with various hex patterns
f.Add("abcdef123456", 0, 1)
f.Add("0123456789", 5, 2)
f.Add("", 0, 1)
f.Add("xyz", 0, 1)
f.Add("ffffffffff", 10, 5)
f.Fuzz(func(t *testing.T, hash string, position, octets int) {
// ParseHex should never panic, even with invalid inputs
result, err := util.ParseHex(hash, position, octets)
// Determine the actual slice being parsed (mimic ParseHex logic)
startPosition := position
if startPosition < 0 {
startPosition = len(hash) + startPosition
}
// Only check substring if it would be valid to parse
if startPosition >= 0 && startPosition < len(hash) {
end := len(hash)
if octets > 0 {
end = startPosition + octets
if end > len(hash) {
end = len(hash)
}
}
// Extract the substring that ParseHex would actually process
if startPosition < end {
substr := hash[startPosition:end]
// Check if the relevant substring contains invalid hex characters
isInvalidHex := containsNonHex(substr)
if isInvalidHex && err == nil {
t.Errorf("ParseHex should have returned an error for invalid hex substring %q, but didn't", substr)
}
}
}
// Check for position out of bounds (after negative position handling)
if startPosition >= len(hash) && len(hash) > 0 && err == nil {
t.Errorf("ParseHex should return error for position %d >= hash length %d", startPosition, len(hash))
}
if err != nil {
return // Correctly returned an error
}
// On success, verify the result is reasonable
_ = result // Result could be any valid integer
})
}
// FuzzColorGeneration tests color generation with arbitrary hue values
func FuzzColorGeneration(f *testing.F) {
// Seed with various hue values
f.Add(0.0, 0.5)
f.Add(0.5, 0.7)
f.Add(1.0, 0.3)
f.Add(-0.1, 0.9)
f.Add(1.1, 0.1)
f.Fuzz(func(t *testing.T, hue, lightnessValue float64) {
// Skip extreme values that might cause issues
if math.IsNaN(hue) || math.IsInf(hue, 0) || math.IsNaN(lightnessValue) || math.IsInf(lightnessValue, 0) {
return
}
config := DefaultColorConfig()
// Test actual production color generation functions
color := GenerateColor(hue, config, lightnessValue)
// Verify color has reasonable RGB values (0-255)
r, g, b, err := color.ToRGB()
if err != nil {
t.Errorf("color.ToRGB failed: %v", err)
return
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_, _, _ = r, g, b
// Test grayscale generation as well
grayscale := GenerateGrayscale(config, lightnessValue)
gr, gg, gb, err := grayscale.ToRGB()
if err != nil {
t.Errorf("grayscale.ToRGB failed: %v", err)
return
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_, _, _ = gr, gg, gb
// Test color theme generation
theme := GenerateColorTheme(hue, config)
if len(theme) != 5 {
t.Errorf("GenerateColorTheme should return 5 colors, got %d", len(theme))
}
for _, themeColor := range theme {
tr, tg, tb, err := themeColor.ToRGB()
if err != nil {
t.Errorf("themeColor.ToRGB failed: %v", err)
continue
}
// RGB values are uint8, so they're guaranteed to be in 0-255 range
_, _, _ = tr, tg, tb
}
})
}
// FuzzHexColorParsing tests hex color parsing with arbitrary strings
func FuzzHexColorParsing(f *testing.F) {
// Seed with various hex color patterns
f.Add("#ffffff")
f.Add("#000000")
f.Add("#fff")
f.Add("#12345678")
f.Add("invalid")
f.Add("")
f.Add("#")
f.Add("#gggggg")
f.Fuzz(func(t *testing.T, colorStr string) {
// ValidateHexColor should never panic
err := ValidateHexColor(colorStr)
_ = err
// If validation passes, try parsing
if err == nil {
color, parseErr := ParseHexColorToEngine(colorStr)
if parseErr == nil {
// Verify parsed color has valid properties
r, g, b, err := color.ToRGB()
if err != nil {
t.Errorf("color.ToRGB failed: %v", err)
return
}
// RGB and alpha values are uint8, so they're guaranteed to be in 0-255 range
_, _, _ = r, g, b
_ = color.A
}
}
})
}
// FuzzGeneratorCaching tests generator caching behavior with arbitrary inputs
func FuzzGeneratorCaching(f *testing.F) {
// Seed with various cache scenarios
f.Add("hash1", 64.0, "hash2", 128.0)
f.Add("same", 64.0, "same", 64.0)
f.Add("", 1.0, "different", 1.0)
f.Fuzz(func(t *testing.T, hash1 string, size1 float64, hash2 string, size2 float64) {
// Skip invalid sizes
if size1 <= 0 || size1 > 1000 || size2 <= 0 || size2 > 1000 {
return
}
if math.IsNaN(size1) || math.IsInf(size1, 0) || math.IsNaN(size2) || math.IsInf(size2, 0) {
return
}
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 10,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
return
}
// Generate first icon
icon1, err1 := generator.Generate(context.Background(), hash1, size1)
if err1 != nil {
return
}
// Check cache metrics after first generation
initialHits, initialMisses := generator.GetCacheMetrics()
// Generate second icon (might be cache hit if same)
icon2, err2 := generator.Generate(context.Background(), hash2, size2)
if err2 != nil {
return
}
// Verify cache behavior
finalSize := generator.GetCacheSize()
finalHits, finalMisses := generator.GetCacheMetrics()
// Cache size should not exceed capacity
if finalSize > generator.GetCacheCapacity() {
t.Errorf("Cache size %d exceeds capacity %d", finalSize, generator.GetCacheCapacity())
}
// Metrics should increase appropriately
if finalHits < initialHits || finalMisses < initialMisses {
t.Errorf("Cache metrics decreased: hits %d->%d, misses %d->%d",
initialHits, finalHits, initialMisses, finalMisses)
}
// If same hash and size, should be cache hit
if hash1 == hash2 && size1 == size2 && icon1 != nil && icon2 != nil {
if finalHits <= initialHits {
t.Errorf("Expected cache hit for identical inputs, but hits did not increase. Initial: %d, Final: %d", initialHits, finalHits)
}
}
// Clear cache should not panic and should reset metrics
generator.ClearCache()
clearedSize := generator.GetCacheSize()
clearedHits, clearedMisses := generator.GetCacheMetrics()
if clearedSize != 0 {
t.Errorf("Cache size after clear: expected 0, got %d", clearedSize)
}
if clearedHits != 0 || clearedMisses != 0 {
t.Errorf("Metrics after clear: expected 0,0 got %d,%d", clearedHits, clearedMisses)
}
})
}
// FuzzLightnessRangeOperations tests lightness range calculations
func FuzzLightnessRangeOperations(f *testing.F) {
// Seed with various lightness range values
f.Add(0.0, 1.0, 0.5)
f.Add(0.4, 0.8, 0.7)
f.Add(-0.1, 1.1, 0.5)
f.Add(0.9, 0.1, 0.5) // Invalid range (min > max)
f.Fuzz(func(t *testing.T, min, max, value float64) {
// Skip NaN and infinite values
if math.IsNaN(min) || math.IsInf(min, 0) ||
math.IsNaN(max) || math.IsInf(max, 0) ||
math.IsNaN(value) || math.IsInf(value, 0) {
return
}
lightnessRange := LightnessRange{Min: min, Max: max}
// Test actual production LightnessRange.GetLightness method
result := lightnessRange.GetLightness(value)
// GetLightness should never panic and should return a valid result
if math.IsNaN(result) || math.IsInf(result, 0) {
t.Errorf("GetLightness(%f) with range [%f, %f] returned invalid result: %f", value, min, max, result)
}
// Result should be clamped to [0, 1] range
if result < 0 || result > 1 {
t.Errorf("GetLightness(%f) with range [%f, %f] returned out-of-range result: %f", value, min, max, result)
}
// If input range is valid and value is in [0,1], result should be in range
if min >= 0 && max <= 1 && min <= max && value >= 0 && value <= 1 {
expectedMin := math.Min(min, max)
expectedMax := math.Max(min, max)
if result < expectedMin || result > expectedMax {
// Allow for floating point precision issues
if math.Abs(result-expectedMin) > 1e-10 && math.Abs(result-expectedMax) > 1e-10 {
t.Errorf("GetLightness(%f) with valid range [%f, %f] returned result %f outside expected range [%f, %f]",
value, min, max, result, expectedMin, expectedMax)
}
}
}
})
}
// containsNonHex checks if a string contains non-hexadecimal characters
// Note: strconv.ParseInt allows negative hex numbers, so '-' is valid at the start
func containsNonHex(s string) bool {
for i, r := range s {
isHexDigit := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
isValidMinus := (r == '-' && i == 0) // Minus only valid at start
if !isHexDigit && !isValidMinus {
return true
}
}
return false
}

View File

@@ -1,10 +1,60 @@
package engine
import (
"context"
"fmt"
"strconv"
"sync"
"github.com/kevin/go-jdenticon/internal/util"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
"github.com/ungluedlabs/go-jdenticon/internal/util"
"golang.org/x/sync/singleflight"
)
// Hash position constants for extracting values from the hash string
const (
// Shape type selection positions
hashPosSideShape = 2 // Position for side shape selection
hashPosCornerShape = 4 // Position for corner shape selection
hashPosCenterShape = 1 // Position for center shape selection
// Rotation positions
hashPosSideRotation = 3 // Position for side shape rotation
hashPosCornerRotation = 5 // Position for corner shape rotation
hashPosCenterRotation = -1 // Center shapes use incremental rotation (no hash position)
// Color selection positions
hashPosColorStart = 8 // Starting position for color selection (8, 9, 10)
// Hue extraction
hashPosHueStart = -7 // Start position for hue extraction (last 7 chars)
hashPosHueLength = 7 // Number of characters for hue
hueMaxValue = 0xfffffff // Maximum hue value for normalization
)
// Grid and layout constants
const (
gridSize = 4 // Standard 4x4 grid for jdenticon layout
paddingMultiple = 2 // Padding is applied on both sides (2x)
)
// Color conflict resolution constants
const (
colorDarkGray = 0 // Index for dark gray color
colorDarkMain = 4 // Index for dark main color
colorLightGray = 2 // Index for light gray color
colorLightMain = 3 // Index for light main color
colorMidFallback = 1 // Fallback color index for conflicts
)
// Shape rendering constants
const (
shapeColorIndexSides = 0 // Color index for side shapes
shapeColorIndexCorners = 1 // Color index for corner shapes
shapeColorIndexCenter = 2 // Color index for center shapes
numColorSelections = 3 // Total number of color selections needed
)
// Icon represents a generated jdenticon with its configuration and geometry
@@ -27,155 +77,188 @@ type ShapeGroup struct {
// - For "polygon", `Points` is used.
// - For "circle", `CircleX`, `CircleY`, and `CircleSize` are used.
type Shape struct {
Type string
Points []Point
Transform Transform
Invert bool
Type string
Points []Point
Transform Transform
Invert bool
// Circle-specific fields
CircleX float64
CircleY float64
CircleSize float64
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config ColorConfig
cache map[string]*Icon
mu sync.RWMutex
// GeneratorConfig holds configuration for the generator including cache settings
type GeneratorConfig struct {
ColorConfig ColorConfig
CacheSize int // Maximum number of items in the LRU cache (default: 1000)
MaxComplexity int // Maximum geometric complexity score (-1 to disable, 0 for default)
MaxIconSize int // Maximum allowed icon size in pixels (0 for default from constants.DefaultMaxIconSize)
}
// NewGenerator creates a new Generator with the specified configuration
func NewGenerator(config ColorConfig) *Generator {
config.Validate()
return &Generator{
config: config,
cache: make(map[string]*Icon),
// DefaultGeneratorConfig returns the default generator configuration
func DefaultGeneratorConfig() GeneratorConfig {
return GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1000,
MaxComplexity: 0, // Use default from constants
MaxIconSize: 0, // Use default from constants.DefaultMaxIconSize
}
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config GeneratorConfig
cache *lru.Cache[string, *Icon]
mu sync.RWMutex
metrics CacheMetrics
sf singleflight.Group // Prevents thundering herd on cache misses
maxIconSize int // Resolved maximum icon size (from config or default)
}
// NewGenerator creates a new Generator with the specified color configuration
// and default cache size of 1000 entries
func NewGenerator(colorConfig ColorConfig) (*Generator, error) {
generatorConfig := GeneratorConfig{
ColorConfig: colorConfig,
CacheSize: 1000,
}
return NewGeneratorWithConfig(generatorConfig)
}
// NewGeneratorWithConfig creates a new Generator with the specified configuration
func NewGeneratorWithConfig(config GeneratorConfig) (*Generator, error) {
if config.CacheSize <= 0 {
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: invalid cache size: %d", config.CacheSize)
}
config.ColorConfig.Normalize()
// Resolve the effective maximum icon size
maxIconSize := config.MaxIconSize
if maxIconSize == 0 || (maxIconSize < 0 && maxIconSize != -1) {
maxIconSize = constants.DefaultMaxIconSize
}
// If maxIconSize is -1, keep it as -1 to disable the limit
// Create LRU cache with specified size
cache, err := lru.New[string, *Icon](config.CacheSize)
if err != nil {
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: %w", err)
}
return &Generator{
config: config,
cache: cache,
metrics: CacheMetrics{},
maxIconSize: maxIconSize,
}, nil
}
// NewDefaultGenerator creates a new Generator with default configuration
func NewDefaultGenerator() *Generator {
return NewGenerator(DefaultColorConfig())
func NewDefaultGenerator() (*Generator, error) {
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
if err != nil {
return nil, fmt.Errorf("jdenticon: engine: default generator creation failed: %w", err)
}
return generator, nil
}
// Generate creates an icon from a hash string using the configured settings
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
if hash == "" {
return nil, fmt.Errorf("hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %f", size)
}
// Check cache first
cacheKey := g.cacheKey(hash, size)
g.mu.RLock()
if cached, exists := g.cache[cacheKey]; exists {
g.mu.RUnlock()
return cached, nil
}
g.mu.RUnlock()
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("invalid hash format: %s", hash)
}
// Generate new icon
icon, err := g.generateIcon(hash, size)
if err != nil {
// generateIcon performs the actual icon generation with context support and complexity checking
func (g *Generator) generateIcon(ctx context.Context, hash string, size float64) (*Icon, error) {
// Check for cancellation before expensive operations
if err := ctx.Err(); err != nil {
return nil, err
}
// Cache the result
g.mu.Lock()
g.cache[cacheKey] = icon
g.mu.Unlock()
return icon, nil
}
// generateIcon performs the actual icon generation
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// Complexity validation is now handled at the jdenticon package level
// to ensure proper structured error types are returned
// Calculate padding and round to nearest integer (matching JavaScript)
padding := int((0.5 + size*g.config.IconPadding))
iconSize := size - float64(padding*2)
padding := int((0.5 + size*g.config.ColorConfig.IconPadding))
iconSize := size - float64(padding*paddingMultiple)
// Calculate cell size and ensure it is an integer (matching JavaScript)
cell := int(iconSize / 4)
cell := int(iconSize / gridSize)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := int(float64(padding) + iconSize/2 - float64(cell*2))
y := int(float64(padding) + iconSize/2 - float64(cell*2))
x := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
y := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
// Extract hue from hash (last 7 characters)
hue, err := g.extractHue(hash)
if err != nil {
return nil, fmt.Errorf("generateIcon: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: %w", err)
}
// Generate color theme
availableColors := GenerateColorTheme(hue, g.config)
availableColors := GenerateColorTheme(hue, g.config.ColorConfig)
// Select colors for each shape layer
selectedColorIndexes, err := g.selectColors(hash, availableColors)
if err != nil {
return nil, err
}
// Generate shape groups in exact JavaScript order
shapeGroups := make([]ShapeGroup, 0, 3)
shapeGroups := make([]ShapeGroup, 0, numColorSelections)
// Check for cancellation before rendering shapes
if err = ctx.Err(); err != nil {
return nil, err
}
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
sideShapes, err := g.renderShape(hash, 0, 2, 3,
var sideShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexSides, hashPosSideShape, hashPosSideRotation,
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
x, y, cell, true)
x, y, cell, true, &sideShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: side shapes rendering failed: %w", err)
}
if len(sideShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[0]],
Color: availableColors[selectedColorIndexes[shapeColorIndexSides]],
Shapes: sideShapes,
ShapeType: "sides",
})
}
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
var cornerShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexCorners, hashPosCornerShape, hashPosCornerRotation,
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
x, y, cell, true)
x, y, cell, true, &cornerShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: corner shapes rendering failed: %w", err)
}
if len(cornerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[1]],
Color: availableColors[selectedColorIndexes[shapeColorIndexCorners]],
Shapes: cornerShapes,
ShapeType: "corners",
})
}
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
centerShapes, err := g.renderShape(hash, 2, 1, -1,
var centerShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexCenter, hashPosCenterShape, hashPosCenterRotation,
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
x, y, cell, false)
x, y, cell, false, &centerShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: center shapes rendering failed: %w", err)
}
if len(centerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[2]],
Color: availableColors[selectedColorIndexes[shapeColorIndexCenter]],
Shapes: centerShapes,
ShapeType: "center",
})
}
return &Icon{
Hash: hash,
Size: size,
Config: g.config,
Config: g.config.ColorConfig,
Shapes: shapeGroups,
}, nil
}
@@ -183,113 +266,138 @@ func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// extractHue extracts the hue value from the hash string
func (g *Generator) extractHue(hash string) (float64, error) {
// Use the last 7 characters of the hash to determine hue
hueValue, err := util.ParseHex(hash, -7, 7)
if err != nil {
return 0, fmt.Errorf("extractHue: %w", err)
if len(hash) < hashPosHueLength {
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: hash too short for hue extraction")
}
return float64(hueValue) / 0xfffffff, nil
hueStr := hash[len(hash)-hashPosHueLength:]
hueValue64, err := strconv.ParseInt(hueStr, 16, 64)
if err != nil {
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: failed to parse hue '%s': %w", hueStr, err)
}
hueValue := int(hueValue64)
return float64(hueValue) / hueMaxValue, nil
}
// selectColors selects 3 colors from the available color palette
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
if len(availableColors) == 0 {
return nil, fmt.Errorf("no available colors")
return nil, fmt.Errorf("jdenticon: engine: color selection failed: no available colors")
}
selectedIndexes := make([]int, 3)
for i := 0; i < 3; i++ {
indexValue, err := util.ParseHex(hash, 8+i, 1)
selectedIndexes := make([]int, numColorSelections)
for i := 0; i < numColorSelections; i++ {
indexValue, err := util.ParseHex(hash, hashPosColorStart+i, 1)
if err != nil {
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
return nil, fmt.Errorf("jdenticon: engine: color selection failed: failed to parse color index at position %d: %w", hashPosColorStart+i, err)
}
// Defensive check: ensure availableColors is not empty before modulo operation
// This should never happen due to the check at the start of the function,
// but provides additional safety for future modifications
if len(availableColors) == 0 {
return nil, fmt.Errorf("jdenticon: engine: color selection failed: available colors became empty during selection")
}
index := indexValue % len(availableColors)
// Apply color conflict resolution rules from JavaScript implementation
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
index = 1 // Use mid color as fallback
if g.isDuplicateColor(index, selectedIndexes[:i], []int{colorDarkGray, colorDarkMain}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{colorLightGray, colorLightMain}) { // Disallow light gray and light color combo
index = colorMidFallback // Use mid color as fallback
}
selectedIndexes[i] = index
}
return selectedIndexes, nil
}
// contains checks if a slice contains a specific value
func contains(slice []int, value int) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
return selectedIndexes, nil
}
// isDuplicateColor checks for problematic color combinations
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
if !contains(forbidden, index) {
if !isColorInForbiddenSet(index, forbidden) {
return false
}
return hasSelectedColorInForbiddenSet(selected, forbidden)
}
// isColorInForbiddenSet checks if the given color index is in the forbidden set
func isColorInForbiddenSet(index int, forbidden []int) bool {
return util.ContainsInt(forbidden, index)
}
// hasSelectedColorInForbiddenSet checks if any selected color is in the forbidden set
func hasSelectedColorInForbiddenSet(selected []int, forbidden []int) bool {
for _, s := range selected {
if contains(forbidden, s) {
if util.ContainsInt(forbidden, s) {
return true
}
}
return false
}
// renderShape implements the JavaScript renderShape function exactly
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
// renderShape implements the JavaScript renderShape function exactly with context support
// Shapes are appended directly to the provided destination slice to avoid intermediate allocations
func (g *Generator) renderShape(ctx context.Context, hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool, dest *[]Shape) error { //nolint:unparam // colorIndex is passed for API consistency with JavaScript implementation
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse shape index at position %d: %w", shapeHashIndex, err)
}
shapeIndex := shapeIndexValue
var rotation int
if rotationHashIndex >= 0 {
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse rotation at position %d: %w", rotationHashIndex, err)
}
rotation = rotationValue
}
shapes := make([]Shape, 0, len(positions))
for i, pos := range positions {
// Check for cancellation in the rendering loop
if err := ctx.Err(); err != nil {
return err
}
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
transformX := float64(x + pos[0]*cell)
transformY := float64(y + pos[1]*cell)
var transformRotation int
if rotationHashIndex >= 0 {
transformRotation = (rotation + i) % 4
transformRotation = (rotation + i) % gridSize
} else {
// For center shapes (rotationIndex is null), r starts at 0 and increments
transformRotation = i % 4
transformRotation = i % gridSize
}
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
// Create shape using graphics with transform
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
// Get a collector from the pool and reset it
collector := shapeCollectorPool.Get().(*shapeCollector)
collector.Reset()
// Create shape using graphics with pooled collector
graphics := NewGraphicsWithTransform(collector, transform)
if isOuter {
RenderOuterShape(graphics, shapeIndex, float64(cell))
} else {
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
}
collector := graphics.renderer.(*shapeCollector)
for _, shape := range collector.shapes {
shapes = append(shapes, shape)
}
// Append shapes directly to destination slice and return collector to pool
*dest = append(*dest, collector.shapes...)
shapeCollectorPool.Put(collector)
}
return shapes, nil
return nil
}
// shapeCollectorPool provides pooled shapeCollector instances for efficient reuse
var shapeCollectorPool = sync.Pool{
New: func() interface{} {
// Pre-allocate with reasonable capacity - typical identicon has 4-8 shapes per collector
return &shapeCollector{shapes: make([]Shape, 0, 8)}
},
}
// shapeCollector implements Renderer interface to collect shapes during generation
@@ -297,6 +405,12 @@ type shapeCollector struct {
shapes []Shape
}
// Reset clears the shape collector for reuse while preserving capacity
func (sc *shapeCollector) Reset() {
// Keep capacity but reset length to 0 for efficient reuse
sc.shapes = sc.shapes[:0]
}
func (sc *shapeCollector) AddPolygon(points []Point) {
sc.shapes = append(sc.shapes, Shape{
Type: "polygon",
@@ -315,39 +429,89 @@ func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
})
}
// cacheKey generates a cache key for the given parameters
func (g *Generator) cacheKey(hash string, size float64) string {
return fmt.Sprintf("%s:%.2f", hash, size)
func getOuterShapeComplexity(shapeIndex int) int {
index := shapeIndex % 4
switch index {
case 0: // Triangle
return 3
case 1: // Triangle (different orientation)
return 3
case 2: // Rhombus (diamond)
return 4
case 3: // Circle
return 5 // Circles are more expensive to render
default:
return 1 // Fallback for unknown shapes
}
}
// ClearCache clears the internal cache
func (g *Generator) ClearCache() {
g.mu.Lock()
defer g.mu.Unlock()
g.cache = make(map[string]*Icon)
// getCenterShapeComplexity returns the complexity score for a center shape type.
// Scoring accounts for multiple geometric elements and cutouts.
func getCenterShapeComplexity(shapeIndex int) int {
index := shapeIndex % 14
switch index {
case 0: // Asymmetric polygon (5 points)
return 5
case 1: // Triangle
return 3
case 2: // Rectangle
return 4
case 3: // Nested rectangles (2 rectangles)
return 8
case 4: // Circle
return 5
case 5: // Rectangle with triangular cutout (rect + inverted triangle)
return 7
case 6: // Complex polygon (6 points)
return 6
case 7: // Small triangle
return 3
case 8: // Composite shape (2 rectangles + 1 triangle)
return 11
case 9: // Rectangle with rectangular cutout (rect + inverted rect)
return 8
case 10: // Rectangle with circular cutout (rect + inverted circle)
return 9
case 11: // Small triangle (same as 7)
return 3
case 12: // Rectangle with rhombus cutout (rect + inverted rhombus)
return 8
case 13: // Large circle (conditional rendering)
return 5
default:
return 1 // Fallback for unknown shapes
}
}
// GetCacheSize returns the number of cached icons
func (g *Generator) GetCacheSize() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.cache)
}
// CalculateComplexity calculates the total geometric complexity for an identicon
// based on the hash string. This provides a fast complexity assessment before
// any expensive rendering operations.
func (g *Generator) CalculateComplexity(hash string) (int, error) {
totalComplexity := 0
// SetConfig updates the generator configuration and clears cache
func (g *Generator) SetConfig(config ColorConfig) {
config.Validate()
g.mu.Lock()
g.config = config
g.cache = make(map[string]*Icon)
g.mu.Unlock()
}
// Calculate complexity for side shapes (8 positions)
sideShapeIndexValue, err := util.ParseHex(hash, hashPosSideShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse side shape index: %w", err)
}
sideShapeComplexity := getOuterShapeComplexity(sideShapeIndexValue)
totalComplexity += sideShapeComplexity * 8 // 8 side positions
// GetConfig returns a copy of the current configuration
func (g *Generator) GetConfig() ColorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config
}
// Calculate complexity for corner shapes (4 positions)
cornerShapeIndexValue, err := util.ParseHex(hash, hashPosCornerShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse corner shape index: %w", err)
}
cornerShapeComplexity := getOuterShapeComplexity(cornerShapeIndexValue)
totalComplexity += cornerShapeComplexity * 4 // 4 corner positions
// Calculate complexity for center shapes (4 positions)
centerShapeIndexValue, err := util.ParseHex(hash, hashPosCenterShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse center shape index: %w", err)
}
centerShapeComplexity := getCenterShapeComplexity(centerShapeIndexValue)
totalComplexity += centerShapeComplexity * 4 // 4 center positions
return totalComplexity, nil
}

View File

@@ -0,0 +1,413 @@
package engine
import (
"context"
"fmt"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/util"
)
var benchmarkHashes = []string{
"7c4a8d09ca3762af61e59520943dc26494f8941b", // test-hash
"b36d9b6a07d0b5bfb7e0e77a7f8d1e5e6f7a8b9c", // example1@gmail.com
"a9d8e7f6c5b4a3d2e1f0e9d8c7b6a5d4e3f2a1b0", // example2@yahoo.com
"1234567890abcdef1234567890abcdef12345678",
"fedcba0987654321fedcba0987654321fedcba09",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"0000000000000000000000000000000000000000",
"ffffffffffffffffffffffffffffffffffffffffffff",
}
var benchmarkSizesFloat = []float64{
16.0, 32.0, 64.0, 128.0, 256.0, 512.0,
}
// Benchmark core generator creation
func BenchmarkNewGeneratorWithConfig(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1000,
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
_ = generator
}
}
// Benchmark icon generation without cache (per size)
func BenchmarkGenerateWithoutCachePerSize(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1000,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
for _, size := range benchmarkSizesFloat {
b.Run(fmt.Sprintf("size-%.0f", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
b.Fatalf("GenerateWithoutCache failed: %v", err)
}
}
})
}
}
// Benchmark icon generation with cache (different from generator_test.go)
func BenchmarkGenerateWithCacheHeavy(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 100,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use limited set of hashes to test cache hits
hash := benchmarkHashes[i%3] // Only use first 3 hashes
size := 64.0
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
// Benchmark hash parsing functions
func BenchmarkParseHex(b *testing.B) {
hash := "7c4a8d09ca3762af61e59520943dc26494f8941b"
b.Run("offset2_len1", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = util.ParseHex(hash, 2, 1)
}
})
b.Run("offset4_len1", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = util.ParseHex(hash, 4, 1)
}
})
b.Run("offset1_len1", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = util.ParseHex(hash, 1, 1)
}
})
b.Run("offset8_len3", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = util.ParseHex(hash, 8, 3)
}
})
}
// Benchmark hue extraction
func BenchmarkExtractHue(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
_, _ = generator.extractHue(hash)
}
}
// Benchmark shape selection
func BenchmarkShapeSelection(b *testing.B) {
hash := "7c4a8d09ca3762af61e59520943dc26494f8941b"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Simulate shape selection process using util.ParseHex
sideShapeIndex, _ := util.ParseHex(hash, hashPosSideShape, 1)
cornerShapeIndex, _ := util.ParseHex(hash, hashPosCornerShape, 1)
centerShapeIndex, _ := util.ParseHex(hash, hashPosCenterShape, 1)
// Use modulo with arbitrary shape counts (simulating actual shape arrays)
sideShapeIndex = sideShapeIndex % 16 // Assume 16 outer shapes
cornerShapeIndex = cornerShapeIndex % 16
centerShapeIndex = centerShapeIndex % 8 // Assume 8 center shapes
_, _, _ = sideShapeIndex, cornerShapeIndex, centerShapeIndex
}
}
// Benchmark color theme generation
func BenchmarkGenerateColorTheme(b *testing.B) {
config := DefaultColorConfig()
generator, err := NewGeneratorWithConfig(GeneratorConfig{
ColorConfig: config,
CacheSize: 1,
})
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
hue, _ := generator.extractHue(hash)
_ = GenerateColorTheme(hue, config)
}
}
// Benchmark position computation
func BenchmarkComputePositions(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Test both side and corner positions
_ = getSidePositions()
_ = getCornerPositions()
}
}
// Benchmark transform applications
func BenchmarkTransformApplication(b *testing.B) {
transform := Transform{
x: 1.0,
y: 2.0,
size: 64.0,
rotation: 1,
}
b.Run("center_point", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = transform.TransformIconPoint(0.5, 0.5, 0, 0)
}
})
b.Run("corner_point", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = transform.TransformIconPoint(1.0, 1.0, 0, 0)
}
})
b.Run("origin_point", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = transform.TransformIconPoint(0.0, 0.0, 0, 0)
}
})
}
// Benchmark icon size calculations
func BenchmarkIconSizeCalculations(b *testing.B) {
sizes := benchmarkSizesFloat
padding := 0.1
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := sizes[i%len(sizes)]
// Simulate size calculations from generator
paddingPixels := size * padding * paddingMultiple
iconSize := size - paddingPixels
cellSize := iconSize / gridSize
_, _, _ = paddingPixels, iconSize, cellSize
}
}
// Benchmark cache key generation
func BenchmarkCacheKeyGeneration(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
size := benchmarkSizesFloat[i%len(benchmarkSizesFloat)]
_ = benchmarkCacheKey(hash, size)
}
}
// Helper function to simulate cache key generation
func benchmarkCacheKey(hash string, size float64) string {
return hash + ":" + fmt.Sprintf("%.0f", size)
}
// Benchmark full icon generation pipeline
func BenchmarkFullGenerationPipeline(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1, // Minimal cache to avoid cache hits
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
size := 64.0
// This tests the full pipeline: hash parsing, color generation,
// shape selection, positioning, and rendering preparation
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
b.Fatalf("GenerateWithoutCache failed: %v", err)
}
}
}
// Benchmark different grid sizes (theoretical)
func BenchmarkGridSizeCalculations(b *testing.B) {
sizes := benchmarkSizesFloat
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := sizes[i%len(sizes)]
padding := 0.1
// Test calculations for different theoretical grid sizes
for gridSizeTest := 3; gridSizeTest <= 6; gridSizeTest++ {
paddingPixels := size * padding * paddingMultiple
iconSize := size - paddingPixels
cellSize := iconSize / float64(gridSizeTest)
_ = cellSize
}
}
}
// Benchmark color conflict resolution
func BenchmarkColorConflictResolution(b *testing.B) {
config := DefaultColorConfig()
generator, err := NewGeneratorWithConfig(GeneratorConfig{
ColorConfig: config,
CacheSize: 1,
})
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
hash := benchmarkHashes[i%len(benchmarkHashes)]
hue, _ := generator.extractHue(hash)
colorTheme := GenerateColorTheme(hue, config)
// Simulate color conflict resolution
for j := 0; j < 5; j++ {
colorHash, _ := util.ParseHex(hash, hashPosColorStart+j%3, 1)
selectedColor := colorTheme[colorHash%len(colorTheme)]
_ = selectedColor
}
}
}
// Helper function to get side positions (matching generator logic)
func getSidePositions() [][]int {
return [][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}}
}
// Helper function to get corner positions (matching generator logic)
func getCornerPositions() [][]int {
return [][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}}
}
// Benchmark concurrent icon generation for high-traffic scenarios
func BenchmarkGenerateWithoutCacheParallel(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1, // Minimal cache to avoid cache effects
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
for _, size := range []float64{64.0, 128.0, 256.0} {
b.Run(fmt.Sprintf("size-%.0f", size), func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
hash := benchmarkHashes[i%len(benchmarkHashes)]
_, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
b.Errorf("GenerateWithoutCache failed: %v", err)
}
i++
}
})
})
}
}
// Benchmark concurrent cached generation
func BenchmarkGenerateWithCacheParallel(b *testing.B) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 100, // Shared cache for concurrent access
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
b.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
// Use limited set of hashes to test cache hits under concurrency
hash := benchmarkHashes[i%3] // Only use first 3 hashes
size := 64.0
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
b.Errorf("Generate failed: %v", err)
}
i++
}
})
}

View File

@@ -0,0 +1,635 @@
package engine
import (
"context"
"fmt"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/util"
)
func TestNewGenerator(t *testing.T) {
config := DefaultColorConfig()
generator, err := NewGenerator(config)
if err != nil {
t.Fatalf("NewGenerator failed: %v", err)
}
if generator == nil {
t.Fatal("NewGenerator returned nil")
}
if generator.config.ColorConfig.IconPadding != config.IconPadding {
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.ColorConfig.IconPadding)
}
if generator.cache == nil {
t.Error("Generator cache was not initialized")
}
}
func TestNewDefaultGenerator(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
if generator == nil {
t.Fatal("NewDefaultGenerator returned nil")
}
expectedConfig := DefaultColorConfig()
if generator.config.ColorConfig.IconPadding != expectedConfig.IconPadding {
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.ColorConfig.IconPadding)
}
}
func TestNewGeneratorWithConfig(t *testing.T) {
config := GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 500,
}
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("NewGeneratorWithConfig failed: %v", err)
}
if generator == nil {
t.Fatal("NewGeneratorWithConfig returned nil")
}
if generator.config.CacheSize != 500 {
t.Errorf("Expected cache size 500, got %d", generator.config.CacheSize)
}
}
func TestDefaultGeneratorConfig(t *testing.T) {
config := DefaultGeneratorConfig()
if config.CacheSize != 1000 {
t.Errorf("Expected default cache size 1000, got %d", config.CacheSize)
}
if config.MaxComplexity != 0 {
t.Errorf("Expected default max complexity 0, got %d", config.MaxComplexity)
}
}
func TestExtractHue(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
tests := []struct {
name string
hash string
expectedHue float64
expectsError bool
}{
{
name: "Valid 40-character hash",
hash: "abcdef1234567890abcdef1234567890abcdef12",
expectedHue: float64(0xbcdef12) / float64(0xfffffff),
expectsError: false,
},
{
name: "Valid hash with different values",
hash: "1234567890abcdef1234567890abcdef12345678",
expectedHue: float64(0x2345678) / float64(0xfffffff),
expectsError: false,
},
{
name: "Hash too short",
hash: "abc",
expectedHue: 0,
expectsError: true,
},
{
name: "Invalid hex characters",
hash: "abcdef1234567890abcdef1234567890abcdefgh",
expectedHue: 0,
expectsError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
hue, err := generator.extractHue(test.hash)
if test.expectsError {
if err == nil {
t.Errorf("Expected error for hash %s, but got none", test.hash)
}
return
}
if err != nil {
t.Errorf("Unexpected error for hash %s: %v", test.hash, err)
return
}
if fmt.Sprintf("%.6f", hue) != fmt.Sprintf("%.6f", test.expectedHue) {
t.Errorf("Expected hue %.6f, got %.6f", test.expectedHue, hue)
}
})
}
}
func TestSelectColors(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
availableColors := []Color{
{H: 0.0, S: 1.0, L: 0.5, A: 255}, // Red
{H: 0.33, S: 1.0, L: 0.5, A: 255}, // Green
{H: 0.67, S: 1.0, L: 0.5, A: 255}, // Blue
{H: 0.17, S: 1.0, L: 0.5, A: 255}, // Yellow
{H: 0.0, S: 0.0, L: 0.5, A: 255}, // Gray
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
selectedIndexes, err := generator.selectColors(hash, availableColors)
if err != nil {
t.Fatalf("selectColors failed: %v", err)
}
if len(selectedIndexes) != 3 {
t.Errorf("Expected 3 selected color indexes, got %d", len(selectedIndexes))
}
for i, index := range selectedIndexes {
if index < 0 || index >= len(availableColors) {
t.Errorf("Selected index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
}
}
}
func TestSelectColorsEmptyPalette(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
_, err = generator.selectColors(hash, []Color{})
if err == nil {
t.Error("Expected error for empty color palette, but got none")
}
}
func TestConsistentGeneration(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
icon1, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
t.Fatalf("First generation failed: %v", err)
}
icon2, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
t.Fatalf("Second generation failed: %v", err)
}
if icon1.Hash != icon2.Hash {
t.Error("Icons have different hashes")
}
if icon1.Size != icon2.Size {
t.Error("Icons have different sizes")
}
if len(icon1.Shapes) != len(icon2.Shapes) {
t.Errorf("Icons have different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
}
for i, group1 := range icon1.Shapes {
group2 := icon2.Shapes[i]
if len(group1.Shapes) != len(group2.Shapes) {
t.Errorf("Shape group %d has different number of shapes: %d vs %d", i, len(group1.Shapes), len(group2.Shapes))
}
}
}
func TestIsColorInForbiddenSet(t *testing.T) {
tests := []struct {
name string
index int
forbidden []int
expected bool
}{
{
name: "Index in forbidden set",
index: 2,
forbidden: []int{0, 2, 4},
expected: true,
},
{
name: "Index not in forbidden set",
index: 1,
forbidden: []int{0, 2, 4},
expected: false,
},
{
name: "Empty forbidden set",
index: 1,
forbidden: []int{},
expected: false,
},
{
name: "Single element forbidden set - match",
index: 5,
forbidden: []int{5},
expected: true,
},
{
name: "Single element forbidden set - no match",
index: 3,
forbidden: []int{5},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := isColorInForbiddenSet(test.index, test.forbidden)
if result != test.expected {
t.Errorf("Expected %v, got %v", test.expected, result)
}
})
}
}
func TestHasSelectedColorInForbiddenSet(t *testing.T) {
tests := []struct {
name string
selected []int
forbidden []int
expected bool
}{
{
name: "No overlap",
selected: []int{1, 3, 5},
forbidden: []int{0, 2, 4},
expected: false,
},
{
name: "Partial overlap",
selected: []int{1, 2, 5},
forbidden: []int{0, 2, 4},
expected: true,
},
{
name: "Complete overlap",
selected: []int{0, 2, 4},
forbidden: []int{0, 2, 4},
expected: true,
},
{
name: "Empty selected",
selected: []int{},
forbidden: []int{0, 2, 4},
expected: false,
},
{
name: "Empty forbidden",
selected: []int{1, 3, 5},
forbidden: []int{},
expected: false,
},
{
name: "Both empty",
selected: []int{},
forbidden: []int{},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := hasSelectedColorInForbiddenSet(test.selected, test.forbidden)
if result != test.expected {
t.Errorf("Expected %v, got %v", test.expected, result)
}
})
}
}
func TestIsDuplicateColorRefactored(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
tests := []struct {
name string
index int
selected []int
forbidden []int
expected bool
}{
{
name: "Index not in forbidden set",
index: 1,
selected: []int{0, 4},
forbidden: []int{0, 4},
expected: false,
},
{
name: "Index in forbidden set, no selected colors in forbidden set",
index: 0,
selected: []int{1, 3},
forbidden: []int{0, 4},
expected: false,
},
{
name: "Index in forbidden set, has selected colors in forbidden set",
index: 0,
selected: []int{1, 4},
forbidden: []int{0, 4},
expected: true,
},
{
name: "Dark gray and dark main conflict",
index: colorDarkGray,
selected: []int{colorDarkMain},
forbidden: []int{colorDarkGray, colorDarkMain},
expected: true,
},
{
name: "Light gray and light main conflict",
index: colorLightGray,
selected: []int{colorLightMain},
forbidden: []int{colorLightGray, colorLightMain},
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := generator.isDuplicateColor(test.index, test.selected, test.forbidden)
if result != test.expected {
t.Errorf("Expected %v, got %v", test.expected, result)
}
})
}
}
func TestShapeCollector(t *testing.T) {
collector := &shapeCollector{}
// Test initial state
if len(collector.shapes) != 0 {
t.Error("Expected empty shapes slice initially")
}
// Test AddPolygon
points := []Point{{X: 0, Y: 0}, {X: 10, Y: 0}, {X: 5, Y: 10}}
collector.AddPolygon(points)
if len(collector.shapes) != 1 {
t.Errorf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
}
shape := collector.shapes[0]
if shape.Type != "polygon" {
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
}
if len(shape.Points) != 3 {
t.Errorf("Expected 3 points, got %d", len(shape.Points))
}
// Test AddCircle
collector.AddCircle(Point{X: 5, Y: 5}, 20, false)
if len(collector.shapes) != 2 {
t.Errorf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
}
circleShape := collector.shapes[1]
if circleShape.Type != "circle" {
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
}
if circleShape.CircleX != 5 {
t.Errorf("Expected CircleX 5, got %f", circleShape.CircleX)
}
if circleShape.CircleY != 5 {
t.Errorf("Expected CircleY 5, got %f", circleShape.CircleY)
}
if circleShape.CircleSize != 20 {
t.Errorf("Expected CircleSize 20, got %f", circleShape.CircleSize)
}
if circleShape.Invert != false {
t.Errorf("Expected Invert false, got %v", circleShape.Invert)
}
// Test Reset
collector.Reset()
if len(collector.shapes) != 0 {
t.Errorf("Expected empty shapes slice after Reset, got %d", len(collector.shapes))
}
// Test that we can add shapes again after reset
collector.AddPolygon([]Point{{X: 1, Y: 1}})
if len(collector.shapes) != 1 {
t.Errorf("Expected 1 shape after Reset and AddPolygon, got %d", len(collector.shapes))
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
hash string
expected bool
}{
{
name: "Valid 40-character hex hash",
hash: "abcdef1234567890abcdef1234567890abcdef12",
expected: true,
},
{
name: "Valid 32-character hex hash",
hash: "abcdef1234567890abcdef1234567890",
expected: true,
},
{
name: "Empty hash",
hash: "",
expected: false,
},
{
name: "Hash too short",
hash: "abc",
expected: false,
},
{
name: "Hash with invalid characters",
hash: "abcdef1234567890abcdef1234567890abcdefgh",
expected: false,
},
{
name: "Hash with uppercase letters",
hash: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
expected: true,
},
{
name: "Mixed case hash",
hash: "AbCdEf1234567890aBcDeF1234567890AbCdEf12",
expected: true,
},
{
name: "Hash with spaces",
hash: "abcdef12 34567890abcdef1234567890abcdef12",
expected: false,
},
{
name: "All zeros",
hash: "0000000000000000000000000000000000000000",
expected: true,
},
{
name: "All f's",
hash: "ffffffffffffffffffffffffffffffffffffffff",
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := util.IsValidHash(test.hash)
if result != test.expected {
t.Errorf("Expected %v for hash '%s', got %v", test.expected, test.hash, result)
}
})
}
}
func TestParseHex(t *testing.T) {
tests := []struct {
name string
hash string
position int
octets int
expected int
expectsError bool
}{
{
name: "Valid single octet",
hash: "abcdef1234567890",
position: 0,
octets: 1,
expected: 0xa,
expectsError: false,
},
{
name: "Valid two octets",
hash: "abcdef1234567890",
position: 1,
octets: 2,
expected: 0xbc,
expectsError: false,
},
{
name: "Position at end of hash",
hash: "abcdef12",
position: 7,
octets: 1,
expected: 0x2,
expectsError: false,
},
{
name: "Position beyond hash length",
hash: "abc",
position: 5,
octets: 1,
expected: 0,
expectsError: true,
},
{
name: "Octets extend beyond hash",
hash: "abcdef12",
position: 6,
octets: 3,
expected: 0x12, // Should read to end of hash
expectsError: false,
},
{
name: "Zero octets",
hash: "abcdef12",
position: 0,
octets: 0,
expected: 0xabcdef12, // Should read to end when octets is 0
expectsError: false,
},
{
name: "Negative position",
hash: "abcdef12",
position: -1,
octets: 1,
expected: 0x2, // Should read from end
expectsError: false,
},
{
name: "Empty hash",
hash: "",
position: 0,
octets: 1,
expected: 0,
expectsError: true,
},
{
name: "All f's",
hash: "ffffffff",
position: 0,
octets: 4,
expected: 0xffff,
expectsError: false,
},
{
name: "Mixed case",
hash: "AbCdEf12",
position: 2,
octets: 2,
expected: 0xcd,
expectsError: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := util.ParseHex(test.hash, test.position, test.octets)
if test.expectsError {
if err == nil {
t.Errorf("Expected error for ParseHex(%s, %d, %d), but got none", test.hash, test.position, test.octets)
}
return
}
if err != nil {
t.Errorf("Unexpected error for ParseHex(%s, %d, %d): %v", test.hash, test.position, test.octets, err)
return
}
if result != test.expected {
t.Errorf("Expected %d (0x%x), got %d (0x%x)", test.expected, test.expected, result, result)
}
})
}
}

View File

@@ -0,0 +1,160 @@
package engine
import (
"context"
"testing"
)
// TestSelectColors_EmptyColors tests the defensive check for empty available colors
func TestSelectColors_EmptyColors(t *testing.T) {
// Create a generator for testing
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
// Test with empty available colors slice
hash := "1234567890abcdef"
emptyColors := []Color{}
_, err = generator.selectColors(hash, emptyColors)
if err == nil {
t.Fatal("expected error for empty available colors, got nil")
}
expectedMsg := "no available colors"
if !contains(err.Error(), expectedMsg) {
t.Errorf("expected error message to contain %q, got %q", expectedMsg, err.Error())
}
t.Logf("Got expected error: %v", err)
}
// TestSelectColors_ValidColors tests that selectColors works correctly with valid input
func TestSelectColors_ValidColors(t *testing.T) {
// Create a generator for testing
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
// Create a sample set of colors (similar to what GenerateColorTheme returns)
config := DefaultColorConfig()
availableColors := GenerateColorTheme(0.5, config)
if len(availableColors) == 0 {
t.Fatal("GenerateColorTheme returned empty colors")
}
hash := "1234567890abcdef"
selectedIndexes, err := generator.selectColors(hash, availableColors)
if err != nil {
t.Fatalf("selectColors failed with valid input: %v", err)
}
// Should return exactly numColorSelections (3) color indexes
if len(selectedIndexes) != numColorSelections {
t.Errorf("expected %d selected colors, got %d", numColorSelections, len(selectedIndexes))
}
// All indexes should be valid (within bounds of available colors)
for i, index := range selectedIndexes {
if index < 0 || index >= len(availableColors) {
t.Errorf("selected index %d at position %d is out of bounds (0-%d)", index, i, len(availableColors)-1)
}
}
}
// TestGenerator_GenerateIcon_RobustnessChecks tests that generateIcon handles edge cases gracefully
func TestGenerator_GenerateIcon_RobustnessChecks(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
testCases := []struct {
name string
hash string
size float64
expectError bool
}{
{"valid_input", "1234567890abcdef12345", 64.0, false},
{"minimum_size", "1234567890abcdef12345", 1.0, false},
{"large_size", "1234567890abcdef12345", 1024.0, false},
{"zero_size", "1234567890abcdef12345", 0.0, false}, // generateIcon doesn't validate size
{"negative_size", "1234567890abcdef12345", -10.0, false}, // generateIcon doesn't validate size
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
icon, err := generator.generateIcon(context.Background(), tc.hash, tc.size)
if tc.expectError {
if err == nil {
t.Errorf("expected error for %s, got none", tc.name)
}
} else {
if err != nil {
t.Errorf("unexpected error for %s: %v", tc.name, err)
}
if icon == nil {
t.Errorf("got nil icon for valid input %s", tc.name)
}
// Validate icon properties
if icon != nil {
if icon.Size != tc.size {
t.Errorf("icon size mismatch: expected %f, got %f", tc.size, icon.Size)
}
if icon.Hash != tc.hash {
t.Errorf("icon hash mismatch: expected %s, got %s", tc.hash, icon.Hash)
}
}
}
})
}
}
// TestHueExtraction_EdgeCases tests hue extraction with edge case inputs
func TestHueExtraction_EdgeCases(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
testCases := []struct {
name string
hash string
expectError bool
}{
{"valid_hash", "1234567890abcdef12345", false},
{"minimum_length", "1234567890a", false}, // Exactly 11 characters
{"hex_only", "abcdefabcdefabcdef123", false},
{"numbers_only", "12345678901234567890", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
hue, err := generator.extractHue(tc.hash)
if tc.expectError {
if err == nil {
t.Errorf("expected error for %s, got none", tc.name)
}
} else {
if err != nil {
t.Errorf("unexpected error for %s: %v", tc.name, err)
}
// Hue should be in range [0, 1]
if hue < 0 || hue > 1 {
t.Errorf("hue out of range for %s: %f (should be 0-1)", tc.name, hue)
}
}
})
}
}
// Helper function to check if a string contains a substring (defined in color_graceful_degradation_test.go)

View File

@@ -1,517 +0,0 @@
package engine
import (
"testing"
"github.com/kevin/go-jdenticon/internal/util"
)
func TestNewGenerator(t *testing.T) {
config := DefaultColorConfig()
generator := NewGenerator(config)
if generator == nil {
t.Fatal("NewGenerator returned nil")
}
if generator.config.IconPadding != config.IconPadding {
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.IconPadding)
}
if generator.cache == nil {
t.Error("Generator cache was not initialized")
}
}
func TestNewDefaultGenerator(t *testing.T) {
generator := NewDefaultGenerator()
if generator == nil {
t.Fatal("NewDefaultGenerator returned nil")
}
expectedConfig := DefaultColorConfig()
if generator.config.IconPadding != expectedConfig.IconPadding {
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.IconPadding)
}
}
func TestGenerateValidHash(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed with error: %v", err)
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
if icon.Hash != hash {
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
}
if icon.Size != size {
t.Errorf("Expected size %f, got %f", size, icon.Size)
}
if len(icon.Shapes) == 0 {
t.Error("Generated icon has no shapes")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
size float64
wantErr bool
}{
{
name: "empty hash",
hash: "",
size: 64.0,
wantErr: true,
},
{
name: "zero size",
hash: "abcdef123456789",
size: 0.0,
wantErr: true,
},
{
name: "negative size",
hash: "abcdef123456789",
size: -10.0,
wantErr: true,
},
{
name: "short hash",
hash: "abc",
size: 64.0,
wantErr: true,
},
{
name: "invalid hex characters",
hash: "xyz123456789abc",
size: 64.0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := generator.Generate(tt.hash, tt.size)
if (err != nil) != tt.wantErr {
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGenerateCaching(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate icon first time
icon1, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
// Check cache size
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
}
// Generate same icon again
icon2, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// Should be the same instance from cache
if icon1 != icon2 {
t.Error("Second generate did not return cached instance")
}
// Cache size should still be 1
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
}
}
func TestClearCache(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Clear cache
generator.ClearCache()
// Verify cache is empty
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
}
}
func TestSetConfig(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Set new config
newConfig := DefaultColorConfig()
newConfig.IconPadding = 0.1
generator.SetConfig(newConfig)
// Verify config was updated
if generator.GetConfig().IconPadding != 0.1 {
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
}
// Verify cache was cleared
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
}
func TestExtractHue(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
expected float64
tolerance float64
}{
{
name: "all zeros",
hash: "0000000000000000000",
expected: 0.0,
tolerance: 0.0001,
},
{
name: "all fs",
hash: "ffffffffffffffffff",
expected: 1.0,
tolerance: 0.0001,
},
{
name: "half value",
hash: "000000000007ffffff",
expected: 0.5,
tolerance: 0.001, // Allow small floating point variance
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := generator.extractHue(tt.hash)
if err != nil {
t.Fatalf("extractHue failed: %v", err)
}
diff := result - tt.expected
if diff < 0 {
diff = -diff
}
if diff > tt.tolerance {
t.Errorf("Expected hue %f, got %f (tolerance %f)", tt.expected, result, tt.tolerance)
}
})
}
}
func TestSelectColors(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
// Create test color palette
availableColors := []Color{
NewColorRGB(50, 50, 50), // 0: Dark gray
NewColorRGB(100, 100, 200), // 1: Mid color
NewColorRGB(200, 200, 200), // 2: Light gray
NewColorRGB(150, 150, 255), // 3: Light color
NewColorRGB(25, 25, 100), // 4: Dark color
}
selectedIndexes, err := generator.selectColors(hash, availableColors)
if err != nil {
t.Fatalf("selectColors failed: %v", err)
}
if len(selectedIndexes) != 3 {
t.Fatalf("Expected 3 selected colors, got %d", len(selectedIndexes))
}
for i, index := range selectedIndexes {
if index < 0 || index >= len(availableColors) {
t.Errorf("Color index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
}
}
}
func TestSelectColorsEmptyPalette(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
_, err := generator.selectColors(hash, []Color{})
if err == nil {
t.Error("Expected error for empty color palette")
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
hash string
valid bool
}{
{
name: "valid hash",
hash: "abcdef123456789",
valid: true,
},
{
name: "too short",
hash: "abc",
valid: false,
},
{
name: "invalid characters",
hash: "xyz123456789abc",
valid: false,
},
{
name: "uppercase valid",
hash: "ABCDEF123456789",
valid: true,
},
{
name: "mixed case valid",
hash: "AbCdEf123456789",
valid: true,
},
{
name: "empty",
hash: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := util.IsValidHash(tt.hash)
if result != tt.valid {
t.Errorf("Expected isValidHash(%s) = %v, got %v", tt.hash, tt.valid, result)
}
})
}
}
func TestParseHex(t *testing.T) {
hash := "123456789abcdef"
tests := []struct {
name string
start int
octets int
expected int
wantErr bool
}{
{
name: "single character",
start: 0,
octets: 1,
expected: 1,
wantErr: false,
},
{
name: "two characters",
start: 1,
octets: 2,
expected: 0x23,
wantErr: false,
},
{
name: "negative index",
start: -1,
octets: 1,
expected: 0xf,
wantErr: false,
},
{
name: "out of bounds",
start: 100,
octets: 1,
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := util.ParseHex(hash, tt.start, tt.octets)
if tt.wantErr {
if err == nil {
t.Errorf("Expected an error, but got nil")
}
return // Test is done for error cases
}
if err != nil {
t.Fatalf("parseHex failed unexpectedly: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %d, got %d", tt.expected, result)
}
})
}
}
func TestShapeCollector(t *testing.T) {
collector := &shapeCollector{}
// Test AddPolygon
points := []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}}
collector.AddPolygon(points)
if len(collector.shapes) != 1 {
t.Fatalf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
}
shape := collector.shapes[0]
if shape.Type != "polygon" {
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
}
if len(shape.Points) != len(points) {
t.Errorf("Expected %d points, got %d", len(points), len(shape.Points))
}
// Test AddCircle
center := Point{X: 5, Y: 5}
radius := 2.5
collector.AddCircle(center, radius, false)
if len(collector.shapes) != 2 {
t.Fatalf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
}
circleShape := collector.shapes[1]
if circleShape.Type != "circle" {
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
}
// Verify circle fields are set correctly
if circleShape.CircleX != center.X {
t.Errorf("Expected CircleX %f, got %f", center.X, circleShape.CircleX)
}
if circleShape.CircleY != center.Y {
t.Errorf("Expected CircleY %f, got %f", center.Y, circleShape.CircleY)
}
if circleShape.CircleSize != radius {
t.Errorf("Expected CircleSize %f, got %f", radius, circleShape.CircleSize)
}
if circleShape.Invert != false {
t.Errorf("Expected Invert false, got %t", circleShape.Invert)
}
}
func BenchmarkGenerate(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func BenchmarkGenerateWithCache(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Pre-populate cache
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Initial generate failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func TestConsistentGeneration(t *testing.T) {
generator1 := NewDefaultGenerator()
generator2 := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon1, err := generator1.Generate(hash, size)
if err != nil {
t.Fatalf("Generator1 failed: %v", err)
}
icon2, err := generator2.Generate(hash, size)
if err != nil {
t.Fatalf("Generator2 failed: %v", err)
}
// Icons should have same number of shape groups
if len(icon1.Shapes) != len(icon2.Shapes) {
t.Errorf("Different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
}
// Colors should be the same
for i := range icon1.Shapes {
if i >= len(icon2.Shapes) {
break
}
if !icon1.Shapes[i].Color.Equals(icon2.Shapes[i].Color) {
t.Errorf("Different colors at group %d", i)
}
}
}

View File

@@ -1,136 +0,0 @@
package engine
// Grid represents a 4x4 layout grid for positioning shapes in a jdenticon
type Grid struct {
Size float64
Cell int
X int
Y int
Padding int
}
// Position represents an x, y coordinate pair
type Position struct {
X, Y int
}
// NewGrid creates a new Grid with the specified icon size and padding ratio
func NewGrid(iconSize float64, paddingRatio float64) *Grid {
// Calculate padding and round to nearest integer (matches JS: (0.5 + size * parsedConfig.iconPadding) | 0)
padding := int(0.5 + iconSize*paddingRatio)
size := iconSize - float64(padding*2)
// Calculate cell size and ensure it is an integer (matches JS: 0 | (size / 4))
cell := int(size / 4)
// Center the icon since cell size is integer-based (matches JS implementation)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := padding + int((size - float64(cell*4))/2)
y := padding + int((size - float64(cell*4))/2)
return &Grid{
Size: size,
Cell: cell,
X: x,
Y: y,
Padding: padding,
}
}
// CellToCoordinate converts a grid cell position to actual coordinates
func (g *Grid) CellToCoordinate(cellX, cellY int) (x, y float64) {
return float64(g.X + cellX*g.Cell), float64(g.Y + cellY*g.Cell)
}
// GetCellSize returns the size of each cell in the grid
func (g *Grid) GetCellSize() float64 {
return float64(g.Cell)
}
// LayoutEngine manages the overall layout and positioning of icon elements
type LayoutEngine struct {
grid *Grid
}
// NewLayoutEngine creates a new LayoutEngine with the specified parameters
func NewLayoutEngine(iconSize float64, paddingRatio float64) *LayoutEngine {
return &LayoutEngine{
grid: NewGrid(iconSize, paddingRatio),
}
}
// Grid returns the underlying grid
func (le *LayoutEngine) Grid() *Grid {
return le.grid
}
// GetShapePositions returns the positions for different shape types based on the jdenticon pattern
func (le *LayoutEngine) GetShapePositions(shapeType string) []Position {
switch shapeType {
case "sides":
// Sides: positions around the perimeter (8 positions)
return []Position{
{1, 0}, {2, 0}, {2, 3}, {1, 3}, // top and bottom
{0, 1}, {3, 1}, {3, 2}, {0, 2}, // left and right
}
case "corners":
// Corners: four corner positions
return []Position{
{0, 0}, {3, 0}, {3, 3}, {0, 3},
}
case "center":
// Center: four center positions
return []Position{
{1, 1}, {2, 1}, {2, 2}, {1, 2},
}
default:
return []Position{}
}
}
// ApplySymmetry applies symmetrical transformations to position indices
// This ensures the icon has the characteristic jdenticon symmetry
func ApplySymmetry(positions []Position, index int) []Position {
if index >= len(positions) {
return positions
}
// For jdenticon, we apply rotational symmetry
// The pattern is designed to be symmetrical, so we don't need to modify positions
// The symmetry is achieved through the predefined position arrays
return positions
}
// GetTransformedPosition applies rotation and returns the final position
func (le *LayoutEngine) GetTransformedPosition(cellX, cellY int, rotation int) (x, y float64, cellSize float64) {
// Apply rotation if needed (rotation is 0-3 for 0°, 90°, 180°, 270°)
switch rotation % 4 {
case 0: // 0°
// No rotation
case 1: // 90° clockwise
cellX, cellY = cellY, 3-cellX
case 2: // 180°
cellX, cellY = 3-cellX, 3-cellY
case 3: // 270° clockwise (90° counter-clockwise)
cellX, cellY = 3-cellY, cellX
}
x, y = le.grid.CellToCoordinate(cellX, cellY)
cellSize = le.grid.GetCellSize()
return
}
// ValidateGrid checks if the grid configuration is valid
func (g *Grid) ValidateGrid() bool {
return g.Cell > 0 && g.Size > 0 && g.Padding >= 0
}
// GetIconBounds returns the bounds of the icon within the grid
func (g *Grid) GetIconBounds() (x, y, width, height float64) {
return float64(g.X), float64(g.Y), float64(g.Cell * 4), float64(g.Cell * 4)
}
// GetCenterOffset returns the offset needed to center content within a cell
func (g *Grid) GetCenterOffset() (dx, dy float64) {
return float64(g.Cell) / 2, float64(g.Cell) / 2
}

View File

@@ -1,380 +0,0 @@
package engine
import (
"math"
"testing"
)
func TestNewGrid(t *testing.T) {
tests := []struct {
name string
iconSize float64
paddingRatio float64
wantPadding int
wantCell int
}{
{
name: "standard 64px icon with 8% padding",
iconSize: 64.0,
paddingRatio: 0.08,
wantPadding: 5, // 0.5 + 64 * 0.08 = 5.62, rounded to 5
wantCell: 13, // (64 - 5*2) / 4 = 54/4 = 13.5, truncated to 13
},
{
name: "large 256px icon with 10% padding",
iconSize: 256.0,
paddingRatio: 0.10,
wantPadding: 26, // 0.5 + 256 * 0.10 = 26.1, rounded to 26
wantCell: 51, // (256 - 26*2) / 4 = 204/4 = 51
},
{
name: "small 32px icon with 5% padding",
iconSize: 32.0,
paddingRatio: 0.05,
wantPadding: 2, // 0.5 + 32 * 0.05 = 2.1, rounded to 2
wantCell: 7, // (32 - 2*2) / 4 = 28/4 = 7
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grid := NewGrid(tt.iconSize, tt.paddingRatio)
if grid.Padding != tt.wantPadding {
t.Errorf("NewGrid() padding = %v, want %v", grid.Padding, tt.wantPadding)
}
if grid.Cell != tt.wantCell {
t.Errorf("NewGrid() cell = %v, want %v", grid.Cell, tt.wantCell)
}
// Verify that the grid is centered
expectedSize := tt.iconSize - float64(tt.wantPadding*2)
if math.Abs(grid.Size-expectedSize) > 0.1 {
t.Errorf("NewGrid() size = %v, want %v", grid.Size, expectedSize)
}
})
}
}
func TestGridCellToCoordinate(t *testing.T) {
grid := NewGrid(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
wantX float64
wantY float64
}{
{
name: "origin cell (0,0)",
cellX: 0,
cellY: 0,
wantX: float64(grid.X),
wantY: float64(grid.Y),
},
{
name: "center cell (1,1)",
cellX: 1,
cellY: 1,
wantX: float64(grid.X + grid.Cell),
wantY: float64(grid.Y + grid.Cell),
},
{
name: "corner cell (3,3)",
cellX: 3,
cellY: 3,
wantX: float64(grid.X + 3*grid.Cell),
wantY: float64(grid.Y + 3*grid.Cell),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY := grid.CellToCoordinate(tt.cellX, tt.cellY)
if gotX != tt.wantX {
t.Errorf("CellToCoordinate() x = %v, want %v", gotX, tt.wantX)
}
if gotY != tt.wantY {
t.Errorf("CellToCoordinate() y = %v, want %v", gotY, tt.wantY)
}
})
}
}
func TestLayoutEngineGetShapePositions(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
shapeType string
wantLen int
wantFirst Position
wantLast Position
}{
{
name: "sides positions",
shapeType: "sides",
wantLen: 8,
wantFirst: Position{1, 0},
wantLast: Position{0, 2},
},
{
name: "corners positions",
shapeType: "corners",
wantLen: 4,
wantFirst: Position{0, 0},
wantLast: Position{0, 3},
},
{
name: "center positions",
shapeType: "center",
wantLen: 4,
wantFirst: Position{1, 1},
wantLast: Position{1, 2},
},
{
name: "invalid shape type",
shapeType: "invalid",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positions := le.GetShapePositions(tt.shapeType)
if len(positions) != tt.wantLen {
t.Errorf("GetShapePositions() len = %v, want %v", len(positions), tt.wantLen)
}
if tt.wantLen > 0 {
if positions[0] != tt.wantFirst {
t.Errorf("GetShapePositions() first = %v, want %v", positions[0], tt.wantFirst)
}
if positions[len(positions)-1] != tt.wantLast {
t.Errorf("GetShapePositions() last = %v, want %v", positions[len(positions)-1], tt.wantLast)
}
}
})
}
}
func TestLayoutEngineGetTransformedPosition(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
rotation int
wantX int // Expected cell X after rotation
wantY int // Expected cell Y after rotation
}{
{
name: "no rotation",
cellX: 1,
cellY: 0,
rotation: 0,
wantX: 1,
wantY: 0,
},
{
name: "90 degree rotation",
cellX: 1,
cellY: 0,
rotation: 1,
wantX: 0,
wantY: 2, // 3-1 = 2
},
{
name: "180 degree rotation",
cellX: 1,
cellY: 0,
rotation: 2,
wantX: 2, // 3-1 = 2
wantY: 3, // 3-0 = 3
},
{
name: "270 degree rotation",
cellX: 1,
cellY: 0,
rotation: 3,
wantX: 3, // 3-0 = 3
wantY: 1,
},
{
name: "rotation overflow (4 = 0)",
cellX: 1,
cellY: 0,
rotation: 4,
wantX: 1,
wantY: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY, gotCellSize := le.GetTransformedPosition(tt.cellX, tt.cellY, tt.rotation)
// Convert back to cell coordinates to verify rotation
expectedX, expectedY := le.grid.CellToCoordinate(tt.wantX, tt.wantY)
if gotX != expectedX {
t.Errorf("GetTransformedPosition() x = %v, want %v", gotX, expectedX)
}
if gotY != expectedY {
t.Errorf("GetTransformedPosition() y = %v, want %v", gotY, expectedY)
}
if gotCellSize != float64(le.grid.Cell) {
t.Errorf("GetTransformedPosition() cellSize = %v, want %v", gotCellSize, float64(le.grid.Cell))
}
})
}
}
func TestApplySymmetry(t *testing.T) {
positions := []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
tests := []struct {
name string
index int
want int // expected length
}{
{
name: "valid index",
index: 1,
want: 4,
},
{
name: "index out of bounds",
index: 10,
want: 4,
},
{
name: "negative index",
index: -1,
want: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ApplySymmetry(positions, tt.index)
if len(result) != tt.want {
t.Errorf("ApplySymmetry() len = %v, want %v", len(result), tt.want)
}
// Verify that the positions are unchanged (current implementation)
for i, pos := range result {
if pos != positions[i] {
t.Errorf("ApplySymmetry() changed position at index %d: got %v, want %v", i, pos, positions[i])
}
}
})
}
}
func TestGridValidateGrid(t *testing.T) {
tests := []struct {
name string
grid *Grid
want bool
}{
{
name: "valid grid",
grid: &Grid{Size: 64, Cell: 16, Padding: 4},
want: true,
},
{
name: "zero cell size",
grid: &Grid{Size: 64, Cell: 0, Padding: 4},
want: false,
},
{
name: "zero size",
grid: &Grid{Size: 0, Cell: 16, Padding: 4},
want: false,
},
{
name: "negative padding",
grid: &Grid{Size: 64, Cell: 16, Padding: -1},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.grid.ValidateGrid(); got != tt.want {
t.Errorf("ValidateGrid() = %v, want %v", got, tt.want)
}
})
}
}
func TestGridGetIconBounds(t *testing.T) {
grid := NewGrid(64.0, 0.08)
x, y, width, height := grid.GetIconBounds()
expectedX := float64(grid.X)
expectedY := float64(grid.Y)
expectedWidth := float64(grid.Cell * 4)
expectedHeight := float64(grid.Cell * 4)
if x != expectedX {
t.Errorf("GetIconBounds() x = %v, want %v", x, expectedX)
}
if y != expectedY {
t.Errorf("GetIconBounds() y = %v, want %v", y, expectedY)
}
if width != expectedWidth {
t.Errorf("GetIconBounds() width = %v, want %v", width, expectedWidth)
}
if height != expectedHeight {
t.Errorf("GetIconBounds() height = %v, want %v", height, expectedHeight)
}
}
func TestGridGetCenterOffset(t *testing.T) {
grid := NewGrid(64.0, 0.08)
dx, dy := grid.GetCenterOffset()
expected := float64(grid.Cell) / 2
if dx != expected {
t.Errorf("GetCenterOffset() dx = %v, want %v", dx, expected)
}
if dy != expected {
t.Errorf("GetCenterOffset() dy = %v, want %v", dy, expected)
}
}
func TestNewLayoutEngine(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
if le.grid == nil {
t.Error("NewLayoutEngine() grid is nil")
}
if le.Grid() != le.grid {
t.Error("NewLayoutEngine() Grid() does not return internal grid")
}
// Verify grid configuration
if !le.grid.ValidateGrid() {
t.Error("NewLayoutEngine() created invalid grid")
}
}

View File

@@ -0,0 +1,294 @@
package engine
import (
"context"
"runtime"
"strings"
"testing"
"time"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
)
// TestResourceExhaustionProtection tests that the generator properly blocks
// attempts to create extremely large icons that could cause memory exhaustion.
func TestResourceExhaustionProtection(t *testing.T) {
tests := []struct {
name string
maxIconSize int
requestedSize float64
expectError bool
errorContains string
}{
{
name: "valid size within default limit",
maxIconSize: 0, // Use default
requestedSize: 1024,
expectError: false,
},
{
name: "valid size at exact default limit",
maxIconSize: 0, // Use default
requestedSize: constants.DefaultMaxIconSize,
expectError: false,
},
{
name: "invalid size exceeds default limit by 1",
maxIconSize: 0, // Use default
requestedSize: constants.DefaultMaxIconSize + 1,
expectError: true,
errorContains: "exceeds maximum allowed size",
},
{
name: "extremely large size should be blocked",
maxIconSize: 0, // Use default
requestedSize: 100000,
expectError: true,
errorContains: "exceeds maximum allowed size",
},
{
name: "custom limit - valid size",
maxIconSize: 1000,
requestedSize: 1000,
expectError: false,
},
{
name: "custom limit - invalid size",
maxIconSize: 1000,
requestedSize: 1001,
expectError: true,
errorContains: "exceeds maximum allowed size",
},
{
name: "disabled limit allows oversized requests",
maxIconSize: -1, // Disabled
requestedSize: constants.DefaultMaxIconSize + 1000,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultGeneratorConfig()
config.MaxIconSize = tt.maxIconSize
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use a simple hash for testing
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
icon, err := generator.Generate(ctx, testHash, tt.requestedSize)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for size %f, but got none", tt.requestedSize)
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
}
if icon != nil {
t.Errorf("Expected nil icon when error occurs, but got non-nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error for size %f: %v", tt.requestedSize, err)
return
}
if icon == nil {
t.Errorf("Expected non-nil icon for valid size %f", tt.requestedSize)
}
}
})
}
}
// TestMemoryUsageDoesNotSpikeOnRejection verifies that memory usage doesn't
// spike when oversized icon requests are rejected, proving that the validation
// happens before any memory allocation.
func TestMemoryUsageDoesNotSpikeOnRejection(t *testing.T) {
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
// Force garbage collection and get baseline memory stats
runtime.GC()
runtime.GC() // Run twice to ensure clean baseline
var m1 runtime.MemStats
runtime.ReadMemStats(&m1)
baselineAlloc := m1.Alloc
ctx := context.Background()
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
// Attempt to generate an extremely large icon (should be rejected)
oversizedRequest := float64(constants.DefaultMaxIconSize * 10) // 10x the limit
icon, err := generator.Generate(ctx, testHash, oversizedRequest)
// Verify the request was properly rejected
if err == nil {
t.Fatalf("Expected error for oversized request, but got none")
}
if icon != nil {
t.Fatalf("Expected nil icon for oversized request, but got non-nil")
}
if !strings.Contains(err.Error(), "exceeds maximum allowed size") {
t.Fatalf("Expected specific error message, got: %v", err)
}
// Check memory usage after the rejected request
runtime.GC()
var m2 runtime.MemStats
runtime.ReadMemStats(&m2)
postRejectionAlloc := m2.Alloc
// Calculate memory increase (allow for some variance due to test overhead)
memoryIncrease := postRejectionAlloc - baselineAlloc
maxAcceptableIncrease := uint64(1024 * 1024) // 1MB tolerance for test overhead
if memoryIncrease > maxAcceptableIncrease {
t.Errorf("Memory usage spiked by %d bytes after rejection (baseline: %d, post: %d). "+
"This suggests memory allocation occurred before validation.",
memoryIncrease, baselineAlloc, postRejectionAlloc)
}
t.Logf("Memory baseline: %d bytes, post-rejection: %d bytes, increase: %d bytes",
baselineAlloc, postRejectionAlloc, memoryIncrease)
}
// TestConfigurationDefaults verifies that the default MaxIconSize is properly applied
// when not explicitly set in the configuration.
func TestConfigurationDefaults(t *testing.T) {
tests := []struct {
name string
configSize int
expectedMax int
}{
{
name: "zero config uses default",
configSize: 0,
expectedMax: constants.DefaultMaxIconSize,
},
{
name: "other negative config uses default",
configSize: -5,
expectedMax: constants.DefaultMaxIconSize,
},
{
name: "custom config is respected",
configSize: 2000,
expectedMax: 2000,
},
{
name: "disabled config is respected",
configSize: -1,
expectedMax: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultGeneratorConfig()
config.MaxIconSize = tt.configSize
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
// Check that the effective max size was set correctly
if generator.maxIconSize != tt.expectedMax {
t.Errorf("Expected maxIconSize to be %d, but got %d", tt.expectedMax, generator.maxIconSize)
}
// Verify the limit is enforced (skip if disabled)
if tt.expectedMax > 0 {
ctx := context.Background()
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
// Try a size just over the limit
oversizedRequest := float64(tt.expectedMax + 1)
icon, err := generator.Generate(ctx, testHash, oversizedRequest)
if err == nil {
t.Errorf("Expected error for size %f (limit: %d), but got none", oversizedRequest, tt.expectedMax)
}
if icon != nil {
t.Errorf("Expected nil icon for oversized request")
}
} else if tt.expectedMax == -1 {
// Test that disabled limit allows large sizes
ctx := context.Background()
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
// Try a very large size that would normally be blocked
largeRequest := float64(constants.DefaultMaxIconSize + 1000)
icon, err := generator.Generate(ctx, testHash, largeRequest)
if err != nil {
t.Errorf("Unexpected error for large size with disabled limit: %v", err)
}
if icon == nil {
t.Errorf("Expected non-nil icon for large size with disabled limit")
}
}
})
}
}
// TestBoundaryConditions tests edge cases around the size limit boundaries
func TestBoundaryConditions(t *testing.T) {
config := DefaultGeneratorConfig()
config.MaxIconSize = 1000
generator, err := NewGeneratorWithConfig(config)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
ctx := context.Background()
testHash := "7b824bb99b5b4a4b7b824bb99b5b4a4b7b824bb99b5b4a4b"
tests := []struct {
name string
size float64
expectError bool
}{
{"size at exact limit", 1000, false},
{"size just under limit", 999, false},
{"size just over limit", 1001, true},
{"floating point at limit", 1000.0, false},
{"floating point just over", 1001.0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
icon, err := generator.Generate(ctx, testHash, tt.size)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for size %f, but got none", tt.size)
}
if icon != nil {
t.Errorf("Expected nil icon for oversized request")
}
} else {
if err != nil {
t.Errorf("Unexpected error for size %f: %v", tt.size, err)
}
if icon == nil {
t.Errorf("Expected non-nil icon for valid size %f", tt.size)
}
}
})
}
}

View File

@@ -2,6 +2,50 @@ package engine
import "math"
// Shape rendering constants for visual proportions
const (
// Center shape proportions - these ratios determine the visual appearance
centerShapeAsymmetricCornerRatio = 0.42 // Shape 0: corner cut proportion
centerShapeTriangleWidthRatio = 0.5 // Shape 1: triangle width relative to cell
centerShapeTriangleHeightRatio = 0.8 // Shape 1: triangle height relative to cell
centerShapeInnerMarginRatio = 0.1 // Shape 3,5,9,10: inner margin ratio
centerShapeOuterMarginRatio = 0.25 // Shape 3: outer margin ratio for large cells
centerShapeOuterMarginRatio9 = 0.35 // Shape 9: outer margin ratio for large cells
centerShapeOuterMarginRatio10 = 0.12 // Shape 10: inner ratio for circular cutout
centerShapeCircleMarginRatio = 0.15 // Shape 4: circle margin ratio
centerShapeCircleWidthRatio = 0.5 // Shape 4: circle width ratio
// Shape 6 complex polygon proportions
centerShapeComplexHeight1Ratio = 0.7 // First height point
centerShapeComplexPoint1XRatio = 0.4 // First point X ratio
centerShapeComplexPoint1YRatio = 0.4 // First point Y ratio
centerShapeComplexPoint2XRatio = 0.7 // Second point X ratio
// Shape 9 rectangular cutout proportions
centerShapeRect9InnerRatio = 0.14 // Shape 9: inner rectangle ratio
// Shape 12 rhombus cutout proportion
centerShapeRhombusCutoutRatio = 0.25 // Shape 12: rhombus cutout margin
// Shape 13 large circle proportions (only for center position)
centerShapeLargeCircleMarginRatio = 0.4 // Shape 13: circle margin ratio
centerShapeLargeCircleWidthRatio = 1.2 // Shape 13: circle width ratio
// Outer shape proportions
outerShapeCircleMarginRatio = 1.0 / 6.0 // Shape 3: circle margin (1/6 of cell)
// Size thresholds for conditional rendering
smallCellThreshold4 = 4 // Threshold for shape 3,9 outer margin calculation
smallCellThreshold6 = 6 // Threshold for shape 3 outer margin calculation
smallCellThreshold8 = 8 // Threshold for shape 3,9 inner margin floor calculation
// Multipliers for margin calculations
innerOuterMultiplier5 = 4 // Shape 5: inner to outer multiplier
innerOuterMultiplier10 = 3 // Shape 10: inner to outer multiplier
)
// Point represents a 2D point
type Point struct {
X, Y float64
@@ -80,13 +124,13 @@ func (g *Graphics) AddTriangle(x, y, w, h float64, r int, invert bool) {
{X: x, Y: y + h},
{X: x, Y: y},
}
// Remove one corner based on rotation
removeIndex := (r % 4) * 1
if removeIndex < len(points) {
points = append(points[:removeIndex], points[removeIndex+1:]...)
}
g.AddPolygon(points, invert)
}
@@ -104,11 +148,11 @@ func (g *Graphics) AddRhombus(x, y, w, h float64, invert bool) {
// RenderCenterShape renders one of the 14 distinct center shape patterns
func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64) {
index := shapeIndex % 14
switch index {
case 0:
// Shape 0: Asymmetric polygon
k := cell * 0.42
k := cell * centerShapeAsymmetricCornerRatio
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
@@ -117,53 +161,53 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 1:
// Shape 1: Triangle
w := math.Floor(cell * 0.5)
h := math.Floor(cell * 0.8)
w := math.Floor(cell * centerShapeTriangleWidthRatio)
h := math.Floor(cell * centerShapeTriangleHeightRatio)
g.AddTriangle(cell-w, 0, w, h, 2, false)
case 2:
// Shape 2: Rectangle
w := math.Floor(cell / 3)
g.AddRectangle(w, w, cell-w, cell-w, false)
case 3:
// Shape 3: Nested rectangles
inner := cell * 0.1
inner := cell * centerShapeInnerMarginRatio
var outer float64
if cell < 6 {
if cell < smallCellThreshold6 {
outer = 1
} else if cell < 8 {
} else if cell < smallCellThreshold8 {
outer = 2
} else {
outer = math.Floor(cell * 0.25)
outer = math.Floor(cell * centerShapeOuterMarginRatio)
}
if inner > 1 {
inner = math.Floor(inner)
} else if inner > 0.5 {
inner = 1
}
g.AddRectangle(outer, outer, cell-inner-outer, cell-inner-outer, false)
case 4:
// Shape 4: Circle
m := math.Floor(cell * 0.15)
w := math.Floor(cell * 0.5)
m := math.Floor(cell * centerShapeCircleMarginRatio)
w := math.Floor(cell * centerShapeCircleWidthRatio)
g.AddCircle(cell-w-m, cell-w-m, w, false)
case 5:
// Shape 5: Rectangle with triangular cutout
inner := cell * 0.1
outer := inner * 4
inner := cell * centerShapeInnerMarginRatio
outer := inner * innerOuterMultiplier5
if outer > 3 {
outer = math.Floor(outer)
}
g.AddRectangle(0, 0, cell, cell, false)
points := []Point{
{X: outer, Y: outer},
@@ -171,71 +215,71 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
{X: outer + (cell-outer-inner)/2, Y: cell - inner},
}
g.AddPolygon(points, true)
case 6:
// Shape 6: Complex polygon
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
{X: cell, Y: cell * 0.7},
{X: cell * 0.4, Y: cell * 0.4},
{X: cell * 0.7, Y: cell},
{X: cell, Y: cell * centerShapeComplexHeight1Ratio},
{X: cell * centerShapeComplexPoint1XRatio, Y: cell * centerShapeComplexPoint1YRatio},
{X: cell * centerShapeComplexPoint2XRatio, Y: cell},
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 7:
// Shape 7: Small triangle
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 8:
// Shape 8: Composite shape
g.AddRectangle(0, 0, cell, cell/2, false)
g.AddRectangle(0, cell/2, cell/2, cell/2, false)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 1, false)
case 9:
// Shape 9: Rectangle with rectangular cutout
inner := cell * 0.14
inner := cell * centerShapeRect9InnerRatio
var outer float64
if cell < 4 {
if cell < smallCellThreshold4 {
outer = 1
} else if cell < 6 {
} else if cell < smallCellThreshold6 {
outer = 2
} else {
outer = math.Floor(cell * 0.35)
outer = math.Floor(cell * centerShapeOuterMarginRatio9)
}
if cell >= 8 {
if cell >= smallCellThreshold8 {
inner = math.Floor(inner)
}
g.AddRectangle(0, 0, cell, cell, false)
g.AddRectangle(outer, outer, cell-outer-inner, cell-outer-inner, true)
case 10:
// Shape 10: Rectangle with circular cutout
inner := cell * 0.12
outer := inner * 3
inner := cell * centerShapeOuterMarginRatio10
outer := inner * innerOuterMultiplier10
g.AddRectangle(0, 0, cell, cell, false)
g.AddCircle(outer, outer, cell-inner-outer, true)
case 11:
// Shape 11: Small triangle (same as 7)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 12:
// Shape 12: Rectangle with rhombus cutout
m := cell * 0.25
m := cell * centerShapeRhombusCutoutRatio
g.AddRectangle(0, 0, cell, cell, false)
g.AddRhombus(m, m, cell-m, cell-m, true)
case 13:
// Shape 13: Large circle (only for position 0)
if positionIndex == 0 {
m := cell * 0.4
w := cell * 1.2
m := cell * centerShapeLargeCircleMarginRatio
w := cell * centerShapeLargeCircleWidthRatio
g.AddCircle(m, m, w, false)
}
}
@@ -244,23 +288,23 @@ func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64)
// RenderOuterShape renders one of the 4 distinct outer shape patterns
func RenderOuterShape(g *Graphics, shapeIndex int, cell float64) {
index := shapeIndex % 4
switch index {
case 0:
// Shape 0: Triangle
g.AddTriangle(0, 0, cell, cell, 0, false)
case 1:
// Shape 1: Triangle (different orientation)
g.AddTriangle(0, cell/2, cell, cell/2, 0, false)
case 2:
// Shape 2: Rhombus
g.AddRhombus(0, 0, cell, cell, false)
case 3:
// Shape 3: Circle
m := cell / 6
m := cell * outerShapeCircleMarginRatio
g.AddCircle(m, m, cell-2*m, false)
}
}
}

View File

@@ -36,30 +36,30 @@ func (m *MockRenderer) Reset() {
func TestGraphicsAddRectangle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRectangle(10, 20, 30, 40, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 20},
{X: 40, Y: 20},
{X: 40, Y: 60},
{X: 10, Y: 60},
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
@@ -68,27 +68,27 @@ func TestGraphicsAddRectangle(t *testing.T) {
func TestGraphicsAddCircle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddCircle(10, 20, 30, false)
if len(mock.Circles) != 1 {
t.Errorf("Expected 1 circle, got %d", len(mock.Circles))
return
}
circle := mock.Circles[0]
expectedTopLeft := Point{X: 10, Y: 20}
expectedSize := float64(30)
if circle.TopLeft.X != expectedTopLeft.X || circle.TopLeft.Y != expectedTopLeft.Y {
t.Errorf("Expected top-left (%f, %f), got (%f, %f)",
expectedTopLeft.X, expectedTopLeft.Y, circle.TopLeft.X, circle.TopLeft.Y)
}
if circle.Size != expectedSize {
t.Errorf("Expected size %f, got %f", expectedSize, circle.Size)
}
if circle.Invert != false {
t.Errorf("Expected invert false, got %t", circle.Invert)
}
@@ -97,30 +97,30 @@ func TestGraphicsAddCircle(t *testing.T) {
func TestGraphicsAddRhombus(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRhombus(0, 0, 20, 30, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 0}, // top
{X: 20, Y: 15}, // right
{X: 10, Y: 30}, // bottom
{X: 0, Y: 15}, // left
{X: 10, Y: 0}, // top
{X: 20, Y: 15}, // right
{X: 10, Y: 30}, // bottom
{X: 0, Y: 15}, // left
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
@@ -130,12 +130,12 @@ func TestRenderCenterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each center shape
for i := 0; i < 14; i++ {
mock.Reset()
RenderCenterShape(g, i, cell, 0)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
// Shape 13 at position != 0 doesn't draw anything, which is expected
@@ -151,35 +151,35 @@ func TestRenderCenterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test shape 2 (rectangle)
mock.Reset()
RenderCenterShape(g, 2, cell, 0)
if len(mock.Polygons) != 1 {
t.Errorf("Shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test shape 4 (circle)
mock.Reset()
RenderCenterShape(g, 4, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 4: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 0 (should draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 13 at position 0: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 1 (should not draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 1)
if len(mock.Circles) != 0 {
t.Errorf("Shape 13 at position 1: expected 0 circles, got %d", len(mock.Circles))
}
@@ -189,12 +189,12 @@ func TestRenderOuterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each outer shape
for i := 0; i < 4; i++ {
mock.Reset()
RenderOuterShape(g, i, cell)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
t.Errorf("Outer shape %d: expected some drawing commands, got none", i)
@@ -206,19 +206,19 @@ func TestRenderOuterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test outer shape 2 (rhombus)
mock.Reset()
RenderOuterShape(g, 2, cell)
if len(mock.Polygons) != 1 {
t.Errorf("Outer shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test outer shape 3 (circle)
mock.Reset()
RenderOuterShape(g, 3, cell)
if len(mock.Circles) != 1 {
t.Errorf("Outer shape 3: expected 1 circle, got %d", len(mock.Circles))
}
@@ -228,30 +228,30 @@ func TestShapeIndexModulo(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test that shape indices wrap around correctly
mock.Reset()
RenderCenterShape(g, 0, cell, 0)
polygonsShape0 := len(mock.Polygons)
circlesShape0 := len(mock.Circles)
mock.Reset()
RenderCenterShape(g, 14, cell, 0) // Should be same as shape 0
if len(mock.Polygons) != polygonsShape0 || len(mock.Circles) != circlesShape0 {
t.Errorf("Shape 14 should be equivalent to shape 0")
}
// Test outer shapes
mock.Reset()
RenderOuterShape(g, 0, cell)
polygonsOuter0 := len(mock.Polygons)
circlesOuter0 := len(mock.Circles)
mock.Reset()
RenderOuterShape(g, 4, cell) // Should be same as outer shape 0
if len(mock.Polygons) != polygonsOuter0 || len(mock.Circles) != circlesOuter0 {
t.Errorf("Outer shape 4 should be equivalent to outer shape 0")
}
}
}

View File

@@ -0,0 +1,103 @@
package engine
import (
"context"
"fmt"
"github.com/ungluedlabs/go-jdenticon/internal/util"
)
// Generate creates an identicon with the specified hash and size
// This method includes caching and singleflight to prevent duplicate work
func (g *Generator) Generate(ctx context.Context, hash string, size float64) (*Icon, error) {
// Basic validation
if hash == "" {
return nil, fmt.Errorf("jdenticon: engine: generation failed: hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid size: %f", size)
}
// Check icon size limits
if g.maxIconSize > 0 && int(size) > g.maxIconSize {
return nil, fmt.Errorf("jdenticon: engine: generation failed: icon size %d exceeds maximum allowed size %d", int(size), g.maxIconSize)
}
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid hash format: %s", hash)
}
// Check for context cancellation before proceeding
if err := ctx.Err(); err != nil {
return nil, err
}
// Generate cache key
key := g.cacheKey(hash, size)
// Check cache first (with read lock)
g.mu.RLock()
if cached, ok := g.cache.Get(key); ok {
g.mu.RUnlock()
g.metrics.recordHit()
return cached, nil
}
g.mu.RUnlock()
// Use singleflight to prevent multiple concurrent generations for the same key
result, err, _ := g.sf.Do(key, func() (interface{}, error) {
// Check cache again inside singleflight (another goroutine might have populated it)
g.mu.RLock()
if cached, ok := g.cache.Get(key); ok {
g.mu.RUnlock()
g.metrics.recordHit()
return cached, nil
}
g.mu.RUnlock()
// Generate the icon
icon, err := g.generateIcon(ctx, hash, size)
if err != nil {
return nil, err
}
// Store in cache (with write lock)
g.mu.Lock()
g.cache.Add(key, icon)
g.mu.Unlock()
g.metrics.recordMiss()
return icon, nil
})
if err != nil {
return nil, err
}
return result.(*Icon), nil
}
// GenerateWithoutCache creates an identicon without using cache
// This method is useful for testing or when caching is not desired
func (g *Generator) GenerateWithoutCache(ctx context.Context, hash string, size float64) (*Icon, error) {
// Basic validation
if hash == "" {
return nil, fmt.Errorf("jdenticon: engine: generation failed: hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid size: %f", size)
}
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("jdenticon: engine: generation failed: invalid hash format: %s", hash)
}
// Check for context cancellation
if err := ctx.Err(); err != nil {
return nil, err
}
return g.generateIcon(ctx, hash, size)
}

View File

@@ -0,0 +1,415 @@
package engine
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
func TestGenerateValidHash(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef123456789"
size := 64.0
icon, err := generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Generate failed with error: %v", err)
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
if icon.Hash != hash {
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
}
if icon.Size != size {
t.Errorf("Expected size %f, got %f", size, icon.Size)
}
if len(icon.Shapes) == 0 {
t.Error("Generated icon has no shapes")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
tests := []struct {
name string
hash string
size float64
}{
{
name: "Empty hash",
hash: "",
size: 64.0,
},
{
name: "Zero size",
hash: "abcdef1234567890",
size: 0.0,
},
{
name: "Negative size",
hash: "abcdef1234567890",
size: -10.0,
},
{
name: "Invalid hash format",
hash: "invalid_hash_format",
size: 64.0,
},
{
name: "Hash too short",
hash: "abc",
size: 64.0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := generator.Generate(context.Background(), test.hash, test.size)
if err == nil {
t.Errorf("Expected error for %s, but got none", test.name)
}
})
}
}
func TestGenerateWithoutCache(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// Generate without cache
icon1, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
t.Fatalf("GenerateWithoutCache failed: %v", err)
}
// Generate again without cache - should be different instances
icon2, err := generator.GenerateWithoutCache(context.Background(), hash, size)
if err != nil {
t.Fatalf("Second GenerateWithoutCache failed: %v", err)
}
// Should be different instances
if icon1 == icon2 {
t.Error("GenerateWithoutCache returned same instance - should be different")
}
// But should have same content
if icon1.Hash != icon2.Hash {
t.Error("Icons have different hashes")
}
if icon1.Size != icon2.Size {
t.Error("Icons have different sizes")
}
// Cache should remain empty
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after GenerateWithoutCache, got %d", generator.GetCacheSize())
}
}
func TestGenerateWithCancellation(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// Create canceled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err = generator.Generate(ctx, hash, size)
if err == nil {
t.Error("Expected error for canceled context, but got none")
}
if err != context.Canceled {
t.Errorf("Expected context.Canceled error, got %v", err)
}
}
func TestGenerateWithTimeout(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// Create context with very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Sleep to ensure timeout
time.Sleep(1 * time.Millisecond)
_, err = generator.Generate(ctx, hash, size)
if err == nil {
t.Error("Expected timeout error, but got none")
}
if err != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded error, got %v", err)
}
}
func TestConcurrentGenerate(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
const numGoroutines = 20
icons := make([]*Icon, numGoroutines)
errors := make([]error, numGoroutines)
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Start multiple goroutines that generate the same icon concurrently
for i := 0; i < numGoroutines; i++ {
go func(index int) {
defer wg.Done()
icon, genErr := generator.Generate(context.Background(), hash, size)
icons[index] = icon
errors[index] = genErr
}(i)
}
wg.Wait()
// Check that all generations succeeded
for i, err := range errors {
if err != nil {
t.Errorf("Goroutine %d failed: %v", i, err)
}
}
// All icons should be identical (same instance due to singleflight)
firstIcon := icons[0]
for i, icon := range icons[1:] {
if icon != firstIcon {
t.Errorf("Icon %d is different instance from first icon", i+1)
}
}
// Cache should contain exactly one item
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
}
// Should have exactly one cache miss (the actual generation)
// Note: With singleflight, concurrent requests share the result directly from singleflight,
// not from the cache. Cache hits only occur for requests that arrive AFTER the initial
// generation completes. So we only verify the miss count is 1.
_, misses := generator.GetCacheMetrics()
if misses != 1 {
t.Errorf("Expected exactly 1 cache miss due to singleflight, got %d", misses)
}
// Verify subsequent requests DO get cache hits
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Subsequent Generate failed: %v", err)
}
hits, _ := generator.GetCacheMetrics()
if hits == 0 {
t.Error("Expected cache hit for subsequent request, got none")
}
}
func TestConcurrentGenerateDifferentHashes(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
const numGoroutines = 10
size := 64.0
var wg sync.WaitGroup
wg.Add(numGoroutines)
icons := make([]*Icon, numGoroutines)
errors := make([]error, numGoroutines)
// Start multiple goroutines that generate different icons concurrently
for i := 0; i < numGoroutines; i++ {
go func(index int) {
defer wg.Done()
hash := fmt.Sprintf("%032x", index)
icon, err := generator.Generate(context.Background(), hash, size)
icons[index] = icon
errors[index] = err
}(i)
}
wg.Wait()
// Check that all generations succeeded
for i, err := range errors {
if err != nil {
t.Errorf("Goroutine %d failed: %v", i, err)
}
}
// All icons should be different instances
for i := 0; i < numGoroutines; i++ {
for j := i + 1; j < numGoroutines; j++ {
if icons[i] == icons[j] {
t.Errorf("Icons %d and %d are the same instance - should be different", i, j)
}
}
}
// Cache should contain all generated icons
if generator.GetCacheSize() != numGoroutines {
t.Errorf("Expected cache size %d, got %d", numGoroutines, generator.GetCacheSize())
}
// Should have exactly numGoroutines cache misses and no hits
hits, misses := generator.GetCacheMetrics()
if misses != int64(numGoroutines) {
t.Errorf("Expected %d cache misses, got %d", numGoroutines, misses)
}
if hits != 0 {
t.Errorf("Expected 0 cache hits, got %d", hits)
}
}
func TestSingleflightDeduplication(t *testing.T) {
generator, err := NewDefaultGenerator()
if err != nil {
t.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
const numGoroutines = 50
// Use a channel to coordinate goroutine starts
start := make(chan struct{})
icons := make([]*Icon, numGoroutines)
errors := make([]error, numGoroutines)
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Start all goroutines and have them wait for the signal
for i := 0; i < numGoroutines; i++ {
go func(index int) {
defer wg.Done()
<-start // Wait for start signal
icon, genErr := generator.Generate(context.Background(), hash, size)
icons[index] = icon
errors[index] = genErr
}(i)
}
// Signal all goroutines to start at once
close(start)
wg.Wait()
// Check that all generations succeeded
for i, err := range errors {
if err != nil {
t.Errorf("Goroutine %d failed: %v", i, err)
}
}
// All icons should be the exact same instance due to singleflight
firstIcon := icons[0]
for i, icon := range icons[1:] {
if icon != firstIcon {
t.Errorf("Icon %d is different instance - singleflight deduplication failed", i+1)
}
}
// Should have exactly one cache miss due to singleflight deduplication
// Note: Singleflight shares results directly with waiting goroutines, so they don't
// hit the cache. Cache hits only occur for requests that arrive AFTER generation completes.
_, misses := generator.GetCacheMetrics()
if misses != 1 {
t.Errorf("Expected exactly 1 cache miss due to singleflight deduplication, got %d", misses)
}
// Verify subsequent requests DO get cache hits
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
t.Fatalf("Subsequent Generate failed: %v", err)
}
hits, _ := generator.GetCacheMetrics()
if hits == 0 {
t.Error("Expected cache hit for subsequent request, got none")
}
}
func BenchmarkGenerate(b *testing.B) {
generator, err := NewDefaultGenerator()
if err != nil {
b.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func BenchmarkGenerateWithCache(b *testing.B) {
generator, err := NewDefaultGenerator()
if err != nil {
b.Fatalf("NewDefaultGenerator failed: %v", err)
}
hash := "abcdef1234567890abcdef1234567890abcdef12"
size := 64.0
// Pre-populate cache
_, err = generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Pre-populate failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(context.Background(), hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}

View File

@@ -0,0 +1,4 @@
go test fuzz v1
string("-1")
int(-2)
int(5)

View File

@@ -1,61 +1,5 @@
package engine
import "math"
// Matrix represents a 2D transformation matrix in the form:
// | A C E |
// | B D F |
// | 0 0 1 |
type Matrix struct {
A, B, C, D, E, F float64
}
// NewIdentityMatrix creates an identity matrix
func NewIdentityMatrix() Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: 0, F: 0,
}
}
// Translate creates a translation matrix
func Translate(x, y float64) Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: x, F: y,
}
}
// Rotate creates a rotation matrix for the given angle in radians
func Rotate(angle float64) Matrix {
cos := math.Cos(angle)
sin := math.Sin(angle)
return Matrix{
A: cos, B: sin, C: -sin,
D: cos, E: 0, F: 0,
}
}
// Scale creates a scaling matrix
func Scale(sx, sy float64) Matrix {
return Matrix{
A: sx, B: 0, C: 0,
D: sy, E: 0, F: 0,
}
}
// Multiply multiplies two matrices
func (m Matrix) Multiply(other Matrix) Matrix {
return Matrix{
A: m.A*other.A + m.C*other.B,
B: m.B*other.A + m.D*other.B,
C: m.A*other.C + m.C*other.D,
D: m.B*other.C + m.D*other.D,
E: m.A*other.E + m.C*other.F + m.E,
F: m.B*other.E + m.D*other.F + m.F,
}
}
// Transform represents a geometric transformation
type Transform struct {
x, y, size float64
@@ -91,13 +35,5 @@ func (t Transform) TransformIconPoint(x, y, w, h float64) Point {
}
}
// ApplyTransform applies a transformation matrix to a point
func ApplyTransform(point Point, matrix Matrix) Point {
return Point{
X: matrix.A*point.X + matrix.C*point.Y + matrix.E,
Y: matrix.B*point.X + matrix.D*point.Y + matrix.F,
}
}
// NoTransform represents an identity transformation
var NoTransform = NewTransform(0, 0, 0, 0)
var NoTransform = NewTransform(0, 0, 0, 0)

View File

@@ -5,123 +5,6 @@ import (
"testing"
)
func TestNewIdentityMatrix(t *testing.T) {
m := NewIdentityMatrix()
expected := Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}
if m != expected {
t.Errorf("NewIdentityMatrix() = %v, want %v", m, expected)
}
}
func TestTranslate(t *testing.T) {
tests := []struct {
x, y float64
expected Matrix
}{
{10, 20, Matrix{A: 1, B: 0, C: 0, D: 1, E: 10, F: 20}},
{0, 0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{-5, 15, Matrix{A: 1, B: 0, C: 0, D: 1, E: -5, F: 15}},
}
for _, tt := range tests {
result := Translate(tt.x, tt.y)
if result != tt.expected {
t.Errorf("Translate(%v, %v) = %v, want %v", tt.x, tt.y, result, tt.expected)
}
}
}
func TestRotate(t *testing.T) {
tests := []struct {
angle float64
expected Matrix
}{
{0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{math.Pi / 2, Matrix{A: 0, B: 1, C: -1, D: 0, E: 0, F: 0}},
{math.Pi, Matrix{A: -1, B: 0, C: 0, D: -1, E: 0, F: 0}},
{3 * math.Pi / 2, Matrix{A: 0, B: -1, C: 1, D: 0, E: 0, F: 0}},
}
for _, tt := range tests {
result := Rotate(tt.angle)
// Use approximate equality for floating point comparison
if !approximatelyEqual(result.A, tt.expected.A) ||
!approximatelyEqual(result.B, tt.expected.B) ||
!approximatelyEqual(result.C, tt.expected.C) ||
!approximatelyEqual(result.D, tt.expected.D) ||
!approximatelyEqual(result.E, tt.expected.E) ||
!approximatelyEqual(result.F, tt.expected.F) {
t.Errorf("Rotate(%v) = %v, want %v", tt.angle, result, tt.expected)
}
}
}
func TestScale(t *testing.T) {
tests := []struct {
sx, sy float64
expected Matrix
}{
{1, 1, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{2, 3, Matrix{A: 2, B: 0, C: 0, D: 3, E: 0, F: 0}},
{0.5, 2, Matrix{A: 0.5, B: 0, C: 0, D: 2, E: 0, F: 0}},
}
for _, tt := range tests {
result := Scale(tt.sx, tt.sy)
if result != tt.expected {
t.Errorf("Scale(%v, %v) = %v, want %v", tt.sx, tt.sy, result, tt.expected)
}
}
}
func TestMatrixMultiply(t *testing.T) {
// Test identity multiplication
identity := NewIdentityMatrix()
translate := Translate(10, 20)
result := identity.Multiply(translate)
if result != translate {
t.Errorf("Identity * Translate = %v, want %v", result, translate)
}
// Test translation composition
t1 := Translate(10, 20)
t2 := Translate(5, 10)
result = t1.Multiply(t2)
expected := Translate(15, 30)
if result != expected {
t.Errorf("Translate(10,20) * Translate(5,10) = %v, want %v", result, expected)
}
// Test scale composition
s1 := Scale(2, 3)
s2 := Scale(0.5, 0.5)
result = s1.Multiply(s2)
expected = Scale(1, 1.5)
if result != expected {
t.Errorf("Scale(2,3) * Scale(0.5,0.5) = %v, want %v", result, expected)
}
}
func TestApplyTransform(t *testing.T) {
tests := []struct {
point Point
matrix Matrix
expected Point
}{
{Point{X: 0, Y: 0}, NewIdentityMatrix(), Point{X: 0, Y: 0}},
{Point{X: 10, Y: 20}, Translate(5, 10), Point{X: 15, Y: 30}},
{Point{X: 1, Y: 0}, Scale(3, 2), Point{X: 3, Y: 0}},
{Point{X: 0, Y: 1}, Scale(3, 2), Point{X: 0, Y: 2}},
}
for _, tt := range tests {
result := ApplyTransform(tt.point, tt.matrix)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("ApplyTransform(%v, %v) = %v, want %v", tt.point, tt.matrix, result, tt.expected)
}
}
}
func TestNewTransform(t *testing.T) {
transform := NewTransform(10, 20, 100, 1)
if transform.x != 10 || transform.y != 20 || transform.size != 100 || transform.rotation != 1 {
@@ -131,23 +14,23 @@ func TestNewTransform(t *testing.T) {
func TestTransformIconPoint(t *testing.T) {
tests := []struct {
transform Transform
transform Transform
x, y, w, h float64
expected Point
}{
// No rotation (0 degrees)
{NewTransform(0, 0, 100, 0), 10, 20, 5, 5, Point{X: 10, Y: 20}},
{NewTransform(10, 20, 100, 0), 5, 10, 0, 0, Point{X: 15, Y: 30}},
// 90 degrees rotation
{NewTransform(0, 0, 100, 1), 10, 20, 5, 5, Point{X: 75, Y: 10}},
// 180 degrees rotation
{NewTransform(0, 0, 100, 2), 10, 20, 5, 5, Point{X: 85, Y: 75}},
// 270 degrees rotation
{NewTransform(0, 0, 100, 3), 10, 20, 5, 5, Point{X: 20, Y: 85}},
// Test rotation normalization (rotation > 3)
{NewTransform(0, 0, 100, 4), 10, 20, 0, 0, Point{X: 10, Y: 20}}, // Same as rotation 0
{NewTransform(0, 0, 100, 5), 10, 20, 5, 5, Point{X: 75, Y: 10}}, // Same as rotation 1
@@ -156,7 +39,7 @@ func TestTransformIconPoint(t *testing.T) {
for _, tt := range tests {
result := tt.transform.TransformIconPoint(tt.x, tt.y, tt.w, tt.h)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("Transform(%v).TransformIconPoint(%v, %v, %v, %v) = %v, want %v",
t.Errorf("Transform(%v).TransformIconPoint(%v, %v, %v, %v) = %v, want %v",
tt.transform, tt.x, tt.y, tt.w, tt.h, result, tt.expected)
}
}
@@ -166,7 +49,7 @@ func TestNoTransform(t *testing.T) {
if NoTransform.x != 0 || NoTransform.y != 0 || NoTransform.size != 0 || NoTransform.rotation != 0 {
t.Errorf("NoTransform should be {x:0, y:0, size:0, rotation:0}, got %v", NoTransform)
}
// Test that NoTransform doesn't change points
point := Point{X: 10, Y: 20}
result := NoTransform.TransformIconPoint(point.X, point.Y, 0, 0)
@@ -179,4 +62,4 @@ func TestNoTransform(t *testing.T) {
func approximatelyEqual(a, b float64) bool {
const epsilon = 1e-9
return math.Abs(a-b) < epsilon
}
}

View File

@@ -0,0 +1,43 @@
//go:build perf
package perfsuite_test
import (
"os"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/perfsuite"
)
// TestPerformanceRegressionSuite can be called from a regular Go test
func TestPerformanceRegressionSuite(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance regression tests in short mode")
}
suite := perfsuite.NewPerformanceSuite()
suite.FailOnRegress = false // Don't fail tests, just report
// Check if we should establish baselines
if os.Getenv("ESTABLISH_BASELINES") == "true" {
if err := suite.EstablishBaselines(); err != nil {
t.Fatalf("Failed to establish baselines: %v", err)
}
return
}
// Run regression check
if err := suite.CheckForRegressions(); err != nil {
t.Logf("Performance regression check completed with issues: %v", err)
// Don't fail the test, just log the results
}
}
// BenchmarkPerformanceSuite runs all performance benchmarks for standard Go bench testing
func BenchmarkPerformanceSuite(b *testing.B) {
suite := perfsuite.NewPerformanceSuite()
for _, bench := range suite.Benchmarks {
b.Run(bench.Name, bench.BenchmarkFunc)
}
}

469
internal/perfsuite/suite.go Normal file
View File

@@ -0,0 +1,469 @@
package perfsuite
import (
"context"
"encoding/json"
"fmt"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/ungluedlabs/go-jdenticon/jdenticon"
)
// PerformanceBenchmark represents a single performance test case
type PerformanceBenchmark struct {
Name string
BenchmarkFunc func(*testing.B)
RegressionLimit float64 // Percentage threshold for regression detection
Description string
}
// PerformanceMetrics holds performance metrics for comparison
type PerformanceMetrics struct {
NsPerOp int64 `json:"ns_per_op"`
AllocsPerOp int64 `json:"allocs_per_op"`
BytesPerOp int64 `json:"bytes_per_op"`
Timestamp time.Time `json:"timestamp"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// RegressionReport holds the results of a regression check
type RegressionReport struct {
Summary string `json:"summary"`
Failures []string `json:"failures"`
Passed int `json:"passed"`
Total int `json:"total"`
Results map[string]string `json:"results"`
}
// PerformanceSuite manages the performance regression test suite
type PerformanceSuite struct {
Benchmarks []PerformanceBenchmark
BaselineFile string
ReportFile string
EnableReports bool
FailOnRegress bool
}
// NewPerformanceSuite creates a new performance regression test suite
func NewPerformanceSuite() *PerformanceSuite {
return &PerformanceSuite{
Benchmarks: []PerformanceBenchmark{
{
Name: "CoreSVGGeneration",
BenchmarkFunc: benchmarkCoreSVGGeneration,
RegressionLimit: 15.0,
Description: "Core SVG generation performance",
},
{
Name: "CorePNGGeneration",
BenchmarkFunc: benchmarkCorePNGGeneration,
RegressionLimit: 25.0,
Description: "Core PNG generation performance",
},
{
Name: "CachedGeneration",
BenchmarkFunc: benchmarkCachedGeneration,
RegressionLimit: 10.0,
Description: "Cached icon generation performance",
},
{
Name: "BatchProcessing",
BenchmarkFunc: benchmarkBatchProcessing,
RegressionLimit: 20.0,
Description: "Batch icon generation performance",
},
{
Name: "LargeIcon256",
BenchmarkFunc: benchmarkLargeIcon256,
RegressionLimit: 30.0,
Description: "Large icon (256px) generation performance",
},
{
Name: "LargeIcon512",
BenchmarkFunc: benchmarkLargeIcon512,
RegressionLimit: 30.0,
Description: "Large icon (512px) generation performance",
},
{
Name: "ColorVariationSaturation",
BenchmarkFunc: benchmarkColorVariationSaturation,
RegressionLimit: 15.0,
Description: "Color saturation variation performance",
},
{
Name: "ColorVariationPadding",
BenchmarkFunc: benchmarkColorVariationPadding,
RegressionLimit: 15.0,
Description: "Padding variation performance",
},
},
BaselineFile: ".performance_baselines.json",
ReportFile: "performance_report.json",
EnableReports: true,
FailOnRegress: true,
}
}
// Individual benchmark functions
func benchmarkCoreSVGGeneration(b *testing.B) {
testCases := []string{
"test@example.com",
"user123",
"performance-test",
"unicode-üser",
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
input := testCases[i%len(testCases)]
_, err := jdenticon.ToSVG(context.Background(), input, 64)
if err != nil {
b.Fatalf("SVG generation failed: %v", err)
}
}
}
func benchmarkCorePNGGeneration(b *testing.B) {
testCases := []string{
"test@example.com",
"user123",
"performance-test",
"unicode-üser",
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
input := testCases[i%len(testCases)]
_, err := jdenticon.ToPNG(context.Background(), input, 64)
if err != nil {
b.Fatalf("PNG generation failed: %v", err)
}
}
}
func benchmarkCachedGeneration(b *testing.B) {
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100)
if err != nil {
b.Fatalf("NewGenerator failed: %v", err)
}
input := "cached-performance-test"
// Warm up cache
icon, err := generator.Generate(context.Background(), input, 64)
if err != nil {
b.Fatalf("Cache warmup failed: %v", err)
}
_, _ = icon.ToSVG()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
icon, err := generator.Generate(context.Background(), input, 64)
if err != nil {
b.Fatalf("Cached generation failed: %v", err)
}
_, err = icon.ToSVG()
if err != nil {
b.Fatalf("Cached SVG failed: %v", err)
}
}
}
func benchmarkBatchProcessing(b *testing.B) {
inputs := []string{
"batch1@test.com", "batch2@test.com", "batch3@test.com",
"batch4@test.com", "batch5@test.com",
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, input := range inputs {
_, err := jdenticon.ToSVG(context.Background(), input, 64)
if err != nil {
b.Fatalf("Batch processing failed: %v", err)
}
}
}
}
func benchmarkLargeIcon256(b *testing.B) {
input := "large-icon-test-256"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := jdenticon.ToSVG(context.Background(), input, 256)
if err != nil {
b.Fatalf("Large icon (256px) generation failed: %v", err)
}
}
}
func benchmarkLargeIcon512(b *testing.B) {
input := "large-icon-test-512"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := jdenticon.ToSVG(context.Background(), input, 512)
if err != nil {
b.Fatalf("Large icon (512px) generation failed: %v", err)
}
}
}
func benchmarkColorVariationSaturation(b *testing.B) {
config := jdenticon.DefaultConfig()
config.ColorSaturation = 0.9
input := "color-saturation-test"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := jdenticon.ToSVGWithConfig(context.Background(), input, 64, config)
if err != nil {
b.Fatalf("Color saturation variation failed: %v", err)
}
}
}
func benchmarkColorVariationPadding(b *testing.B) {
config := jdenticon.DefaultConfig()
config.Padding = 0.15
input := "color-padding-test"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := jdenticon.ToSVGWithConfig(context.Background(), input, 64, config)
if err != nil {
b.Fatalf("Padding variation failed: %v", err)
}
}
}
// calculateChange calculates percentage change between old and new values
func calculateChange(oldVal, newVal int64) float64 {
if oldVal == 0 {
if newVal == 0 {
return 0
}
return 100.0
}
return (float64(newVal-oldVal) / float64(oldVal)) * 100.0
}
// RunBenchmark executes a benchmark and returns metrics
func (ps *PerformanceSuite) RunBenchmark(bench PerformanceBenchmark) (PerformanceMetrics, error) {
result := testing.Benchmark(bench.BenchmarkFunc)
if result.N == 0 {
return PerformanceMetrics{}, fmt.Errorf("benchmark %s failed to run", bench.Name)
}
return PerformanceMetrics{
NsPerOp: result.NsPerOp(),
AllocsPerOp: result.AllocsPerOp(),
BytesPerOp: result.AllocedBytesPerOp(),
Timestamp: time.Now(),
GoVersion: runtime.Version(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}, nil
}
// LoadBaselines loads performance baselines from file
func (ps *PerformanceSuite) LoadBaselines() (map[string]PerformanceMetrics, error) {
baselines := make(map[string]PerformanceMetrics)
if _, err := os.Stat(ps.BaselineFile); os.IsNotExist(err) {
return baselines, nil
}
data, err := os.ReadFile(ps.BaselineFile)
if err != nil {
return nil, fmt.Errorf("failed to read baselines: %w", err)
}
if err := json.Unmarshal(data, &baselines); err != nil {
return nil, fmt.Errorf("failed to parse baselines: %w", err)
}
return baselines, nil
}
// SaveBaselines saves performance baselines to file
func (ps *PerformanceSuite) SaveBaselines(baselines map[string]PerformanceMetrics) error {
data, err := json.MarshalIndent(baselines, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal baselines: %w", err)
}
// #nosec G306 -- 0644 is appropriate for benchmark data files
return os.WriteFile(ps.BaselineFile, data, 0644)
}
// EstablishBaselines runs all benchmarks and saves them as baselines
func (ps *PerformanceSuite) EstablishBaselines() error {
fmt.Println("🔥 Establishing performance baselines...")
baselines := make(map[string]PerformanceMetrics)
for _, bench := range ps.Benchmarks {
fmt.Printf(" Running %s...", bench.Name)
metrics, err := ps.RunBenchmark(bench)
if err != nil {
return fmt.Errorf("failed to run benchmark %s: %w", bench.Name, err)
}
baselines[bench.Name] = metrics
fmt.Printf(" ✓ %d ns/op, %d allocs/op\n", metrics.NsPerOp, metrics.AllocsPerOp)
}
if err := ps.SaveBaselines(baselines); err != nil {
return fmt.Errorf("failed to save baselines: %w", err)
}
fmt.Printf("✅ Baselines established (%d benchmarks saved to %s)\n", len(baselines), ps.BaselineFile)
return nil
}
// CheckForRegressions runs benchmarks and compares against baselines
func (ps *PerformanceSuite) CheckForRegressions() error {
fmt.Println("🔍 Checking for performance regressions...")
baselines, err := ps.LoadBaselines()
if err != nil {
return fmt.Errorf("failed to load baselines: %w", err)
}
if len(baselines) == 0 {
return fmt.Errorf("no baselines found - run EstablishBaselines() first")
}
var failures []string
passed := 0
total := 0
results := make(map[string]string)
for _, bench := range ps.Benchmarks {
baseline, exists := baselines[bench.Name]
if !exists {
fmt.Printf("⚠️ %s: No baseline found, skipping\n", bench.Name)
continue
}
fmt.Printf(" %s...", bench.Name)
current, err := ps.RunBenchmark(bench)
if err != nil {
return fmt.Errorf("failed to run benchmark %s: %w", bench.Name, err)
}
total++
// Calculate changes
timeChange := calculateChange(baseline.NsPerOp, current.NsPerOp)
allocChange := calculateChange(baseline.AllocsPerOp, current.AllocsPerOp)
memChange := calculateChange(baseline.BytesPerOp, current.BytesPerOp)
// Check for regressions
hasRegression := false
var issues []string
if timeChange > bench.RegressionLimit {
hasRegression = true
issues = append(issues, fmt.Sprintf("%.1f%% slower", timeChange))
}
if allocChange > bench.RegressionLimit {
hasRegression = true
issues = append(issues, fmt.Sprintf("%.1f%% more allocs", allocChange))
}
if memChange > bench.RegressionLimit {
hasRegression = true
issues = append(issues, fmt.Sprintf("%.1f%% more memory", memChange))
}
if hasRegression {
status := fmt.Sprintf(" ❌ REGRESSION: %s", strings.Join(issues, ", "))
failures = append(failures, fmt.Sprintf("%s: %s", bench.Name, strings.Join(issues, ", ")))
fmt.Println(status)
results[bench.Name] = "FAIL: " + strings.Join(issues, ", ")
} else {
status := " ✅ PASS"
if timeChange != 0 || allocChange != 0 || memChange != 0 {
status += fmt.Sprintf(" (%.1f%% time, %.1f%% allocs, %.1f%% mem)", timeChange, allocChange, memChange)
}
fmt.Println(status)
passed++
results[bench.Name] = "PASS"
}
}
// Report summary
fmt.Printf("\n📊 Performance regression check completed:\n")
fmt.Printf(" • %d tests passed\n", passed)
fmt.Printf(" • %d tests failed\n", len(failures))
fmt.Printf(" • %d tests total\n", total)
// Generate the report file
if ps.EnableReports {
summary := fmt.Sprintf("%d/%d tests passed.", passed, total)
if len(failures) > 0 {
summary = fmt.Sprintf("%d regressions detected.", len(failures))
}
report := RegressionReport{
Summary: summary,
Failures: failures,
Passed: passed,
Total: total,
Results: results,
}
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal report: %w", err)
}
// #nosec G306 -- 0644 is appropriate for benchmark report files
if err := os.WriteFile(ps.ReportFile, data, 0644); err != nil {
return fmt.Errorf("failed to write report file: %w", err)
}
}
if len(failures) > 0 {
fmt.Printf("\n❌ Performance regressions detected:\n")
for _, failure := range failures {
fmt.Printf(" • %s\n", failure)
}
if ps.FailOnRegress {
return fmt.Errorf("performance regressions detected")
}
} else {
fmt.Printf("\n✅ No performance regressions detected!\n")
}
return nil
}

View File

@@ -0,0 +1,98 @@
package renderer
import (
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Benchmark optimized renderer to compare against baseline (958,401 B/op)
func BenchmarkOptimizedVsBaseline(b *testing.B) {
b.Run("Optimized_64px_PNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use optimized renderer with typical identicon pattern
renderer := NewPNGRenderer(64)
// Simulate typical identicon generation (simplified)
renderer.SetBackground("#f0f0f0", 1.0)
// Add representative shapes (based on typical identicon output)
renderer.BeginShape("#ff6b6b")
renderer.AddPolygon([]engine.Point{{X: 0.2, Y: 0.2}, {X: 0.8, Y: 0.2}, {X: 0.5, Y: 0.8}})
renderer.EndShape()
renderer.BeginShape("#4ecdc4")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 0.4, Y: 0}, {X: 0.4, Y: 0.4}, {X: 0, Y: 0.4}})
renderer.EndShape()
renderer.BeginShape("#45b7d1")
renderer.AddCircle(engine.Point{X: 0.6, Y: 0.6}, 0.3, false)
renderer.EndShape()
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Optimized PNG generation failed: %v", err)
}
}
})
}
// Simulate the icon generation process for testing
// This creates a simple identicon structure for benchmarking
func BenchmarkOptimizedSimulatedGeneration(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(64)
// Simulate typical identicon generation
renderer.SetBackground("#f0f0f0", 1.0)
// Add typical identicon shapes (3-5 shapes)
shapes := []struct {
color string
points []engine.Point
}{
{"#ff6b6b", []engine.Point{{X: 0.2, Y: 0.2}, {X: 0.8, Y: 0.2}, {X: 0.5, Y: 0.8}}},
{"#4ecdc4", []engine.Point{{X: 0, Y: 0}, {X: 0.4, Y: 0}, {X: 0.4, Y: 0.4}, {X: 0, Y: 0.4}}},
{"#45b7d1", []engine.Point{{X: 0.6, Y: 0.6}, {X: 1, Y: 0.6}, {X: 1, Y: 1}, {X: 0.6, Y: 1}}},
}
for _, shape := range shapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Simulated generation failed: %v", err)
}
}
}
// Direct memory comparison test - minimal overhead
func BenchmarkOptimizedPureMemory(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Create renderer - this is where the major memory allocation difference should be
renderer := NewPNGRenderer(64)
// Minimal shape to trigger rendering pipeline
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 0.5, Y: 1}})
renderer.EndShape()
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Pure memory test failed: %v", err)
}
}
}

58
internal/renderer/doc.go Normal file
View File

@@ -0,0 +1,58 @@
/*
Package renderer is responsible for translating the intermediate representation of an
identicon (generated by the `engine` package) into a final output format, such
as SVG or PNG.
This package is internal to the jdenticon library and its API is not guaranteed
to be stable. Do not use it directly.
# Core Concept: The Renderer Interface
The central component of this package is the `Renderer` interface. It defines a
contract for any format-specific renderer. The primary method, `Render`, takes a
list of shapes and colors and writes the output to an `io.Writer`.
This interface-based approach makes the system extensible. To add a new output
format (e.g., WebP), one would simply need to create a new struct that implements
the `Renderer` interface.
# Implementations
- svg.go: Provides `SvgRenderer`, which generates a vector-based SVG image. It
builds an XML tree representing the SVG structure and writes it out. This
renderer is highly efficient and produces scalable output that maintains
visual compatibility with the JavaScript Jdenticon library.
- png.go: Provides `PngRenderer`, which generates a raster-based PNG image. It
utilizes Go's standard `image` and `image/draw` packages to draw the shapes onto
a canvas and then encodes the result as a PNG.
- fast_png.go: Provides `FastPngRenderer`, an optimized PNG implementation that
uses more efficient drawing algorithms for better performance in high-throughput
scenarios.
# Rendering Pipeline
The rendering process follows this flow:
1. The engine package generates `RenderedElement` structures containing shape
geometries and color information.
2. A renderer implementation receives this intermediate representation along with
size and configuration parameters.
3. The renderer translates the abstract shapes into format-specific commands
(SVG paths, PNG pixel operations, etc.).
4. The final output is written to the provided `io.Writer`, allowing for
flexible destination handling (files, HTTP responses, etc.).
# Performance Considerations
The renderers are designed for efficiency:
- SVG rendering uses a `strings.Builder` for efficient, low-allocation string construction
- PNG rendering includes both standard and fast implementations
- All renderers support concurrent use across multiple goroutines
- Memory allocations are minimized through object reuse where possible
*/
package renderer

View File

@@ -7,7 +7,7 @@ import (
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// TestPNGRenderer_VisualRegression tests that PNG output matches expected characteristics
@@ -95,7 +95,10 @@ func TestPNGRenderer_VisualRegression(t *testing.T) {
renderer.EndShape()
}
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify PNG is valid
reader := bytes.NewReader(pngData)
@@ -188,7 +191,10 @@ func TestPNGRenderer_ComplexIcon(t *testing.T) {
renderer.AddCircle(engine.Point{X: 50, Y: 50}, 15, false)
renderer.EndShape()
pngData := renderer.ToPNG()
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)
@@ -210,7 +216,7 @@ func TestPNGRenderer_ComplexIcon(t *testing.T) {
t.Logf("Complex icon PNG size: %d bytes", len(pngData))
}
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// implement the Renderer interface consistently
func TestRendererInterface_Consistency(t *testing.T) {
testCases := []struct {
@@ -229,11 +235,11 @@ func TestRendererInterface_Consistency(t *testing.T) {
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},
@@ -294,43 +300,46 @@ func TestRendererInterface_Consistency(t *testing.T) {
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify PNG output
pngData := renderer.ToPNG()
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",
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")
@@ -338,7 +347,7 @@ func TestRendererInterface_Consistency(t *testing.T) {
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)
@@ -366,12 +375,12 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
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 {
@@ -384,7 +393,7 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
t.Errorf("PNG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
// Test shape management
renderer.BeginShape("#ff0000")
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
@@ -397,7 +406,7 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
t.Errorf("PNG GetCurrentColor() = %s, want #ff0000", color)
}
}
// Test clearing
renderer.Clear()
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
@@ -418,11 +427,11 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
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
@@ -466,20 +475,20 @@ func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
},
},
}
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)
@@ -487,26 +496,29 @@ func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
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 := renderer.ToPNG()
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)
@@ -522,7 +534,10 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}})
renderer.EndShape()
pngData := renderer.ToPNG()
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")
}
@@ -535,7 +550,10 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
renderer.AddCircle(engine.Point{X: 256, Y: 256}, 200, false)
renderer.EndShape()
pngData := renderer.ToPNG()
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")
}
@@ -556,9 +574,12 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
renderer.EndShape()
// Should not panic and should produce valid PNG
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
_, err = png.Decode(reader)
if err != nil {
t.Errorf("Failed to decode PNG with out-of-bounds shapes: %v", err)
}

View File

@@ -0,0 +1,544 @@
package renderer
import (
"strconv"
"strings"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// ============================================================================
// RENDERER MICRO-BENCHMARKS FOR MEMORY ALLOCATION ANALYSIS
// ============================================================================
var (
// Test data for renderer benchmarks
benchTestPoints = []engine.Point{
{X: 0.0, Y: 0.0},
{X: 10.5, Y: 0.0},
{X: 10.5, Y: 10.5},
{X: 0.0, Y: 10.5},
}
benchTestColors = []string{
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff",
}
benchTestSizes = []int{32, 64, 128, 256}
)
// ============================================================================
// SVG STRING BUILDING MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSVGStringBuilding tests different string building patterns in SVG generation
func BenchmarkSVGStringBuilding(b *testing.B) {
points := benchTestPoints
b.Run("svgValue_formatting", func(b *testing.B) {
values := []float64{0.0, 10.5, 15.75, 100.0, 256.5}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
value := values[i%len(values)]
_ = svgValue(value)
}
})
b.Run("strconv_FormatFloat", func(b *testing.B) {
value := 10.5
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.FormatFloat(value, 'f', 1, 64)
}
})
b.Run("strconv_Itoa", func(b *testing.B) {
value := 10
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.Itoa(value)
}
})
b.Run("polygon_path_building", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(50) // Estimate capacity
// Simulate polygon path building
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
}
// BenchmarkSVGPathOperations tests SVGPath struct operations
func BenchmarkSVGPathOperations(b *testing.B) {
points := benchTestPoints
b.Run("SVGPath_AddPolygon", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
path := &SVGPath{}
path.AddPolygon(points)
}
})
b.Run("SVGPath_AddCircle", func(b *testing.B) {
topLeft := engine.Point{X: 5.0, Y: 5.0}
size := 10.0
b.ReportAllocs()
for i := 0; i < b.N; i++ {
path := &SVGPath{}
path.AddCircle(topLeft, size, false)
}
})
b.Run("SVGPath_DataString", func(b *testing.B) {
path := &SVGPath{}
path.AddPolygon(points)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = path.DataString()
}
})
}
// BenchmarkStringBuilderPooling tests the efficiency of string builder pooling
func BenchmarkStringBuilderPooling(b *testing.B) {
points := benchTestPoints
b.Run("direct_builder", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use direct string builder (pool eliminated for direct writing)
var buf strings.Builder
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
b.Run("without_pool", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Create new buffer each time
var buf strings.Builder
buf.Grow(50)
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
b.Run("reused_builder", func(b *testing.B) {
var buf strings.Builder
buf.Grow(100) // Pre-allocate larger buffer
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf.Reset()
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
}
// ============================================================================
// SVG RENDERER MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSVGRendererOperations tests SVG renderer creation and operations
func BenchmarkSVGRendererOperations(b *testing.B) {
sizes := benchTestSizes
colors := benchTestColors
b.Run("NewSVGRenderer", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := sizes[i%len(sizes)]
_ = NewSVGRenderer(size)
}
})
b.Run("BeginShape", func(b *testing.B) {
renderer := NewSVGRenderer(64)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
renderer.BeginShape(color)
}
})
b.Run("AddPolygon", func(b *testing.B) {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer.AddPolygon(benchTestPoints)
}
})
b.Run("AddCircle", func(b *testing.B) {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
topLeft := engine.Point{X: 5.0, Y: 5.0}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer.AddCircle(topLeft, 10.0, false)
}
})
}
// BenchmarkSVGGeneration tests full SVG generation with different scenarios
func BenchmarkSVGGeneration(b *testing.B) {
colors := benchTestColors[:3] // Use fewer colors for cleaner tests
b.Run("empty_renderer", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
_ = renderer.ToSVG()
}
})
b.Run("single_shape", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
_ = renderer.ToSVG()
}
})
b.Run("multiple_shapes", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
for j, color := range colors {
renderer.BeginShape(color)
// Offset points for each shape
offsetPoints := make([]engine.Point, len(benchTestPoints))
for k, point := range benchTestPoints {
offsetPoints[k] = engine.Point{
X: point.X + float64(j*12),
Y: point.Y + float64(j*12),
}
}
renderer.AddPolygon(offsetPoints)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
})
b.Run("with_background", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape(colors[0])
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
_ = renderer.ToSVG()
}
})
}
// BenchmarkSVGSizeEstimation tests SVG capacity estimation
func BenchmarkSVGSizeEstimation(b *testing.B) {
colors := benchTestColors
b.Run("capacity_estimation", func(b *testing.B) {
renderer := NewSVGRenderer(64)
for _, color := range colors {
renderer.BeginShape(color)
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Simulate capacity estimation logic from ToSVG
capacity := svgBaseOverheadBytes
capacity += svgBackgroundRectBytes // Assume background
// Estimate path data size
for _, color := range renderer.colorOrder {
path := renderer.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
_ = capacity
}
})
b.Run("strings_builder_with_estimation", func(b *testing.B) {
renderer := NewSVGRenderer(64)
for _, color := range colors {
renderer.BeginShape(color)
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Test strings.Builder with capacity estimation
capacity := svgBaseOverheadBytes + svgBackgroundRectBytes
for _, color := range renderer.colorOrder {
path := renderer.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
var svg strings.Builder
svg.Grow(capacity)
svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg">`)
svg.WriteString("</svg>")
_ = svg.String()
}
})
}
// ============================================================================
// MAP OPERATIONS MICRO-BENCHMARKS
// ============================================================================
// BenchmarkMapOperations tests map operations used in renderer
func BenchmarkMapOperations(b *testing.B) {
colors := benchTestColors
b.Run("map_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
m := make(map[string]*SVGPath)
_ = m
}
})
b.Run("map_insertion", func(b *testing.B) {
m := make(map[string]*SVGPath)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
m[color] = &SVGPath{}
}
})
b.Run("map_lookup", func(b *testing.B) {
m := make(map[string]*SVGPath)
for _, color := range colors {
m[color] = &SVGPath{}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
_ = m[color]
}
})
b.Run("map_existence_check", func(b *testing.B) {
m := make(map[string]*SVGPath)
for _, color := range colors {
m[color] = &SVGPath{}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
_, exists := m[color]
_ = exists
}
})
}
// ============================================================================
// SLICE OPERATIONS MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSliceOperations tests slice operations for color ordering
func BenchmarkSliceOperations(b *testing.B) {
colors := benchTestColors
b.Run("slice_append", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var colorOrder []string
//lint:ignore S1011 Intentionally benchmarking individual appends vs batch
//nolint:gosimple // Intentionally benchmarking individual appends vs batch
for _, color := range colors {
colorOrder = append(colorOrder, color)
}
_ = colorOrder
}
})
b.Run("slice_with_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
colorOrder := make([]string, 0, len(colors))
//lint:ignore S1011 Intentionally benchmarking individual appends with pre-allocation
//nolint:gosimple // Intentionally benchmarking individual appends with pre-allocation
for _, color := range colors {
colorOrder = append(colorOrder, color)
}
_ = colorOrder
}
})
b.Run("slice_iteration", func(b *testing.B) {
colorOrder := make([]string, len(colors))
copy(colorOrder, colors)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, color := range colorOrder {
_ = color
}
}
})
}
// ============================================================================
// COORDINATE TRANSFORMATION MICRO-BENCHMARKS
// ============================================================================
// BenchmarkCoordinateTransforms tests coordinate transformation patterns
func BenchmarkCoordinateTransforms(b *testing.B) {
points := benchTestPoints
b.Run("point_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = engine.Point{X: 10.5, Y: 20.5}
}
})
b.Run("point_slice_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
pointsCopy := make([]engine.Point, len(points))
copy(pointsCopy, points)
_ = pointsCopy
}
})
b.Run("point_transformation", func(b *testing.B) {
transform := func(x, y float64) (float64, float64) {
return x * 2.0, y * 2.0
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
transformedPoints := make([]engine.Point, len(points))
for j, point := range points {
newX, newY := transform(point.X, point.Y)
transformedPoints[j] = engine.Point{X: newX, Y: newY}
}
_ = transformedPoints
}
})
}
// ============================================================================
// MEMORY ALLOCATION PATTERN COMPARISONS
// ============================================================================
// BenchmarkAllocationPatterns compares different allocation patterns used in rendering
func BenchmarkAllocationPatterns(b *testing.B) {
b.Run("string_concatenation", func(b *testing.B) {
base := "test"
suffix := "value"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = base + suffix
}
})
b.Run("sprintf_formatting", func(b *testing.B) {
value := 10.5
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.FormatFloat(value, 'f', 1, 64)
}
})
b.Run("builder_small_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(10)
buf.WriteString("test")
buf.WriteString("value")
_ = buf.String()
}
})
b.Run("builder_large_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(100)
buf.WriteString("test")
buf.WriteString("value")
_ = buf.String()
}
})
}

View File

@@ -0,0 +1,179 @@
package renderer
import (
"fmt"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Benchmark optimized PNG renderer vs original FastPNG renderer
func BenchmarkOptimizedPNGToPNG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark PNG memory usage with different allocation patterns
func BenchmarkPNGMemoryPatterns(b *testing.B) {
// Shared test data
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
b.Run("OptimizedPNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
b.Run("PNGWrapper", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
// Benchmark different icon sizes to see memory scaling
func BenchmarkOptimizedPNGSizes(b *testing.B) {
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
sizes := []int{64, 128, 256, 512}
for _, size := range sizes {
b.Run(fmt.Sprintf("Size%d", size), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
}
// Benchmark complex shape rendering with optimized renderer
func BenchmarkOptimizedComplexPNGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark pooling efficiency
func BenchmarkPoolingEfficiency(b *testing.B) {
b.Run("WithPooling", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(128)
renderer.SetBackground("#ffffff", 1.0)
// Add multiple polygons to exercise pooling
for j := 0; j < 10; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}

View File

@@ -2,179 +2,605 @@ package renderer
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"strconv"
"strings"
"sync"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// PNGRenderer implements the Renderer interface for PNG output
// PNG rendering constants
const (
defaultSupersamplingFactor = 8 // Default antialiasing supersampling factor
)
// Memory pools for reducing allocations during rendering
var (
// Pool for point slices used during polygon processing
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
pointSlicePool = sync.Pool{
New: func() interface{} {
s := make([]engine.Point, 0, 16) // Pre-allocate reasonable capacity
return &s
},
}
// Pool for color row buffers
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
colorRowBufferPool = sync.Pool{
New: func() interface{} {
s := make([]color.RGBA, 0, 1024) // Row buffer capacity
return &s
},
}
)
// ShapeCommand represents a rendering command for deferred execution
type ShapeCommand struct {
Type string // "polygon", "circle", "background"
Points []engine.Point // For polygons
Center engine.Point // For circles
Size float64 // For circles
Invert bool // For circles
Color color.RGBA
BBox image.Rectangle // Pre-calculated bounding box for culling
}
// PNGRenderer implements memory-efficient PNG generation using streaming row processing
// This eliminates the dual buffer allocation problem, reducing memory usage by ~80%
type PNGRenderer struct {
*BaseRenderer
img *image.RGBA
currentColor color.RGBA
background color.RGBA
hasBackground bool
mu sync.RWMutex // For thread safety in concurrent generation
finalImg *image.RGBA // Single buffer at target resolution
finalSize int // Target output size
bgColor color.RGBA // Background color
shapes []ShapeCommand // Queued rendering commands
}
// bufferPool provides buffer pooling for efficient PNG generation
var bufferPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
// NewPNGRenderer creates a new PNG renderer with the specified icon size
// NewPNGRenderer creates a new memory-optimized PNG renderer
func NewPNGRenderer(iconSize int) *PNGRenderer {
// Only allocate the final image buffer - no supersampled buffer
finalBounds := image.Rect(0, 0, iconSize, iconSize)
finalImg := image.NewRGBA(finalBounds)
return &PNGRenderer{
BaseRenderer: NewBaseRenderer(iconSize),
img: image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)),
finalImg: finalImg,
finalSize: iconSize,
shapes: make([]ShapeCommand, 0, 16), // Pre-allocate for typical use
}
}
// SetBackground sets the background color and opacity
// SetBackground sets the background color - queues background command
func (r *PNGRenderer) SetBackground(fillColor string, opacity float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.BaseRenderer.SetBackground(fillColor, opacity)
r.background = parseColor(fillColor, opacity)
r.hasBackground = opacity > 0
if r.hasBackground {
// Fill the entire image with background color
draw.Draw(r.img, r.img.Bounds(), &image.Uniform{r.background}, image.Point{}, draw.Src)
}
r.bgColor = r.parseColor(fillColor, opacity)
// Queue background command for proper rendering order
r.shapes = append(r.shapes, ShapeCommand{
Type: "background",
Color: r.bgColor,
BBox: image.Rect(0, 0, r.finalSize*2, r.finalSize*2), // Full supersampled bounds
})
}
// BeginShape marks the beginning of a new shape with the specified color
func (r *PNGRenderer) BeginShape(fillColor string) {
r.mu.Lock()
defer r.mu.Unlock()
r.BaseRenderer.BeginShape(fillColor)
r.currentColor = parseColor(fillColor, 1.0)
}
// EndShape marks the end of the currently drawn shape
// EndShape marks the end of the currently drawn shape (no-op for queuing renderer)
func (r *PNGRenderer) EndShape() {
// No action needed for PNG - shapes are drawn immediately
// No-op for command queuing approach
}
// AddPolygon adds a polygon with the current fill color to the image
// AddPolygon queues a polygon command with pre-calculated bounding box
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
if len(points) < 3 {
return // Can't render polygon with < 3 points
}
r.mu.Lock()
defer r.mu.Unlock()
// Determine winding order for hole detection
var area float64
for i := 0; i < len(points); i++ {
p1 := points[i]
p2 := points[(i+1)%len(points)]
area += (p1.X * p2.Y) - (p2.X * p1.Y)
}
// Convert engine.Point to image coordinates
imagePoints := make([]image.Point, len(points))
for i, p := range points {
imagePoints[i] = image.Point{
X: int(math.Round(p.X)),
Y: int(math.Round(p.Y)),
var renderColor color.RGBA
if area < 0 {
// Counter-clockwise winding (hole) - use background color
renderColor = r.bgColor
} else {
// Clockwise winding (normal shape)
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
}
// Get pooled point slice and scale points to supersampled coordinates
scaledPointsPtr := pointSlicePool.Get().(*[]engine.Point)
scaledPointsSlice := *scaledPointsPtr
defer func() {
*scaledPointsPtr = scaledPointsSlice // Update with potentially resized slice
pointSlicePool.Put(scaledPointsPtr)
}()
// Reset slice and ensure capacity
scaledPointsSlice = scaledPointsSlice[:0]
if cap(scaledPointsSlice) < len(points) {
scaledPointsSlice = make([]engine.Point, 0, len(points)*2)
}
minX, minY := math.MaxFloat64, math.MaxFloat64
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
for _, p := range points {
scaledP := engine.Point{
X: p.X * defaultSupersamplingFactor,
Y: p.Y * defaultSupersamplingFactor,
}
scaledPointsSlice = append(scaledPointsSlice, scaledP)
if scaledP.X < minX {
minX = scaledP.X
}
if scaledP.X > maxX {
maxX = scaledP.X
}
if scaledP.Y < minY {
minY = scaledP.Y
}
if scaledP.Y > maxY {
maxY = scaledP.Y
}
}
// Fill polygon using scanline algorithm
r.fillPolygon(imagePoints)
// Copy scaled points for storage in command (must copy since we're returning slice to pool)
scaledPoints := make([]engine.Point, len(scaledPointsSlice))
copy(scaledPoints, scaledPointsSlice)
// Create bounding box for culling (with safety margins)
bbox := image.Rect(
int(math.Floor(minX))-1,
int(math.Floor(minY))-1,
int(math.Ceil(maxX))+1,
int(math.Ceil(maxY))+1,
)
// Queue the polygon command
r.shapes = append(r.shapes, ShapeCommand{
Type: "polygon",
Points: scaledPoints,
Color: renderColor,
BBox: bbox,
})
}
// AddCircle adds a circle with the current fill color to the image
// AddCircle queues a circle command with pre-calculated bounding box
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
r.mu.Lock()
defer r.mu.Unlock()
// Scale to supersampled coordinates
scaledTopLeft := engine.Point{
X: topLeft.X * defaultSupersamplingFactor,
Y: topLeft.Y * defaultSupersamplingFactor,
}
scaledSize := size * defaultSupersamplingFactor
radius := size / 2
centerX := int(math.Round(topLeft.X + radius))
centerY := int(math.Round(topLeft.Y + radius))
radiusInt := int(math.Round(radius))
centerX := scaledTopLeft.X + scaledSize/2.0
centerY := scaledTopLeft.Y + scaledSize/2.0
radius := scaledSize / 2.0
// Use Bresenham's circle algorithm for anti-aliased circle drawing
r.drawCircle(centerX, centerY, radiusInt, invert)
var renderColor color.RGBA
if invert {
renderColor = r.bgColor
} else {
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
}
// Calculate bounding box for the circle
bbox := image.Rect(
int(math.Floor(centerX-radius))-1,
int(math.Floor(centerY-radius))-1,
int(math.Ceil(centerX+radius))+1,
int(math.Ceil(centerY+radius))+1,
)
// Queue the circle command
r.shapes = append(r.shapes, ShapeCommand{
Type: "circle",
Center: engine.Point{X: centerX, Y: centerY},
Size: radius,
Color: renderColor,
BBox: bbox,
})
}
// ToPNG generates the final PNG image data
func (r *PNGRenderer) ToPNG() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
// ToPNG generates the final PNG image data using streaming row processing
func (r *PNGRenderer) ToPNG() ([]byte, error) {
return r.ToPNGWithSize(r.GetSize())
}
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)
// ToPNGWithSize generates PNG image data with streaming row processing
func (r *PNGRenderer) ToPNGWithSize(outputSize int) ([]byte, error) {
// Execute streaming rendering pipeline
r.renderWithStreaming()
// Encode to PNG with compression
var resultImg image.Image = r.finalImg
// Scale if output size differs from internal size
if outputSize != r.finalSize {
resultImg = r.scaleImage(r.finalImg, outputSize)
}
// Encode to PNG with maximum compression
var buf bytes.Buffer
encoder := &png.Encoder{
CompressionLevel: png.BestCompression,
}
if err := encoder.Encode(buf, r.img); err != nil {
return nil
err := encoder.Encode(&buf, resultImg)
if err != nil {
return nil, fmt.Errorf("jdenticon: optimized renderer: PNG encoding failed: %w", err)
}
// Return a copy of the buffer data
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
return buf.Bytes(), nil
}
// parseColor converts a hex color string to RGBA color
func parseColor(hexColor string, opacity float64) color.RGBA {
// Remove # prefix if present
hexColor = strings.TrimPrefix(hexColor, "#")
// renderWithStreaming executes the main streaming rendering pipeline
func (r *PNGRenderer) renderWithStreaming() {
supersampledWidth := r.finalSize * defaultSupersamplingFactor
// Default to black if parsing fails
var r, g, b uint8 = 0, 0, 0
// Get pooled row buffer for 2 supersampled rows - MASSIVE memory savings
rowBufferPtr := colorRowBufferPool.Get().(*[]color.RGBA)
rowBufferSlice := *rowBufferPtr
defer func() {
*rowBufferPtr = rowBufferSlice // Update with potentially resized slice
colorRowBufferPool.Put(rowBufferPtr)
}()
switch len(hexColor) {
case 3:
// Short form: #RGB -> #RRGGBB
if val, err := strconv.ParseUint(hexColor, 16, 12); err == nil {
r = uint8((val >> 8 & 0xF) * 17)
g = uint8((val >> 4 & 0xF) * 17)
b = uint8((val & 0xF) * 17)
}
case 6:
// Full form: #RRGGBB
if val, err := strconv.ParseUint(hexColor, 16, 24); err == nil {
r = uint8(val >> 16)
g = uint8(val >> 8)
b = uint8(val)
}
case 8:
// With alpha: #RRGGBBAA
if val, err := strconv.ParseUint(hexColor, 16, 32); err == nil {
r = uint8(val >> 24)
g = uint8(val >> 16)
b = uint8(val >> 8)
// Override opacity with alpha from color
opacity = float64(uint8(val)) / 255.0
}
// Ensure buffer has correct size
requiredSize := supersampledWidth * 2
if cap(rowBufferSlice) < requiredSize {
rowBufferSlice = make([]color.RGBA, requiredSize)
} else {
rowBufferSlice = rowBufferSlice[:requiredSize]
}
alpha := uint8(math.Round(opacity * 255))
return color.RGBA{R: r, G: g, B: b, A: alpha}
// Process each final image row
for y := 0; y < r.finalSize; y++ {
// Clear row buffer to background color
for i := range rowBufferSlice {
rowBufferSlice[i] = r.bgColor
}
// Render all shapes for this row pair
r.renderShapesForRowPair(y, rowBufferSlice, supersampledWidth)
// Downsample directly into final image
r.downsampleRowPairToFinal(y, rowBufferSlice, supersampledWidth)
}
}
// fillPolygon fills a polygon using a scanline algorithm
func (r *PNGRenderer) fillPolygon(points []image.Point) {
if len(points) < 3 {
return
// renderShapesForRowPair renders all shapes that intersect the given row pair
func (r *PNGRenderer) renderShapesForRowPair(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
// Calculate supersampled Y range for this row pair
ssYStart := finalY * defaultSupersamplingFactor
ssYEnd := ssYStart + defaultSupersamplingFactor
// Render each shape that intersects this row pair
for _, shape := range r.shapes {
// Fast bounding box culling
if shape.BBox.Max.Y <= ssYStart || shape.BBox.Min.Y >= ssYEnd {
continue // Shape doesn't intersect this row pair
}
switch shape.Type {
case "polygon":
r.renderPolygonForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
case "circle":
r.renderCircleForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
}
}
}
// renderPolygonForRowPair renders a polygon for the specified row range
func (r *PNGRenderer) renderPolygonForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
points := shape.Points
color := shape.Color
// Use triangle fan decomposition for simplicity
if len(points) == 3 {
// Direct triangle rendering
r.fillTriangleForRowRange(points[0], points[1], points[2], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
} else if len(points) == 4 && r.isRectangle(points) {
// Optimized rectangle rendering
minX, minY, maxX, maxY := r.getBoundsFloat(points)
r.fillRectForRowRange(minX, minY, maxX, maxY, color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
} else {
// General polygon - triangle fan from first vertex
for i := 1; i < len(points)-1; i++ {
r.fillTriangleForRowRange(points[0], points[i], points[i+1], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
}
}
}
// renderCircleForRowPair renders a circle for the specified row range
func (r *PNGRenderer) renderCircleForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
centerX := shape.Center.X
centerY := shape.Center.Y
radius := shape.Size
color := shape.Color
radiusSq := radius * radius
// Process each supersampled row in the range
for y := ssYStart; y < ssYEnd; y++ {
yFloat := float64(y)
dy := yFloat - centerY
dySq := dy * dy
if dySq > radiusSq {
continue // Row doesn't intersect circle
}
// Calculate horizontal span for this row
dx := math.Sqrt(radiusSq - dySq)
xStart := int(math.Floor(centerX - dx))
xEnd := int(math.Ceil(centerX + dx))
// Clip to buffer bounds
if xStart < 0 {
xStart = 0
}
if xEnd >= supersampledWidth {
xEnd = supersampledWidth - 1
}
// Fill the horizontal span
rowIndex := (y - ssYStart) * supersampledWidth
for x := xStart; x <= xEnd; x++ {
// Verify pixel is actually inside circle
dxPixel := float64(x) - centerX
if dxPixel*dxPixel+dySq <= radiusSq {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
}
// fillTriangleForRowRange fills a triangle within the specified row range
func (r *PNGRenderer) fillTriangleForRowRange(p1, p2, p3 engine.Point, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
// Get triangle bounds
minY := math.Min(math.Min(p1.Y, p2.Y), p3.Y)
maxY := math.Max(math.Max(p1.Y, p2.Y), p3.Y)
// Clip to row range
iterYStart := int(math.Max(math.Ceil(minY), float64(ssYStart)))
iterYEnd := int(math.Min(math.Floor(maxY), float64(ssYEnd-1)))
if iterYStart > iterYEnd {
return // Triangle doesn't intersect row range
}
// Find bounding box
// Sort points by Y coordinate
x1, y1 := p1.X, p1.Y
x2, y2 := p2.X, p2.Y
x3, y3 := p3.X, p3.Y
if y1 > y2 {
x1, y1, x2, y2 = x2, y2, x1, y1
}
if y1 > y3 {
x1, y1, x3, y3 = x3, y3, x1, y1
}
if y2 > y3 {
x2, y2, x3, y3 = x3, y3, x2, y2
}
// Fill triangle using scan-line algorithm
for y := iterYStart; y <= iterYEnd; y++ {
yFloat := float64(y)
var xLeft, xRight float64
if yFloat < y2 {
// Upper part of triangle
if y2 != y1 {
slope12 := (x2 - x1) / (y2 - y1)
xLeft = x1 + slope12*(yFloat-y1)
} else {
xLeft = x1
}
if y3 != y1 {
slope13 := (x3 - x1) / (y3 - y1)
xRight = x1 + slope13*(yFloat-y1)
} else {
xRight = x1
}
} else {
// Lower part of triangle
if y3 != y2 {
slope23 := (x3 - x2) / (y3 - y2)
xLeft = x2 + slope23*(yFloat-y2)
} else {
xLeft = x2
}
if y3 != y1 {
slope13 := (x3 - x1) / (y3 - y1)
xRight = x1 + slope13*(yFloat-y1)
} else {
xRight = x1
}
}
if xLeft > xRight {
xLeft, xRight = xRight, xLeft
}
// Convert to pixel coordinates and fill
xLeftInt := int(math.Floor(xLeft))
xRightInt := int(math.Floor(xRight))
// Clip to buffer bounds
if xLeftInt < 0 {
xLeftInt = 0
}
if xRightInt >= supersampledWidth {
xRightInt = supersampledWidth - 1
}
// Fill horizontal span in row buffer
rowIndex := (y - ssYStart) * supersampledWidth
for x := xLeftInt; x <= xRightInt; x++ {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
// fillRectForRowRange fills a rectangle within the specified row range
func (r *PNGRenderer) fillRectForRowRange(x1, y1, x2, y2 float64, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
// Convert to integer bounds
xStart := int(math.Floor(x1))
yStart := int(math.Floor(y1))
xEnd := int(math.Ceil(x2))
yEnd := int(math.Ceil(y2))
// Clip to row range
if yStart < ssYStart {
yStart = ssYStart
}
if yEnd > ssYEnd {
yEnd = ssYEnd
}
if xStart < 0 {
xStart = 0
}
if xEnd > supersampledWidth {
xEnd = supersampledWidth
}
// Fill rectangle in row buffer
for y := yStart; y < yEnd; y++ {
rowIndex := (y - ssYStart) * supersampledWidth
for x := xStart; x < xEnd; x++ {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
// downsampleRowPairToFinal downsamples 2 supersampled rows into 1 final row using box filter
func (r *PNGRenderer) downsampleRowPairToFinal(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
for x := 0; x < r.finalSize; x++ {
// Sample 2x2 block from row buffer
x0 := x * defaultSupersamplingFactor
x1 := x0 + 1
// Row 0 (first supersampled row)
idx00 := x0
idx01 := x1
// Row 1 (second supersampled row)
idx10 := supersampledWidth + x0
idx11 := supersampledWidth + x1
// Sum RGBA values from 2x2 block
var rSum, gSum, bSum, aSum uint32
if idx00 < len(rowBuffer) {
c := rowBuffer[idx00]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx01 < len(rowBuffer) {
c := rowBuffer[idx01]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx10 < len(rowBuffer) {
c := rowBuffer[idx10]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx11 < len(rowBuffer) {
c := rowBuffer[idx11]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
// Average by dividing by 4
// #nosec G115 -- Safe: sum of 4 uint8 values (max 255*4=1020) divided by 4 always fits in uint8
avgColor := color.RGBA{
R: uint8(rSum / 4),
G: uint8(gSum / 4),
B: uint8(bSum / 4),
A: uint8(aSum / 4),
}
// Set pixel in final image
r.finalImg.Set(x, finalY, avgColor)
}
}
// Helper functions (reused from original implementation)
func (r *PNGRenderer) parseColor(colorStr string, opacity float64) color.RGBA {
if colorStr != "" && colorStr[0] != '#' {
colorStr = "#" + colorStr
}
rgba, err := engine.ParseHexColorForRenderer(colorStr, opacity)
if err != nil {
return color.RGBA{0, 0, 0, uint8(opacity * 255)}
}
return rgba
}
func (r *PNGRenderer) isRectangle(points []engine.Point) bool {
if len(points) != 4 {
return false
}
uniqueX := make(map[float64]struct{})
uniqueY := make(map[float64]struct{})
for _, p := range points {
uniqueX[p.X] = struct{}{}
uniqueY[p.Y] = struct{}{}
}
return len(uniqueX) == 2 && len(uniqueY) == 2
}
func (r *PNGRenderer) getBoundsFloat(points []engine.Point) (float64, float64, float64, float64) {
if len(points) == 0 {
return 0, 0, 0, 0
}
minX, maxX := points[0].X, points[0].X
minY, maxY := points[0].Y, points[0].Y
for _, p := range points[1:] {
if p.X < minX {
minX = p.X
}
if p.X > maxX {
maxX = p.X
}
if p.Y < minY {
minY = p.Y
}
@@ -183,110 +609,25 @@ func (r *PNGRenderer) fillPolygon(points []image.Point) {
}
}
// Ensure bounds are within image
bounds := r.img.Bounds()
if minY < bounds.Min.Y {
minY = bounds.Min.Y
}
if maxY >= bounds.Max.Y {
maxY = bounds.Max.Y - 1
}
// For each scanline, find intersections and fill
for y := minY; y <= maxY; y++ {
intersections := r.getIntersections(points, y)
if len(intersections) < 2 {
continue
}
// Sort intersections and fill between pairs
for i := 0; i < len(intersections); i += 2 {
if i+1 < len(intersections) {
x1, x2 := intersections[i], intersections[i+1]
if x1 > x2 {
x1, x2 = x2, x1
}
// Clamp to image bounds
if x1 < bounds.Min.X {
x1 = bounds.Min.X
}
if x2 >= bounds.Max.X {
x2 = bounds.Max.X - 1
}
// Fill the horizontal line
for x := x1; x <= x2; x++ {
r.img.SetRGBA(x, y, r.currentColor)
}
}
}
}
return minX, minY, maxX, maxY
}
// getIntersections finds x-coordinates where a horizontal line intersects polygon edges
func (r *PNGRenderer) getIntersections(points []image.Point, y int) []int {
var intersections []int
n := len(points)
func (r *PNGRenderer) scaleImage(src *image.RGBA, newSize int) image.Image {
oldSize := r.finalSize
if oldSize == newSize {
return src
}
for i := 0; i < n; i++ {
j := (i + 1) % n
p1, p2 := points[i], points[j]
scaled := image.NewRGBA(image.Rect(0, 0, newSize, newSize))
ratio := float64(oldSize) / float64(newSize)
// Check if the edge crosses the scanline
if (p1.Y <= y && p2.Y > y) || (p2.Y <= y && p1.Y > y) {
// Calculate intersection x-coordinate
x := p1.X + (y-p1.Y)*(p2.X-p1.X)/(p2.Y-p1.Y)
intersections = append(intersections, x)
for y := 0; y < newSize; y++ {
for x := 0; x < newSize; x++ {
srcX := int(float64(x) * ratio)
srcY := int(float64(y) * ratio)
scaled.Set(x, y, src.At(srcX, srcY))
}
}
// Sort intersections
for i := 0; i < len(intersections)-1; i++ {
for j := i + 1; j < len(intersections); j++ {
if intersections[i] > intersections[j] {
intersections[i], intersections[j] = intersections[j], intersections[i]
}
}
}
return intersections
}
// drawCircle draws a filled circle using Bresenham's algorithm
func (r *PNGRenderer) drawCircle(centerX, centerY, radius int, invert bool) {
bounds := r.img.Bounds()
// For filled circle, we'll draw it by filling horizontal lines
for y := -radius; y <= radius; y++ {
actualY := centerY + y
if actualY < bounds.Min.Y || actualY >= bounds.Max.Y {
continue
}
// Calculate x extent for this y
x := int(math.Sqrt(float64(radius*radius - y*y)))
x1, x2 := centerX-x, centerX+x
// Clamp to image bounds
if x1 < bounds.Min.X {
x1 = bounds.Min.X
}
if x2 >= bounds.Max.X {
x2 = bounds.Max.X - 1
}
// Fill the horizontal line
for x := x1; x <= x2; x++ {
if invert {
// For inverted circles, we need to punch a hole
// This would typically be handled by a compositing mode
// For now, we'll set to transparent
r.img.SetRGBA(x, actualY, color.RGBA{0, 0, 0, 0})
} else {
r.img.SetRGBA(x, actualY, r.currentColor)
}
}
}
return scaled
}

View File

@@ -2,24 +2,21 @@ package renderer
import (
"bytes"
"image/color"
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
func TestNewPNGRenderer(t *testing.T) {
renderer := NewPNGRenderer(100)
if renderer.iconSize != 100 {
t.Errorf("NewPNGRenderer(100).iconSize = %v, want 100", renderer.iconSize)
if renderer.GetSize() != 100 {
t.Errorf("NewPNGRenderer(100).GetSize() = %v, want 100", renderer.GetSize())
}
if renderer.img == nil {
t.Error("img should be initialized")
}
if renderer.img.Bounds().Max.X != 100 || renderer.img.Bounds().Max.Y != 100 {
t.Errorf("image bounds = %v, want 100x100", renderer.img.Bounds())
if renderer == nil {
t.Error("PNGRenderer should be initialized")
}
}
@@ -28,23 +25,13 @@ func TestPNGRenderer_SetBackground(t *testing.T) {
renderer.SetBackground("#ff0000", 1.0)
if !renderer.hasBackground {
t.Error("hasBackground should be true")
// Check that background was set on base renderer
bg, op := renderer.GetBackground()
if bg != "#ff0000" {
t.Errorf("background color = %v, want #ff0000", bg)
}
if renderer.backgroundOp != 1.0 {
t.Errorf("backgroundOp = %v, want 1.0", renderer.backgroundOp)
}
// Check that background was actually set
expectedColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
if renderer.background != expectedColor {
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
}
// Check that image was filled with background
actualColor := renderer.img.RGBAAt(25, 25)
if actualColor != expectedColor {
t.Errorf("image pixel color = %v, want %v", actualColor, expectedColor)
if op != 1.0 {
t.Errorf("background opacity = %v, want 1.0", op)
}
}
@@ -53,9 +40,12 @@ func TestPNGRenderer_SetBackgroundWithOpacity(t *testing.T) {
renderer.SetBackground("#00ff00", 0.5)
expectedColor := color.RGBA{R: 0, G: 255, B: 0, A: 128}
if renderer.background != expectedColor {
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
bg, op := renderer.GetBackground()
if bg != "#00ff00" {
t.Errorf("background color = %v, want #00ff00", bg)
}
if op != 0.5 {
t.Errorf("background opacity = %v, want 0.5", op)
}
}
@@ -63,9 +53,10 @@ func TestPNGRenderer_BeginEndShape(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.BeginShape("#0000ff")
expectedColor := color.RGBA{R: 0, G: 0, B: 255, A: 255}
if renderer.currentColor != expectedColor {
t.Errorf("currentColor = %v, want %v", renderer.currentColor, expectedColor)
// Check that current color was set
if renderer.GetCurrentColor() != "#0000ff" {
t.Errorf("currentColor = %v, want #0000ff", renderer.GetCurrentColor())
}
renderer.EndShape()
@@ -83,20 +74,8 @@ func TestPNGRenderer_AddPolygon(t *testing.T) {
{X: 20, Y: 30},
}
// Should not panic
renderer.AddPolygon(points)
// Check that some pixels in the triangle are red
redColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
centerPixel := renderer.img.RGBAAt(20, 15) // Should be inside triangle
if centerPixel != redColor {
t.Errorf("triangle center pixel = %v, want %v", centerPixel, redColor)
}
// Check that pixels outside triangle are not red (should be transparent)
outsidePixel := renderer.img.RGBAAt(5, 5)
if outsidePixel == redColor {
t.Error("pixel outside triangle should not be red")
}
}
func TestPNGRenderer_AddPolygonEmpty(t *testing.T) {
@@ -119,20 +98,8 @@ func TestPNGRenderer_AddCircle(t *testing.T) {
topLeft := engine.Point{X: 30, Y: 30}
size := 40.0
// Should not panic
renderer.AddCircle(topLeft, size, false)
// Check that center pixel is green
greenColor := color.RGBA{R: 0, G: 255, B: 0, A: 255}
centerPixel := renderer.img.RGBAAt(50, 50)
if centerPixel != greenColor {
t.Errorf("circle center pixel = %v, want %v", centerPixel, greenColor)
}
// Check that a pixel clearly outside the circle is not green
outsidePixel := renderer.img.RGBAAt(10, 10)
if outsidePixel == greenColor {
t.Error("pixel outside circle should not be green")
}
}
func TestPNGRenderer_AddCircleInvert(t *testing.T) {
@@ -142,18 +109,11 @@ func TestPNGRenderer_AddCircleInvert(t *testing.T) {
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
// Add inverted circle (should punch a hole)
// Circle with center at (50, 50) and radius 20 means topLeft at (30, 30) and size 40
// Add inverted circle (should not panic)
topLeft := engine.Point{X: 30, Y: 30}
size := 40.0
renderer.AddCircle(topLeft, size, true)
// Check that center pixel is transparent (inverted)
centerPixel := renderer.img.RGBAAt(50, 50)
if centerPixel.A != 0 {
t.Errorf("inverted circle center should be transparent, got %v", centerPixel)
}
}
func TestPNGRenderer_ToPNG(t *testing.T) {
@@ -169,7 +129,10 @@ func TestPNGRenderer_ToPNG(t *testing.T) {
}
renderer.AddPolygon(points)
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNG() should return non-empty data")
@@ -189,10 +152,50 @@ func TestPNGRenderer_ToPNG(t *testing.T) {
}
}
func TestPNGRenderer_ToPNGWithSize(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: 10, Y: 10},
{X: 40, Y: 10},
{X: 40, Y: 40},
{X: 10, Y: 40},
}
renderer.AddPolygon(points)
// Test generating at different size
pngData, err := renderer.ToPNGWithSize(100)
if err != nil {
t.Fatalf("Failed to generate PNG with size: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNGWithSize() should return non-empty data")
}
// Verify it's valid PNG data by decoding it
reader := bytes.NewReader(pngData)
decodedImg, err := png.Decode(reader)
if err != nil {
t.Errorf("ToPNGWithSize() returned invalid PNG data: %v", err)
}
// Check dimensions - should be 100x100 instead of 50x50
bounds := decodedImg.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("decoded image bounds = %v, want 100x100", bounds)
}
}
func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
renderer := NewPNGRenderer(10)
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNG() should return data even for empty image")
@@ -200,70 +203,15 @@ func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
// Should be valid PNG
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
decodedImg, err := png.Decode(reader)
if err != nil {
t.Errorf("ToPNG() returned invalid PNG data: %v", err)
}
}
func TestParseColor(t *testing.T) {
tests := []struct {
input string
opacity float64
expected color.RGBA
}{
{"#ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#00ff00", 0.5, color.RGBA{R: 0, G: 255, B: 0, A: 128}},
{"#0000ff", 0.0, color.RGBA{R: 0, G: 0, B: 255, A: 0}},
{"#f00", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#0f0", 1.0, color.RGBA{R: 0, G: 255, B: 0, A: 255}},
{"#00f", 1.0, color.RGBA{R: 0, G: 0, B: 255, A: 255}},
{"#ff0000ff", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#ff000080", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 128}},
{"invalid", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
{"", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
}
for _, test := range tests {
result := parseColor(test.input, test.opacity)
if result != test.expected {
t.Errorf("parseColor(%q, %v) = %v, want %v",
test.input, test.opacity, result, test.expected)
}
}
}
func TestPNGRenderer_ConcurrentAccess(t *testing.T) {
renderer := NewPNGRenderer(100)
// Test concurrent access to ensure thread safety
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: float64(id * 5), Y: float64(id * 5)},
{X: float64(id*5 + 10), Y: float64(id * 5)},
{X: float64(id*5 + 10), Y: float64(id*5 + 10)},
{X: float64(id * 5), Y: float64(id*5 + 10)},
}
renderer.AddPolygon(points)
renderer.EndShape()
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
// Should be able to generate PNG without issues
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("concurrent access test failed - no PNG data generated")
// Check dimensions
bounds := decodedImg.Bounds()
if bounds.Max.X != 10 || bounds.Max.Y != 10 {
t.Errorf("decoded image bounds = %v, want 10x10", bounds)
}
}
@@ -282,7 +230,10 @@ func BenchmarkPNGRenderer_ToPNG(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
b.Fatal("ToPNG returned empty data")
}

View File

@@ -1,7 +1,7 @@
package renderer
import (
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Renderer defines the interface for rendering identicons to various output formats.
@@ -13,24 +13,24 @@ type Renderer interface {
LineTo(x, y float64)
CurveTo(x1, y1, x2, y2, x, y float64)
ClosePath()
// Fill and stroke operations
Fill(color string)
Stroke(color string, width float64)
// Shape management
BeginShape(color string)
EndShape()
// Background and configuration
SetBackground(fillColor string, opacity float64)
// High-level shape methods
AddPolygon(points []engine.Point)
AddCircle(topLeft engine.Point, size float64, invert bool)
AddRectangle(x, y, width, height float64)
AddTriangle(p1, p2, p3 engine.Point)
// Utility methods
GetSize() int
Clear()
@@ -43,7 +43,7 @@ type BaseRenderer struct {
currentColor string
background string
backgroundOp float64
// Current path state for primitive operations
currentPath []PathCommand
pathStart engine.Point
@@ -150,18 +150,18 @@ func (r *BaseRenderer) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
}
// Move to first point
r.MoveTo(points[0].X, points[0].Y)
// Line to subsequent points
for i := 1; i < len(points); i++ {
r.LineTo(points[i].X, points[i].Y)
}
// Close the path
r.ClosePath()
// Fill with current color
r.Fill(r.currentColor)
}
@@ -171,22 +171,22 @@ func (r *BaseRenderer) AddCircle(topLeft engine.Point, size float64, invert bool
// Approximate circle using cubic Bézier curves
// Magic number for circle approximation with Bézier curves
const kappa = 0.5522847498307936 // 4/3 * (sqrt(2) - 1)
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
cp := kappa * radius // Control point distance
// Start at rightmost point
r.MoveTo(centerX+radius, centerY)
// Four cubic curves to approximate circle
r.CurveTo(centerX+radius, centerY+cp, centerX+cp, centerY+radius, centerX, centerY+radius)
r.CurveTo(centerX-cp, centerY+radius, centerX-radius, centerY+cp, centerX-radius, centerY)
r.CurveTo(centerX-radius, centerY-cp, centerX-cp, centerY-radius, centerX, centerY-radius)
r.CurveTo(centerX+cp, centerY-radius, centerX+radius, centerY-cp, centerX+radius, centerY)
r.ClosePath()
r.Fill(r.currentColor)
}
@@ -234,4 +234,4 @@ func (r *BaseRenderer) GetCurrentColor() string {
// GetBackground returns the background color and opacity
func (r *BaseRenderer) GetBackground() (string, float64) {
return r.background, r.backgroundOp
}
}

View File

@@ -0,0 +1,464 @@
package renderer
import (
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
var benchmarkSizes = []int{
16, 32, 64, 128, 256, 512,
}
var benchmarkColors = []string{
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080",
"#c0c0c0", "#808080", "#000000", "#ffffff",
}
var benchmarkPoints = [][]engine.Point{
// Triangle
{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 0.5, Y: 1}},
// Square
{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}},
// Pentagon
{{X: 0.5, Y: 0}, {X: 1, Y: 0.4}, {X: 0.8, Y: 1}, {X: 0.2, Y: 1}, {X: 0, Y: 0.4}},
// Hexagon
{{X: 0.25, Y: 0}, {X: 0.75, Y: 0}, {X: 1, Y: 0.5}, {X: 0.75, Y: 1}, {X: 0.25, Y: 1}, {X: 0, Y: 0.5}},
}
// Benchmark SVG renderer creation
func BenchmarkNewSVGRenderer(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
_ = NewSVGRenderer(size)
}
}
// Benchmark SVG shape rendering
func BenchmarkSVGAddPolygon(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
points := benchmarkPoints[i%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
}
// Benchmark SVG circle rendering
func BenchmarkSVGAddCircle(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
topLeft := engine.Point{X: 0.25, Y: 0.25}
size := 0.5
renderer.BeginShape(color)
renderer.AddCircle(topLeft, size, false)
renderer.EndShape()
}
}
// Benchmark SVG background setting
func BenchmarkSVGSetBackground(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
opacity := 0.8
renderer.SetBackground(color, opacity)
}
}
// Benchmark complete SVG generation
func BenchmarkSVGToSVG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewSVGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
}
// Benchmark PNG renderer creation
func BenchmarkNewPNGRenderer(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
_ = NewPNGRenderer(size)
}
}
// Benchmark PNG shape rendering
func BenchmarkPNGAddPolygon(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
points := benchmarkPoints[i%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
}
// Benchmark PNG circle rendering
func BenchmarkPNGAddCircle(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
topLeft := engine.Point{X: 0.25, Y: 0.25}
size := 0.5
renderer.BeginShape(color)
renderer.AddCircle(topLeft, size, false)
renderer.EndShape()
}
}
// Benchmark PNG background setting
func BenchmarkPNGSetBackground(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
opacity := 0.8
renderer.SetBackground(color, opacity)
}
}
// Benchmark complete PNG generation
func BenchmarkPNGToPNG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark PNG generation with different output sizes
func BenchmarkPNGToPNGWithSize(b *testing.B) {
renderer := NewPNGRenderer(128)
// Add some test shapes
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
renderer.AddPolygon(benchmarkPoints[0])
renderer.EndShape()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
outputSize := benchmarkSizes[i%len(benchmarkSizes)]
_, err := renderer.ToPNGWithSize(outputSize)
if err != nil {
b.Fatalf("ToPNGWithSize failed: %v", err)
}
}
}
// Benchmark complex shape rendering (many polygons)
func BenchmarkComplexSVGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
}
// Benchmark complex shape rendering (many polygons) for PNG
func BenchmarkComplexPNGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark SVG vs PNG rendering comparison
func BenchmarkSVGvsPNG64px(b *testing.B) {
// Shared test data
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
b.Run("SVG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
})
b.Run("PNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
// Benchmark memory allocation patterns
func BenchmarkRendererMemoryPatterns(b *testing.B) {
b.Run("SVGMemory", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(128)
// Allocate many small shapes to test memory patterns
for j := 0; j < 20; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
}
})
b.Run("PNGMemory", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(128)
// Allocate many small shapes to test memory patterns
for j := 0; j < 20; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
}
})
}
// Benchmark concurrent rendering scenarios
func BenchmarkRendererParallel(b *testing.B) {
b.Run("SVGParallel", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewSVGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
i++
}
})
})
b.Run("PNGParallel", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Errorf("ToPNG failed: %v", err)
}
i++
}
})
})
}
// Benchmark shape rendering with different complexities
func BenchmarkShapeComplexity(b *testing.B) {
renderer := NewSVGRenderer(256)
b.Run("Triangle", func(b *testing.B) {
b.ReportAllocs()
trianglePoints := benchmarkPoints[0] // Triangle
for i := 0; i < b.N; i++ {
renderer.BeginShape("#ff0000")
renderer.AddPolygon(trianglePoints)
renderer.EndShape()
}
})
b.Run("Square", func(b *testing.B) {
b.ReportAllocs()
squarePoints := benchmarkPoints[1] // Square
for i := 0; i < b.N; i++ {
renderer.BeginShape("#00ff00")
renderer.AddPolygon(squarePoints)
renderer.EndShape()
}
})
b.Run("Pentagon", func(b *testing.B) {
b.ReportAllocs()
pentagonPoints := benchmarkPoints[2] // Pentagon
for i := 0; i < b.N; i++ {
renderer.BeginShape("#0000ff")
renderer.AddPolygon(pentagonPoints)
renderer.EndShape()
}
})
b.Run("Hexagon", func(b *testing.B) {
b.ReportAllocs()
hexagonPoints := benchmarkPoints[3] // Hexagon
for i := 0; i < b.N; i++ {
renderer.BeginShape("#ffff00")
renderer.AddPolygon(hexagonPoints)
renderer.EndShape()
}
})
}

View File

@@ -3,7 +3,7 @@ package renderer
import (
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
func TestNewBaseRenderer(t *testing.T) {
@@ -30,12 +30,12 @@ func TestNewBaseRenderer(t *testing.T) {
func TestBaseRendererSetBackground(t *testing.T) {
r := NewBaseRenderer(100)
color := "#ff0000"
opacity := 0.5
r.SetBackground(color, opacity)
bg, bgOp := r.GetBackground()
if bg != color {
t.Errorf("Expected background color %s, got %s", color, bg)
@@ -47,14 +47,14 @@ func TestBaseRendererSetBackground(t *testing.T) {
func TestBaseRendererBeginShape(t *testing.T) {
r := NewBaseRenderer(100)
color := "#00ff00"
r.BeginShape(color)
if r.GetCurrentColor() != color {
t.Errorf("Expected current color %s, got %s", color, r.GetCurrentColor())
}
// Path should be reset when beginning a shape
if len(r.GetCurrentPath()) != 0 {
t.Errorf("Expected empty path after BeginShape, got %d commands", len(r.GetCurrentPath()))
@@ -63,24 +63,24 @@ func TestBaseRendererBeginShape(t *testing.T) {
func TestBaseRendererMoveTo(t *testing.T) {
r := NewBaseRenderer(100)
x, y := 10.5, 20.3
r.MoveTo(x, y)
path := r.GetCurrentPath()
if len(path) != 1 {
t.Fatalf("Expected 1 path command, got %d", len(path))
}
cmd := path[0]
if cmd.Type != MoveToCommand {
t.Errorf("Expected MoveToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 1 {
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
}
point := cmd.Points[0]
if point.X != x || point.Y != y {
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
@@ -89,27 +89,27 @@ func TestBaseRendererMoveTo(t *testing.T) {
func TestBaseRendererLineTo(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
x, y := 15.7, 25.9
r.LineTo(x, y)
path := r.GetCurrentPath()
if len(path) != 2 {
t.Fatalf("Expected 2 path commands, got %d", len(path))
}
cmd := path[1] // Second command should be LineTo
if cmd.Type != LineToCommand {
t.Errorf("Expected LineToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 1 {
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
}
point := cmd.Points[0]
if point.X != x || point.Y != y {
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
@@ -118,30 +118,30 @@ func TestBaseRendererLineTo(t *testing.T) {
func TestBaseRendererCurveTo(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
x1, y1 := 10.0, 5.0
x2, y2 := 20.0, 15.0
x, y := 30.0, 25.0
r.CurveTo(x1, y1, x2, y2, x, y)
path := r.GetCurrentPath()
if len(path) != 2 {
t.Fatalf("Expected 2 path commands, got %d", len(path))
}
cmd := path[1] // Second command should be CurveTo
if cmd.Type != CurveToCommand {
t.Errorf("Expected CurveToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 3 {
t.Fatalf("Expected 3 points, got %d", len(cmd.Points))
}
// Check control points and end point
if cmd.Points[0].X != x1 || cmd.Points[0].Y != y1 {
t.Errorf("Expected first control point (%f, %f), got (%f, %f)", x1, y1, cmd.Points[0].X, cmd.Points[0].Y)
@@ -156,22 +156,22 @@ func TestBaseRendererCurveTo(t *testing.T) {
func TestBaseRendererClosePath(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
r.LineTo(10, 10)
r.ClosePath()
path := r.GetCurrentPath()
if len(path) != 3 {
t.Fatalf("Expected 3 path commands, got %d", len(path))
}
cmd := path[2] // Third command should be ClosePath
if cmd.Type != ClosePathCommand {
t.Errorf("Expected ClosePathCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 0 {
t.Errorf("Expected 0 points for ClosePath, got %d", len(cmd.Points))
}
@@ -180,29 +180,29 @@ func TestBaseRendererClosePath(t *testing.T) {
func TestBaseRendererAddPolygon(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ff0000")
points := []engine.Point{
{X: 0, Y: 0},
{X: 10, Y: 0},
{X: 10, Y: 10},
{X: 0, Y: 10},
}
r.AddPolygon(points)
path := r.GetCurrentPath()
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
expectedCommands := len(points) + 1 // +1 for ClosePath
if len(path) != expectedCommands {
t.Fatalf("Expected %d path commands, got %d", expectedCommands, len(path))
}
// Check first command is MoveTo
if path[0].Type != MoveToCommand {
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
}
// Check last command is ClosePath
if path[len(path)-1].Type != ClosePathCommand {
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
@@ -212,30 +212,30 @@ func TestBaseRendererAddPolygon(t *testing.T) {
func TestBaseRendererAddRectangle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#0000ff")
x, y, width, height := 5.0, 10.0, 20.0, 15.0
r.AddRectangle(x, y, width, height)
path := r.GetCurrentPath()
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
if len(path) != 5 {
t.Fatalf("Expected 5 path commands, got %d", len(path))
}
// Verify the rectangle points
expectedPoints := []engine.Point{
{X: x, Y: y}, // bottom-left
{X: x + width, Y: y}, // bottom-right
{X: x + width, Y: y + height}, // top-right
{X: x, Y: y + height}, // top-left
{X: x, Y: y}, // bottom-left
{X: x + width, Y: y}, // bottom-right
{X: x + width, Y: y + height}, // top-right
{X: x, Y: y + height}, // top-left
}
// Check MoveTo point
if path[0].Points[0] != expectedPoints[0] {
t.Errorf("Expected first point %v, got %v", expectedPoints[0], path[0].Points[0])
}
// Check LineTo points
for i := 1; i < 4; i++ {
if path[i].Type != LineToCommand {
@@ -250,20 +250,20 @@ func TestBaseRendererAddRectangle(t *testing.T) {
func TestBaseRendererAddTriangle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#00ffff")
p1 := engine.Point{X: 0, Y: 0}
p2 := engine.Point{X: 10, Y: 0}
p3 := engine.Point{X: 5, Y: 10}
r.AddTriangle(p1, p2, p3)
path := r.GetCurrentPath()
// Should have MoveTo + 2 LineTo + ClosePath = 4 commands
if len(path) != 4 {
t.Fatalf("Expected 4 path commands, got %d", len(path))
}
// Check the triangle points
if path[0].Points[0] != p1 {
t.Errorf("Expected first point %v, got %v", p1, path[0].Points[0])
@@ -279,24 +279,24 @@ func TestBaseRendererAddTriangle(t *testing.T) {
func TestBaseRendererAddCircle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ffff00")
center := engine.Point{X: 50, Y: 50}
radius := 25.0
r.AddCircle(center, radius, false)
path := r.GetCurrentPath()
// Should have MoveTo + 4 CurveTo + ClosePath = 6 commands
if len(path) != 6 {
t.Fatalf("Expected 6 path commands for circle, got %d", len(path))
}
// Check first command is MoveTo
if path[0].Type != MoveToCommand {
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
}
// Check that we have 4 CurveTo commands
curveCount := 0
for i := 1; i < len(path)-1; i++ {
@@ -307,7 +307,7 @@ func TestBaseRendererAddCircle(t *testing.T) {
if curveCount != 4 {
t.Errorf("Expected 4 CurveTo commands for circle, got %d", curveCount)
}
// Check last command is ClosePath
if path[len(path)-1].Type != ClosePathCommand {
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
@@ -316,13 +316,13 @@ func TestBaseRendererAddCircle(t *testing.T) {
func TestBaseRendererClear(t *testing.T) {
r := NewBaseRenderer(100)
// Set some state
r.BeginShape("#ff0000")
r.SetBackground("#ffffff", 0.8)
r.MoveTo(10, 20)
r.LineTo(30, 40)
// Verify state is set
if r.GetCurrentColor() == "" {
t.Error("Expected current color to be set before clear")
@@ -330,10 +330,10 @@ func TestBaseRendererClear(t *testing.T) {
if len(r.GetCurrentPath()) == 0 {
t.Error("Expected path commands before clear")
}
// Clear the renderer
r.Clear()
// Verify state is cleared
if r.GetCurrentColor() != "" {
t.Errorf("Expected empty current color after clear, got %s", r.GetCurrentColor())
@@ -341,7 +341,7 @@ func TestBaseRendererClear(t *testing.T) {
if len(r.GetCurrentPath()) != 0 {
t.Errorf("Expected empty path after clear, got %d commands", len(r.GetCurrentPath()))
}
bg, bgOp := r.GetBackground()
if bg != "" || bgOp != 0 {
t.Errorf("Expected empty background after clear, got %s with opacity %f", bg, bgOp)
@@ -351,12 +351,12 @@ func TestBaseRendererClear(t *testing.T) {
func TestBaseRendererEmptyPolygon(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ff0000")
// Test with empty points slice
r.AddPolygon([]engine.Point{})
path := r.GetCurrentPath()
if len(path) != 0 {
t.Errorf("Expected no path commands for empty polygon, got %d", len(path))
}
}
}

View File

@@ -1,14 +1,28 @@
package renderer
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// SVG rendering constants
const (
// SVG generation size estimation constants
svgBaseOverheadBytes = 150 // Base SVG document overhead
svgBackgroundRectBytes = 100 // Background rectangle overhead
svgPathOverheadBytes = 50 // Per-path element overhead
// Precision constants
svgCoordinatePrecision = 10 // Precision factor for SVG coordinates (0.1 precision)
svgRoundingOffset = 0.5 // Rounding offset for "round half up" behavior
)
// Note: Previously used polygonBufferPool for intermediate buffering, but eliminated
// to write directly to main builder and avoid unnecessary allocations
// SVGPath represents an SVG path element
type SVGPath struct {
data strings.Builder
@@ -20,12 +34,19 @@ func (p *SVGPath) AddPolygon(points []engine.Point) {
return
}
// Write directly to main data builder to avoid intermediate allocations
// Move to first point
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(points[0].X), svgValue(points[0].Y)))
p.data.WriteString("M")
svgAppendValue(&p.data, points[0].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[0].Y)
// Line to subsequent points
for i := 1; i < len(points); i++ {
p.data.WriteString(fmt.Sprintf("L%s %s", svgValue(points[i].X), svgValue(points[i].Y)))
p.data.WriteString("L")
svgAppendValue(&p.data, points[i].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[i].Y)
}
// Close path
@@ -42,18 +63,38 @@ func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
svgRadius := svgValue(radius)
svgDiameter := svgValue(size)
svgArc := fmt.Sprintf("a%s,%s 0 1,%s ", svgRadius, svgRadius, sweepFlag)
// Move to start point (left side of circle)
startX := centerX - radius
startY := centerY
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(startX), svgValue(startY)))
p.data.WriteString(svgArc + svgDiameter + ",0")
p.data.WriteString(svgArc + "-" + svgDiameter + ",0")
// Build circle path directly in main data builder
p.data.WriteString("M")
svgAppendValue(&p.data, startX)
p.data.WriteString(" ")
svgAppendValue(&p.data, startY)
// Draw first arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" ")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
// Draw second arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" -")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
}
// DataString returns the SVG path data string
@@ -84,6 +125,14 @@ func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
// BeginShape marks the beginning of a new shape with the specified color
func (r *SVGRenderer) BeginShape(color string) {
// Defense-in-depth validation: ensure color is safe for SVG output
// Invalid colors are silently ignored to maintain interface compatibility
if err := engine.ValidateHexColor(color); err != nil {
// Log validation failure but continue - the shape will not be rendered
// This prevents breaking the interface while maintaining security
return
}
r.BaseRenderer.BeginShape(color)
if _, exists := r.pathsByColor[color]; !exists {
r.pathsByColor[color] = &SVGPath{}
@@ -121,22 +170,49 @@ func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool)
// ToSVG generates the final SVG XML string
func (r *SVGRenderer) ToSVG() string {
var svg strings.Builder
iconSize := r.GetSize()
background, backgroundOp := r.GetBackground()
// Estimate capacity to reduce allocations
capacity := svgBaseOverheadBytes
if background != "" && backgroundOp > 0 {
capacity += svgBackgroundRectBytes
}
// Estimate path data size
for _, color := range r.colorOrder {
path := r.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
var svg strings.Builder
svg.Grow(capacity)
// SVG opening tag with namespace and dimensions
svg.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
iconSize, iconSize, iconSize, iconSize))
iconSizeStr := strconv.Itoa(iconSize)
svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" height="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" viewBox="0 0 `)
svg.WriteString(iconSizeStr)
svg.WriteString(` `)
svg.WriteString(iconSizeStr)
svg.WriteString(`">`)
// Add background rectangle if specified
if background != "" && backgroundOp > 0 {
if backgroundOp >= 1.0 {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s"/>`, background))
// Validate background color for safe SVG output
if err := engine.ValidateHexColor(background); err != nil {
// Skip invalid background colors to prevent injection
} else {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s" opacity="%.2f"/>`,
background, backgroundOp))
svg.WriteString(`<rect width="100%" height="100%" fill="`)
svg.WriteString(background) // Now validated
svg.WriteString(`" opacity="`)
svg.WriteString(strconv.FormatFloat(backgroundOp, 'f', 2, 64))
svg.WriteString(`"/>`)
}
}
@@ -145,7 +221,16 @@ func (r *SVGRenderer) ToSVG() string {
path := r.pathsByColor[color]
dataString := path.DataString()
if dataString != "" {
svg.WriteString(fmt.Sprintf(`<path fill="%s" d="%s"/>`, color, dataString))
// Final defense-in-depth validation before writing to SVG
if err := engine.ValidateHexColor(color); err != nil {
// Skip invalid colors to prevent injection attacks
continue
}
svg.WriteString(`<path fill="`)
svg.WriteString(color) // Now validated - safe injection point
svg.WriteString(`" d="`)
svg.WriteString(dataString)
svg.WriteString(`"/>`)
}
}
@@ -160,13 +245,33 @@ func (r *SVGRenderer) ToSVG() string {
func svgValue(value float64) string {
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
// JavaScript: ((value * 10 + 0.5) | 0) / 10
rounded := math.Floor(value*10 + 0.5) / 10
rounded := math.Floor(value*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
return strconv.Itoa(int(rounded))
}
// Otherwise, format to one decimal place.
return strconv.FormatFloat(rounded, 'f', 1, 64)
}
// svgAppendValue appends a formatted float64 directly to a strings.Builder to avoid string allocations
func svgAppendValue(buf *strings.Builder, value float64) {
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
// JavaScript: ((value * 10 + 0.5) | 0) / 10
rounded := math.Floor(value*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// Use stack-allocated buffer for AppendFloat to avoid heap allocations
var tempBuf [32]byte
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
result := strconv.AppendInt(tempBuf[:0], int64(rounded), 10)
buf.Write(result)
} else {
// Otherwise, format to one decimal place using AppendFloat
result := strconv.AppendFloat(tempBuf[:0], rounded, 'f', 1, 64)
buf.Write(result)
}
}

View File

@@ -0,0 +1,284 @@
package renderer
import (
"strings"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// TestSVGRenderer_SecurityValidation tests defense-in-depth color validation
// This test addresses SEC-06 from the security report by verifying that
// the SVG renderer properly validates color inputs and prevents injection attacks.
func TestSVGRenderer_SecurityValidation(t *testing.T) {
tests := []struct {
name string
color string
expectInSVG bool
description string
}{
{
name: "valid_hex_color_3_digit",
color: "#f00",
expectInSVG: true,
description: "Valid 3-digit hex color should be rendered",
},
{
name: "valid_hex_color_6_digit",
color: "#ff0000",
expectInSVG: true,
description: "Valid 6-digit hex color should be rendered",
},
{
name: "valid_hex_color_8_digit",
color: "#ff0000ff",
expectInSVG: true,
description: "Valid 8-digit hex color with alpha should be rendered",
},
{
name: "injection_attempt_script",
color: "\"><script>alert('xss')</script><path fill=\"#000",
expectInSVG: false,
description: "Script injection attempt should be blocked",
},
{
name: "injection_attempt_svg_element",
color: "#f00\"/><use href=\"#malicious\"/><path fill=\"#000",
expectInSVG: false,
description: "SVG element injection attempt should be blocked",
},
{
name: "malformed_hex_no_hash",
color: "ff0000",
expectInSVG: false,
description: "Hex color without # should be rejected",
},
{
name: "valid_hex_color_4_digit_rgba",
color: "#ff00",
expectInSVG: true,
description: "Valid 4-digit RGBA hex color should be rendered",
},
{
name: "malformed_hex_invalid_length_5",
color: "#ff000",
expectInSVG: false,
description: "Invalid 5-character hex color should be rejected",
},
{
name: "malformed_hex_invalid_chars",
color: "#gggggg",
expectInSVG: false,
description: "Invalid hex characters should be rejected",
},
{
name: "empty_color",
color: "",
expectInSVG: false,
description: "Empty color string should be rejected",
},
{
name: "xml_entity_injection",
color: "#ff0000&lt;script&gt;",
expectInSVG: false,
description: "XML entity injection attempt should be blocked",
},
{
name: "path_data_injection",
color: "#f00\" d=\"M0 0L100 100Z\"/><script>alert('xss')</script><path fill=\"#000",
expectInSVG: false,
description: "Path data injection attempt should be blocked",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := NewSVGRenderer(100)
// Test BeginShape validation
renderer.BeginShape(tt.color)
// Add some path data to ensure the color would be rendered if valid
points := []engine.Point{
{X: 10, Y: 10},
{X: 50, Y: 10},
{X: 50, Y: 50},
{X: 10, Y: 50},
}
renderer.AddPolygon(points)
renderer.EndShape()
// Generate SVG output
svgOutput := renderer.ToSVG()
if tt.expectInSVG {
// Verify valid colors are present in the output
if !strings.Contains(svgOutput, `fill="`+tt.color+`"`) {
t.Errorf("Expected valid color %s to be present in SVG output, but it was not found.\nSVG: %s", tt.color, svgOutput)
}
// Ensure the path element is present for valid colors
if !strings.Contains(svgOutput, "<path") {
t.Errorf("Expected path element to be present for valid color %s, but it was not found", tt.color)
}
} else {
// Verify invalid/malicious colors are NOT present in the output
// Special handling for empty string since it's always "contained" in any string
if tt.color != "" && strings.Contains(svgOutput, tt.color) {
t.Errorf("Expected invalid/malicious color %s to be rejected, but it was found in SVG output.\nSVG: %s", tt.color, svgOutput)
}
// For invalid colors, no path should be rendered with that color
if strings.Contains(svgOutput, `fill="`+tt.color+`"`) {
t.Errorf("Expected invalid color %s to be rejected from fill attribute, but it was found", tt.color)
}
}
// Verify the SVG is still well-formed XML
if !strings.HasPrefix(svgOutput, "<svg") {
t.Errorf("SVG output should start with <svg tag")
}
if !strings.HasSuffix(svgOutput, "</svg>") {
t.Errorf("SVG output should end with </svg> tag")
}
})
}
}
// TestSVGRenderer_BackgroundColorValidation tests background color validation
func TestSVGRenderer_BackgroundColorValidation(t *testing.T) {
tests := []struct {
name string
bgColor string
opacity float64
expectInSVG bool
description string
}{
{
name: "valid_background_color",
bgColor: "#ffffff",
opacity: 1.0,
expectInSVG: true,
description: "Valid background color should be rendered",
},
{
name: "invalid_background_injection",
bgColor: "#fff\"/><script>alert('bg')</script><rect fill=\"#000",
opacity: 1.0,
expectInSVG: false,
description: "Background color injection should be blocked",
},
{
name: "malformed_background_color",
bgColor: "not-a-color",
opacity: 1.0,
expectInSVG: false,
description: "Invalid background color format should be rejected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.SetBackground(tt.bgColor, tt.opacity)
svgOutput := renderer.ToSVG()
if tt.expectInSVG {
// Verify valid background colors are present
if !strings.Contains(svgOutput, `<rect`) {
t.Errorf("Expected background rectangle to be present for valid color %s", tt.bgColor)
}
if !strings.Contains(svgOutput, `fill="`+tt.bgColor+`"`) {
t.Errorf("Expected valid background color %s to be present in SVG", tt.bgColor)
}
} else {
// Verify invalid background colors are rejected
if strings.Contains(svgOutput, tt.bgColor) {
t.Errorf("Expected invalid background color %s to be rejected, but found in: %s", tt.bgColor, svgOutput)
}
}
})
}
}
// TestSVGRenderer_MultipleInvalidColors tests behavior with multiple invalid colors
func TestSVGRenderer_MultipleInvalidColors(t *testing.T) {
renderer := NewSVGRenderer(100)
maliciousColors := []string{
"\"><script>alert(1)</script><path fill=\"#000",
"#invalid-color",
"javascript:alert('xss')",
"#ff0000\"/><use href=\"#malicious\"/>",
}
// Try to add shapes with all malicious colors
for _, color := range maliciousColors {
renderer.BeginShape(color)
points := []engine.Point{{X: 0, Y: 0}, {X: 50, Y: 50}}
renderer.AddPolygon(points)
renderer.EndShape()
}
svgOutput := renderer.ToSVG()
// Verify none of the malicious colors appear in the output
for _, color := range maliciousColors {
if strings.Contains(svgOutput, color) {
t.Errorf("Malicious color %s should not appear in SVG output, but was found: %s", color, svgOutput)
}
}
// Verify the SVG is still valid and doesn't contain path elements for rejected colors
pathCount := strings.Count(svgOutput, "<path")
if pathCount > 0 {
t.Errorf("Expected no path elements for invalid colors, but found %d", pathCount)
}
// Ensure SVG structure is intact
if !strings.Contains(svgOutput, `<svg xmlns="http://www.w3.org/2000/svg"`) {
t.Errorf("SVG should still have proper structure even with all invalid colors")
}
}
// TestSVGRenderer_ValidAndInvalidColorMix tests mixed valid/invalid colors
func TestSVGRenderer_ValidAndInvalidColorMix(t *testing.T) {
renderer := NewSVGRenderer(100)
// Add valid color
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 25, Y: 25}})
renderer.EndShape()
// Add invalid color
renderer.BeginShape("\"><script>alert('xss')</script><path fill=\"#000")
renderer.AddPolygon([]engine.Point{{X: 25, Y: 25}, {X: 50, Y: 50}})
renderer.EndShape()
// Add another valid color
renderer.BeginShape("#00ff00")
renderer.AddPolygon([]engine.Point{{X: 50, Y: 50}, {X: 75, Y: 75}})
renderer.EndShape()
svgOutput := renderer.ToSVG()
// Valid colors should be present
if !strings.Contains(svgOutput, `fill="#ff0000"`) {
t.Errorf("Valid color #ff0000 should be present in output")
}
if !strings.Contains(svgOutput, `fill="#00ff00"`) {
t.Errorf("Valid color #00ff00 should be present in output")
}
// Invalid color should be rejected
if strings.Contains(svgOutput, "script") {
t.Errorf("Invalid color with script injection should be rejected")
}
// Should have exactly 2 path elements (for the 2 valid colors)
pathCount := strings.Count(svgOutput, "<path")
if pathCount != 2 {
t.Errorf("Expected exactly 2 path elements for valid colors, got %d", pathCount)
}
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
func TestSVGPath_AddPolygon(t *testing.T) {
@@ -149,7 +149,7 @@ func TestSVGRenderer_ToSVG(t *testing.T) {
if !strings.Contains(svg, `viewBox="0 0 100 100"`) {
t.Error("SVG should contain correct viewBox")
}
if !strings.Contains(svg, `<rect width="100%" height="100%" fill="#ffffff"/>`) {
if !strings.Contains(svg, `<rect width="100%" height="100%" fill="#ffffff" opacity="1.00"/>`) {
t.Error("SVG should contain background rect")
}
if !strings.Contains(svg, `<path fill="#ff0000" d="M0 0L10 0L10 10Z"/>`) {

View File

@@ -1,23 +1,35 @@
package util
import (
"crypto/sha1" // #nosec G505 -- SHA1 used for visual identity hashing (jdenticon compatibility), not cryptographic security
"fmt"
"strconv"
)
// ParseHex parses a hexadecimal value from the hash string
// ComputeHash generates a SHA1 hash from the input string.
// This matches the hash generation used by the JavaScript jdenticon library.
// Note: SHA1 is used here for visual identity generation (deterministic icon creation),
// not for cryptographic security purposes.
func ComputeHash(input string) string {
hasher := sha1.New() // #nosec G401 -- SHA1 used for visual identity hashing, not cryptographic security
hasher.Write([]byte(input))
hash := hasher.Sum(nil)
return fmt.Sprintf("%x", hash)
}
// ParseHex parses a hexadecimal value from the hash string with smart byte/character detection
// This implementation is shared between engine and jdenticon packages for consistency
func ParseHex(hash string, startPosition, octets int) (int, error) {
// Handle negative indices (count from end like JavaScript)
if startPosition < 0 {
startPosition = len(hash) + startPosition
}
// Ensure we don't go out of bounds
if startPosition < 0 || startPosition >= len(hash) {
return 0, fmt.Errorf("parseHex: position %d out of bounds for hash length %d", startPosition, len(hash))
return 0, fmt.Errorf("jdenticon: hash: parsing failed: position out of bounds: position %d out of bounds for hash length %d", startPosition, len(hash))
}
// If octets is 0 or negative, read from startPosition to end (like JavaScript default)
end := len(hash)
if octets > 0 {
@@ -26,34 +38,49 @@ func ParseHex(hash string, startPosition, octets int) (int, error) {
end = len(hash)
}
}
// Extract substring and parse as hexadecimal
substr := hash[startPosition:end]
if len(substr) == 0 {
return 0, fmt.Errorf("parseHex: empty substring at position %d", startPosition)
return 0, fmt.Errorf("jdenticon: hash: parsing failed: empty substring: empty substring at position %d", startPosition)
}
result, err := strconv.ParseInt(substr, 16, 64)
result, err := strconv.ParseInt(substr, 16, 0)
if err != nil {
return 0, fmt.Errorf("parseHex: failed to parse hex '%s' at position %d: %w", substr, startPosition, err)
return 0, fmt.Errorf("jdenticon: hash: parsing failed: invalid hex format: failed to parse hex '%s' at position %d: %w", substr, startPosition, err)
}
return int(result), nil
}
// ValidateHash validates a hash string and returns detailed error information
func ValidateHash(hash string) error {
if len(hash) < 11 {
return fmt.Errorf("jdenticon: hash: validation failed: insufficient length: hash too short: %d characters (minimum 11 required)", len(hash))
}
// Check if all characters are valid hexadecimal
for i, r := range hash {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return fmt.Errorf("jdenticon: hash: validation failed: invalid character: invalid hexadecimal character '%c' at position %d", r, i)
}
}
return nil
}
// IsValidHash checks if a hash string is valid for jdenticon generation
// This implementation is shared between engine and jdenticon packages for consistency
func IsValidHash(hash string) bool {
if len(hash) < 11 {
return false
}
// Check if all characters are valid hexadecimal
for _, r := range hash {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
return ValidateHash(hash) == nil
}
// ContainsInt checks if an integer slice contains a specific value
func ContainsInt(slice []int, value int) bool {
for _, item := range slice {
if item == value {
return true
}
}
return true
}
return false
}

360
internal/util/hash_test.go Normal file
View File

@@ -0,0 +1,360 @@
package util
import (
"math"
"strconv"
"testing"
)
func TestContainsInt(t *testing.T) {
tests := []struct {
name string
slice []int
value int
expected bool
}{
{
name: "value exists in slice",
slice: []int{1, 2, 3, 4, 5},
value: 3,
expected: true,
},
{
name: "value does not exist in slice",
slice: []int{1, 2, 3, 4, 5},
value: 6,
expected: false,
},
{
name: "empty slice",
slice: []int{},
value: 1,
expected: false,
},
{
name: "single element slice - match",
slice: []int{42},
value: 42,
expected: true,
},
{
name: "single element slice - no match",
slice: []int{42},
value: 43,
expected: false,
},
{
name: "duplicate values in slice",
slice: []int{1, 2, 2, 3, 2},
value: 2,
expected: true,
},
{
name: "negative values",
slice: []int{-5, -3, -1, 0, 1},
value: -3,
expected: true,
},
{
name: "zero value",
slice: []int{-1, 0, 1},
value: 0,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContainsInt(tt.slice, tt.value)
if result != tt.expected {
t.Errorf("ContainsInt(%v, %d) = %v, expected %v",
tt.slice, tt.value, result, tt.expected)
}
})
}
}
func TestParseHex(t *testing.T) {
tests := []struct {
name string
hash string
startPosition int
octets int
expected int
expectError bool
errorType string
}{
// Valid cases
{
name: "simple hex parsing",
hash: "abc123def456",
startPosition: 0,
octets: 2,
expected: 0xab,
expectError: false,
},
{
name: "parse single character",
hash: "a1b2c3d4e5f6",
startPosition: 1,
octets: 1,
expected: 0x1,
expectError: false,
},
{
name: "parse from middle",
hash: "123456789abc",
startPosition: 6,
octets: 3,
expected: 0x789,
expectError: false,
},
{
name: "negative position (from end)",
hash: "abcdef123456",
startPosition: -2,
octets: 2,
expected: 0x56,
expectError: false,
},
{
name: "octets 0 reads to end",
hash: "abc123",
startPosition: 3,
octets: 0,
expected: 0x123,
expectError: false,
},
// Error cases
{
name: "position out of bounds",
hash: "abc123",
startPosition: 10,
octets: 1,
expectError: true,
errorType: "position out of bounds",
},
{
name: "negative position too large",
hash: "abc123",
startPosition: -10,
octets: 1,
expectError: true,
errorType: "position out of bounds",
},
{
name: "invalid hex character",
hash: "abcghi",
startPosition: 3,
octets: 3,
expectError: true,
errorType: "invalid hex format",
},
// Platform-specific overflow tests
{
name: "value at 32-bit int boundary (safe)",
hash: "7fffffff",
startPosition: 0,
octets: 8,
expected: math.MaxInt32,
expectError: false,
},
}
// Add platform-specific overflow test that should fail on 32-bit systems
if strconv.IntSize == 32 {
tests = append(tests, struct {
name string
hash string
startPosition int
octets int
expected int
expectError bool
errorType string
}{
name: "overflow on 32-bit systems",
hash: "80000000", // This exceeds math.MaxInt32
startPosition: 0,
octets: 8,
expectError: true,
errorType: "value out of range",
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHex(tt.hash, tt.startPosition, tt.octets)
if tt.expectError {
if err == nil {
t.Errorf("ParseHex(%q, %d, %d) expected error but got none",
tt.hash, tt.startPosition, tt.octets)
return
}
if tt.errorType != "" && !containsString(err.Error(), tt.errorType) {
t.Errorf("ParseHex(%q, %d, %d) error %q does not contain expected type %q",
tt.hash, tt.startPosition, tt.octets, err.Error(), tt.errorType)
}
return
}
if err != nil {
t.Errorf("ParseHex(%q, %d, %d) unexpected error: %v",
tt.hash, tt.startPosition, tt.octets, err)
return
}
if result != tt.expected {
t.Errorf("ParseHex(%q, %d, %d) = %d, expected %d",
tt.hash, tt.startPosition, tt.octets, result, tt.expected)
}
})
}
}
func TestValidateHash(t *testing.T) {
tests := []struct {
name string
hash string
expectError bool
errorType string
}{
{
name: "valid hash",
hash: "abc123def456789",
expectError: false,
},
{
name: "minimum valid length",
hash: "abc123def45", // exactly 11 chars
expectError: false,
},
{
name: "hash too short",
hash: "abc123def4", // 10 chars
expectError: true,
errorType: "insufficient length",
},
{
name: "invalid character",
hash: "abc123gef456789",
expectError: true,
errorType: "invalid character",
},
{
name: "uppercase hex is valid",
hash: "ABC123DEF456789",
expectError: false,
},
{
name: "mixed case is valid",
hash: "AbC123dEf456789",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHash(tt.hash)
if tt.expectError {
if err == nil {
t.Errorf("ValidateHash(%q) expected error but got none", tt.hash)
return
}
if tt.errorType != "" && !containsString(err.Error(), tt.errorType) {
t.Errorf("ValidateHash(%q) error %q does not contain expected type %q",
tt.hash, err.Error(), tt.errorType)
}
return
}
if err != nil {
t.Errorf("ValidateHash(%q) unexpected error: %v", tt.hash, err)
}
})
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
hash string
expected bool
}{
{
name: "valid hash returns true",
hash: "abc123def456789",
expected: true,
},
{
name: "invalid hash returns false",
hash: "abc123g", // too short and invalid char
expected: false,
},
{
name: "empty hash returns false",
hash: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsValidHash(tt.hash)
if result != tt.expected {
t.Errorf("IsValidHash(%q) = %v, expected %v", tt.hash, result, tt.expected)
}
})
}
}
// TestParseHexDeterministic verifies that ParseHex produces consistent results
func TestParseHexDeterministic(t *testing.T) {
testCases := []struct {
hash string
pos int
oct int
}{
{"abc123def456", 0, 2},
{"fedcba987654", 3, 4},
{"123456789abc", 6, 3},
}
for _, tc := range testCases {
t.Run("deterministic_"+tc.hash, func(t *testing.T) {
// Parse the same input multiple times
results := make([]int, 10)
for i := 0; i < 10; i++ {
result, err := ParseHex(tc.hash, tc.pos, tc.oct)
if err != nil {
t.Fatalf("ParseHex failed on iteration %d: %v", i, err)
}
results[i] = result
}
// Verify all results are identical
first := results[0]
for i, result := range results[1:] {
if result != first {
t.Errorf("ParseHex result not deterministic: iteration %d gave %d, expected %d",
i+1, result, first)
}
}
})
}
}
// Helper function to check if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) &&
(len(substr) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}())
}