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