summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 11:06:19 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 11:06:19 +0300
commit05f54cc0cb8cf3535698ab5027d200842bdb28e3 (patch)
tree30b7e778be29db63301119b7cfa8be5a5edc041e
parent23160bce9a18a70080a85dda6e9c654499aba7f7 (diff)
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 <noreply@anthropic.com>
-rw-r--r--internal/audio/provider.go6
-rw-r--r--internal/gui/app.go20
-rw-r--r--internal/gui/generator.go8
-rw-r--r--internal/gui/generator_test.go4
-rw-r--r--internal/gui/orchestrator.go46
-rw-r--r--internal/image/search.go55
-rw-r--r--internal/processor/image_downloader.go50
-rw-r--r--internal/processor/processor.go23
-rw-r--r--internal/processor/processor_test.go8
9 files changed, 138 insertions, 82 deletions
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
index e4e5888..296521d 100644
--- a/internal/audio/provider.go
+++ b/internal/audio/provider.go
@@ -131,6 +131,12 @@ func DefaultProviderConfig() *Config {
}
}
+// ProviderFactory is the canonical type for functions that construct an audio
+// Provider from a Config. Using a named type avoids duplicating the raw function
+// signature in every package that needs to inject or replace the factory
+// (processor, gui). The production default is audio.NewProvider itself.
+type ProviderFactory func(*Config) (Provider, error)
+
// NewProvider creates the appropriate audio provider based on configuration.
// It extracts provider-specific sub-configs so each implementation only
// receives the fields it needs (ISP).
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 126a50b..e1895a4 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -109,9 +109,10 @@ type Application struct {
// Injectable factory functions — replaced in tests to avoid real API calls.
// These are kept on Application so tests can set them before construction
// of the orchestrator; New() copies them into the orchestrator.
- newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient
- newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient
- newAudioProvider func(*audio.Config) (audio.Provider, error)
+ // imageFactories uses the shared image.ClientFactories type so the factory
+ // signatures are defined once in the image package rather than duplicated here.
+ imageFactories image.ClientFactories
+ newAudioProvider audio.ProviderFactory
// Service layer — decoupled from the UI event-wiring in Application.
cardSvc *CardService // file discovery, directory management, persistence
@@ -200,9 +201,10 @@ func New(config *Config) *Application {
autoPlayEnabled: config.AutoPlay,
// Production-default factory functions; replaced in tests.
- newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) },
- newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) },
- newAudioProvider: audio.NewProvider,
+ // image.DefaultClientFactories() is the single source of truth for the
+ // image factory signatures shared with the processor package.
+ imageFactories: image.DefaultClientFactories(),
+ newAudioProvider: audio.NewProvider,
}
a.initAppServices(config)
@@ -285,8 +287,7 @@ func (a *Application) initAppServices(config *Config) {
a.audioConfig,
a.phoneticFetcher,
a.translator,
- a.newOpenAIImageClient,
- a.newNanoBananaImageClient,
+ a.imageFactories,
a.newAudioProvider,
)
}
@@ -368,8 +369,7 @@ func (a *Application) getOrchestrator() *GenerationOrchestrator {
a.audioConfig,
a.phoneticFetcher,
a.translator,
- a.newOpenAIImageClient,
- a.newNanoBananaImageClient,
+ a.imageFactories,
a.newAudioProvider,
)
}
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 312baba..153b365 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -6,16 +6,8 @@ import (
"time"
"codeberg.org/snonux/totalrecall/internal/audio"
- "codeberg.org/snonux/totalrecall/internal/image"
)
-// promptAwareImageClient extends ImageClient with prompt-callback support
-// used by the GUI to capture and display the last generated image prompt.
-type promptAwareImageClient interface {
- image.ImageClient
- SetPromptCallback(func(prompt string))
-}
-
// randomVoice picks a random voice from the provided list.
// Used by GenerationOrchestrator for both OpenAI and Gemini voice selection.
func randomVoice(voices []string) string {
diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go
index c8bcf42..40c0e2c 100644
--- a/internal/gui/generator_test.go
+++ b/internal/gui/generator_test.go
@@ -107,7 +107,7 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
},
currentWord: "друго",
}
- app.newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient {
+ app.imageFactories.NewNanoBananaClient = func(config *image.NanoBananaConfig) image.PromptAwareClient {
capturedConfig = &image.NanoBananaConfig{
APIKey: config.APIKey,
Model: config.Model,
@@ -115,7 +115,7 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
}
return fakeClient
}
- app.newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient {
+ app.imageFactories.NewOpenAIClient = func(*image.OpenAIConfig) image.PromptAwareClient {
t.Fatal("unexpected OpenAI image client construction")
return nil
}
diff --git a/internal/gui/orchestrator.go b/internal/gui/orchestrator.go
index 5081a98..2861c4f 100644
--- a/internal/gui/orchestrator.go
+++ b/internal/gui/orchestrator.go
@@ -19,37 +19,42 @@ import (
// GenerationOrchestrator coordinates audio, image, and phonetics generation
// for a single card. It holds all injectable factory functions so tests can
// substitute fakes without touching the UI layer.
+// image.ClientFactories groups the two image-factory functions so the field
+// definitions are not duplicated between this type and processor.Processor.
type GenerationOrchestrator struct {
config *Config
audioConfig *audio.Config
phonetics *phonetic.Fetcher
translator *translation.Translator
- // Injectable factory functions — replaced in tests to avoid real API calls.
- newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient
- newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient
- newAudioProvider func(*audio.Config) (audio.Provider, error)
+ // 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
}
// NewGenerationOrchestrator constructs an orchestrator wired to the given app
-// configuration and service dependencies.
+// configuration and service dependencies. imageFactories and newAudio are the
+// injectable test seams — pass image.DefaultClientFactories() and
+// audio.NewProvider for production behaviour.
func NewGenerationOrchestrator(
config *Config,
audioCfg *audio.Config,
phonetics *phonetic.Fetcher,
translator *translation.Translator,
- newOpenAI func(*image.OpenAIConfig) promptAwareImageClient,
- newNanoBanana func(*image.NanoBananaConfig) promptAwareImageClient,
- newAudio func(*audio.Config) (audio.Provider, error),
+ imageFactories image.ClientFactories,
+ newAudio audio.ProviderFactory,
) *GenerationOrchestrator {
return &GenerationOrchestrator{
- config: config,
- audioConfig: audioCfg,
- phonetics: phonetics,
- translator: translator,
- newOpenAIImageClient: newOpenAI,
- newNanoBananaImageClient: newNanoBanana,
- newAudioProvider: newAudio,
+ config: config,
+ audioConfig: audioCfg,
+ phonetics: phonetics,
+ translator: translator,
+ imageFactories: imageFactories,
+ newAudioProvider: newAudio,
}
}
@@ -478,8 +483,11 @@ func (o *GenerationOrchestrator) imagePromptCallback(cardDir, word string) func(
}
// newImageSearcher constructs the appropriate image client based on the
-// configured image provider.
-func (o *GenerationOrchestrator) newImageSearcher() (promptAwareImageClient, error) {
+// configured image provider. Returns image.PromptAwareClient so callers can
+// call SetPromptCallback directly without a type-assertion. The factory
+// functions are sourced from imageFactories (the shared image.ClientFactories
+// value) to avoid duplicating the factory signatures in this package.
+func (o *GenerationOrchestrator) newImageSearcher() (image.PromptAwareClient, error) {
switch o.config.ImageProvider {
case imageProviderOpenAI:
if o.config.OpenAIKey == "" {
@@ -494,7 +502,7 @@ func (o *GenerationOrchestrator) newImageSearcher() (promptAwareImageClient, err
Style: "natural",
}
- return o.newOpenAIImageClient(openaiConfig), nil
+ return o.imageFactories.NewOpenAIClient(openaiConfig), nil
case imageProviderNanoBanana:
cfg := o.config
@@ -511,7 +519,7 @@ func (o *GenerationOrchestrator) newImageSearcher() (promptAwareImageClient, err
TextModel: cfg.NanoBananaTextModel,
}
- return o.newNanoBananaImageClient(nanoBananaConfig), nil
+ return o.imageFactories.NewNanoBananaClient(nanoBananaConfig), nil
default:
return nil, fmt.Errorf("unknown image provider: %s", o.config.ImageProvider)
diff --git a/internal/image/search.go b/internal/image/search.go
index 7e405da..61176cd 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -77,6 +77,61 @@ type ImageClient interface {
AttributionProvider
}
+// PromptAwareClient extends ImageClient with a callback for receiving the
+// generated image prompt before the actual image download begins. Both
+// OpenAIClient and NanoBananaClient implement this interface. It is the
+// preferred return type for factory functions so callers (processor, gui) can
+// register a prompt callback without a type-assertion.
+type PromptAwareClient interface {
+ ImageClient
+ // SetPromptCallback registers a function that is called with the generated
+ // prompt text before the image download begins.
+ SetPromptCallback(func(prompt string))
+}
+
+// OpenAIClientFactory is the canonical type for functions that construct an
+// OpenAI image client from config. Using a named type avoids duplicating the
+// raw function signature in every package that needs to inject or replace the
+// factory (processor, gui).
+type OpenAIClientFactory func(*OpenAIConfig) PromptAwareClient
+
+// NanoBananaClientFactory is the canonical type for functions that construct a
+// NanoBanana (Gemini) image client from config. Using a named type avoids
+// duplicating the raw function signature in every package that needs to inject
+// or replace the factory (processor, gui).
+type NanoBananaClientFactory func(*NanoBananaConfig) PromptAwareClient
+
+// ClientFactories groups the two image-provider construction functions.
+// Embedding or holding a ClientFactories value is the single source of truth
+// for the image-factory test seams; packages no longer redeclare the same
+// fields independently. Audio factory injection is kept separate (audio.ProviderFactory)
+// to avoid an import cycle between the image and audio packages.
+type ClientFactories struct {
+ // NewOpenAIClient constructs a PromptAwareClient from an OpenAI config.
+ // Production code uses the real constructor; tests replace it with a fake.
+ NewOpenAIClient OpenAIClientFactory
+
+ // NewNanoBananaClient constructs a PromptAwareClient from a NanoBanana config.
+ // Production code uses the real constructor; tests replace it with a fake.
+ NewNanoBananaClient NanoBananaClientFactory
+}
+
+// DefaultClientFactories returns a ClientFactories wired to the real production
+// constructors. Callers that need test doubles replace individual fields before
+// passing the value to a constructor.
+func DefaultClientFactories() ClientFactories {
+ return ClientFactories{
+ NewOpenAIClient: func(c *OpenAIConfig) PromptAwareClient {
+ // NewOpenAIClient returns *OpenAIClient which implements PromptAwareClient.
+ return NewOpenAIClient(c)
+ },
+ NewNanoBananaClient: func(c *NanoBananaConfig) PromptAwareClient {
+ // NewNanoBananaClient returns *NanoBananaClient which implements PromptAwareClient.
+ return NewNanoBananaClient(c)
+ },
+ }
+}
+
// SearchError represents an error from an image search provider
type SearchError struct {
Provider string
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{}
}