diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/audio/provider.go | 6 | ||||
| -rw-r--r-- | internal/gui/app.go | 20 | ||||
| -rw-r--r-- | internal/gui/generator.go | 8 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 4 | ||||
| -rw-r--r-- | internal/gui/orchestrator.go | 46 | ||||
| -rw-r--r-- | internal/image/search.go | 55 | ||||
| -rw-r--r-- | internal/processor/image_downloader.go | 50 | ||||
| -rw-r--r-- | internal/processor/processor.go | 23 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 8 |
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{} } |
