summaryrefslogtreecommitdiff
path: root/internal/testutil
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/testutil
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/testutil')
-rw-r--r--internal/testutil/helpers.go218
-rw-r--r--internal/testutil/mocks.go187
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}
+}