From 9e3328a6aaefe4bd1aa0ec3e8bf6e93d6033180b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 20 Jul 2025 21:20:40 +0300 Subject: test: add comprehensive test suite for audio and anki packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/audio/openai_provider_test.go | 349 +++++++++++++++++++++++++++++++++ internal/audio/provider_test.go | 196 ++++++++++++++++++ internal/audio/validate_test.go | 64 ++++++ 3 files changed, 609 insertions(+) create mode 100644 internal/audio/openai_provider_test.go create mode 100644 internal/audio/provider_test.go create mode 100644 internal/audio/validate_test.go (limited to 'internal/audio') 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) + } + } + }) + } +} -- cgit v1.2.3