diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 08:03:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 08:03:05 +0300 |
| commit | 63bd86d8046949e80e5b1122a1fcf717f51915c6 (patch) | |
| tree | 10bc72c97d1ba597bc82fb273987716eabf2e989 /internal/gui | |
| parent | 5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (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/gui')
| -rw-r--r-- | internal/gui/app.go | 10 | ||||
| -rw-r--r-- | internal/gui/generator.go | 16 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 145 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 38 |
4 files changed, 68 insertions, 141 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 4f8311e..e5cc9dd 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -105,6 +105,11 @@ type Application struct { // Active operations tracking activeOperations map[string]int // Map of word -> count of active operations activeOpMu sync.Mutex // Mutex for activeOperations map + + // 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) } // Config holds GUI application configuration @@ -218,6 +223,11 @@ func New(config *Config) *Application { cardContexts: make(map[string]context.CancelFunc), activeOperations: make(map[string]int), autoPlayEnabled: config.AutoPlay, // Use config setting + + // Production defaults for 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, } // Initialize the word processing queue diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 4763149..4b55608 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -22,16 +22,6 @@ type promptAwareImageClient interface { SetPromptCallback(func(prompt string)) } -var newOpenAIImageClient = func(config *image.OpenAIConfig) promptAwareImageClient { - return image.NewOpenAIClient(config) -} - -var newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { - return image.NewNanoBananaClient(config) -} - -var newAudioProvider = audio.NewProvider - func randomVoice(voices []string) string { rng := rand.New(rand.NewSource(time.Now().UnixNano())) return voices[rng.Intn(len(voices))] @@ -123,7 +113,7 @@ func (a *Application) audioConfigForGeneration(voice string, speed float64) audi func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error { audioConfig := a.audioConfigForGeneration(voice, speed) - provider, err := newAudioProvider(&audioConfig) + provider, err := a.newAudioProvider(&audioConfig) if err != nil { return err } @@ -410,7 +400,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) { Style: "natural", } - return newOpenAIImageClient(openaiConfig), nil + return a.newOpenAIImageClient(openaiConfig), nil case imageProviderNanoBanana: config := a.config if config == nil { @@ -426,7 +416,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) { TextModel: config.NanoBananaTextModel, } - return newNanoBananaImageClient(nanoBananaConfig), nil + return a.newNanoBananaImageClient(nanoBananaConfig), nil default: return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 4e4e3cc..c8bcf42 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -93,29 +93,9 @@ func (f *fakeAudioProvider) BuildAttribution(params audio.AttributionParams) str } func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { - originalNanoBananaClient := newNanoBananaImageClient - originalOpenAIClient := newOpenAIImageClient - t.Cleanup(func() { - newNanoBananaImageClient = originalNanoBananaClient - newOpenAIImageClient = originalOpenAIClient - }) - fakeClient := &fakePromptAwareImageClient{} var capturedConfig *image.NanoBananaConfig - newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { - capturedConfig = &image.NanoBananaConfig{ - APIKey: config.APIKey, - Model: config.Model, - TextModel: config.TextModel, - } - return fakeClient - } - newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient { - t.Fatal("unexpected OpenAI image client construction") - return nil - } - tempDir := t.TempDir() app := &Application{ config: &Config{ @@ -127,6 +107,18 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { }, currentWord: "друго", } + app.newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { + capturedConfig = &image.NanoBananaConfig{ + APIKey: config.APIKey, + Model: config.Model, + TextModel: config.TextModel, + } + return fakeClient + } + app.newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient { + t.Fatal("unexpected OpenAI image client construction") + return nil + } outputPath, err := app.generateImagesWithPrompt(context.Background(), "ябълка", "custom prompt", "apple", tempDir) if err != nil { @@ -176,11 +168,6 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(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 @@ -190,11 +177,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -215,6 +197,11 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir) if err != nil { @@ -267,11 +254,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.GeminiVoices...) t.Cleanup(func() { audio.GeminiVoices = originalVoices @@ -281,11 +263,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -306,6 +283,11 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { GeminiVoice: "", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir) if err != nil { @@ -367,11 +349,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(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 @@ -379,17 +356,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 - } tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "audio.wav") @@ -405,6 +371,17 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { GeminiTTSModel: "gemini-2.5-flash-preview-tts", }, } + app.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 + } voice, err := audio.RunWithVoiceFallbacks("Charon", func(candidate string) error { return app.generateAudioFile(context.Background(), "ябълка", outputPath, candidate, 1.0) @@ -422,11 +399,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(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 @@ -436,11 +408,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -460,6 +427,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } frontPath, backPath, err := app.generateAudioBgBg(context.Background(), "ябълка", "круша", cardDir) if err != nil { @@ -526,11 +498,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -540,11 +507,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -564,6 +526,11 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudioFront(context.Background(), "ябълка", cardDir) if err != nil { @@ -604,11 +571,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { } func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { - originalFactory := newAudioProvider - t.Cleanup(func() { - newAudioProvider = originalFactory - }) - originalVoices := append([]string(nil), audio.OpenAIVoices...) t.Cleanup(func() { audio.OpenAIVoices = originalVoices @@ -618,11 +580,6 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { fakeProvider := &fakeAudioProvider{} var capturedConfig *audio.Config - newAudioProvider = func(config *audio.Config) (audio.Provider, error) { - copyConfig := *config - capturedConfig = ©Config - return fakeProvider, nil - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -642,6 +599,11 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } outputPath, err := app.generateAudioBack(context.Background(), "круша", cardDir) if err != nil { @@ -682,20 +644,12 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(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-error-voice"} - newAudioProvider = func(*audio.Config) (audio.Provider, error) { - return nil, errors.New("provider factory failed") - } tempDir := t.TempDir() cardDir := filepath.Join(tempDir, "card") @@ -715,6 +669,9 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { OpenAIInstruction: "Speak clearly.", }, } + app.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return nil, errors.New("provider factory failed") + } _, err := app.generateAudioFront(context.Background(), "ябълка", cardDir) if err == nil { diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 206da00..c7e9264 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -15,41 +15,11 @@ import ( "codeberg.org/snonux/totalrecall/internal/anki" ) -// findCardDirectory finds the directory for a given Bulgarian word +// findCardDirectory finds the directory for a given Bulgarian word. +// Delegates to the shared internal.FindCardDirectory which also handles the +// legacy _word.txt fallback for backward compatibility. func (a *Application) findCardDirectory(word string) string { - entries, err := os.ReadDir(a.config.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(a.config.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 - } - } else { - // Try old format with underscore for backward compatibility - wordFile = filepath.Join(dirPath, "_word.txt") - if data, err := os.ReadFile(wordFile); err == nil { - storedWord := strings.TrimSpace(string(data)) - if storedWord == word { - return dirPath - } - } - } - } - - return "" + return internal.FindCardDirectory(a.config.OutputDir, word) } // scanExistingWords scans the output directory for existing words |
