diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 10:58:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 10:58:58 +0300 |
| commit | 23160bce9a18a70080a85dda6e9c654499aba7f7 (patch) | |
| tree | ec0f9930233aef96a221980310073081679bb4bb /internal/processor | |
| parent | 95dd36d28d18615ad3f8dd7122a404850dcb39f8 (diff) | |
refactor: remove Viper coupling from processor package (DIP)
Resolve all Viper config values once at startup in cmd/totalrecall/main.go
via the new newProcessorConfig() helper. The exported processor.Config struct
replaces the internal viperConfig type, and NewProcessor now accepts *Config
instead of querying Viper internally. The processor package no longer imports
Viper at all. Tests are updated to pass Config values directly, eliminating
the viper.Set/Reset boilerplate that coupled tests to the global singleton.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/processor')
| -rw-r--r-- | internal/processor/audio_coordinator.go | 57 | ||||
| -rw-r--r-- | internal/processor/image_downloader.go | 43 | ||||
| -rw-r--r-- | internal/processor/processor.go | 141 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 322 |
4 files changed, 189 insertions, 374 deletions
diff --git a/internal/processor/audio_coordinator.go b/internal/processor/audio_coordinator.go index 7153c98..97f233e 100644 --- a/internal/processor/audio_coordinator.go +++ b/internal/processor/audio_coordinator.go @@ -1,9 +1,10 @@ package processor // AudioCoordinator assembles audio provider configurations, selects voices, -// generates audio files, and writes attribution/metadata sidecars. It holds -// the audio-related viperCfg fields and flag references so that the main -// Processor struct does not need to deal with audio details directly. +// generates audio files, and writes attribution/metadata sidecars. It reads +// audio-related values from the processor's Config struct (resolved once at +// startup) and the CLI flags, so the main Processor struct does not need to +// deal with audio details directly. // // All methods are on *Processor rather than a separate struct to avoid an // extra layer of indirection while still keeping the concerns separated into @@ -23,10 +24,10 @@ import ( ) // audioProviderName returns the configured audio provider name, preferring -// the viper config value over the CLI flag so config-file settings win. +// the config-file value over the CLI flag so config-file settings win. func (p *Processor) audioProviderName() string { - if p.viperCfg.audioProvider != "" { - return p.viperCfg.audioProvider + if p.cfg.AudioProvider != "" { + return p.cfg.AudioProvider } if p != nil && p.flags != nil { return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider)) @@ -35,7 +36,7 @@ func (p *Processor) audioProviderName() string { } // effectiveAudioFormat resolves the audio format from flags and config, with -// CLI flag taking precedence, then viper config, then provider-specific defaults. +// CLI flag taking precedence, then the config-file value, then provider-specific defaults. func (p *Processor) effectiveAudioFormat() string { if p != nil && p.flags != nil && p.flags.AudioFormatSpecified { if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { @@ -43,8 +44,8 @@ func (p *Processor) effectiveAudioFormat() string { } } - if p.viperCfg.audioFormatSet && p.viperCfg.audioFormat != "" { - return p.viperCfg.audioFormat + if p.cfg.AudioFormatSet && p.cfg.AudioFormat != "" { + return p.cfg.AudioFormat } if p != nil && p.flags != nil { @@ -60,10 +61,10 @@ func (p *Processor) effectiveAudioFormat() string { return "mp3" } -// geminiTTSModel returns the Gemini TTS model, preferring viper config over CLI flag. +// geminiTTSModel returns the Gemini TTS model, preferring the config-file value over the CLI flag. func (p *Processor) geminiTTSModel() string { - if p.viperCfg.geminiTTSModel != "" { - return p.viperCfg.geminiTTSModel + if p.cfg.GeminiTTSModel != "" { + return p.cfg.GeminiTTSModel } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.GeminiTTSModel) @@ -71,10 +72,10 @@ func (p *Processor) geminiTTSModel() string { return "" } -// geminiVoice returns the Gemini voice, preferring viper config over CLI flag. +// geminiVoice returns the Gemini voice, preferring the config-file value over the CLI flag. func (p *Processor) geminiVoice() string { - if p.viperCfg.geminiVoice != "" { - return p.viperCfg.geminiVoice + if p.cfg.GeminiVoice != "" { + return p.cfg.GeminiVoice } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.GeminiVoice) @@ -82,10 +83,10 @@ func (p *Processor) geminiVoice() string { return "" } -// openAIVoice returns the OpenAI voice, preferring viper config over CLI flag. +// openAIVoice returns the OpenAI voice, preferring the config-file value over the CLI flag. func (p *Processor) openAIVoice() string { - if p.viperCfg.openAIVoice != "" { - return p.viperCfg.openAIVoice + if p.cfg.OpenAIVoice != "" { + return p.cfg.OpenAIVoice } if p != nil && p.flags != nil { return strings.TrimSpace(p.flags.OpenAIVoice) @@ -269,15 +270,15 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, return nil } -// buildAudioProviderConfig assembles an audio.Config from flags and viper -// config. The voice argument is the already-resolved voice string for this call. +// buildAudioProviderConfig assembles an audio.Config from CLI flags and the +// resolved processor Config. The voice argument is the already-resolved voice string for this call. func (p *Processor) buildAudioProviderConfig(voice string) *audio.Config { audioProvider := p.audioProviderName() audioFormat := p.effectiveAudioFormat() // 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 && !p.viperCfg.openAISpeedSet { + if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !p.cfg.OpenAISpeedSet { speed = 0.90 + rand.Float64()*0.10 } @@ -305,7 +306,7 @@ func (p *Processor) buildAudioProviderConfig(voice string) *audio.Config { } // applyOpenAIAudioConfig populates the OpenAI-specific fields of providerConfig, -// applying viper overrides where the flag value still equals its default. +// applying config-file overrides where the CLI flag still holds its default value. func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice string, speed float64, audioFormat string) { providerConfig.OutputFormat = audioFormat providerConfig.OpenAIModel = p.flags.OpenAIModel @@ -314,14 +315,14 @@ func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice s providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction // Override with config-file values when the CLI flag is still at its default. - if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.viperCfg.openAIModelSet { - providerConfig.OpenAIModel = p.viperCfg.openAIModel + if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.cfg.OpenAIModelSet { + providerConfig.OpenAIModel = p.cfg.OpenAIModel } - if p.flags.OpenAISpeed == 0.9 && p.viperCfg.openAISpeedSet { - providerConfig.OpenAISpeed = p.viperCfg.openAISpeed + if p.flags.OpenAISpeed == 0.9 && p.cfg.OpenAISpeedSet { + providerConfig.OpenAISpeed = p.cfg.OpenAISpeed } - if p.flags.OpenAIInstruction == "" && p.viperCfg.openAIInstructionSet { - providerConfig.OpenAIInstruction = p.viperCfg.openAIInstruction + if p.flags.OpenAIInstruction == "" && p.cfg.OpenAIInstructionSet { + providerConfig.OpenAIInstruction = p.cfg.OpenAIInstruction } } diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go index c956c02..d2bb399 100644 --- a/internal/processor/image_downloader.go +++ b/internal/processor/image_downloader.go @@ -120,20 +120,20 @@ func (p *Processor) newImageSearcher() (image.ImageClient, error) { } // imageProviderForRunMode resolves the image provider, giving precedence to -// the CLI flag when it was explicitly set, then the viper config value. +// the CLI flag when it was explicitly set, then the config-file value. func (p *Processor) imageProviderForRunMode() string { if p.flags.ImageAPISpecified { return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } - if p.viperCfg.imageProvider != "" { - return p.viperCfg.imageProvider + if p.cfg.ImageProvider != "" { + return p.cfg.ImageProvider } return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } -// newOpenAIImageSearcher builds an OpenAI ImageClient from flags and viper -// config. Config-file overrides are applied only when the flag still holds its -// default value so explicit CLI flags always win. +// newOpenAIImageSearcher builds an OpenAI ImageClient from CLI flags and the +// resolved processor Config. Config-file overrides are applied only when the +// flag still holds its default value so explicit CLI flags always win. func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { openaiConfig := &image.OpenAIConfig{ APIKey: cli.GetOpenAIKey(), @@ -143,18 +143,18 @@ func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { Style: p.flags.OpenAIImageStyle, } - // Apply viper overrides when CLI flag holds its zero/default value. - if p.flags.OpenAIImageModel == "dall-e-2" && p.viperCfg.imageOpenAIModelSet { - openaiConfig.Model = p.viperCfg.imageOpenAIModel + // Apply config-file overrides when CLI flag holds its zero/default value. + if p.flags.OpenAIImageModel == "dall-e-2" && p.cfg.ImageOpenAIModelSet { + openaiConfig.Model = p.cfg.ImageOpenAIModel } - if p.flags.OpenAIImageSize == "512x512" && p.viperCfg.imageOpenAISizeSet { - openaiConfig.Size = p.viperCfg.imageOpenAISize + if p.flags.OpenAIImageSize == "512x512" && p.cfg.ImageOpenAISizeSet { + openaiConfig.Size = p.cfg.ImageOpenAISize } - if p.flags.OpenAIImageQuality == "standard" && p.viperCfg.imageOpenAIQualitySet { - openaiConfig.Quality = p.viperCfg.imageOpenAIQuality + if p.flags.OpenAIImageQuality == "standard" && p.cfg.ImageOpenAIQualitySet { + openaiConfig.Quality = p.cfg.ImageOpenAIQuality } - if p.flags.OpenAIImageStyle == "natural" && p.viperCfg.imageOpenAIStyleSet { - openaiConfig.Style = p.viperCfg.imageOpenAIStyle + if p.flags.OpenAIImageStyle == "natural" && p.cfg.ImageOpenAIStyleSet { + openaiConfig.Style = p.cfg.ImageOpenAIStyle } if openaiConfig.APIKey == "" { @@ -164,8 +164,9 @@ func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { return p.newOpenAIImageClient(openaiConfig), nil } -// newNanoBananaImageSearcher builds a NanoBanana ImageClient from flags and -// viper config, applying overrides in the same flag-wins-over-config pattern. +// newNanoBananaImageSearcher builds a NanoBanana ImageClient from CLI flags +// and the resolved processor Config, applying overrides in the same +// flag-wins-over-config pattern. func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { nanoBananaConfig := &image.NanoBananaConfig{ APIKey: cli.GetGoogleAPIKey(), @@ -173,11 +174,11 @@ func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { TextModel: p.flags.NanoBananaTextModel, } - if !p.flags.NanoBananaModelSpecified && p.viperCfg.imageNanoBananaModelSet { - nanoBananaConfig.Model = p.viperCfg.imageNanoBananaModel + if !p.flags.NanoBananaModelSpecified && p.cfg.ImageNanoBananaModelSet { + nanoBananaConfig.Model = p.cfg.ImageNanoBananaModel } - if !p.flags.NanoBananaTextModelSpecified && p.viperCfg.imageNanoBananaTextModelSet { - nanoBananaConfig.TextModel = p.viperCfg.imageNanoBananaTextModel + if !p.flags.NanoBananaTextModelSpecified && p.cfg.ImageNanoBananaTextModelSet { + nanoBananaConfig.TextModel = p.cfg.ImageNanoBananaTextModel } if nanoBananaConfig.APIKey == "" { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 107fd12..7d83149 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -9,8 +9,6 @@ import ( "strings" "time" - "github.com/spf13/viper" - "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/anki" "codeberg.org/snonux/totalrecall/internal/audio" @@ -22,80 +20,45 @@ import ( "codeberg.org/snonux/totalrecall/internal/translation" ) -// 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 { +// Config holds all configuration-file values resolved once at startup by the +// composition root (cmd/totalrecall/main.go via cli.NewProcessorConfig). +// Keeping config in a dedicated struct means the processor package never +// imports or queries Viper directly, which improves testability and removes +// tight coupling to the global Viper singleton. +type Config struct { // Translation & phonetic settings - translationProvider string - phoneticProvider string - translationGeminiModel string + 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 + 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"), - } + 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 } // Processor handles the main word processing logic. @@ -109,9 +72,9 @@ type Processor struct { translationCache *translation.TranslationCache phoneticFetcher *phonetic.Fetcher randomIntn func(n int) int - // viperCfg holds all config-file values read once at construction time, + // cfg holds all config-file values resolved once at startup by the caller, // so individual methods never call Viper directly. - viperCfg viperConfig + cfg *Config // Factories — replaced by tests to inject fakes. newOpenAIImageClient func(*image.OpenAIConfig) image.ImageClient @@ -120,18 +83,18 @@ type Processor struct { } // 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. +// cfg must be fully resolved before calling NewProcessor; the composition root +// (cmd/totalrecall/main.go) builds it via cli.NewProcessorConfig() so that +// the processor package never imports or queries Viper. // Tests can replace the factory fields on the returned struct to inject fakes. -func NewProcessor(flags *cli.Flags) *Processor { - cfg := newViperConfig() +func NewProcessor(flags *cli.Flags, cfg *Config) *Processor { openAIKey := cli.GetOpenAIKey() googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(cfg.translationProvider) - phoneticProvider := phonetic.Provider(cfg.phoneticProvider) + translationProvider := translation.Provider(cfg.TranslationProvider) + phoneticProvider := phonetic.Provider(cfg.PhoneticProvider) return &Processor{ flags: flags, - viperCfg: cfg, + cfg: 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}), @@ -501,8 +464,8 @@ func (p *Processor) GUIConfig() *gui.Config { openAIKey := cli.GetOpenAIKey() googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(p.viperCfg.translationProvider) - phoneticProvider := phonetic.Provider(p.viperCfg.phoneticProvider) + translationProvider := translation.Provider(p.cfg.TranslationProvider) + phoneticProvider := phonetic.Provider(p.cfg.PhoneticProvider) // Construct and inject phonetic/translation dependencies at the composition // root so gui.New() receives ready-to-use instances rather than raw config strings. @@ -514,7 +477,7 @@ func (p *Processor) GUIConfig() *gui.Config { translator := translation.NewTranslator(&translation.Config{ Provider: translationProvider, OpenAIKey: openAIKey, - GeminiModel: p.viperCfg.translationGeminiModel, + GeminiModel: p.cfg.TranslationGeminiModel, }) return &gui.Config{ @@ -536,7 +499,7 @@ func (p *Processor) GUIConfig() *gui.Config { } // nanoBananaModelForRunMode resolves the NanoBanana image model, preferring -// the explicit CLI flag value when set, then the viper config value, then the +// the explicit CLI flag value when set, then the config-file value, then the // package default. func (p *Processor) nanoBananaModelForRunMode() string { if p != nil && p.flags != nil && p.flags.NanoBananaModelSpecified { @@ -544,8 +507,8 @@ func (p *Processor) nanoBananaModelForRunMode() string { return model } } - if p.viperCfg.imageNanoBananaModel != "" { - return p.viperCfg.imageNanoBananaModel + if p.cfg.ImageNanoBananaModel != "" { + return p.cfg.ImageNanoBananaModel } if p != nil && p.flags != nil { if model := strings.TrimSpace(p.flags.NanoBananaModel); model != "" { @@ -556,15 +519,15 @@ func (p *Processor) nanoBananaModelForRunMode() string { } // nanoBananaTextModelForRunMode resolves the NanoBanana text (prompt) model -// using the same precedence as nanoBananaModelForRunMode. +// using the same CLI-flag-over-config precedence as nanoBananaModelForRunMode. func (p *Processor) nanoBananaTextModelForRunMode() string { if p != nil && p.flags != nil && p.flags.NanoBananaTextModelSpecified { if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { return model } } - if p.viperCfg.imageNanoBananaTextModel != "" { - return p.viperCfg.imageNanoBananaTextModel + if p.cfg.ImageNanoBananaTextModel != "" { + return p.cfg.ImageNanoBananaTextModel } if p != nil && p.flags != nil { if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index bcd57a7..17b56e9 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -16,7 +16,6 @@ import ( "codeberg.org/snonux/totalrecall/internal/gui" "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/phonetic" - "github.com/spf13/viper" ) type stubImageSearcher struct { @@ -146,15 +145,8 @@ func TestNewProcessor(t *testing.T) { t.Setenv("OPENAI_API_KEY", "test-openai-key") t.Setenv("GOOGLE_API_KEY", "test-google-key") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - flags := cli.NewFlags() - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) if p == nil { t.Fatal("NewProcessor returned nil") @@ -181,15 +173,8 @@ func TestNewProcessor_DefaultPhoneticProviderUsesGemini(t *testing.T) { t.Setenv("OPENAI_API_KEY", "") t.Setenv("GOOGLE_API_KEY", "") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - flags := cli.NewFlags() - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) if got := p.phoneticFetcher.Provider(); got != phonetic.ProviderGemini { t.Fatalf("expected default phonetic provider %q, got %q", phonetic.ProviderGemini, got) @@ -200,16 +185,8 @@ func TestNewProcessor_ExplicitGeminiPhoneticProvider(t *testing.T) { t.Setenv("OPENAI_API_KEY", "") t.Setenv("GOOGLE_API_KEY", "") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("phonetic.provider", "gemini") - flags := cli.NewFlags() - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{PhoneticProvider: "gemini"}) if got := p.phoneticFetcher.Provider(); got != phonetic.ProviderGemini { t.Fatalf("expected gemini phonetic provider %q, got %q", phonetic.ProviderGemini, got) @@ -220,15 +197,8 @@ func TestNewProcessor_DefaultTranslationProviderUsesGemini(t *testing.T) { t.Setenv("OPENAI_API_KEY", "") t.Setenv("GOOGLE_API_KEY", "") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - flags := cli.NewFlags() - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) _, err := p.translator.TranslateWord("ябълка") if err == nil { @@ -243,16 +213,8 @@ func TestNewProcessor_ExplicitGeminiTranslationProvider(t *testing.T) { t.Setenv("OPENAI_API_KEY", "") t.Setenv("GOOGLE_API_KEY", "") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("translation.provider", "gemini") - flags := cli.NewFlags() - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{TranslationProvider: "gemini"}) _, err := p.translator.TranslateWord("ябълка") if err == nil { @@ -267,20 +229,17 @@ func TestGUIConfigForRunModeUsesNanoBananaDefaultWhenImageAPIIsNotSpecified(t *t t.Setenv("OPENAI_API_KEY", "test-openai-key") t.Setenv("GOOGLE_API_KEY", "test-google-key") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("image.nanobanana_model", "config-image-model") - viper.Set("image.nanobanana_text_model", "config-text-model") - flags := cli.NewFlags() flags.AudioFormat = "mp3" flags.ImageAPI = "openai" flags.ImageAPISpecified = false - p := NewProcessor(flags) + cfg := &Config{ + ImageNanoBananaModel: "config-image-model", + ImageNanoBananaModelSet: true, + ImageNanoBananaTextModel: "config-text-model", + ImageNanoBananaTextModelSet: true, + } + p := NewProcessor(flags, cfg) guiConfig := p.GUIConfig() if guiConfig.ImageProvider != gui.DefaultConfig().ImageProvider { @@ -310,17 +269,10 @@ func TestGUIConfigForRunModeHonorsExplicitImageAPI(t *testing.T) { t.Setenv("OPENAI_API_KEY", "test-openai-key") t.Setenv("GOOGLE_API_KEY", "test-google-key") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - flags := cli.NewFlags() flags.ImageAPI = "openai" flags.ImageAPISpecified = true - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) guiConfig := p.GUIConfig() if guiConfig.ImageProvider != "openai" { @@ -338,15 +290,6 @@ func TestGUIConfigForRunModeHonorsExplicitNanoBananaModelFlags(t *testing.T) { t.Setenv("OPENAI_API_KEY", "test-openai-key") t.Setenv("GOOGLE_API_KEY", "test-google-key") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("image.nanobanana_model", "config-image-model") - viper.Set("image.nanobanana_text_model", "config-text-model") - flags := cli.NewFlags() flags.ImageAPI = "openai" flags.ImageAPISpecified = false @@ -354,7 +297,13 @@ func TestGUIConfigForRunModeHonorsExplicitNanoBananaModelFlags(t *testing.T) { flags.NanoBananaModelSpecified = true flags.NanoBananaTextModel = "flag-text-model" flags.NanoBananaTextModelSpecified = true - p := NewProcessor(flags) + cfg := &Config{ + ImageNanoBananaModel: "config-image-model", + ImageNanoBananaModelSet: true, + ImageNanoBananaTextModel: "config-text-model", + ImageNanoBananaTextModelSet: true, + } + p := NewProcessor(flags, cfg) guiConfig := p.GUIConfig() if guiConfig.NanoBananaModel != "flag-image-model" { @@ -386,7 +335,7 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { flags.AllVoices = true flags.AudioProvider = "openai" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -431,7 +380,7 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { flags.AudioFormat = "mp3" flags.AudioProvider = "openai" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -509,7 +458,7 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { flags.AudioFormat = "mp3" flags.AudioProvider = "openai" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{}) p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { return nil, errors.New("provider factory failed") } @@ -526,21 +475,16 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - viper.Set("audio.gemini_tts_model", "gemini-2.5-flash-preview-tts") - viper.Set("audio.gemini_voice", "Kore") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioFormat = "mp3" - p := NewProcessor(flags) + cfg := &Config{ + AudioProvider: "gemini", + GeminiTTSModel: "gemini-2.5-flash-preview-tts", + GeminiVoice: "Kore", + } + p := NewProcessor(flags, cfg) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -622,20 +566,12 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioFormat = "mp3" flags.AudioProvider = "gemini" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -701,19 +637,11 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { var attemptedVoices []string - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioProvider = "gemini" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { attemptedVoices = append(attemptedVoices, config.GeminiVoice) return &fakeAudioProvider{ @@ -765,19 +693,11 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) { }) audio.GeminiVoices = []string{"Charon"} - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioProvider = "gemini" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { return &fakeAudioProvider{ generateFunc: func(_ string, _ string) error { @@ -804,20 +724,12 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfigs []*audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioFormat = "mp3" flags.AudioProvider = "gemini" - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfigs = append(capturedConfigs, ©Config) @@ -859,21 +771,17 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "openai") - viper.Set("audio.format", "mp3") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioProvider = "openai" flags.AudioFormat = "wav" - p := NewProcessor(flags) + cfg := &Config{ + AudioProvider: "openai", + AudioFormat: "mp3", + AudioFormatSet: true, + } + p := NewProcessor(flags, cfg) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -932,20 +840,15 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "openai") - viper.Set("audio.openai_voice", "shimmer") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioFormat = "mp3" - p := NewProcessor(flags) + cfg := &Config{ + AudioProvider: "openai", + OpenAIVoice: "shimmer", + } + p := NewProcessor(flags, cfg) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -1002,22 +905,18 @@ func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "openai") - viper.Set("audio.openai_instruction", "Speak clearly.") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioProvider = "openai" flags.AudioFormat = "mp3" flags.OpenAIModel = "tts-1" - p := NewProcessor(flags) + cfg := &Config{ + AudioProvider: "openai", + OpenAIInstruction: "Speak clearly.", + OpenAIInstructionSet: true, + } + p := NewProcessor(flags, cfg) p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { copyConfig := *config capturedConfig = ©Config @@ -1068,14 +967,6 @@ func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) { } func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - tempDir := t.TempDir() flags := cli.NewFlags() flags.OutputDir = tempDir @@ -1083,7 +974,7 @@ func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { flags.AudioFormat = "mp3" flags.AnkiCSV = true - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) p.translationCache.Add("ябълка", "apple") wordDir := p.findOrCreateWordDirectory("ябълка") @@ -1120,21 +1011,13 @@ func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { } func TestIsWordFullyProcessedUsesMultiVoiceAttributionFiles(t *testing.T) { - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("audio.provider", "gemini") - flags := cli.NewFlags() flags.OutputDir = t.TempDir() flags.AudioProvider = "gemini" flags.AudioFormat = "mp3" flags.SkipImages = true - p := NewProcessor(flags) + p := NewProcessor(flags, &Config{AudioProvider: "gemini"}) wordDir := p.findOrCreateWordDirectory("ябълка") files := map[string]string{ "translation.txt": "ябълка = apple", @@ -1160,15 +1043,6 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test t.Setenv("OPENAI_API_KEY", "test-openai-key") t.Setenv("GOOGLE_API_KEY", "test-google-key") - originalConfig := viper.New() - *originalConfig = *viper.GetViper() - defer func() { - *viper.GetViper() = *originalConfig - }() - viper.Reset() - viper.Set("image.nanobanana_model", "custom-image-model |
