- 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
416 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|