summaryrefslogtreecommitdiff
path: root/internal/audio
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-20 21:20:40 +0300
committerPaul Buetow <paul@buetow.org>2025-07-20 21:20:40 +0300
commit9e3328a6aaefe4bd1aa0ec3e8bf6e93d6033180b (patch)
treef70a6b53facc81a8bddbe5eeee76708e474e3298 /internal/audio
parent1afd19206720af695625dd46ff0ded0dedeef329 (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.go349
-rw-r--r--internal/audio/provider_test.go196
-rw-r--r--internal/audio/validate_test.go64
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)
+ }
+ }
+ })
+ }
+}