summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
commit63bd86d8046949e80e5b1122a1fcf717f51915c6 (patch)
tree10bc72c97d1ba597bc82fb273987716eabf2e989 /internal/processor
parent5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (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>
Diffstat (limited to 'internal/processor')
-rw-r--r--internal/processor/processor.go246
-rw-r--r--internal/processor/processor_test.go222
2 files changed, 221 insertions, 247 deletions
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 = &copyConfig
- 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 = &copyConfig
+ 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 = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
flags := cli.NewFlags()
@@ -447,6 +432,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
flags.AudioProvider = "openai"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudioBgBg(context.Background(), "ябълка", "круша"); err != nil {
t.Fatalf("generateAudioBgBg() unexpected error: %v", err)
}
@@ -506,20 +496,12 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioProviderFactoryError(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
})
audio.OpenAIVoices = []string{"sentinel-failure-voice"}
- newAudioProvider = func(*audio.Config) (audio.Provider, error) {
- return nil, errors.New("provider factory failed")
- }
tempDir := t.TempDir()
flags := cli.NewFlags()
@@ -528,6 +510,9 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) {
flags.AudioProvider = "openai"
p := NewProcessor(flags)
+ p.newAudioProvider = func(*audio.Config) (audio.Provider, error) {
+ return nil, errors.New("provider factory failed")
+ }
err := p.generateAudio(context.Background(), "ябълка")
if err == nil {
t.Fatal("generateAudio() expected error from provider factory")
@@ -538,18 +523,8 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) {
}
func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -566,6 +541,11 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
flags.AudioFormat = "mp3"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil {
t.Fatalf("generateAudio() unexpected error: %v", err)
}
@@ -633,10 +613,6 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
}
func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
@@ -645,11 +621,6 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -665,6 +636,11 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
flags.AudioProvider = "gemini"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil {
t.Fatalf("generateAudio() unexpected error: %v", err)
}
@@ -717,11 +693,6 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
}
func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
@@ -729,17 +700,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
audio.GeminiVoices = []string{"Charon", "Kore", "Leda"}
var attemptedVoices []string
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- attemptedVoices = append(attemptedVoices, config.GeminiVoice)
- return &fakeAudioProvider{
- generateFunc: func(_ string, _ string) error {
- if config.GeminiVoice == "Charon" {
- return audio.ErrGeminiNoAudioData
- }
- return nil
- },
- }, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -754,6 +714,17 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
flags.AudioProvider = "gemini"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ attemptedVoices = append(attemptedVoices, config.GeminiVoice)
+ return &fakeAudioProvider{
+ generateFunc: func(_ string, _ string) error {
+ if config.GeminiVoice == "Charon" {
+ return audio.ErrGeminiNoAudioData
+ }
+ return nil
+ },
+ }, nil
+ }
p.randomIntn = func(int) int { return 0 }
output := captureStdout(t, func() {
if err := p.generateAudio(context.Background(), "ябълка"); err != nil {
@@ -788,25 +759,12 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
}
func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
})
audio.GeminiVoices = []string{"Charon"}
- newAudioProvider = func(*audio.Config) (audio.Provider, error) {
- return &fakeAudioProvider{
- generateFunc: func(_ string, _ string) error {
- return audio.ErrGeminiNoAudioData
- },
- }, nil
- }
-
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
defer func() {
@@ -820,6 +778,13 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) {
flags.AudioProvider = "gemini"
p := NewProcessor(flags)
+ p.newAudioProvider = func(*audio.Config) (audio.Provider, error) {
+ return &fakeAudioProvider{
+ generateFunc: func(_ string, _ string) error {
+ return audio.ErrGeminiNoAudioData
+ },
+ }, nil
+ }
err := p.generateAudio(context.Background(), "ябълка")
if !errors.Is(err, audio.ErrGeminiNoAudioData) {
t.Fatalf("generateAudio() error = %v, want wrapped ErrGeminiNoAudioData", err)
@@ -830,10 +795,6 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) {
}
func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
@@ -842,11 +803,6 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfigs []*audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfigs = append(capturedConfigs, &copyConfig)
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -862,6 +818,11 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
flags.AudioProvider = "gemini"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfigs = append(capturedConfigs, &copyConfig)
+ return fakeProvider, nil
+ }
if err := p.generateAudioBgBg(context.Background(), "ябълка!?", "круша."); err != nil {
t.Fatalf("generateAudioBgBg() unexpected error: %v", err)
}
@@ -895,18 +856,8 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
}
func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -923,6 +874,11 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test
flags.AudioFormat = "wav"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
wordDir := p.findOrCreateWordDirectory("ябълка!?")
if err := p.generateAudioWithVoiceAndFilenameInDir(context.Background(), "ябълка!?", "alloy", "audio", wordDir); err != nil {
t.Fatalf("generateAudioWithVoiceAndFilenameInDir() unexpected error: %v", err)
@@ -973,18 +929,8 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test
}
func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -1000,6 +946,11 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) {
flags.AudioFormat = "mp3"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudio(context.Background(), "ябълка"); err != nil {
t.Fatalf("generateAudio() unexpected error: %v", err)
}
@@ -1048,18 +999,8 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) {
}
func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
originalConfig := viper.New()
*originalConfig = *viper.GetViper()
@@ -1077,6 +1018,11 @@ func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) {
flags.OpenAIModel = "tts-1"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil {
t.Fatalf("generateAudio() unexpected error: %v", err)
}
@@ -1223,16 +1169,8 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test
viper.Set("image.nanobanana_model", "custom-image-model")
viper.Set("image.nanobanana_text_model", "custom-text-model")
- originalConstructor := newNanoBananaImageClient
stubSearcher := &stubImageSearcher{}
capturedConfig := new(image.NanoBananaConfig)
- newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
- *capturedConfig = *config
- return stubSearcher
- }
- t.Cleanup(func() {
- newNanoBananaImageClient = originalConstructor
- })
flags := cli.NewFlags()
flags.OutputDir = t.TempDir()
@@ -1240,6 +1178,10 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test
flags.ImageAPISpecified = true
p := NewProcessor(flags)
+ p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
+ *capturedConfig = *config
+ return stubSearcher
+ }
if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil {
t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err)
}
@@ -1280,14 +1222,7 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing
viper.Reset()
viper.Set("image.provider", "nanobanana")
- originalConstructor := newNanoBananaImageClient
stubSearcher := &stubImageSearcher{downloadErr: errors.New("download failed")}
- newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
- return stubSearcher
- }
- t.Cleanup(func() {
- newNanoBananaImageClient = originalConstructor
- })
flags := cli.NewFlags()
flags.OutputDir = t.TempDir()
@@ -1295,6 +1230,9 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing
flags.ImageAPISpecified = true
p := NewProcessor(flags)
+ p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
+ return stubSearcher
+ }
err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple")
if err == nil {
t.Fatal("downloadImagesWithTranslation() expected error from failed download")
@@ -1328,16 +1266,8 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe
viper.Set("image.nanobanana_model", "config-image-model")
viper.Set("image.nanobanana_text_model", "config-text-model")
- originalConstructor := newNanoBananaImageClient
stubSearcher := &stubImageSearcher{}
capturedConfig := new(image.NanoBananaConfig)
- newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
- *capturedConfig = *config
- return stubSearcher
- }
- t.Cleanup(func() {
- newNanoBananaImageClient = originalConstructor
- })
flags := cli.NewFlags()
flags.OutputDir = t.TempDir()
@@ -1345,6 +1275,10 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe
flags.ImageAPISpecified = false
p := NewProcessor(flags)
+ p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
+ *capturedConfig = *config
+ return stubSearcher
+ }
if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil {
t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err)
}
@@ -1435,15 +1369,7 @@ func TestNewNanoBananaImageSearcherExplicitDefaultWinsOverConfig(t *testing.T) {
viper.Set("image.nanobanana_model", "config-image-model")
viper.Set("image.nanobanana_text_model", "config-text-model")
- originalConstructor := newNanoBananaImageClient
capturedConfig := new(image.NanoBananaConfig)
- newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
- *capturedConfig = *config
- return &stubImageSearcher{}
- }
- t.Cleanup(func() {
- newNanoBananaImageClient = originalConstructor
- })
flags := cli.NewFlags()
flags.OutputDir = t.TempDir()
@@ -1455,6 +1381,10 @@ func TestNewNanoBananaImageSearcherExplicitDefaultWinsOverConfig(t *testing.T) {
flags.NanoBananaTextModelSpecified = true
p := NewProcessor(flags)
+ p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
+ *capturedConfig = *config
+ return &stubImageSearcher{}
+ }
searcher, err := p.newNanoBananaImageSearcher()
if err != nil {
t.Fatalf("newNanoBananaImageSearcher() unexpected error: %v", err)