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:
22
internal/constants/limits.go
Normal file
22
internal/constants/limits.go
Normal 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
131
internal/engine/cache.go
Normal 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
|
||||
}
|
||||
449
internal/engine/cache_test.go
Normal file
449
internal/engine/cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
internal/engine/color_graceful_degradation_test.go
Normal file
197
internal/engine/color_graceful_degradation_test.go
Normal 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
|
||||
}())))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
178
internal/engine/colorutils.go
Normal file
178
internal/engine/colorutils.go
Normal 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
|
||||
}
|
||||
229
internal/engine/colorutils_test.go
Normal file
229
internal/engine/colorutils_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
51
internal/engine/doc.go
Normal 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
|
||||
412
internal/engine/fuzz_test.go
Normal file
412
internal/engine/fuzz_test.go
Normal 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
|
||||
}
|
||||
@@ -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, ¢erShapes)
|
||||
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
|
||||
}
|
||||
|
||||
413
internal/engine/generator_bench_test.go
Normal file
413
internal/engine/generator_bench_test.go
Normal 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++
|
||||
}
|
||||
})
|
||||
}
|
||||
635
internal/engine/generator_core_test.go
Normal file
635
internal/engine/generator_core_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
160
internal/engine/generator_graceful_degradation_test.go
Normal file
160
internal/engine/generator_graceful_degradation_test.go
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
294
internal/engine/security_memory_test.go
Normal file
294
internal/engine/security_memory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
internal/engine/singleflight.go
Normal file
103
internal/engine/singleflight.go
Normal 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)
|
||||
}
|
||||
415
internal/engine/singleflight_test.go
Normal file
415
internal/engine/singleflight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
4
internal/engine/testdata/fuzz/FuzzParseHex/e30600eab4f84cf4
vendored
Normal file
4
internal/engine/testdata/fuzz/FuzzParseHex/e30600eab4f84cf4
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
go test fuzz v1
|
||||
string("-1")
|
||||
int(-2)
|
||||
int(5)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/perfsuite/regression_test.go
Normal file
43
internal/perfsuite/regression_test.go
Normal 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
469
internal/perfsuite/suite.go
Normal 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
|
||||
}
|
||||
98
internal/renderer/baseline_comparison_test.go
Normal file
98
internal/renderer/baseline_comparison_test.go
Normal 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
58
internal/renderer/doc.go
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
544
internal/renderer/micro_bench_test.go
Normal file
544
internal/renderer/micro_bench_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
179
internal/renderer/optimized_bench_test.go
Normal file
179
internal/renderer/optimized_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
464
internal/renderer/renderer_bench_test.go
Normal file
464
internal/renderer/renderer_bench_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
284
internal/renderer/svg_security_test.go
Normal file
284
internal/renderer/svg_security_test.go
Normal 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<script>",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"/>`) {
|
||||
|
||||
@@ -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
360
internal/util/hash_test.go
Normal 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
|
||||
}())
|
||||
}
|
||||
Reference in New Issue
Block a user