- 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
600 lines
16 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|