summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-20 23:10:50 +0300
committerPaul Buetow <paul@buetow.org>2025-07-20 23:10:50 +0300
commit9c12e879c5d6833ce50f5b6d646ccce03a78db31 (patch)
tree206906b551d595b35d00586b6cc5bf9e1f3fe7f8 /internal
parente580fb57a29ec3c3f3e180b20cfa6ec28687689b (diff)
test: add comprehensive test coverage for refactored packages
Add test suites for all newly created packages from the main.go refactoring: - batch: 100% coverage - file reading, parsing, edge cases - cli: 96.7% coverage - command setup, flags, configuration - translation: 92% coverage - API integration, caching, errors - phonetic: 87.5% coverage - API fetching, file operations - models: 77.3% coverage - model listing functionality - processor: 18% coverage - basic tests (limited by API dependencies) Total: 1159 lines of test code across 7 new test files πŸ€– Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <noreply@opencode.ai>
Diffstat (limited to 'internal')
-rw-r--r--internal/batch/processor_test.go247
-rw-r--r--internal/cli/command_test.go260
-rw-r--r--internal/cli/flags_test.go99
-rw-r--r--internal/models/lister_test.go53
-rw-r--r--internal/phonetic/fetcher_test.go91
-rw-r--r--internal/processor/processor_test.go253
-rw-r--r--internal/translation/translator_test.go156
7 files changed, 1159 insertions, 0 deletions
diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go
new file mode 100644
index 0000000..1df8284
--- /dev/null
+++ b/internal/batch/processor_test.go
@@ -0,0 +1,247 @@
+package batch
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestReadBatchFile(t *testing.T) {
+ tests := []struct {
+ name string
+ fileContent string
+ want []WordEntry
+ wantErr bool
+ }{
+ {
+ name: "empty file",
+ fileContent: "",
+ want: nil,
+ },
+ {
+ name: "only whitespace",
+ fileContent: " \n\t\r\n ",
+ want: nil,
+ },
+ {
+ name: "words with translations",
+ fileContent: `ябълка = apple
+ΠΊΠΎΡ‚ΠΊΠ° = cat
+ΠΊΡƒΡ‡Π΅ = dog`,
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: "apple"},
+ {Bulgarian: "ΠΊΠΎΡ‚ΠΊΠ°", Translation: "cat"},
+ {Bulgarian: "ΠΊΡƒΡ‡Π΅", Translation: "dog"},
+ },
+ },
+ {
+ name: "mixed format",
+ fileContent: `ябълка
+ΠΊΠΎΡ‚ΠΊΠ° = cat
+ΠΊΡƒΡ‡Π΅
+хляб = bread`,
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: ""},
+ {Bulgarian: "ΠΊΠΎΡ‚ΠΊΠ°", Translation: "cat"},
+ {Bulgarian: "ΠΊΡƒΡ‡Π΅", Translation: ""},
+ {Bulgarian: "хляб", Translation: "bread"},
+ },
+ },
+ {
+ name: "empty lines and whitespace",
+ fileContent: `
+ябълка
+
+ΠΊΠΎΡ‚ΠΊΠ° = cat
+
+ ΠΊΡƒΡ‡Π΅
+
+`,
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: ""},
+ {Bulgarian: "ΠΊΠΎΡ‚ΠΊΠ°", Translation: "cat"},
+ {Bulgarian: "ΠΊΡƒΡ‡Π΅", Translation: ""},
+ },
+ },
+ {
+ name: "windows line endings",
+ fileContent: "ябълка\r\nΠΊΠΎΡ‚ΠΊΠ° = cat\r\nΠΊΡƒΡ‡Π΅",
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: ""},
+ {Bulgarian: "ΠΊΠΎΡ‚ΠΊΠ°", Translation: "cat"},
+ {Bulgarian: "ΠΊΡƒΡ‡Π΅", Translation: ""},
+ },
+ },
+ {
+ name: "multiple equals signs",
+ fileContent: `test = word = with = equals`,
+ want: []WordEntry{
+ {Bulgarian: "test", Translation: "word = with = equals"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temp file
+ tmpDir := t.TempDir()
+ tmpFile := filepath.Join(tmpDir, "test.txt")
+ err := os.WriteFile(tmpFile, []byte(tt.fileContent), 0644)
+ if err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ got, err := ReadBatchFile(tmpFile)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ReadBatchFile() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("ReadBatchFile() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestReadBatchFile_FileNotFound(t *testing.T) {
+ _, err := ReadBatchFile("/nonexistent/file.txt")
+ if err == nil {
+ t.Error("Expected error for non-existent file")
+ }
+}
+
+func TestSplitLines(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want []string
+ }{
+ {
+ name: "unix line endings",
+ input: "line1\nline2\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "windows line endings",
+ input: "line1\r\nline2\r\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "mixed line endings",
+ input: "line1\nline2\r\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "empty string",
+ input: "",
+ want: nil,
+ },
+ {
+ name: "single line no ending",
+ input: "single line",
+ want: []string{"single line"},
+ },
+ {
+ name: "trailing newline",
+ input: "line1\nline2\n",
+ want: []string{"line1", "line2"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := splitLines(tt.input)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("splitLines() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTrimSpace(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "no whitespace",
+ input: "hello",
+ want: "hello",
+ },
+ {
+ name: "leading spaces",
+ input: " hello",
+ want: "hello",
+ },
+ {
+ name: "trailing spaces",
+ input: "hello ",
+ want: "hello",
+ },
+ {
+ name: "both sides",
+ input: " hello ",
+ want: "hello",
+ },
+ {
+ name: "tabs and spaces",
+ input: "\t hello \t",
+ want: "hello",
+ },
+ {
+ name: "newlines",
+ input: "\nhello\n",
+ want: "hello",
+ },
+ {
+ name: "all whitespace types",
+ input: " \t\n\rhello \t\n\r",
+ want: "hello",
+ },
+ {
+ name: "empty string",
+ input: "",
+ want: "",
+ },
+ {
+ name: "only whitespace",
+ input: " \t\n\r ",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := trimSpace(tt.input)
+ if got != tt.want {
+ t.Errorf("trimSpace() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsSpace(t *testing.T) {
+ tests := []struct {
+ r rune
+ want bool
+ }{
+ {' ', true},
+ {'\t', true},
+ {'\n', true},
+ {'\r', true},
+ {'a', false},
+ {'1', false},
+ {'!', false},
+ {0, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.r), func(t *testing.T) {
+ if got := isSpace(tt.r); got != tt.want {
+ t.Errorf("isSpace(%q) = %v, want %v", tt.r, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go
new file mode 100644
index 0000000..55e60e8
--- /dev/null
+++ b/internal/cli/command_test.go
@@ -0,0 +1,260 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "github.com/spf13/viper"
+)
+
+func TestCreateRootCommand(t *testing.T) {
+ flags := NewFlags()
+ cmd := CreateRootCommand(flags)
+
+ // Test basic command properties
+ if cmd.Use != "totalrecall [word]" {
+ t.Errorf("Expected Use to be 'totalrecall [word]', got %s", cmd.Use)
+ }
+
+ if !strings.Contains(cmd.Short, "Bulgarian Anki Flashcard Generator") {
+ t.Errorf("Expected Short description to contain 'Bulgarian Anki Flashcard Generator'")
+ }
+
+ // Test that flags are set up
+ flagTests := []struct {
+ name string
+ expected bool
+ }{
+ {"config", true},
+ {"output", true},
+ {"format", true},
+ {"image-api", true},
+ {"batch", true},
+ {"skip-audio", true},
+ {"skip-images", true},
+ {"anki", true},
+ {"anki-csv", true},
+ {"deck-name", true},
+ {"list-models", true},
+ {"all-voices", true},
+ {"gui", true},
+ {"openai-model", true},
+ {"openai-voice", true},
+ {"openai-speed", true},
+ {"openai-instruction", true},
+ {"openai-image-model", true},
+ {"openai-image-size", true},
+ {"openai-image-quality", true},
+ {"openai-image-style", true},
+ }
+
+ for _, tt := range flagTests {
+ t.Run("flag_"+tt.name, func(t *testing.T) {
+ var flag *pflag.Flag
+ if tt.name == "config" {
+ flag = cmd.PersistentFlags().Lookup(tt.name)
+ } else {
+ flag = cmd.Flags().Lookup(tt.name)
+ }
+ if flag == nil && tt.expected {
+ t.Errorf("Expected flag %s to exist", tt.name)
+ }
+ })
+ }
+}
+
+func TestSetupFlags(t *testing.T) {
+ cmd := &cobra.Command{}
+ flags := NewFlags()
+
+ setupFlags(cmd, flags)
+
+ // Test default values
+ outputFlag := cmd.Flags().Lookup("output")
+ if outputFlag == nil {
+ t.Fatal("output flag not found")
+ }
+
+ home, _ := os.UserHomeDir()
+ expectedDefault := filepath.Join(home, "Downloads")
+ if outputFlag.DefValue != expectedDefault {
+ t.Errorf("Expected default output dir to be %s, got %s", expectedDefault, outputFlag.DefValue)
+ }
+
+ // Test audio format default
+ formatFlag := cmd.Flags().Lookup("format")
+ if formatFlag == nil {
+ t.Fatal("format flag not found")
+ }
+ if formatFlag.DefValue != "mp3" {
+ t.Errorf("Expected default format to be mp3, got %s", formatFlag.DefValue)
+ }
+}
+
+func TestInitConfig(t *testing.T) {
+ // Save original viper state
+ originalConfig := viper.New()
+ *originalConfig = *viper.GetViper()
+ defer func() {
+ *viper.GetViper() = *originalConfig
+ }()
+
+ tests := []struct {
+ name string
+ cfgFile string
+ setupFunc func(t *testing.T) string
+ cleanupFunc func(string)
+ }{
+ {
+ name: "with config file",
+ cfgFile: "test-config.yaml",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ cfgPath := filepath.Join(tmpDir, "test-config.yaml")
+ content := `audio:
+ provider: openai
+ openai_key: test-key
+output:
+ directory: /test/output`
+ err := os.WriteFile(cfgPath, []byte(content), 0644)
+ if err != nil {
+ t.Fatalf("Failed to create test config: %v", err)
+ }
+ return cfgPath
+ },
+ cleanupFunc: func(path string) {},
+ },
+ {
+ name: "without config file",
+ cfgFile: "",
+ setupFunc: func(t *testing.T) string {
+ return ""
+ },
+ cleanupFunc: func(path string) {},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Reset viper for each test
+ viper.Reset()
+
+ cfgPath := tt.setupFunc(t)
+ if tt.cfgFile != "" && cfgPath != "" {
+ tt.cfgFile = cfgPath
+ }
+
+ InitConfig(tt.cfgFile)
+
+ // Test environment variable prefix
+ os.Setenv("TOTALRECALL_TEST_VAR", "test-value")
+ defer os.Unsetenv("TOTALRECALL_TEST_VAR")
+
+ if viper.GetString("test_var") != "test-value" {
+ t.Error("Environment variable not properly loaded")
+ }
+
+ tt.cleanupFunc(cfgPath)
+ })
+ }
+}
+
+func TestGetOpenAIKey(t *testing.T) {
+ // Save original viper state
+ originalConfig := viper.New()
+ *originalConfig = *viper.GetViper()
+ defer func() {
+ *viper.GetViper() = *originalConfig
+ }()
+
+ tests := []struct {
+ name string
+ envKey string
+ configKey string
+ expected string
+ }{
+ {
+ name: "from environment",
+ envKey: "env-test-key",
+ configKey: "config-test-key",
+ expected: "env-test-key",
+ },
+ {
+ name: "from config when no env",
+ envKey: "",
+ configKey: "config-test-key",
+ expected: "config-test-key",
+ },
+ {
+ name: "empty when neither set",
+ envKey: "",
+ configKey: "",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Reset viper
+ viper.Reset()
+
+ // Set up environment
+ if tt.envKey != "" {
+ os.Setenv("OPENAI_API_KEY", tt.envKey)
+ defer os.Unsetenv("OPENAI_API_KEY")
+ } else {
+ os.Unsetenv("OPENAI_API_KEY")
+ }
+
+ // Set up config
+ if tt.configKey != "" {
+ viper.Set("audio.openai_key", tt.configKey)
+ }
+
+ got := GetOpenAIKey()
+ if got != tt.expected {
+ t.Errorf("GetOpenAIKey() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestBindFlagsToViper(t *testing.T) {
+ // Save original viper state
+ originalConfig := viper.New()
+ *originalConfig = *viper.GetViper()
+ defer func() {
+ *viper.GetViper() = *originalConfig
+ }()
+
+ // Reset viper
+ viper.Reset()
+
+ cmd := &cobra.Command{}
+ flags := NewFlags()
+ setupFlags(cmd, flags)
+
+ // Set some flag values
+ cmd.Flags().Set("output", "/test/output")
+ cmd.Flags().Set("format", "wav")
+ cmd.Flags().Set("openai-model", "tts-1-hd")
+
+ bindFlagsToViper(cmd)
+
+ // Test that values are bound
+ if viper.GetString("output.directory") != "/test/output" {
+ t.Errorf("Expected output.directory to be /test/output, got %s", viper.GetString("output.directory"))
+ }
+
+ if viper.GetString("audio.format") != "wav" {
+ t.Errorf("Expected audio.format to be wav, got %s", viper.GetString("audio.format"))
+ }
+
+ if viper.GetString("audio.openai_model") != "tts-1-hd" {
+ t.Errorf("Expected audio.openai_model to be tts-1-hd, got %s", viper.GetString("audio.openai_model"))
+ }
+}
diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go
new file mode 100644
index 0000000..d308bb7
--- /dev/null
+++ b/internal/cli/flags_test.go
@@ -0,0 +1,99 @@
+package cli
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestNewFlags(t *testing.T) {
+ flags := NewFlags()
+
+ // Test default values
+ tests := []struct {
+ name string
+ got interface{}
+ expected interface{}
+ }{
+ {"AudioFormat", flags.AudioFormat, "mp3"},
+ {"ImageAPI", flags.ImageAPI, "openai"},
+ {"DeckName", flags.DeckName, "Bulgarian Vocabulary"},
+ {"OpenAIModel", flags.OpenAIModel, "gpt-4o-mini-tts"},
+ {"OpenAISpeed", flags.OpenAISpeed, 0.9},
+ {"OpenAIImageModel", flags.OpenAIImageModel, "dall-e-3"},
+ {"OpenAIImageSize", flags.OpenAIImageSize, "1024x1024"},
+ {"OpenAIImageQuality", flags.OpenAIImageQuality, "standard"},
+ {"OpenAIImageStyle", flags.OpenAIImageStyle, "natural"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if !reflect.DeepEqual(tt.got, tt.expected) {
+ t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
+ }
+ })
+ }
+
+ // Test boolean defaults (should be false)
+ boolTests := []struct {
+ name string
+ value bool
+ }{
+ {"SkipAudio", flags.SkipAudio},
+ {"SkipImages", flags.SkipImages},
+ {"GenerateAnki", flags.GenerateAnki},
+ {"AnkiCSV", flags.AnkiCSV},
+ {"ListModels", flags.ListModels},
+ {"AllVoices", flags.AllVoices},
+ {"GUIMode", flags.GUIMode},
+ }
+
+ for _, tt := range boolTests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.value != false {
+ t.Errorf("%s = %v, want false", tt.name, tt.value)
+ }
+ })
+ }
+
+ // Test string defaults (should be empty)
+ stringTests := []struct {
+ name string
+ value string
+ }{
+ {"CfgFile", flags.CfgFile},
+ {"OutputDir", flags.OutputDir},
+ {"BatchFile", flags.BatchFile},
+ {"OpenAIVoice", flags.OpenAIVoice},
+ {"OpenAIInstruction", flags.OpenAIInstruction},
+ }
+
+ for _, tt := range stringTests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.value != "" {
+ t.Errorf("%s = %v, want empty string", tt.name, tt.value)
+ }
+ })
+ }
+}
+
+func TestFlagsStructure(t *testing.T) {
+ // Test that Flags struct has all expected fields
+ flags := &Flags{}
+ flagsType := reflect.TypeOf(*flags)
+
+ expectedFields := []string{
+ "CfgFile", "OutputDir", "AudioFormat", "ImageAPI", "BatchFile",
+ "SkipAudio", "SkipImages", "GenerateAnki", "AnkiCSV", "DeckName",
+ "ListModels", "AllVoices", "GUIMode",
+ "OpenAIModel", "OpenAIVoice", "OpenAISpeed", "OpenAIInstruction",
+ "OpenAIImageModel", "OpenAIImageSize", "OpenAIImageQuality", "OpenAIImageStyle",
+ }
+
+ for _, fieldName := range expectedFields {
+ t.Run("has_field_"+fieldName, func(t *testing.T) {
+ if _, ok := flagsType.FieldByName(fieldName); !ok {
+ t.Errorf("Flags struct missing field: %s", fieldName)
+ }
+ })
+ }
+}
diff --git a/internal/models/lister_test.go b/internal/models/lister_test.go
new file mode 100644
index 0000000..b125981
--- /dev/null
+++ b/internal/models/lister_test.go
@@ -0,0 +1,53 @@
+package models
+
+import (
+ "os"
+ "testing"
+)
+
+func TestNewLister(t *testing.T) {
+ lister := NewLister("test-api-key")
+
+ if lister == nil {
+ t.Fatal("NewLister returned nil")
+ }
+
+ if lister.apiKey != "test-api-key" {
+ t.Errorf("Expected API key 'test-api-key', got '%s'", lister.apiKey)
+ }
+
+ if lister.client == nil {
+ t.Error("OpenAI client not initialized")
+ }
+}
+
+func TestListAvailableModels_NoAPIKey(t *testing.T) {
+ lister := NewLister("")
+
+ err := lister.ListAvailableModels()
+ if err == nil {
+ t.Error("Expected error for missing API key")
+ }
+
+ expectedError := "OpenAI API key not found. Set OPENAI_API_KEY environment variable or configure in .totalrecall.yaml"
+ if err.Error() != expectedError {
+ t.Errorf("Expected error '%s', got: %v", expectedError, err)
+ }
+}
+
+func TestListAvailableModels_Integration(t *testing.T) {
+ // Skip if no API key
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ t.Skip("Skipping integration test: OPENAI_API_KEY not set")
+ }
+
+ lister := NewLister(apiKey)
+
+ // This test just verifies the method runs without error
+ // The actual output goes to stdout which we don't capture in tests
+ err := lister.ListAvailableModels()
+ if err != nil {
+ t.Errorf("ListAvailableModels failed: %v", err)
+ }
+}
diff --git a/internal/phonetic/fetcher_test.go b/internal/phonetic/fetcher_test.go
new file mode 100644
index 0000000..0c9916b
--- /dev/null
+++ b/internal/phonetic/fetcher_test.go
@@ -0,0 +1,91 @@
+package phonetic
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestNewFetcher(t *testing.T) {
+ fetcher := NewFetcher("test-api-key")
+
+ if fetcher == nil {
+ t.Fatal("NewFetcher returned nil")
+ }
+
+ if fetcher.apiKey != "test-api-key" {
+ t.Errorf("Expected API key 'test-api-key', got '%s'", fetcher.apiKey)
+ }
+
+ if fetcher.client == nil {
+ t.Error("OpenAI client not initialized")
+ }
+}
+
+func TestFetchAndSave_NoAPIKey(t *testing.T) {
+ fetcher := NewFetcher("")
+ tmpDir := t.TempDir()
+
+ err := fetcher.FetchAndSave("ябълка", tmpDir)
+ if err == nil {
+ t.Error("Expected error for missing API key")
+ }
+
+ if err.Error() != "OpenAI API key not configured" {
+ t.Errorf("Expected 'OpenAI API key not configured' error, got: %v", err)
+ }
+}
+
+func TestFetchAndSave_Integration(t *testing.T) {
+ // Skip if no API key
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ t.Skip("Skipping integration test: OPENAI_API_KEY not set")
+ }
+
+ fetcher := NewFetcher(apiKey)
+ tmpDir := t.TempDir()
+
+ // Test with a simple word
+ err := fetcher.FetchAndSave("ябълка", tmpDir)
+ if err != nil {
+ t.Errorf("FetchAndSave failed: %v", err)
+ }
+
+ // Check file was created
+ phoneticFile := filepath.Join(tmpDir, "phonetic.txt")
+ content, err := os.ReadFile(phoneticFile)
+ if err != nil {
+ t.Errorf("Failed to read phonetic file: %v", err)
+ }
+
+ // Check content is reasonable
+ if len(content) < 50 {
+ t.Error("Phonetic content seems too short")
+ }
+
+ // Should contain IPA symbols or phonetic information
+ contentStr := string(content)
+ if !strings.Contains(contentStr, "/") && !strings.Contains(contentStr, "[") {
+ t.Error("Content doesn't appear to contain IPA transcription")
+ }
+
+ t.Logf("Phonetic info for 'ябълка':\n%s", contentStr)
+}
+
+func TestFetchAndSave_InvalidDirectory(t *testing.T) {
+ // Skip if no API key
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ t.Skip("Skipping test: OPENAI_API_KEY not set")
+ }
+
+ fetcher := NewFetcher(apiKey)
+
+ // Try to save to a non-existent directory
+ err := fetcher.FetchAndSave("ябълка", "/nonexistent/path")
+ if err == nil {
+ t.Error("Expected error for invalid directory")
+ }
+}
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
new file mode 100644
index 0000000..3a90a05
--- /dev/null
+++ b/internal/processor/processor_test.go
@@ -0,0 +1,253 @@
+package processor
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/totalrecall/internal/cli"
+)
+
+func TestNewProcessor(t *testing.T) {
+ // Set up test environment
+ os.Setenv("OPENAI_API_KEY", "test-key")
+ defer os.Unsetenv("OPENAI_API_KEY")
+
+ flags := cli.NewFlags()
+ p := NewProcessor(flags)
+
+ if p == nil {
+ t.Fatal("NewProcessor returned nil")
+ }
+
+ if p.flags != flags {
+ t.Error("Processor flags not set correctly")
+ }
+
+ if p.translator == nil {
+ t.Error("Translator not initialized")
+ }
+
+ if p.translationCache == nil {
+ t.Error("Translation cache not initialized")
+ }
+
+ if p.phoneticFetcher == nil {
+ t.Error("Phonetic fetcher not initialized")
+ }
+}
+
+func TestProcessSingleWord_InvalidWord(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ p := NewProcessor(flags)
+
+ // Test with non-Bulgarian text
+ err := p.ProcessSingleWord("hello")
+ if err == nil {
+ t.Error("Expected error for non-Bulgarian word")
+ }
+
+ // Test with empty string
+ err = p.ProcessSingleWord("")
+ if err == nil {
+ t.Error("Expected error for empty word")
+ }
+}
+
+func TestProcessSingleWord_ValidWord(t *testing.T) {
+ // Skip if no API key
+ if os.Getenv("OPENAI_API_KEY") == "" {
+ t.Skip("Skipping test: OPENAI_API_KEY not set")
+ }
+
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.SkipAudio = true
+ flags.SkipImages = true
+ p := NewProcessor(flags)
+
+ err := p.ProcessSingleWord("ябълка")
+ if err != nil {
+ t.Errorf("ProcessSingleWord failed: %v", err)
+ }
+
+ // Check that output directory was created
+ if _, err := os.Stat(flags.OutputDir); os.IsNotExist(err) {
+ t.Error("Output directory was not created")
+ }
+}
+
+func TestProcessBatch_InvalidFile(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.BatchFile = "/nonexistent/file.txt"
+ p := NewProcessor(flags)
+
+ err := p.ProcessBatch()
+ if err == nil {
+ t.Error("Expected error for non-existent batch file")
+ }
+}
+
+func TestProcessBatch_ValidFile(t *testing.T) {
+ // Create test batch file
+ tmpDir := t.TempDir()
+ batchFile := filepath.Join(tmpDir, "batch.txt")
+ content := `ябълка
+ΠΊΠΎΡ‚ΠΊΠ° = cat
+ΠΊΡƒΡ‡Π΅`
+ err := os.WriteFile(batchFile, []byte(content), 0644)
+ if err != nil {
+ t.Fatalf("Failed to create test batch file: %v", err)
+ }
+
+ flags := cli.NewFlags()
+ flags.OutputDir = tmpDir
+ flags.BatchFile = batchFile
+ flags.SkipAudio = true
+ flags.SkipImages = true
+ p := NewProcessor(flags)
+
+ // Skip if no API key
+ if os.Getenv("OPENAI_API_KEY") == "" {
+ t.Skip("Skipping test: OPENAI_API_KEY not set")
+ }
+
+ err = p.ProcessBatch()
+ if err != nil {
+ t.Errorf("ProcessBatch failed: %v", err)
+ }
+}
+
+func TestProcessWordWithTranslation_ProvidedTranslation(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.SkipAudio = true
+ flags.SkipImages = true
+ p := NewProcessor(flags)
+
+ // Skip if no API key (needed for phonetic fetcher)
+ if os.Getenv("OPENAI_API_KEY") == "" {
+ t.Skip("Skipping test: OPENAI_API_KEY not set")
+ }
+
+ err := p.ProcessWordWithTranslation("ябълка", "apple")
+ if err != nil {
+ t.Errorf("ProcessWordWithTranslation failed: %v", err)
+ }
+
+ // Check that translation was cached
+ cached, found := p.translationCache.Get("ябълка")
+ if !found || cached != "apple" {
+ t.Errorf("Expected cached translation 'apple', got '%s' (found: %v)", cached, found)
+ }
+}
+
+func TestFindOrCreateWordDirectory(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ p := NewProcessor(flags)
+
+ // First call should create directory
+ dir1 := p.findOrCreateWordDirectory("тСст")
+ if dir1 == "" {
+ t.Error("findOrCreateWordDirectory returned empty string")
+ }
+
+ // Check directory exists
+ if _, err := os.Stat(dir1); os.IsNotExist(err) {
+ t.Error("Directory was not created")
+ }
+
+ // Check word.txt was created
+ wordFile := filepath.Join(dir1, "word.txt")
+ content, err := os.ReadFile(wordFile)
+ if err != nil {
+ t.Errorf("Failed to read word.txt: %v", err)
+ }
+ if string(content) != "тСст" {
+ t.Errorf("Expected word.txt to contain 'тСст', got '%s'", string(content))
+ }
+
+ // Second call should find existing directory
+ dir2 := p.findOrCreateWordDirectory("тСст")
+ if dir2 != dir1 {
+ t.Errorf("Expected to find existing directory %s, got %s", dir1, dir2)
+ }
+}
+
+func TestFindCardDirectory(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ p := NewProcessor(flags)
+
+ // Test with non-existent word
+ dir := p.findCardDirectory("Π½Π΅ΡΡŠΡ‰Π΅ΡΡ‚Π²ΡƒΠ²Π°Ρ‰Π°")
+ if dir != "" {
+ t.Error("Expected empty string for non-existent word")
+ }
+
+ // Create a word directory
+ wordDir := p.findOrCreateWordDirectory("тСст")
+
+ // Now should find it
+ dir = p.findCardDirectory("тСст")
+ if dir != wordDir {
+ t.Errorf("Expected to find directory %s, got %s", wordDir, dir)
+ }
+}
+
+func TestGenerateAnkiFile(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.GenerateAnki = true
+ flags.AnkiCSV = true // Test CSV format
+ p := NewProcessor(flags)
+
+ // Add some test translations
+ p.translationCache.Add("ябълка", "apple")
+ p.translationCache.Add("ΠΊΠΎΡ‚ΠΊΠ°", "cat")
+
+ err := p.GenerateAnkiFile()
+ if err != nil {
+ t.Errorf("GenerateAnkiFile failed: %v", err)
+ }
+
+ // Check CSV file was created
+ csvFile := filepath.Join(flags.OutputDir, "anki_import.csv")
+ if _, err := os.Stat(csvFile); os.IsNotExist(err) {
+ t.Error("CSV file was not created")
+ }
+}
+
+func TestGenerateAnkiFile_APKG(t *testing.T) {
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.GenerateAnki = true
+ flags.AnkiCSV = false // Test APKG format
+ flags.DeckName = "Test Deck"
+ p := NewProcessor(flags)
+
+ // Create test word directories with files
+ word1Dir := p.findOrCreateWordDirectory("ябълка")
+ word2Dir := p.findOrCreateWordDirectory("ΠΊΠΎΡ‚ΠΊΠ°")
+
+ // Add test translations
+ p.translationCache.Add("ябълка", "apple")
+ p.translationCache.Add("ΠΊΠΎΡ‚ΠΊΠ°", "cat")
+
+ // Create dummy audio files
+ os.WriteFile(filepath.Join(word1Dir, "ябълка.mp3"), []byte("audio1"), 0644)
+ os.WriteFile(filepath.Join(word2Dir, "ΠΊΠΎΡ‚ΠΊΠ°.mp3"), []byte("audio2"), 0644)
+
+ err := p.GenerateAnkiFile()
+ if err != nil {
+ t.Errorf("GenerateAnkiFile (APKG) failed: %v", err)
+ }
+
+ // Check APKG file was created
+ apkgFile := filepath.Join(flags.OutputDir, "Test_Deck.apkg")
+ if _, err := os.Stat(apkgFile); os.IsNotExist(err) {
+ t.Error("APKG file was not created")
+ }
+}
diff --git a/internal/translation/translator_test.go b/internal/translation/translator_test.go
new file mode 100644
index 0000000..3688311
--- /dev/null
+++ b/internal/translation/translator_test.go
@@ -0,0 +1,156 @@
+package translation
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestNewTranslator(t *testing.T) {
+ translator := NewTranslator("test-api-key")
+
+ if translator == nil {
+ t.Fatal("NewTranslator returned nil")
+ }
+
+ if translator.apiKey != "test-api-key" {
+ t.Errorf("Expected API key 'test-api-key', got '%s'", translator.apiKey)
+ }
+
+ if translator.client == nil {
+ t.Error("OpenAI client not initialized")
+ }
+}
+
+func TestTranslateWord_NoAPIKey(t *testing.T) {
+ translator := NewTranslator("")
+
+ _, err := translator.TranslateWord("ябълка")
+ if err == nil {
+ t.Error("Expected error for missing API key")
+ }
+
+ if err.Error() != "OpenAI API key not found" {
+ t.Errorf("Expected 'OpenAI API key not found' error, got: %v", err)
+ }
+}
+
+func TestTranslateWord_Integration(t *testing.T) {
+ // Skip if no API key
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ t.Skip("Skipping integration test: OPENAI_API_KEY not set")
+ }
+
+ translator := NewTranslator(apiKey)
+
+ // Test with a simple word
+ translation, err := translator.TranslateWord("ябълка")
+ if err != nil {
+ t.Errorf("TranslateWord failed: %v", err)
+ }
+
+ // Check that we got a reasonable translation
+ // The exact translation might vary, but it should contain "apple"
+ if translation == "" {
+ t.Error("Got empty translation")
+ }
+
+ t.Logf("Translation of 'ябълка': %s", translation)
+}
+
+func TestSaveTranslation(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := SaveTranslation(tmpDir, "ябълка", "apple")
+ if err != nil {
+ t.Errorf("SaveTranslation failed: %v", err)
+ }
+
+ // Check file was created
+ translationFile := filepath.Join(tmpDir, "translation.txt")
+ content, err := os.ReadFile(translationFile)
+ if err != nil {
+ t.Errorf("Failed to read translation file: %v", err)
+ }
+
+ expected := "ябълка = apple\n"
+ if string(content) != expected {
+ t.Errorf("Expected content '%s', got '%s'", expected, string(content))
+ }
+}
+
+func TestSaveTranslation_InvalidPath(t *testing.T) {
+ err := SaveTranslation("/nonexistent/path", "ябълка", "apple")
+ if err == nil {
+ t.Error("Expected error for invalid path")
+ }
+}
+
+func TestTranslationCache(t *testing.T) {
+ cache := NewTranslationCache()
+
+ // Test empty cache
+ _, found := cache.Get("ябълка")
+ if found {
+ t.Error("Expected not found in empty cache")
+ }
+
+ // Test adding and retrieving
+ cache.Add("ябълка", "apple")
+ cache.Add("ΠΊΠΎΡ‚ΠΊΠ°", "cat")
+
+ translation, found := cache.Get("ябълка")
+ if !found {
+ t.Error("Expected to find 'ябълка' in cache")
+ }
+ if translation != "apple" {
+ t.Errorf("Expected 'apple', got '%s'", translation)
+ }
+
+ // Test overwriting
+ cache.Add("ябълка", "apple (fruit)")
+ translation, found = cache.Get("ябълка")
+ if !found || translation != "apple (fruit)" {
+ t.Errorf("Expected 'apple (fruit)', got '%s'", translation)
+ }
+}
+
+func TestTranslationCache_GetAll(t *testing.T) {
+ cache := NewTranslationCache()
+
+ // Add some translations
+ cache.Add("ябълка", "apple")
+ cache.Add("ΠΊΠΎΡ‚ΠΊΠ°", "cat")
+ cache.Add("ΠΊΡƒΡ‡Π΅", "dog")
+
+ all := cache.GetAll()
+
+ expected := map[string]string{
+ "ябълка": "apple",
+ "ΠΊΠΎΡ‚ΠΊΠ°": "cat",
+ "ΠΊΡƒΡ‡Π΅": "dog",
+ }
+
+ if !reflect.DeepEqual(all, expected) {
+ t.Errorf("GetAll() = %v, want %v", all, expected)
+ }
+
+ // Test that modifying returned map doesn't affect cache
+ all["ябълка"] = "modified"
+
+ translation, _ := cache.Get("ябълка")
+ if translation != "apple" {
+ t.Error("Cache was modified through returned map")
+ }
+}
+
+func TestTranslationCache_EmptyCache(t *testing.T) {
+ cache := NewTranslationCache()
+
+ all := cache.GetAll()
+ if len(all) != 0 {
+ t.Errorf("Expected empty map, got %v", all)
+ }
+}