Files
go-jdenticon/internal/engine/cache_test.go
Kevin McIntyre d9e84812ff 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
2026-01-03 23:41:48 -05:00

450 lines
12 KiB
Go

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)
}
}
}