diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 08:03:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 08:03:05 +0300 |
| commit | 63bd86d8046949e80e5b1122a1fcf717f51915c6 (patch) | |
| tree | 10bc72c97d1ba597bc82fb273987716eabf2e989 | |
| parent | 5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (diff) | |
tasks 00d, 006, 007: move test seams to struct fields, centralize Viper config, extract shared card-dir logic
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 <noreply@anthropic.com>
| -rw-r--r-- | internal/gui/app.go | 10 | ||||
| -rw-r--r-- | internal/gui/generator.go | 16 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 145 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 38 | ||||
| -rw-r--r-- | internal/processor/processor.go | 246 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 222 | ||||
| -rw-r--r-- | 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< |
