Files
go-jdenticon/internal/engine/singleflight_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

416 lines
10 KiB
Go

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