summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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{}
}