From 63bd86d8046949e80e5b1122a1fcf717f51915c6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 3 Apr 2026 08:03:05 +0300 Subject: tasks 00d, 006, 007: move test seams to struct fields, centralize Viper config, extract shared card-dir logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task 00d: remove package-level var test seams from processor and gui packages; factory functions (newAudioProvider, newOpenAIImageClient, newNanoBananaImageClient) are now struct fields on Processor and Application, initialized with production defaults in constructors and replaced in tests without global mutation. task 006: add viperConfig struct captured once in NewProcessor; no method body calls viper.GetString/IsSet/GetFloat64 directly any more — all config-file values are accessed via p.viperCfg fields. task 007: extract FindCardDirectory and FindOrCreateCardDirectory into internal/utils.go; both Processor.findCardDirectory and Application.findCardDirectory now delegate to the shared implementation, which also handles the legacy _word.txt backward-compat fallback. Co-Authored-By: Claude Sonnet 4.6 --- internal/gui/app.go | 10 ++ internal/gui/generator.go | 16 +-- internal/gui/generator_test.go | 145 ++++++++------------- internal/gui/navigation.go | 38 +----- internal/processor/processor.go | 246 +++++++++++++++++++++-------------- internal/processor/processor_test.go | 222 +++++++++++-------------------- internal/utils.go | 59 +++++++++ 7 files changed, 348 insertions(+), 388 deletions(-) diff --git a/internal/gui/app.go b/internal/gui/app.go index 4f8311e..e5cc9dd 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -105,6 +105,11 @@ type Application struct { // Active operations tracking activeOperations map[string]int // Map of word -> count of active operations activeOpMu sync.Mutex // Mutex for activeOperations map + + // Injectable factory functions — replaced in tests to avoid real API calls. + newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient + newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient + newAudioProvider func(*audio.Config) (audio.Provider, error) } // Config holds GUI application configuration @@ -218,6 +223,11 @@ func New(config *Config) *Application { cardContexts: make(map[string]context.CancelFunc), activeOperations: make(map[string]int), autoPlayEnabled: config.AutoPlay, // Use config setting + + // Production defaults for factory functions; replaced in tests. + newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, + newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) }, + newAudioProvider: audio.NewProvider, } // Initialize the word processing queue diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 4763149..4b55608 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -22,16 +22,6 @@ type promptAwareImageClient interface { SetPromptCallback(func(prompt string)) } -var newOpenAIImageClient = func(config *image.OpenAIConfig) promptAwareImageClient { - return image.NewOpenAIClient(config) -} - -var newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { - return image.NewNanoBananaClient(config) -} - -var newAudioProvider = audio.NewProvider - func randomVoice(voices []string) string { rng := rand.New(rand.NewSource(time.Now().UnixNano())) return voices[rng.Intn(len(voices))] @@ -123,7 +113,7 @@ func (a *Application) audioConfigForGeneration(voice string, speed float64) audi func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error { audioConfig := a.audioConfigForGeneration(voice, speed) - provider, err := newAudioProvider(&audioConfig) + provider, err := a.newAudioProvider(&audioConfig) if err != nil { return err } @@ -410,7 +400,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) { Style: "natural", } - return newOpenAIImageClient(openaiConfig), nil + return a.newOpenAIImageClient(openaiConfig), nil case imageProviderNanoBanana: config := a.config if config == nil { @@ -426,7 +416,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) { TextModel: config.NanoBananaTextModel, } - return newNanoBananaImageClient(nanoBananaConfig), nil + return a.newNanoBananaImageClient(nanoBananaConfig), nil default: return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 4e4e3cc..c8bcf42 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -93,29 +93,9 @@ func (f *fakeAudioProvider) BuildAttribution(params audio.AttributionParams) str } func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { - originalNanoBananaClient := newNanoBananaImageClient - originalOpenAIClient := newOpenAIImageClient - t.Cleanup(func() { - newNanoBananaImageClient = originalNanoBananaClient - newOpenAIImageClient = originalOpenAIClient - }) - fakeClient := &fakePromptAwareImageClient{} var capturedConfig *image.NanoBananaConfig - newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { - capturedConfig = &image.NanoBananaConfig{ - APIKey: config.APIKey, - Model: config.Model, - TextModel: config.TextModel, - } - return fakeClient - } - newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient { - t.Fatal("unexpected OpenAI image client construction") - return nil - } - tempDir := t.TempDir() app := &Application{ config: &Config{ @@ -127,6 +107,18 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { }, currentWord: "друго", } + app.newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { + capturedConfig = &image.NanoBananaConfig{ + APIKey: config.APIKey, + Model: config.Model, + TextModel: config.TextModel, + } + return fakeClient + } + app.newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient { + t.Fatal("unexpected OpenAI image client construction") + return nil + } outputPath, err := app.generateImagesWithPrompt(context.Background(), "ябълка", "custom prompt", "apple", tempDir) if err != nil { @@ -176,11 +168,6 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { } func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -190,11 +177,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -215,6 +197,11 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir) if err != nil { @@ -267,11 +254,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -281,11 +263,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -306,6 +283,11 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { GeminiVoice: "", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir) if err != nil { @@ -367,11 +349,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { } func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -379,17 +356,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { audio.GeminiVoices = []string{"Charon", "Kore", "Leda"} var attemptedVoices []string - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - attemptedVoices = append(attemptedVoices, config.GeminiVoice) - return &fakeAudioProvider{ - generateFunc: func(_ string, _ string) error { - if config.GeminiVoice == "Charon" { - return audio.ErrGeminiNoAudioData - } - return nil - }, - }, nil - } tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "audio.wav") @@ -405,6 +371,17 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { GeminiTTSModel: "gemini-2.5-flash-preview-tts", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + attemptedVoices = append(attemptedVoices, config.GeminiVoice) + return &fakeAudioProvider{ + generateFunc: func(_ string, _ string) error { + if config.GeminiVoice == "Charon" { + return audio.ErrGeminiNoAudioData + } + return nil + }, + }, nil + } voice, err := audio.RunWithVoiceFallbacks("Charon", func(candidate string) error { return app.generateAudioFile(context.Background(), "ябълка", outputPath, candidate, 1.0) @@ -422,11 +399,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { } func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -436,11 +408,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -460,6 +427,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } frontPath, backPath, err := app.generateAudioBgBg(context.Background(), "ябълка", "круша", cardDir) if err != nil { @@ -526,11 +498,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -540,11 +507,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -564,6 +526,11 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudioFront(context.Background(), "ябълка", cardDir) if err != nil { @@ -604,11 +571,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -618,11 +580,6 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -642,6 +599,11 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudioBack(context.Background(), "круша", cardDir) if err != nil { @@ -682,20 +644,12 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioProviderFactoryError(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices }) audio.OpenAIVoices = []string{"sentinel-error-voice"} - newAudioProvider = func(*audio.Config) (audio.Provider, error) { - return nil, errors.New("provider factory failed") - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -715,6 +669,9 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return nil, errors.New("provider factory failed") + } _, err := app.generateAudioFront(context.Background(), "ябълка", cardDir) if err == nil { diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 206da00..c7e9264 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -15,41 +15,11 @@ import ( "codeberg.org/snonux/totalrecall/internal/anki" ) -// findCardDirectory finds the directory for a given Bulgarian word +// findCardDirectory finds the directory for a given Bulgarian word. +// Delegates to the shared internal.FindCardDirectory which also handles the +// legacy _word.txt fallback for backward compatibility. func (a *Application) findCardDirectory(word string) string { - entries, err := os.ReadDir(a.config.OutputDir) - if err != nil { - return "" - } - - // Look through all directories to find one with matching _word.txt - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - - dirPath := filepath.Join(a.config.OutputDir, entry.Name()) - wordFile := filepath.Join(dirPath, "word.txt") - - // Read the word file to check if it matches - if data, err := os.ReadFile(wordFile); err == nil { - storedWord := strings.TrimSpace(string(data)) - if storedWord == word { - return dirPath - } - } else { - // Try old format with underscore for backward compatibility - wordFile = filepath.Join(dirPath, "_word.txt") - if data, err := os.ReadFile(wordFile); err == nil { - storedWord := strings.TrimSpace(string(data)) - if storedWord == word { - return dirPath - } - } - } - } - - return "" + return internal.FindCardDirectory(a.config.OutputDir, word) } // scanExistingWords scans the output directory for existing words diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 41d6858..248bcc2 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -22,37 +22,125 @@ import ( "codeberg.org/snonux/totalrecall/internal/translation" ) -// Processor handles the main word processing logic +// viperConfig holds all Viper-sourced settings captured once in NewProcessor. +// Storing them in a struct avoids repeated global Viper access in method bodies +// and makes the values testable without mutating process-wide Viper state. +type viperConfig struct { + // Translation & phonetic settings + translationProvider string + phoneticProvider string + translationGeminiModel string + + // Audio settings + audioProvider string + audioFormat string + audioFormatSet bool + geminiTTSModel string + geminiVoice string + openAIVoice string + openAIModel string + openAIModelSet bool + openAISpeed float64 + openAISpeedSet bool + openAIInstruction string + openAIInstructionSet bool + + // Image settings + imageProvider string + imageOpenAIModel string + imageOpenAIModelSet bool + imageOpenAISize string + imageOpenAISizeSet bool + imageOpenAIQuality string + imageOpenAIQualitySet bool + imageOpenAIStyle string + imageOpenAIStyleSet bool + imageNanoBananaModel string + imageNanoBananaModelSet bool + imageNanoBananaTextModel string + imageNanoBananaTextModelSet bool +} + +// newViperConfig reads all Viper settings in one pass. Called once from NewProcessor +// so the processor methods never touch the global Viper instance directly. +func newViperConfig() viperConfig { + return viperConfig{ + translationProvider: strings.TrimSpace(viper.GetString("translation.provider")), + phoneticProvider: strings.TrimSpace(viper.GetString("phonetic.provider")), + translationGeminiModel: viper.GetString("translation.gemini_model"), + + audioProvider: strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))), + audioFormat: strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))), + audioFormatSet: viper.IsSet("audio.format"), + geminiTTSModel: strings.TrimSpace(viper.GetString("audio.gemini_tts_model")), + geminiVoice: strings.TrimSpace(viper.GetString("audio.gemini_voice")), + openAIVoice: strings.TrimSpace(viper.GetString("audio.openai_voice")), + openAIModel: viper.GetString("audio.openai_model"), + openAIModelSet: viper.IsSet("audio.openai_model"), + openAISpeed: viper.GetFloat64("audio.openai_speed"), + openAISpeedSet: viper.IsSet("audio.openai_speed"), + openAIInstruction: viper.GetString("audio.openai_instruction"), + openAIInstructionSet: viper.IsSet("audio.openai_instruction"), + + imageProvider: strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))), + imageOpenAIModel: viper.GetString("image.openai_model"), + imageOpenAIModelSet: viper.IsSet("image.openai_model"), + imageOpenAISize: viper.GetString("image.openai_size"), + imageOpenAISizeSet: viper.IsSet("image.openai_size"), + imageOpenAIQuality: viper.GetString("image.openai_quality"), + imageOpenAIQualitySet: viper.IsSet("image.openai_quality"), + imageOpenAIStyle: viper.GetString("image.openai_style"), + imageOpenAIStyleSet: viper.IsSet("image.openai_style"), + imageNanoBananaModel: strings.TrimSpace(viper.GetString("image.nanobanana_model")), + imageNanoBananaModelSet: viper.IsSet("image.nanobanana_model"), + imageNanoBananaTextModel: strings.TrimSpace(viper.GetString("image.nanobanana_text_model")), + imageNanoBananaTextModelSet: viper.IsSet("image.nanobanana_text_model"), + } +} + +// Processor handles the main word processing logic. +// The factory fields (newOpenAIImageClient, newNanoBananaImageClient, newAudioProvider) +// are injected at construction time so tests can swap them without mutating global state. type Processor struct { flags *cli.Flags translator *translation.Translator translationCache *translation.TranslationCache phoneticFetcher *phonetic.Fetcher randomIntn func(n int) int + // viperCfg holds all config-file values read once at construction time, + // so individual methods never call Viper directly. + viperCfg viperConfig + + // Factories — replaced by tests to inject fakes. + newOpenAIImageClient func(*image.OpenAIConfig) image.ImageClient + newNanoBananaImageClient func(*image.NanoBananaConfig) image.ImageClient + newAudioProvider func(*audio.Config) (audio.Provider, error) } -var newOpenAIImageClient = func(config *image.OpenAIConfig) image.ImageClient { - return image.NewOpenAIClient(config) -} - -var newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { - return image.NewNanoBananaClient(config) -} - -var newAudioProvider = audio.NewProvider - -// NewProcessor creates a new word processor +// NewProcessor creates a new word processor with default production factories. +// All Viper config values are read once here via newViperConfig() so that no +// method body ever calls Viper directly. +// Tests can replace the factory fields on the returned struct to inject fakes. func NewProcessor(flags *cli.Flags) *Processor { + cfg := newViperConfig() openAIKey := cli.GetOpenAIKey() googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(viper.GetString("translation.provider")) - phoneticProvider := phonetic.Provider(viper.GetString("phonetic.provider")) + translationProvider := translation.Provider(cfg.translationProvider) + phoneticProvider := phonetic.Provider(cfg.phoneticProvider) return &Processor{ flags: flags, + viperCfg: cfg, translator: translation.NewTranslator(&translation.Config{Provider: translationProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), translationCache: translation.NewTranslationCache(), phoneticFetcher: phonetic.NewFetcher(&phonetic.Config{Provider: phoneticProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), randomIntn: rand.Intn, + newOpenAIImageClient: func(config *image.OpenAIConfig) image.ImageClient { + return image.NewOpenAIClient(config) + }, + newNanoBananaImageClient: func(config *image.NanoBananaConfig) image.ImageClient { + return image.NewNanoBananaClient(config) + }, + newAudioProvider: audio.NewProvider, } } @@ -246,8 +334,8 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, } func (p *Processor) audioProviderName() string { - if provider := strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))); provider != "" { - return provider + if p.viperCfg.audioProvider != "" { + return p.viperCfg.audioProvider } if p != nil && p.flags != nil { return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider)) @@ -262,10 +350,8 @@ func (p *Processor) effectiveAudioFormat() string { } } - if viper.IsSet("audio.format") { - if format := strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))); format != "" { - return format - } + if p.viperCfg.audioFormatSet && p.viperCfg.audioFormat != "" { + return p.viperCfg.audioFormat } if p != nil && p.flags != nil { @@ -282,8 +368,8 @@ func (p *Processor) effectiveAudioFormat() string { } func (p *Processor) geminiTTSModel() string { - if model := strings.TrimSpace(viper.GetString("audio.gemini_tts_model")); model != "" { - return model + if p.viperCfg.geminiTTSModel != "" { + return p.viperCfg.geminiTTSModel } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.GeminiTTSModel) @@ -292,8 +378,8 @@ func (p *Processor) geminiTTSModel() string { } func (p *Processor) geminiVoice() string { - if voice := strings.TrimSpace(viper.GetString("audio.gemini_voice")); voice != "" { - return voice + if p.viperCfg.geminiVoice != "" { + return p.viperCfg.geminiVoice } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.GeminiVoice) @@ -302,8 +388,8 @@ func (p *Processor) geminiVoice() string { } func (p *Processor) openAIVoice() string { - if voice := strings.TrimSpace(viper.GetString("audio.openai_voice")); voice != "" { - return voice + if p.viperCfg.openAIVoice != "" { + return p.viperCfg.openAIVoice } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.OpenAIVoice) @@ -461,7 +547,7 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, // Generate random speed between 0.90 and 1.00 if not explicitly set speed := p.flags.OpenAISpeed - if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") { + if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !p.viperCfg.openAISpeedSet { // Default was used, generate random speed speed = 0.90 + rand.Float64()*0.10 } @@ -491,19 +577,19 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction // Use config file values if not overridden by flags - if p.flags.OpenAIModel == "gpt-4o-mini-tts" && viper.IsSet("audio.openai_model") { - providerConfig.OpenAIModel = viper.GetString("audio.openai_model") + if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.viperCfg.openAIModelSet { + providerConfig.OpenAIModel = p.viperCfg.openAIModel } - if p.flags.OpenAISpeed == 0.9 && viper.IsSet("audio.openai_speed") { - providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed") + if p.flags.OpenAISpeed == 0.9 && p.viperCfg.openAISpeedSet { + providerConfig.OpenAISpeed = p.viperCfg.openAISpeed } - if p.flags.OpenAIInstruction == "" && viper.IsSet("audio.openai_instruction") { - providerConfig.OpenAIInstruction = viper.GetString("audio.openai_instruction") + if p.flags.OpenAIInstruction == "" && p.viperCfg.openAIInstructionSet { + providerConfig.OpenAIInstruction = p.viperCfg.openAIInstruction } } // Create the audio provider - provider, err := newAudioProvider(providerConfig) + provider, err := p.newAudioProvider(providerConfig) if err != nil { return err } @@ -689,8 +775,8 @@ func (p *Processor) GUIConfig() *gui.Config { openAIKey := cli.GetOpenAIKey() googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(viper.GetString("translation.provider")) - phoneticProvider := phonetic.Provider(viper.GetString("phonetic.provider")) + translationProvider := translation.Provider(p.viperCfg.translationProvider) + phoneticProvider := phonetic.Provider(p.viperCfg.phoneticProvider) // Construct and inject phonetic/translation dependencies at the composition root // so gui.New() receives ready-to-use instances rather than raw config strings. @@ -702,7 +788,7 @@ func (p *Processor) GUIConfig() *gui.Config { translator := translation.NewTranslator(&translation.Config{ Provider: translationProvider, OpenAIKey: openAIKey, - GeminiModel: viper.GetString("translation.gemini_model"), + GeminiModel: p.viperCfg.translationGeminiModel, }) return &gui.Config{ @@ -730,8 +816,8 @@ func (p *Processor) nanoBananaModelForRunMode() string { } } - if model := strings.TrimSpace(viper.GetString("image.nanobanana_model")); model != "" { - return model + if p.viperCfg.imageNanoBananaModel != "" { + return p.viperCfg.imageNanoBananaModel } if p != nil && p.flags != nil { @@ -750,8 +836,8 @@ func (p *Processor) nanoBananaTextModelForRunMode() string { } } - if model := strings.TrimSpace(viper.GetString("image.nanobanana_text_model")); model != "" { - return model + if p.viperCfg.imageNanoBananaTextModel != "" { + return p.viperCfg.imageNanoBananaTextModel } if p != nil && p.flags != nil { @@ -781,8 +867,8 @@ func (p *Processor) imageProviderForRunMode() string { return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } - if provider := strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))); provider != "" { - return provider + if p.viperCfg.imageProvider != "" { + return p.viperCfg.imageProvider } return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) @@ -797,24 +883,24 @@ func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { Style: p.flags.OpenAIImageStyle, } - if p.flags.OpenAIImageModel == "dall-e-2" && viper.IsSet("image.openai_model") { - openaiConfig.Model = viper.GetString("image.openai_model") + if p.flags.OpenAIImageModel == "dall-e-2" && p.viperCfg.imageOpenAIModelSet { + openaiConfig.Model = p.viperCfg.imageOpenAIModel } - if p.flags.OpenAIImageSize == "512x512" && viper.IsSet("image.openai_size") { - openaiConfig.Size = viper.GetString("image.openai_size") + if p.flags.OpenAIImageSize == "512x512" && p.viperCfg.imageOpenAISizeSet { + openaiConfig.Size = p.viperCfg.imageOpenAISize } - if p.flags.OpenAIImageQuality == "standard" && viper.IsSet("image.openai_quality") { - openaiConfig.Quality = viper.GetString("image.openai_quality") + if p.flags.OpenAIImageQuality == "standard" && p.viperCfg.imageOpenAIQualitySet { + openaiConfig.Quality = p.viperCfg.imageOpenAIQuality } - if p.flags.OpenAIImageStyle == "natural" && viper.IsSet("image.openai_style") { - openaiConfig.Style = viper.GetString("image.openai_style") + if p.flags.OpenAIImageStyle == "natural" && p.viperCfg.imageOpenAIStyleSet { + openaiConfig.Style = p.viperCfg.imageOpenAIStyle } if openaiConfig.APIKey == "" { return nil, fmt.Errorf("OpenAI API key is required for image generation") } - return newOpenAIImageClient(openaiConfig), nil + return p.newOpenAIImageClient(openaiConfig), nil } func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { @@ -824,18 +910,18 @@ func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { TextModel: p.flags.NanoBananaTextModel, } - if !p.flags.NanoBananaModelSpecified && viper.IsSet("image.nanobanana_model") { - nanoBananaConfig.Model = viper.GetString("image.nanobanana_model") + if !p.flags.NanoBananaModelSpecified && p.viperCfg.imageNanoBananaModelSet { + nanoBananaConfig.Model = p.viperCfg.imageNanoBananaModel } - if !p.flags.NanoBananaTextModelSpecified && viper.IsSet("image.nanobanana_text_model") { - nanoBananaConfig.TextModel = viper.GetString("image.nanobanana_text_model") + if !p.flags.NanoBananaTextModelSpecified && p.viperCfg.imageNanoBananaTextModelSet { + nanoBananaConfig.TextModel = p.viperCfg.imageNanoBananaTextModel } if nanoBananaConfig.APIKey == "" { return nil, fmt.Errorf("google API key is required for image generation") } - return newNanoBananaImageClient(nanoBananaConfig), nil + return p.newNanoBananaImageClient(nanoBananaConfig), nil } func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) { @@ -862,53 +948,11 @@ func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) // Helper methods func (p *Processor) findOrCreateWordDirectory(word string) string { - // Try to find existing directory first - if dir := p.findCardDirectory(word); dir != "" { - return dir - } - - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(word) - wordDir := filepath.Join(p.flags.OutputDir, cardID) - if err := os.MkdirAll(wordDir, 0755); err != nil { - fmt.Printf("Warning: failed to create word directory: %v\n", err) - return p.flags.OutputDir // Fallback to output directory - } - - // Save word metadata - metadataFile := filepath.Join(wordDir, "word.txt") - if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { - fmt.Printf("Warning: failed to save word metadata: %v\n", err) - } - - return wordDir + return internal.FindOrCreateCardDirectory(p.flags.OutputDir, word) } func (p *Processor) findCardDirectory(word string) string { - entries, err := os.ReadDir(p.flags.OutputDir) - if err != nil { - return "" - } - - // Look through all directories to find one with matching word.txt - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - - dirPath := filepath.Join(p.flags.OutputDir, entry.Name()) - wordFile := filepath.Join(dirPath, "word.txt") - - // Read the word file to check if it matches - if data, err := os.ReadFile(wordFile); err == nil { - storedWord := strings.TrimSpace(string(data)) - if storedWord == word { - return dirPath - } - } - } - - return "" + return internal.FindCardDirectory(p.flags.OutputDir, word) } // isWordFullyProcessed checks if a word has already been fully processed diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 34cc844..0d0e9e3 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -369,11 +369,6 @@ func TestGUIConfigForRunModeHonorsExplicitNanoBananaModelFlags(t *testing.T) { } func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -383,11 +378,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() flags := cli.NewFlags() @@ -397,6 +387,11 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { flags.AudioProvider = "openai" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudio(context.Background(), "ябълка"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) @@ -420,11 +415,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -434,11 +424,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() flags := cli.NewFlags() @@ -447,6 +432,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { flags.AudioProvider = "openai" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudioBgBg(context.Background(), "ябълка", "круша"); err != nil { t.Fatalf("generateAudioBgBg() unexpected error: %v", err) } @@ -506,20 +496,12 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioProviderFactoryError(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices }) audio.OpenAIVoices = []string{"sentinel-failure-voice"} - newAudioProvider = func(*audio.Config) (audio.Provider, error) { - return nil, errors.New("provider factory failed") - } tempDir := t.TempDir() flags := cli.NewFlags() @@ -528,6 +510,9 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { flags.AudioProvider = "openai" p := NewProcessor(flags) + p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return nil, errors.New("provider factory failed") + } err := p.generateAudio(context.Background(), "ябълка") if err == nil { t.Fatal("generateAudio() expected error from provider factory") @@ -538,18 +523,8 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { } func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -566,6 +541,11 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { flags.AudioFormat = "mp3" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -633,10 +613,6 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { } func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -645,11 +621,6 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -665,6 +636,11 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -717,11 +693,6 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { } func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -729,17 +700,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { audio.GeminiVoices = []string{"Charon", "Kore", "Leda"} var attemptedVoices []string - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - attemptedVoices = append(attemptedVoices, config.GeminiVoice) - return &fakeAudioProvider{ - generateFunc: func(_ string, _ string) error { - if config.GeminiVoice == "Charon" { - return audio.ErrGeminiNoAudioData - } - return nil - }, - }, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -754,6 +714,17 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + attemptedVoices = append(attemptedVoices, config.GeminiVoice) + return &fakeAudioProvider{ + generateFunc: func(_ string, _ string) error { + if config.GeminiVoice == "Charon" { + return audio.ErrGeminiNoAudioData + } + return nil + }, + }, nil + } p.randomIntn = func(int) int { return 0 } output := captureStdout(t, func() { if err := p.generateAudio(context.Background(), "ябълка"); err != nil { @@ -788,25 +759,12 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { } func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices }) audio.GeminiVoices = []string{"Charon"} - newAudioProvider = func(*audio.Config) (audio.Provider, error) { - return &fakeAudioProvider{ - generateFunc: func(_ string, _ string) error { - return audio.ErrGeminiNoAudioData - }, - }, nil - } - originalConfig := viper.New() *originalConfig = *viper.GetViper() defer func() { @@ -820,6 +778,13 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) + p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return &fakeAudioProvider{ + generateFunc: func(_ string, _ string) error { + return audio.ErrGeminiNoAudioData + }, + }, nil + } err := p.generateAudio(context.Background(), "ябълка") if !errors.Is(err, audio.ErrGeminiNoAudioData) { t.Fatalf("generateAudio() error = %v, want wrapped ErrGeminiNoAudioData", err) @@ -830,10 +795,6 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) { } func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -842,11 +803,6 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfigs []*audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfigs = append(capturedConfigs, ©Config) - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -862,6 +818,11 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfigs = append(capturedConfigs, ©Config) + return fakeProvider, nil + } if err := p.generateAudioBgBg(context.Background(), "ябълка!?", "круша."); err != nil { t.Fatalf("generateAudioBgBg() unexpected error: %v", err) } @@ -895,18 +856,8 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { } func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -923,6 +874,11 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test flags.AudioFormat = "wav" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } wordDir := p.findOrCreateWordDirectory("ябълка!?") if err := p.generateAudioWithVoiceAndFilenameInDir(context.Background(), "ябълка!?", "alloy", "audio", wordDir); err != nil { t.Fatalf("generateAudioWithVoiceAndFilenameInDir() unexpected error: %v", err) @@ -973,18 +929,8 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test } func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -1000,6 +946,11 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) { flags.AudioFormat = "mp3" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudio(context.Background(), "ябълка"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -1048,18 +999,8 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) { } func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } originalConfig := viper.New() *originalConfig = *viper.GetViper() @@ -1077,6 +1018,11 @@ func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) { flags.OpenAIModel = "tts-1" p := NewProcessor(flags) + p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -1223,16 +1169,8 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test viper.Set("image.nanobanana_model", "custom-image-model") viper.Set("image.nanobanana_text_model", "custom-text-model") - originalConstructor := newNanoBananaImageClient stubSearcher := &stubImageSearcher{} capturedConfig := new(image.NanoBananaConfig) - newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { - *capturedConfig = *config - return stubSearcher - } - t.Cleanup(func() { - newNanoBananaImageClient = originalConstructor - }) flags := cli.NewFlags() flags.OutputDir = t.TempDir() @@ -1240,6 +1178,10 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test flags.ImageAPISpecified = true p := NewProcessor(flags) + p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + *capturedConfig = *config + return stubSearcher + } if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil { t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err) } @@ -1280,14 +1222,7 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing viper.Reset() viper.Set("image.provider", "nanobanana") - originalConstructor := newNanoBananaImageClient stubSearcher := &stubImageSearcher{downloadErr: errors.New("download failed")} - newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { - return stubSearcher - } - t.Cleanup(func() { - newNanoBananaImageClient = originalConstructor - }) flags := cli.NewFlags() flags.OutputDir = t.TempDir() @@ -1295,6 +1230,9 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing flags.ImageAPISpecified = true p := NewProcessor(flags) + p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + return stubSearcher + } err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple") if err == nil { t.Fatal("downloadImagesWithTranslation() expected error from failed download") @@ -1328,16 +1266,8 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe viper.Set("image.nanobanana_model", "config-image-model") viper.Set("image.nanobanana_text_model", "config-text-model") - originalConstructor := newNanoBananaImageClient stubSearcher := &stubImageSearcher{} capturedConfig := new(image.NanoBananaConfig) - newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { - *capturedConfig = *config - return stubSearcher - } - t.Cleanup(func() { - newNanoBananaImageClient = originalConstructor - }) flags := cli.NewFlags() flags.OutputDir = t.TempDir() @@ -1345,6 +1275,10 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe flags.ImageAPISpecified = false p := NewProcessor(flags) + p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + *capturedConfig = *config + return stubSearcher + } if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil { t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err) } @@ -1435,15 +1369,7 @@ func TestNewNanoBananaImageSearcherExplicitDefaultWinsOverConfig(t *testing.T) { viper.Set("image.nanobanana_model", "config-image-model") viper.Set("image.nanobanana_text_model", "config-text-model") - originalConstructor := newNanoBananaImageClient capturedConfig := new(image.NanoBananaConfig) - newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { - *capturedConfig = *config - return &stubImageSearcher{} - } - t.Cleanup(func() { - newNanoBananaImageClient = originalConstructor - }) flags := cli.NewFlags() flags.OutputDir = t.TempDir() @@ -1455,6 +1381,10 @@ func TestNewNanoBananaImageSearcherExplicitDefaultWinsOverConfig(t *testing.T) { flags.NanoBananaTextModelSpecified = true p := NewProcessor(flags) + p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + *capturedConfig = *config + return &stubImageSearcher{} + } searcher, err := p.newNanoBananaImageSearcher() if err != nil { t.Fatalf("newNanoBananaImageSearcher() unexpected error: %v", err) diff --git a/internal/utils.go b/internal/utils.go index 47513ae..b83d46a 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -4,6 +4,8 @@ import ( "crypto/md5" "encoding/hex" "fmt" + "os" + "path/filepath" "strings" "time" ) @@ -23,6 +25,63 @@ func GenerateCardID(bulgarianWord string) string { return fmt.Sprintf("%d_%s", epochMillis, hashStr) } +// FindCardDirectory searches outputDir for a subdirectory whose word.txt +// (or legacy _word.txt) matches the given word. Returns the directory path +// or an empty string if not found. +func FindCardDirectory(outputDir, word string) string { + entries, err := os.ReadDir(outputDir) + if err != nil { + return "" + } + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + dirPath := filepath.Join(outputDir, entry.Name()) + wordFile := filepath.Join(dirPath, "word.txt") + + if data, err := os.ReadFile(wordFile); err == nil { + if strings.TrimSpace(string(data)) == word { + return dirPath + } + } else { + // Backward-compatible fallback: old format used _word.txt + wordFile = filepath.Join(dirPath, "_word.txt") + if data, err := os.ReadFile(wordFile); err == nil { + if strings.TrimSpace(string(data)) == word { + return dirPath + } + } + } + } + + return "" +} + +// FindOrCreateCardDirectory returns the existing card directory for word inside +// outputDir, or creates a new one with a generated card ID. It also writes +// word.txt so subsequent calls can find the directory. +func FindOrCreateCardDirectory(outputDir, word string) string { + if dir := FindCardDirectory(outputDir, word); dir != "" { + return dir + } + + cardID := GenerateCardID(word) + wordDir := filepath.Join(outputDir, cardID) + if err := os.MkdirAll(wordDir, 0755); err != nil { + fmt.Printf("Warning: failed to create word directory: %v\n", err) + return outputDir + } + + if err := os.WriteFile(filepath.Join(wordDir, "word.txt"), []byte(word), 0644); err != nil { + fmt.Printf("Warning: failed to save word metadata: %v\n", err) + } + + return wordDir +} + // SanitizeFilename creates a safe filename from a string. // Uses strings.Builder to avoid per-rune heap allocations. func SanitizeFilename(s string) string { -- cgit v1.2.3