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/testutil | |
| 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/testutil')
| -rw-r--r-- | internal/testutil/helpers.go | 218 | ||||
| -rw-r--r-- | internal/testutil/mocks.go | 187 |
2 files changed, 405 insertions, 0 deletions
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} +} |
