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:
599
cmd/jdenticon/batch_test.go
Normal file
599
cmd/jdenticon/batch_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user