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/testutil/helpers.go | 218 +++++++++++++++++++++++++++++++++++++++++++ internal/testutil/mocks.go | 187 +++++++++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 internal/testutil/helpers.go create mode 100644 internal/testutil/mocks.go (limited to 'internal/testutil') diff --git a/internal/testutil/helpers.go b/internal/testutil/helpers.go new file mode 100644 index 0000000..8f82a9e --- /dev/null +++ b/internal/testutil/helpers.go @@ -0,0 +1,218 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" +) + +// CreateTestDirectory creates a temporary directory structure for testing +func CreateTestDirectory(t *testing.T) string { + t.Helper() + + tempDir := t.TempDir() + + // Create common test structure + dirs := []string{ + "audio", + "images", + "output", + "cache", + } + + for _, dir := range dirs { + path := filepath.Join(tempDir, dir) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("Failed to create test directory %s: %v", path, err) + } + } + + return tempDir +} + +// CreateTestFile creates a test file with content +func CreateTestFile(t *testing.T, path string, content []byte) { + t.Helper() + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory for test file: %v", err) + } + + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", path, err) + } +} + +// CreateTestWordDirectory creates a test word directory with standard files +func CreateTestWordDirectory(t *testing.T, baseDir, word string) string { + t.Helper() + + wordDir := filepath.Join(baseDir, word) + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("Failed to create word directory: %v", err) + } + + // Create standard files + files := map[string]string{ + "word.txt": word, + "translation.txt": word + " = test translation", + "phonetic.txt": "test phonetic info", + } + + for filename, content := range files { + path := filepath.Join(wordDir, filename) + CreateTestFile(t, path, []byte(content)) + } + + // Create mock audio file + audioPath := filepath.Join(wordDir, "audio.mp3") + CreateTestFile(t, audioPath, []byte{0xFF, 0xFB, 0x90, 0x00}) + + // Create mock image file + imagePath := filepath.Join(wordDir, "image.jpg") + CreateTestFile(t, imagePath, []byte{0xFF, 0xD8, 0xFF, 0xE0}) + + return wordDir +} + +// AssertFileExists checks if a file exists +func AssertFileExists(t *testing.T, path string) { + t.Helper() + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Expected file to exist: %s", path) + } +} + +// AssertFileNotExists checks if a file does not exist +func AssertFileNotExists(t *testing.T, path string) { + t.Helper() + + if _, err := os.Stat(path); err == nil { + t.Errorf("Expected file to not exist: %s", path) + } +} + +// AssertFileContent checks if a file has expected content +func AssertFileContent(t *testing.T, path string, expected []byte) { + t.Helper() + + actual, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file %s: %v", path, err) + } + + if string(actual) != string(expected) { + t.Errorf("File content mismatch in %s\nExpected: %q\nActual: %q", path, expected, actual) + } +} + +// AssertFileContains checks if a file contains a substring +func AssertFileContains(t *testing.T, path string, substring string) { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file %s: %v", path, err) + } + + if !contains(string(content), substring) { + t.Errorf("File %s does not contain expected substring: %q", path, substring) + } +} + +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr) >= 0)) +} + +// findSubstring finds the index of substr in s, or -1 if not found +func findSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// CompareDirectories compares two directories recursively +func CompareDirectories(t *testing.T, dir1, dir2 string) { + t.Helper() + + err := filepath.Walk(dir1, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(dir1, path) + if err != nil { + return err + } + + // Check if corresponding file exists in dir2 + path2 := filepath.Join(dir2, relPath) + info2, err := os.Stat(path2) + if err != nil { + t.Errorf("File missing in second directory: %s", relPath) + return nil + } + + // Compare file types + if info.IsDir() != info2.IsDir() { + t.Errorf("File type mismatch for %s", relPath) + return nil + } + + // Compare file sizes (for files only) + if !info.IsDir() && info.Size() != info2.Size() { + t.Errorf("File size mismatch for %s: %d vs %d", relPath, info.Size(), info2.Size()) + } + + return nil + }) + + if err != nil { + t.Fatalf("Failed to compare directories: %v", err) + } +} + +// CaptureOutput captures stdout/stderr during test execution +func CaptureOutput(t *testing.T, f func()) (stdout, stderr string) { + t.Helper() + + // Save current stdout/stderr + oldStdout := os.Stdout + oldStderr := os.Stderr + + // Create pipes + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + + // Redirect stdout/stderr + os.Stdout = wOut + os.Stderr = wErr + + // Run function + f() + + // Close writers + wOut.Close() + wErr.Close() + + // Read output + outBytes := make([]byte, 1024) + errBytes := make([]byte, 1024) + + nOut, _ := rOut.Read(outBytes) + nErr, _ := rErr.Read(errBytes) + + // Restore stdout/stderr + os.Stdout = oldStdout + os.Stderr = oldStderr + + return string(outBytes[:nOut]), string(errBytes[:nErr]) +} diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go new file mode 100644 index 0000000..811b840 --- /dev/null +++ b/internal/testutil/mocks.go @@ -0,0 +1,187 @@ +package testutil + +import ( + "context" + "fmt" + "io" + "strings" +) + +// MockHTTPClient mocks HTTP client for testing +type MockHTTPClient struct { + Responses map[string]*MockResponse + Errors map[string]error + Calls []string +} + +// MockResponse represents a mocked HTTP response +type MockResponse struct { + StatusCode int + Body string + Headers map[string]string +} + +// Get mocks an HTTP GET request +func (m *MockHTTPClient) Get(url string) (*MockResponse, error) { + m.Calls = append(m.Calls, fmt.Sprintf("GET %s", url)) + + if err, ok := m.Errors[url]; ok { + return nil, err + } + + if resp, ok := m.Responses[url]; ok { + return resp, nil + } + + return &MockResponse{ + StatusCode: 404, + Body: "Not Found", + }, nil +} + +// Post mocks an HTTP POST request +func (m *MockHTTPClient) Post(url string, body interface{}) (*MockResponse, error) { + m.Calls = append(m.Calls, fmt.Sprintf("POST %s", url)) + + if err, ok := m.Errors[url]; ok { + return nil, err + } + + if resp, ok := m.Responses[url]; ok { + return resp, nil + } + + return &MockResponse{ + StatusCode: 404, + Body: "Not Found", + }, nil +} + +// MockOpenAIClient mocks OpenAI API client +type MockOpenAIClient struct { + TTSResponses map[string][]byte + ImageResponses map[string]string + Errors map[string]error + Calls []string +} + +// CreateSpeech mocks OpenAI TTS API +func (m *MockOpenAIClient) CreateSpeech(ctx context.Context, text, voice, model string) (io.ReadCloser, error) { + call := fmt.Sprintf("TTS: %s (voice=%s, model=%s)", text, voice, model) + m.Calls = append(m.Calls, call) + + key := fmt.Sprintf("%s-%s-%s", text, voice, model) + if err, ok := m.Errors[key]; ok { + return nil, err + } + + if data, ok := m.TTSResponses[key]; ok { + return io.NopCloser(strings.NewReader(string(data))), nil + } + + // Default response + return io.NopCloser(strings.NewReader("mock audio data")), nil +} + +// CreateImage mocks OpenAI DALL-E API +func (m *MockOpenAIClient) CreateImage(ctx context.Context, prompt string) (string, error) { + call := fmt.Sprintf("Image: %s", prompt) + m.Calls = append(m.Calls, call) + + if err, ok := m.Errors[prompt]; ok { + return "", err + } + + if url, ok := m.ImageResponses[prompt]; ok { + return url, nil + } + + // Default response + return "https://example.com/mock-image.jpg", nil +} + +// MockFileSystem mocks file system operations +type MockFileSystem struct { + Files map[string][]byte + Errors map[string]error + Calls []string +} + +// ReadFile mocks reading a file +func (m *MockFileSystem) ReadFile(path string) ([]byte, error) { + m.Calls = append(m.Calls, fmt.Sprintf("READ %s", path)) + + if err, ok := m.Errors[path]; ok { + return nil, err + } + + if data, ok := m.Files[path]; ok { + return data, nil + } + + return nil, fmt.Errorf("file not found: %s", path) +} + +// WriteFile mocks writing a file +func (m *MockFileSystem) WriteFile(path string, data []byte) error { + m.Calls = append(m.Calls, fmt.Sprintf("WRITE %s (%d bytes)", path, len(data))) + + if err, ok := m.Errors[path]; ok { + return err + } + + m.Files[path] = data + return nil +} + +// Exists mocks checking if a file exists +func (m *MockFileSystem) Exists(path string) bool { + m.Calls = append(m.Calls, fmt.Sprintf("EXISTS %s", path)) + _, exists := m.Files[path] + return exists +} + +// MockTranslator mocks translation service +type MockTranslator struct { + Translations map[string]string + Errors map[string]error + Calls []string +} + +// Translate mocks translating text +func (m *MockTranslator) Translate(ctx context.Context, text, fromLang, toLang string) (string, error) { + call := fmt.Sprintf("Translate: %s (%s->%s)", text, fromLang, toLang) + m.Calls = append(m.Calls, call) + + if err, ok := m.Errors[text]; ok { + return "", err + } + + if translation, ok := m.Translations[text]; ok { + return translation, nil + } + + // Default mock translation + return fmt.Sprintf("mock translation of %s", text), nil +} + +// TestDataGenerator generates test data +type TestDataGenerator struct{} + +// GenerateBulgarianWord generates a test Bulgarian word +func (g *TestDataGenerator) GenerateBulgarianWord() string { + words := []string{"ябълка", "ΠΊΠΎΡ‚ΠΊΠ°", "ΠΊΡƒΡ‡Π΅", "хляб", "Π²ΠΎΠ΄Π°", "ΠΊΠ½ΠΈΠ³Π°", "стол", "ΠΏΡ€ΠΎΠ·ΠΎΡ€Π΅Ρ†"} + return words[0] // Simple implementation, could be randomized +} + +// GenerateAudioData generates mock audio data +func (g *TestDataGenerator) GenerateAudioData() []byte { + // Simple mock MP3 header + return []byte{0xFF, 0xFB, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00} +} + +// GenerateImageData generates mock image data +func (g *TestDataGenerator) GenerateImageData() []byte { + // Simple mock JPEG header + return []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46} +} -- cgit v1.2.3