summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 10:58:58 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 10:58:58 +0300
commit23160bce9a18a70080a85dda6e9c654499aba7f7 (patch)
treeec0f9930233aef96a221980310073081679bb4bb /internal/processor
parent95dd36d28d18615ad3f8dd7122a404850dcb39f8 (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.go57
-rw-r--r--internal/processor/image_downloader.go43
-rw-r--r--internal/processor/processor.go141
-rw-r--r--internal/processor/processor_test.go322
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 = &copyConfig
@@ -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 = &copyConfig
@@ -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 = &copyConfig
@@ -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 = &copyConfig
@@ -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, &copyConfig)
@@ -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 = &copyConfig
@@ -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 = &copyConfig
@@ -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 = &copyConfig
@@ -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