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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user