From 9c12e879c5d6833ce50f5b6d646ccce03a78db31 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 20 Jul 2025 23:10:50 +0300 Subject: test: add comprehensive test coverage for refactored packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/batch/processor_test.go | 247 ++++++++++++++++++++++++++++++ internal/cli/command_test.go | 260 ++++++++++++++++++++++++++++++++ internal/cli/flags_test.go | 99 ++++++++++++ internal/models/lister_test.go | 53 +++++++ internal/phonetic/fetcher_test.go | 91 +++++++++++ internal/processor/processor_test.go | 253 +++++++++++++++++++++++++++++++ internal/translation/translator_test.go | 156 +++++++++++++++++++ 7 files changed, 1159 insertions(+) create mode 100644 internal/batch/processor_test.go create mode 100644 internal/cli/command_test.go create mode 100644 internal/cli/flags_test.go create mode 100644 internal/models/lister_test.go create mode 100644 internal/phonetic/fetcher_test.go create mode 100644 internal/processor/processor_test.go create mode 100644 internal/translation/translator_test.go (limited to 'internal') 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) + } +} -- cgit v1.2.3