summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
commit63bd86d8046949e80e5b1122a1fcf717f51915c6 (patch)
tree10bc72c97d1ba597bc82fb273987716eabf2e989 /internal/gui
parent5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (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.go10
-rw-r--r--internal/gui/generator.go16
-rw-r--r--internal/gui/generator_test.go145
-rw-r--r--internal/gui/navigation.go38
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 = &copyConfig
- 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 = &copyConfig
+ 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 = &copyConfig
- 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 = &copyConfig
+ 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 = &copyConfig
- 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 = &copyConfig
+ 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 = &copyConfig
- 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 = &copyConfig
+ 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 = &copyConfig
- 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 = &copyConfig
+ 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