Files
go-jdenticon/cmd/jdenticon/batch_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

600 lines
16 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/ungluedlabs/go-jdenticon/jdenticon"
)
// TestBatchCommand tests the batch command functionality
func TestBatchCommand(t *testing.T) {
// Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "jdenticon-batch-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create test input file
inputFile := filepath.Join(tempDir, "input.txt")
testInputs := []string{
"user1@example.com",
"user2@example.com",
"test-user",
"unicode-üser",
"", // Empty line should be skipped
"special@chars!",
}
inputContent := strings.Join(testInputs, "\n")
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
t.Fatalf("Failed to create input file: %v", err)
}
outputDir := filepath.Join(tempDir, "output")
tests := []struct {
name string
args []string
wantErr bool
outputCheck func(t *testing.T, outputDir string)
}{
{
name: "batch generate SVG",
args: []string{"batch", inputFile, "--output-dir", outputDir},
wantErr: false,
outputCheck: func(t *testing.T, outputDir string) {
// Check that SVG files were created
expectedFiles := []string{
"user1_at_example.com.svg",
"user2_at_example.com.svg",
"test-user.svg",
"unicode-_ser.svg", // Unicode characters get sanitized
"special_at_chars_.svg", // Special chars get sanitized
}
for _, filename := range expectedFiles {
filepath := filepath.Join(outputDir, filename)
if _, err := os.Stat(filepath); os.IsNotExist(err) {
t.Errorf("Expected file %s to be created", filename)
continue
}
// Check file content
content, err := os.ReadFile(filepath)
if err != nil {
t.Errorf("Failed to read file %s: %v", filename, err)
continue
}
if !strings.Contains(string(content), "<svg") {
t.Errorf("File %s should contain SVG content", filename)
}
}
},
},
{
name: "batch generate PNG",
args: []string{"batch", "--format", "png", inputFile, "--output-dir", outputDir + "-png"},
wantErr: false,
outputCheck: func(t *testing.T, outputDir string) {
// Check that PNG files were created
expectedFiles := []string{
"user1_at_example.com.png",
"user2_at_example.com.png",
}
for _, filename := range expectedFiles {
filepath := filepath.Join(outputDir, filename)
if _, err := os.Stat(filepath); os.IsNotExist(err) {
t.Errorf("Expected file %s to be created", filename)
continue
}
// Check PNG magic bytes
content, err := os.ReadFile(filepath)
if err != nil {
t.Errorf("Failed to read file %s: %v", filename, err)
continue
}
if len(content) < 8 || string(content[:4]) != "\x89PNG" {
t.Errorf("File %s should contain PNG content", filename)
}
}
},
},
{
name: "missing output-dir",
args: []string{"batch", inputFile},
wantErr: true,
outputCheck: func(t *testing.T, outputDir string) {
// Should not create any files
},
},
{
name: "missing input file",
args: []string{"batch", "nonexistent.txt", "--output-dir", outputDir},
wantErr: true,
outputCheck: func(t *testing.T, outputDir string) {
// Should not create any files
},
},
{
name: "no arguments",
args: []string{},
wantErr: false, // Root command shows help, doesn't error
outputCheck: func(t *testing.T, outputDir string) {
// Should not create any files
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
cmd := createTestBatchCommand()
cmd.SetArgs(tt.args)
err := cmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.outputCheck != nil {
// Extract output dir from args
var testOutputDir string
for i, arg := range tt.args {
if arg == "--output-dir" && i+1 < len(tt.args) {
testOutputDir = tt.args[i+1]
break
}
}
if testOutputDir != "" {
tt.outputCheck(t, testOutputDir)
}
}
})
}
}
// TestBatchSanitizeFilename tests filename sanitization
func TestBatchSanitizeFilename(t *testing.T) {
tests := []struct {
input string
expected string
}{
{
input: "user@example.com",
expected: "user_at_example.com",
},
{
input: "test-user_123",
expected: "test-user_123",
},
{
input: "unicode-üser",
expected: "unicode-_ser",
},
{
input: "special!@#$%^&*()",
expected: "special__at__",
},
{
input: "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues",
expected: func() string {
longStr := "very-long-username-with-many-characters-that-exceeds-normal-length-limits-and-should-be-truncated-to-prevent-filesystem-issues"
if len(longStr) > 200 {
return longStr[:200]
}
return longStr
}(),
},
{
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := sanitizeFilename(tt.input)
if result != tt.expected {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestBatchWithCustomConfig tests batch generation with custom configuration
func TestBatchWithCustomConfig(t *testing.T) {
tempDir, err := os.MkdirTemp("", "jdenticon-batch-config-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create simple input file
inputFile := filepath.Join(tempDir, "input.txt")
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
t.Fatalf("Failed to create input file: %v", err)
}
outputDir := filepath.Join(tempDir, "output")
tests := []struct {
name string
args []string
wantErr bool
outputCheck func(t *testing.T, outputPath string)
}{
{
name: "custom size",
args: []string{"batch", "--size", "128", inputFile, "--output-dir", outputDir + "-size"},
wantErr: false,
outputCheck: func(t *testing.T, outputPath string) {
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output: %v", err)
}
if !strings.Contains(string(content), "width=\"128\"") {
t.Error("Expected SVG to have custom size")
}
},
},
{
name: "custom background color",
args: []string{"batch", "--bg-color", "#ff0000", inputFile, "--output-dir", outputDir + "-bg"},
wantErr: false,
outputCheck: func(t *testing.T, outputPath string) {
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output: %v", err)
}
if !strings.Contains(string(content), "#ff0000") {
t.Error("Expected SVG to have custom background color")
}
},
},
{
name: "custom padding",
args: []string{"batch", "--padding", "0.2", inputFile, "--output-dir", outputDir + "-pad"},
wantErr: false,
outputCheck: func(t *testing.T, outputPath string) {
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output: %v", err)
}
// Should still generate valid SVG
if !strings.Contains(string(content), "<svg") {
t.Error("Expected valid SVG output")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
cmd := createTestBatchCommand()
cmd.SetArgs(tt.args)
err := cmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("batchCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.outputCheck != nil {
// Find the output directory and check the generated file
var testOutputDir string
for i, arg := range tt.args {
if arg == "--output-dir" && i+1 < len(tt.args) {
testOutputDir = tt.args[i+1]
break
}
}
if testOutputDir != "" {
expectedFile := filepath.Join(testOutputDir, "test_at_example.com.svg")
tt.outputCheck(t, expectedFile)
}
}
})
}
}
// createTestBatchCommand creates a batch command for testing
func createTestBatchCommand() *cobra.Command {
// Create root command with flags
rootCmd := &cobra.Command{
Use: "jdenticon",
Short: "Generate identicons from any input string",
}
// Initialize root flags
initTestFlags(rootCmd)
// Create batch command similar to the actual one
batchCmd := &cobra.Command{
Use: "batch <input-file>",
Short: "Generate multiple identicons from a list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConcurrentBatch(cmd, args)
},
}
// Add batch-specific flags
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
batchCmd.MarkFlagRequired("output-dir")
// Concurrency control
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
// Add to root command
rootCmd.AddCommand(batchCmd)
return rootCmd
}
// TestConcurrentBatchProcessing tests the concurrent batch processing functionality
func TestConcurrentBatchProcessing(t *testing.T) {
// Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "jdenticon-concurrent-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create test input file with more entries to test concurrency
inputFile := filepath.Join(tempDir, "input.txt")
var inputs []string
for i := 0; i < 50; i++ {
inputs = append(inputs, fmt.Sprintf("user%d@example.com", i))
}
inputContent := strings.Join(inputs, "\n")
if err := os.WriteFile(inputFile, []byte(inputContent), 0644); err != nil {
t.Fatalf("Failed to create input file: %v", err)
}
outputDir := filepath.Join(tempDir, "output")
tests := []struct {
name string
concurrency int
expectFiles int
}{
{"sequential", 1, 50},
{"small_pool", 2, 50},
{"medium_pool", 4, 50},
{"large_pool", runtime.NumCPU(), 50},
{"over_provisioned", runtime.NumCPU() * 2, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean output directory
os.RemoveAll(outputDir)
// Test the concurrent batch command
cmd := createTestBatchCommand()
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", fmt.Sprintf("%d", tt.concurrency)}
cmd.SetArgs(args)
start := time.Now()
err := cmd.Execute()
duration := time.Since(start)
if err != nil {
t.Fatalf("Command failed: %v", err)
}
// Verify output files
files, err := os.ReadDir(outputDir)
if err != nil {
t.Fatalf("Failed to read output directory: %v", err)
}
if len(files) != tt.expectFiles {
t.Errorf("Expected %d files, got %d", tt.expectFiles, len(files))
}
// Verify all files are valid SVG
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".svg") {
t.Errorf("Expected SVG file, got %s", file.Name())
continue
}
content, err := os.ReadFile(filepath.Join(outputDir, file.Name()))
if err != nil {
t.Errorf("Failed to read file %s: %v", file.Name(), err)
continue
}
if !strings.Contains(string(content), "<svg") {
t.Errorf("File %s does not contain SVG content", file.Name())
}
}
t.Logf("Processed %d files with %d workers in %v", tt.expectFiles, tt.concurrency, duration)
})
}
}
// TestJobPreparation tests the job preparation functionality
func TestJobPreparation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "jdenticon-job-prep-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Test with various input scenarios
tests := []struct {
name string
content string
expectJobs int
expectError bool
}{
{
name: "normal_inputs",
content: "user1@example.com\nuser2@example.com\nuser3@example.com",
expectJobs: 3,
},
{
name: "with_empty_lines",
content: "user1@example.com\n\nuser2@example.com\n\n",
expectJobs: 2,
},
{
name: "only_empty_lines",
content: "\n\n\n",
expectJobs: 0,
},
{
name: "mixed_content",
content: "user1@example.com\n \nuser2@example.com\n\t\nuser3@example.com",
expectJobs: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputFile := filepath.Join(tempDir, "input.txt")
if err := os.WriteFile(inputFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to create input file: %v", err)
}
outputDir := filepath.Join(tempDir, "output")
jobs, total, err := prepareJobs(inputFile, outputDir, FormatSVG, 64)
if tt.expectError && err == nil {
t.Errorf("Expected error, got none")
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if total != tt.expectJobs {
t.Errorf("Expected %d jobs, got %d", tt.expectJobs, total)
}
if len(jobs) != tt.expectJobs {
t.Errorf("Expected %d jobs in slice, got %d", tt.expectJobs, len(jobs))
}
})
}
}
// TestBatchWorkerShutdown tests graceful shutdown of batch workers
func TestBatchWorkerShutdown(t *testing.T) {
generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
// Create a temp directory for output files (cross-platform)
tempDir := t.TempDir()
// Create a context that will be canceled
ctx, cancel := context.WithCancel(context.Background())
// Create job channel with some jobs
jobChan := make(chan batchJob, 10)
for i := 0; i < 5; i++ {
jobChan <- batchJob{
value: fmt.Sprintf("user%d@example.com", i),
outputPath: filepath.Join(tempDir, fmt.Sprintf("test%d.svg", i)),
size: 64,
}
}
stats := &batchStats{}
// Start worker
done := make(chan struct{})
go func() {
defer close(done)
batchWorker(ctx, 1, jobChan, generator, FormatSVG, stats, nil)
}()
// Let it process a bit, then cancel
time.Sleep(10 * time.Millisecond)
cancel()
// Wait for worker to shutdown (should be quick)
select {
case <-done:
// Good, worker shut down gracefully
case <-time.After(1 * time.Second):
t.Error("Worker did not shut down within timeout")
}
// Verify some jobs were processed
processed := atomic.LoadInt64(&stats.processed)
t.Logf("Processed %d jobs before shutdown", processed)
}
// TestConcurrencyFlagValidation tests the validation of concurrency flag
func TestConcurrencyFlagValidation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "jdenticon-concurrency-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create minimal input file
inputFile := filepath.Join(tempDir, "input.txt")
if err := os.WriteFile(inputFile, []byte("test@example.com\n"), 0644); err != nil {
t.Fatalf("Failed to create input file: %v", err)
}
outputDir := filepath.Join(tempDir, "output")
tests := []struct {
name string
concurrency string
expectError bool
}{
{"valid_positive", "4", false},
{"valid_one", "1", false},
{"invalid_zero", "0", true},
{"invalid_negative", "-1", true},
{"invalid_non_numeric", "abc", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := createTestBatchCommand()
args := []string{"batch", inputFile, "--output-dir", outputDir, "--concurrency", tt.concurrency}
cmd.SetArgs(args)
err := cmd.Execute()
if tt.expectError && err == nil {
t.Errorf("Expected error for concurrency %s, got none", tt.concurrency)
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error for concurrency %s: %v", tt.concurrency, err)
}
})
}
}