diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-20 21:20:40 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-20 21:20:40 +0300 |
| commit | 9e3328a6aaefe4bd1aa0ec3e8bf6e93d6033180b (patch) | |
| tree | f70a6b53facc81a8bddbe5eeee76708e474e3298 /internal/audio | |
| parent | 1afd19206720af695625dd46ff0ded0dedeef329 (diff) | |
test: add comprehensive test suite for audio and anki packages
- Add tests for audio package (62.8% coverage)
- OpenAI provider tests with mocking
- Provider interface and fallback mechanism tests
- Bulgarian text validation tests
- Audio caching functionality tests
- Add tests for anki package (84.8% coverage)
- CSV generation tests
- APKG package generation tests
- Card management and formatting tests
- Directory scanning and media handling tests
- Add test utilities and mocks
- Mock implementations for external dependencies
- Test helpers for common operations
- Utilities for creating test directories and files
- Update Taskfile.yaml with comprehensive test targets
- test: Run all tests
- test-verbose: Run with verbose output
- test-coverage: Run with coverage report
- test-coverage-html: Generate HTML coverage report
- test-race: Run with race detector
- test-short: Run only short tests
- test-all: Comprehensive suite with coverage and race detection
- clean: Remove build artifacts and test files
- Fix existing image package tests
- Remove tests for non-existent methods
- Update tests to match actual implementation
- Skip tests requiring live OpenAI API
This provides a solid foundation for ensuring code quality and catching regressions.
π€ Generated with [opencode](https://opencode.ai)
Co-Authored-By: opencode <noreply@opencode.ai>
Diffstat (limited to 'internal/audio')
| -rw-r--r-- | internal/audio/openai_provider_test.go | 349 | ||||
| -rw-r--r-- | internal/audio/provider_test.go | 196 | ||||
| -rw-r--r-- | internal/audio/validate_test.go | 64 |
3 files changed, 609 insertions, 0 deletions
diff --git a/internal/audio/openai_provider_test.go b/internal/audio/openai_provider_test.go new file mode 100644 index 0000000..7e3f9e5 --- /dev/null +++ b/internal/audio/openai_provider_test.go @@ -0,0 +1,349 @@ +package audio + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewOpenAIProvider(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + errMsg string + }{ + { + name: "missing API key", + config: &Config{ + OpenAIKey: "", + }, + wantErr: true, + errMsg: "OpenAI API key is required", + }, + { + name: "valid config with cache", + config: &Config{ + OpenAIKey: "test-key", + EnableCache: true, + CacheDir: "./test_cache", + }, + wantErr: false, + }, + { + name: "valid config without cache", + config: &Config{ + OpenAIKey: "test-key", + EnableCache: false, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewOpenAIProvider(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewOpenAIProvider() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && err != nil && err.Error() != tt.errMsg { + t.Errorf("NewOpenAIProvider() error = %v, want %v", err.Error(), tt.errMsg) + } + + // Cleanup cache dir if created + if !tt.wantErr && tt.config.EnableCache && tt.config.CacheDir != "" { + os.RemoveAll(tt.config.CacheDir) + } + + // Check provider properties + if !tt.wantErr && provider != nil { + if provider.Name() != "openai" { + t.Errorf("Name() = %v, want %v", provider.Name(), "openai") + } + } + }) + } +} + +func TestOpenAIProviderIsAvailable(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + }{ + { + name: "with API key", + config: &Config{ + OpenAIKey: "test-key", + }, + wantErr: false, + }, + { + name: "without API key", + config: &Config{ + OpenAIKey: "", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &OpenAIProvider{ + config: tt.config, + } + err := provider.IsAvailable() + if (err != nil) != tt.wantErr { + t.Errorf("IsAvailable() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPreprocessBulgarianText(t *testing.T) { + provider := &OpenAIProvider{ + config: &Config{}, + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple word", + input: "ΡΠ±ΡΠ»ΠΊΠ°", + expected: "ΡΠ±ΡΠ»ΠΊΠ°", + }, + { + name: "word with punctuation", + input: "ΡΠ±ΡΠ»ΠΊΠ°!", + expected: "ΡΠ±ΡΠ»ΠΊΠ°", + }, + { + name: "word with multiple punctuation", + input: "\"ΡΠ±ΡΠ»ΠΊΠ°?\"", + expected: "ΡΠ±ΡΠ»ΠΊΠ°", + }, + { + name: "word with spaces", + input: " ΡΠ±ΡΠ»ΠΊΠ° ", + expected: "ΡΠ±ΡΠ»ΠΊΠ°", + }, + { + name: "word with dashes", + input: "ΡΠ±ΡΠ»ΠΊΠ°-ΠΊΡΡΡΠ°", + expected: "ΡΠ±ΡΠ»ΠΊΠ°ΠΊΡΡΡΠ°", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.preprocessBulgarianText(tt.input) + if result != tt.expected { + t.Errorf("preprocessBulgarianText(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetCacheFilePath(t *testing.T) { + provider := &OpenAIProvider{ + config: &Config{ + OpenAIModel: "tts-1", + OpenAIVoice: "alloy", + OpenAISpeed: 1.0, + }, + cacheDir: "./test_cache", + } + + // Test basic cache path generation + path1 := provider.getCacheFilePath("ΡΠ±ΡΠ»ΠΊΠ°") + if !strings.HasPrefix(path1, "test_cache/") { + t.Errorf("Cache path should start with cache dir, got %s", path1) + } + if !strings.HasSuffix(path1, ".mp3") { + t.Errorf("Cache path should end with .mp3, got %s", path1) + } + + // Test that same input produces same path + path2 := provider.getCacheFilePath("ΡΠ±ΡΠ»ΠΊΠ°") + if path1 != path2 { + t.Errorf("Same input should produce same cache path, got %s and %s", path1, path2) + } + + // Test that different input produces different path + path3 := provider.getCacheFilePath("ΠΊΠΎΡΠΊΠ°") + if path1 == path3 { + t.Errorf("Different input should produce different cache path") + } + + // Test that different settings produce different paths + provider.config.OpenAIVoice = "nova" + path4 := provider.getCacheFilePath("ΡΠ±ΡΠ»ΠΊΠ°") + if path1 == path4 { + t.Errorf("Different voice should produce different cache path") + } + + // Test with instruction for gpt-4o-mini-tts + provider.config.OpenAIModel = "gpt-4o-mini-tts" + provider.config.OpenAIInstruction = "Test instruction" + path5 := provider.getCacheFilePath("ΡΠ±ΡΠ»ΠΊΠ°") + + provider.config.OpenAIInstruction = "Different instruction" + path6 := provider.getCacheFilePath("ΡΠ±ΡΠ»ΠΊΠ°") + if path5 == path6 { + t.Errorf("Different instruction should produce different cache path for gpt-4o-mini-tts") + } +} + +func TestCopyFile(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + provider := &OpenAIProvider{} + + // Create source file + srcPath := filepath.Join(tempDir, "source.txt") + srcContent := []byte("test content") + if err := os.WriteFile(srcPath, srcContent, 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + // Test copying to new file + dstPath := filepath.Join(tempDir, "dest.txt") + err := provider.copyFile(srcPath, dstPath) + if err != nil { + t.Errorf("copyFile() error = %v", err) + } + + // Verify content + dstContent, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("Failed to read destination file: %v", err) + } + if string(dstContent) != string(srcContent) { + t.Errorf("Copied content doesn't match: got %q, want %q", dstContent, srcContent) + } + + // Test copying to subdirectory + dstPath2 := filepath.Join(tempDir, "subdir", "dest2.txt") + err = provider.copyFile(srcPath, dstPath2) + if err != nil { + t.Errorf("copyFile() to subdirectory error = %v", err) + } + + // Test copying non-existent file + err = provider.copyFile(filepath.Join(tempDir, "nonexistent.txt"), dstPath) + if err == nil { + t.Error("copyFile() expected error for non-existent source") + } +} + +func TestClearCache(t *testing.T) { + tempDir := t.TempDir() + + provider := &OpenAIProvider{ + cacheDir: filepath.Join(tempDir, "cache"), + } + + // Create cache directory with some files + os.MkdirAll(filepath.Join(provider.cacheDir, "ab"), 0755) + os.WriteFile(filepath.Join(provider.cacheDir, "ab", "test1.mp3"), []byte("data1"), 0644) + os.WriteFile(filepath.Join(provider.cacheDir, "ab", "test2.mp3"), []byte("data2"), 0644) + + // Clear cache + err := provider.ClearCache() + if err != nil { + t.Errorf("ClearCache() error = %v", err) + } + + // Verify cache directory is gone + if _, err := os.Stat(provider.cacheDir); !os.IsNotExist(err) { + t.Error("Cache directory should be removed") + } + + // Test clearing with empty cache dir + provider.cacheDir = "" + err = provider.ClearCache() + if err != nil { + t.Errorf("ClearCache() with empty dir should not error: %v", err) + } +} + +func TestGetCacheStats(t *testing.T) { + tempDir := t.TempDir() + + provider := &OpenAIProvider{ + enableCache: true, + cacheDir: filepath.Join(tempDir, "cache"), + } + + // Create the cache directory first + os.MkdirAll(provider.cacheDir, 0755) + + // Test with no cache files + count, size, err := provider.GetCacheStats() + if err != nil { + t.Errorf("GetCacheStats() error = %v", err) + } + if count != 0 || size != 0 { + t.Errorf("Expected empty cache stats, got count=%d, size=%d", count, size) + } + // Create cache files + os.MkdirAll(filepath.Join(provider.cacheDir, "ab"), 0755) + data1 := []byte("test data 1") + data2 := []byte("test data 22") + os.WriteFile(filepath.Join(provider.cacheDir, "ab", "test1.mp3"), data1, 0644) + os.WriteFile(filepath.Join(provider.cacheDir, "ab", "test2.mp3"), data2, 0644) + + // Get stats + count, size, err = provider.GetCacheStats() + if err != nil { + t.Errorf("GetCacheStats() error = %v", err) + } + if count != 2 { + t.Errorf("Expected 2 files, got %d", count) + } + expectedSize := int64(len(data1) + len(data2)) + if size != expectedSize { + t.Errorf("Expected size %d, got %d", expectedSize, size) + } + + // Test with cache disabled + provider.enableCache = false + count, size, err = provider.GetCacheStats() + if err != nil { + t.Errorf("GetCacheStats() with cache disabled error = %v", err) + } + if count != 0 || size != 0 { + t.Errorf("Expected zero stats with cache disabled, got count=%d, size=%d", count, size) + } +} + +func TestGenerateAudioValidation(t *testing.T) { + provider := &OpenAIProvider{ + config: &Config{ + OpenAIKey: "test-key", + }, + } + + ctx := context.Background() + + // Test with non-Bulgarian text + err := provider.GenerateAudio(ctx, "hello", "output.mp3") + if err == nil { + t.Error("Expected error for non-Bulgarian text") + } + if !strings.Contains(err.Error(), "must contain Cyrillic characters") { + t.Errorf("Expected Bulgarian validation error, got: %v", err) + } + + // Test with empty text + err = provider.GenerateAudio(ctx, "", "output.mp3") + if err == nil { + t.Error("Expected error for empty text") + } +} diff --git a/internal/audio/provider_test.go b/internal/audio/provider_test.go new file mode 100644 index 0000000..7fda932 --- /dev/null +++ b/internal/audio/provider_test.go @@ -0,0 +1,196 @@ +package audio + +import ( + "context" + "errors" + "testing" +) + +// mockProvider implements Provider interface for testing +type mockProvider struct { + name string + generateErr error + availableErr error + generateCalls int +} + +func (m *mockProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error { + m.generateCalls++ + return m.generateErr +} + +func (m *mockProvider) Name() string { + return m.name +} + +func (m *mockProvider) IsAvailable() error { + return m.availableErr +} + +func TestDefaultProviderConfig(t *testing.T) { + config := DefaultProviderConfig() + + if config.Provider != "openai" { + t.Errorf("Expected provider 'openai', got '%s'", config.Provider) + } + + if config.OutputFormat != "mp3" { + t.Errorf("Expected output format 'mp3', got '%s'", config.OutputFormat) + } + + if config.OpenAIModel != "gpt-4o-mini-tts" { + t.Errorf("Expected OpenAI model 'gpt-4o-mini-tts', got '%s'", config.OpenAIModel) + } + + if config.OpenAIVoice != "alloy" { + t.Errorf("Expected OpenAI voice 'alloy', got '%s'", config.OpenAIVoice) + } + + if config.OpenAISpeed != 1.0 { + t.Errorf("Expected OpenAI speed 1.0, got %f", config.OpenAISpeed) + } + + if !config.EnableCache { + t.Error("Expected cache to be enabled by default") + } + + if config.CacheDir != "./.audio_cache" { + t.Errorf("Expected cache dir './.audio_cache', got '%s'", config.CacheDir) + } +} + +func TestNewProvider(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + errMsg string + }{ + { + name: "nil config uses defaults", + config: nil, + wantErr: true, + errMsg: "OpenAI API key is required", + }, + { + name: "openai provider without key", + config: &Config{ + Provider: "openai", + }, + wantErr: true, + errMsg: "OpenAI API key is required", + }, + { + name: "unknown provider", + config: &Config{ + Provider: "unknown", + }, + wantErr: true, + errMsg: "unknown audio provider: unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewProvider(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewProvider() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && err != nil && err.Error() != tt.errMsg { + t.Errorf("NewProvider() error = %v, want %v", err.Error(), tt.errMsg) + } + }) + } +} + +func TestProviderWithFallback(t *testing.T) { + primary := &mockProvider{name: "primary"} + fallback := &mockProvider{name: "fallback"} + + provider := NewProviderWithFallback(primary, fallback) + + // Test successful primary + ctx := context.Background() + err := provider.GenerateAudio(ctx, "test", "output.mp3") + if err != nil { + t.Errorf("GenerateAudio() unexpected error: %v", err) + } + if primary.generateCalls != 1 { + t.Errorf("Expected 1 primary call, got %d", primary.generateCalls) + } + if fallback.generateCalls != 0 { + t.Errorf("Expected 0 fallback calls, got %d", fallback.generateCalls) + } + + // Test primary failure, fallback success + primary.generateErr = errors.New("primary failed") + primary.generateCalls = 0 + + err = provider.GenerateAudio(ctx, "test", "output.mp3") + if err != nil { + t.Errorf("GenerateAudio() unexpected error: %v", err) + } + if primary.generateCalls != 1 { + t.Errorf("Expected 1 primary call, got %d", primary.generateCalls) + } + if fallback.generateCalls != 1 { + t.Errorf("Expected 1 fallback call, got %d", fallback.generateCalls) + } + + // Test both fail + fallback.generateErr = errors.New("fallback failed") + primary.generateCalls = 0 + fallback.generateCalls = 0 + + err = provider.GenerateAudio(ctx, "test", "output.mp3") + if err == nil { + t.Error("GenerateAudio() expected error when both providers fail") + } +} + +func TestProviderWithFallbackName(t *testing.T) { + primary := &mockProvider{name: "primary"} + fallback := &mockProvider{name: "fallback"} + + provider := NewProviderWithFallback(primary, fallback) + + expected := "primary (fallback: fallback)" + if provider.Name() != expected { + t.Errorf("Name() = %v, want %v", provider.Name(), expected) + } +} + +func TestProviderWithFallbackIsAvailable(t *testing.T) { + primary := &mockProvider{name: "primary"} + fallback := &mockProvider{name: "fallback"} + + provider := NewProviderWithFallback(primary, fallback) + + // Both available + err := provider.IsAvailable() + if err != nil { + t.Errorf("IsAvailable() unexpected error: %v", err) + } + + // Primary unavailable, fallback available + primary.availableErr = errors.New("primary unavailable") + err = provider.IsAvailable() + if err != nil { + t.Errorf("IsAvailable() unexpected error when fallback available: %v", err) + } + + // Primary available, fallback unavailable + primary.availableErr = nil + fallback.availableErr = errors.New("fallback unavailable") + err = provider.IsAvailable() + if err != nil { + t.Errorf("IsAvailable() unexpected error when primary available: %v", err) + } + + // Both unavailable + primary.availableErr = errors.New("primary unavailable") + err = provider.IsAvailable() + if err == nil { + t.Error("IsAvailable() expected error when both providers unavailable") + } +} diff --git a/internal/audio/validate_test.go b/internal/audio/validate_test.go new file mode 100644 index 0000000..7bc3c9e --- /dev/null +++ b/internal/audio/validate_test.go @@ -0,0 +1,64 @@ +package audio + +import ( + "strings" + "testing" +) + +func TestValidateBulgarianText(t *testing.T) { + tests := []struct { + name string + text string + wantErr bool + errMsg string + }{ + { + name: "valid Bulgarian word", + text: "ΡΠ±ΡΠ»ΠΊΠ°", + wantErr: false, + }, + { + name: "valid Bulgarian sentence", + text: "ΠΠ΄ΡΠ°Π²Π΅ΠΉ, ΠΊΠ°ΠΊ ΡΠΈ?", + wantErr: false, + }, + { + name: "empty text", + text: "", + wantErr: true, + errMsg: "text cannot be empty", + }, + { + name: "whitespace only", + text: " \t\n", + wantErr: true, + errMsg: "text cannot be empty", + }, + { + name: "English text", + text: "Hello world", + wantErr: true, + errMsg: "text must contain Cyrillic characters", + }, + { + name: "numbers only", + text: "12345", + wantErr: true, + errMsg: "text must contain Cyrillic characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateBulgarianText(tt.text) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateBulgarianText() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && err != nil { + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateBulgarianText() error = %v, want error containing %v", err.Error(), tt.errMsg) + } + } + }) + } +} |
