From 05f54cc0cb8cf3535698ab5027d200842bdb28e3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Apr 2026 11:06:19 +0300 Subject: refactor: consolidate provider factory test seams into shared named types Define audio.ProviderFactory, image.PromptAwareClient, image.OpenAIClientFactory, image.NanoBananaClientFactory, and image.ClientFactories as the single source of truth for the three injectable factory signatures that were previously duplicated across processor.Processor, gui.Application, and gui.GenerationOrchestrator. Replace all three separate function-type fields with imageFactories image.ClientFactories + newAudioProvider audio.ProviderFactory, eliminating the parallel field declarations and the local promptAwareImageClient interface in gui/generator.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/processor/image_downloader.go | 50 ++++++++++++++++------------------ internal/processor/processor.go | 23 ++++++++-------- internal/processor/processor_test.go | 8 +++--- 3 files changed, 38 insertions(+), 43 deletions(-) (limited to 'internal/processor') diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go index d2bb399..74aa58a 100644 --- a/internal/processor/image_downloader.go +++ b/internal/processor/image_downloader.go @@ -59,20 +59,13 @@ func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, tra return nil } -// registerPromptCallback wires a prompt-save callback into searchers that -// support SetPromptCallback. The callback fires during the Search call so the -// prompt is captured even if the subsequent download fails. -func (p *Processor) registerPromptCallback(searcher image.ImageClient, wordDir string) { - type promptSetter interface { - SetPromptCallback(func(prompt string)) - } - promptAware, ok := searcher.(promptSetter) - if !ok { - return - } - +// registerPromptCallback wires a prompt-save callback into the searcher. The +// callback fires during the Search call so the prompt is captured even if the +// subsequent download fails. All searchers returned by newImageSearcher +// implement image.PromptAwareClient, so no type-assertion is needed. +func (p *Processor) registerPromptCallback(searcher image.PromptAwareClient, wordDir string) { promptFile := filepath.Join(wordDir, "image_prompt.txt") - promptAware.SetPromptCallback(func(prompt string) { + searcher.SetPromptCallback(func(prompt string) { if prompt == "" { return } @@ -84,8 +77,9 @@ func (p *Processor) registerPromptCallback(searcher image.ImageClient, wordDir s // saveImagePrompt persists the last prompt used by a searcher that implements // GetLastPrompt. This acts as a fallback when the prompt is not available via -// the callback during the search call itself. -func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) { +// the callback during the search call itself. The local promptGetter interface +// is intentionally narrow: not all PromptAwareClients expose GetLastPrompt. +func (p *Processor) saveImagePrompt(wordDir string, searcher image.PromptAwareClient) { type promptGetter interface { GetLastPrompt() string } @@ -106,9 +100,11 @@ func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) } } -// newImageSearcher creates the appropriate ImageClient based on the configured -// image provider (openai or nanobanana). -func (p *Processor) newImageSearcher() (image.ImageClient, error) { +// newImageSearcher creates the appropriate PromptAwareClient based on the +// configured image provider (openai or nanobanana). Returning PromptAwareClient +// instead of ImageClient means callers can call SetPromptCallback directly +// without a type-assertion. +func (p *Processor) newImageSearcher() (image.PromptAwareClient, error) { switch p.imageProviderForRunMode() { case "openai": return p.newOpenAIImageSearcher() @@ -131,10 +127,10 @@ func (p *Processor) imageProviderForRunMode() string { return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } -// 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) { +// newOpenAIImageSearcher builds an OpenAI PromptAwareClient 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.PromptAwareClient, error) { openaiConfig := &image.OpenAIConfig{ APIKey: cli.GetOpenAIKey(), Model: p.flags.OpenAIImageModel, @@ -161,13 +157,13 @@ func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { return nil, fmt.Errorf("OpenAI API key is required for image generation") } - return p.newOpenAIImageClient(openaiConfig), nil + return p.imageFactories.NewOpenAIClient(openaiConfig), nil } -// newNanoBananaImageSearcher builds a NanoBanana ImageClient from CLI flags -// and the resolved processor Config, applying overrides in the same +// newNanoBananaImageSearcher builds a NanoBanana PromptAwareClient 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) { +func (p *Processor) newNanoBananaImageSearcher() (image.PromptAwareClient, error) { nanoBananaConfig := &image.NanoBananaConfig{ APIKey: cli.GetGoogleAPIKey(), Model: p.flags.NanoBananaModel, @@ -185,5 +181,5 @@ func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { return nil, fmt.Errorf("google API key is required for image generation") } - return p.newNanoBananaImageClient(nanoBananaConfig), nil + return p.imageFactories.NewNanoBananaClient(nanoBananaConfig), nil } diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 7d83149..14e5341 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -64,8 +64,9 @@ type Config struct { // Processor handles the main word processing logic. // Audio coordination is in audio_coordinator.go, card directory management is // in card_store.go, and image downloading is in image_downloader.go. -// The factory fields (newOpenAIImageClient, newNanoBananaImageClient, newAudioProvider) -// are injected at construction time so tests can swap them without mutating global state. +// Factory functions for image and audio providers are grouped in image.ClientFactories +// and the audio.ProviderFactory type so the signatures are defined once and +// shared with the gui package — eliminating parallel field duplication. type Processor struct { flags *cli.Flags translator *translation.Translator @@ -76,10 +77,13 @@ type Processor struct { // so individual methods never call Viper directly. cfg *Config - // 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) + // imageFactories groups the two image-provider construction functions. + // Production code uses image.DefaultClientFactories(); tests replace fields. + imageFactories image.ClientFactories + + // newAudioProvider constructs an audio.Provider from a Config. + // Production code uses audio.NewProvider; tests replace it with a fake. + newAudioProvider audio.ProviderFactory } // NewProcessor creates a new word processor with default production factories. @@ -99,12 +103,7 @@ func NewProcessor(flags *cli.Flags, cfg *Config) *Processor { 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) - }, + imageFactories: image.DefaultClientFactories(), newAudioProvider: audio.NewProvider, } } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 17b56e9..947f9d3 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -1058,7 +1058,7 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test ImageNanoBananaTextModelSet: true, } p := NewProcessor(flags, cfg) - p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + p.imageFactories.NewNanoBananaClient = func(config *image.NanoBananaConfig) image.PromptAwareClient { *capturedConfig = *config return stubSearcher } @@ -1102,7 +1102,7 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing flags.ImageAPISpecified = true p := NewProcessor(flags, &Config{ImageProvider: "nanobanana"}) - p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + p.imageFactories.NewNanoBananaClient = func(config *image.NanoBananaConfig) image.PromptAwareClient { return stubSearcher } err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple") @@ -1144,7 +1144,7 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe ImageNanoBananaTextModelSet: true, } p := NewProcessor(flags, cfg) - p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + p.imageFactories.NewNanoBananaClient = func(config *image.NanoBananaConfig) image.PromptAwareClient { *capturedConfig = *config return stubSearcher } @@ -1231,7 +1231,7 @@ func TestNewNanoBananaImageSearcherExplicitDefaultWinsOverConfig(t *testing.T) { ImageNanoBananaTextModelSet: true, } p := NewProcessor(flags, cfg) - p.newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient { + p.imageFactories.NewNanoBananaClient = func(config *image.NanoBananaConfig) image.PromptAwareClient { *capturedConfig = *config return &stubImageSearcher{} } -- cgit v1.2.3