// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/json" "io" "log/slog" "path/filepath" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/speech" ) func TestPreferencesSessionRestoresPreviewAfterRejectedApplyAndReopen(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") updates := make(chan speech.Settings, 3) worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(_ context.Context, value speech.Settings) error { updates <- value return nil })) worker.announceApply = func(message string) { t.Errorf("preview or rejected Apply announced success: %q", message) } reader, writer := io.Pipe() defer reader.Close() defer writer.Close() stopIO := context.AfterFunc(ctx, func() { reader.CloseWithError(ctx.Err()) writer.CloseWithError(ctx.Err()) }) defer stopIO() requests := make(chan preferences.Request) readDone := make(chan error, 1) done := make(chan error, 1) go func() { done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) }() decoder := json.NewDecoder(reader) read_response := func() preferences.Response { t.Helper() var response preferences.Response if err := decoder.Decode(&response); err != nil { t.Fatal(err) } return response } rate := 75.0 values := preferences.FromSettings(settings) values.Rate = &rate select { case requests <- preferences.Request{Values: values, Preview: true}: case <-ctx.Done(): t.Fatal(ctx.Err()) } if response := read_response(); !response.Accepted || !response.Preview { t.Fatalf("preview was rejected: %+v", response) } if len(updates) != 1 { t.Fatal("preview did not produce exactly one speech update") } if update := <-updates; update.Rate == nil || *update.Rate == 0 { t.Fatalf("preview did not reach speech: %+v", update) } worker.Open() if response := read_response(); !response.Present || response.Close { t.Fatalf("reopen did not raise the existing window: %+v", response) } values.Punctuation = 99 select { case requests <- preferences.Request{Values: values}: case <-ctx.Done(): t.Fatal(ctx.Err()) } if response := read_response(); response.Accepted || response.Close { t.Fatalf("invalid draft committed or closed: %+v", response) } readDone <- io.EOF select { case err := <-done: if err != nil { t.Fatal(err) } case <-ctx.Done(): t.Fatal("preferences session did not join after helper EOF") } if len(updates) != 1 { t.Fatal("exit did not produce exactly one restoration update") } if update := <-updates; update.Rate != nil { t.Fatalf("helper exit did not restore the server default rate: %+v", update) } if len(updates) != 0 || worker.settings.DefaultVoice.Rate != nil || worker.settings.VerbalizePunctuationStyle != settings.VerbalizePunctuationStyle { t.Fatal("reopen or rejected apply committed preview settings") } if loaded, err := config.Load(settings.Path); err != nil || loaded.Loaded { t.Fatalf("preview/rejection wrote settings: loaded=%t, err=%v", loaded.Loaded, err) } } func TestChooserSelectionSavesSelectedProfileWhileAnotherAppIsActive(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") store, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } if err = store.Save(settings); err != nil { t.Fatal(err) } worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) worker.discover = func(context.Context, string) (speech.Catalog, error) { return nil, nil } worker.switchApplication(ctx, "Focused Application") worker.choosingApplication = true reader, writer := io.Pipe() defer reader.Close() defer writer.Close() stopIO := context.AfterFunc(ctx, func() { reader.CloseWithError(ctx.Err()); writer.CloseWithError(ctx.Err()) }) defer stopIO() requests := make(chan preferences.Request) readDone := make(chan error, 1) done := make(chan error, 1) go func() { done <- worker.serve_preferences(ctx, ctx, json.NewEncoder(writer), requests, readDone) }() requests <- preferences.Request{Application: "Chosen Application"} decoder := json.NewDecoder(reader) var initial preferences.Response if err = decoder.Decode(&initial); err != nil { t.Fatal(err) } if initial.Application != "Chosen Application" || initial.ChooseApplication || !initial.CanSave { t.Fatalf("selected application preferences = %+v", initial) } values := initial.Values values.Reading.AutomaticallyReadClipboardChanges = true requests <- preferences.Request{Values: values, Save: true} var response preferences.Response if err = decoder.Decode(&response); err != nil || !response.Accepted { t.Fatalf("apply = %+v, %v", response, err) } readDone <- io.EOF if err = <-done; err != nil { t.Fatal(err) } chosen, mapped, err := store.LoadForApplication("Chosen Application") if err != nil || !mapped || !chosen.Reading.AutomaticallyReadClipboardChanges { t.Fatalf("chosen override = %t, mapped %t, %v", chosen.Reading.AutomaticallyReadClipboardChanges, mapped, err) } focused, _, err := store.LoadForApplication("Focused Application") if err != nil || focused.Reading.AutomaticallyReadClipboardChanges || worker.settings.Reading.AutomaticallyReadClipboardChanges { t.Fatal("chosen application's preference leaked into focused application") } }