// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "fmt" "log/slog" "reflect" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/notification" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/sound" ) func TestIndentationTracksRawSpacingAndModes(t *testing.T) { reading := config.DefaultSettings().Reading reading.EnableIndentation = true reading.IndentationPresentationMode = "both" worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.reading = func() config.ReadingSettings { return reading } var cues []sound.Cue worker.indentationCue = func(cue sound.Cue) { cues = append(cues, cue) } id := atspi.ObjectID{BusName: ":1.42", Path: "/editor"} for _, tc := range []struct { raw, spoken, want string frequency int }{ {"\t text", "text", "indentation 5 columns, text", 400}, {"\t other", "other", "other", 0}, {" text", "text", "indentation 2 columns, text", 210}, {"text", "text", "indentation 0 columns, text", 150}, {"\u00a0text", "text", "indentation 1 column, text", 280}, {" text", "text", "text", 0}, } { cues = nil if got := worker.line_text(tc.spoken, &speechLine{Object: id, Raw: tc.raw}); got != tc.want { t.Fatalf("%q: %q, want %q", tc.raw, got, tc.want) } if tc.frequency == 0 { if len(cues) != 0 { t.Fatal("unchanged indentation beeped") } } else if len(cues) != 1 || cues[0].Name != sound.IndentationCueName(tc.frequency) { t.Fatalf("indentation cue = %+v", cues) } } reading.IndentationPresentationMode = "beeps" if got := worker.line_text("text", &speechLine{Object: id, Raw: " text"}); got != "text" { t.Fatal("beeps mode spoke indentation") } reading.OnlySpeakDisplayedText = true cues = nil if got := worker.line_text("text", &speechLine{Object: id, Raw: " text"}); got != "text" || len(cues) != 0 { t.Fatal("displayed-text mode presented indentation") } reading.OnlySpeakDisplayedText, reading.EnableIndentation = false, false if got := worker.line_text("text", &speechLine{Object: id, Raw: "text"}); got != "text" { t.Fatal("disabled indentation spoke") } } func TestNavigationContextFlagsAndTransitions(t *testing.T) { reading := config.DefaultSettings().Reading worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.reading = func() config.ReadingSettings { return reading } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} panel := atspi.Snapshot{BusName: ":1.42", Path: "/panel", Role: "panel", Name: "Options"} if got := worker.context_messages([]atspi.Snapshot{list, panel}); !reflect.DeepEqual(got, []string{"Options panel", "Items list"}) { t.Fatalf("enter context = %v", got) } if got := worker.context_messages([]atspi.Snapshot{list, panel}); len(got) != 0 { t.Fatal("unchanged context repeated") } reading.SpeakContextList = false if got := worker.context_messages([]atspi.Snapshot{panel}); len(got) != 0 { t.Fatal("disabled list exit announced") } if got := worker.context_messages([]atspi.Snapshot{}); !reflect.DeepEqual(got, []string{"Leaving Options panel."}) { t.Fatalf("exit context = %v", got) } reading.OnlySpeakDisplayedText = true if got := worker.context_messages([]atspi.Snapshot{list, panel}); len(got) != 0 { t.Fatal("displayed-text mode announced context") } } func TestListContextCountFollowsSpeechVerbosity(t *testing.T) { reading := config.DefaultSettings().Reading verbosity := 1 worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.reading = func() config.ReadingSettings { return reading } worker.verbosity = func() int { return verbosity } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Tasks", ChildCount: 3, ChildCountKnown: true} if got := worker.context_messages([]atspi.Snapshot{list}); !reflect.DeepEqual(got, []string{"Tasks, 3 items"}) { t.Fatalf("verbose list context = %v", got) } worker.context_messages([]atspi.Snapshot{}) verbosity = 0 if got := worker.context_messages([]atspi.Snapshot{list}); !reflect.DeepEqual(got, []string{"Tasks list"}) { t.Fatalf("brief list context = %v", got) } } func TestSayAllContextFlagsAreIndependentOfNavigation(t *testing.T) { for _, test := range []struct { role string disable func(*config.ReadingSettings) }{ {"block quote", func(r *config.ReadingSettings) { r.SayAllContextBlockquote = false }}, {"list", func(r *config.ReadingSettings) { r.SayAllContextList = false }}, {"table", func(r *config.ReadingSettings) { r.SayAllContextTable = false }}, {"panel", func(r *config.ReadingSettings) { r.SayAllContextPanel = false }}, {"landmark", func(r *config.ReadingSettings) { r.SayAllContextLandmark = false }}, {"form", func(r *config.ReadingSettings) { r.SayAllContextNonLandmarkForm = false }}, } { reading := config.DefaultSettings().Reading worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.reading = func() config.ReadingSettings { return reading } ancestor := atspi.Snapshot{BusName: ":1.42", Path: "/context", Role: test.role} if got := worker.context_messages_for([]atspi.Snapshot{ancestor}, true); len(got) != 1 { t.Fatalf("%s Say All context = %v", test.role, got) } test.disable(&reading) worker.speechContext = nil if got := worker.context_messages_for([]atspi.Snapshot{ancestor}, true); len(got) != 0 { t.Fatalf("%s disabled Say All context = %v", test.role, got) } worker.speechContext = nil if got := worker.context_messages([]atspi.Snapshot{ancestor}); len(got) != 1 { t.Fatalf("%s navigation context changed = %v", test.role, got) } } } func TestNavigationContextCollapsesRepeatedPanels(t *testing.T) { reading := config.DefaultSettings().Reading worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.reading = func() config.ReadingSettings { return reading } panels := make([]atspi.Snapshot, 5) for index := range panels { panels[index] = atspi.Snapshot{BusName: ":1.42", Path: fmt.Sprintf("/panel/%d", index), Role: "panel"} } check := func(ancestors []atspi.Snapshot, want []string) { t.Helper() if got := worker.context_messages(ancestors); !reflect.DeepEqual(got, want) { t.Fatalf("context = %v, want %v", got, want) } } check(panels, []string{"panel"}) check(panels, nil) // Unknown context must not discard remembered container identities. check(nil, nil) check(panels, nil) check(panels[4:], []string{"Leaving panel."}) check(panels[4:], nil) check([]atspi.Snapshot{}, []string{"Leaving panel."}) check(panels, []string{"panel"}) check([]atspi.Snapshot{}, []string{"Leaving panel."}) panels[0].Name, panels[1].Name = "Inner", "Outer" panels[2].Role = "grouping" list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} ancestors := append([]atspi.Snapshot{list}, panels...) check(ancestors, []string{"panel", "Outer panel", "Inner panel", "Items list"}) check([]atspi.Snapshot{}, []string{"Leaving Items list.", "Leaving Inner panel.", "Leaving Outer panel.", "Leaving panel."}) reading.SpeakContextPanel = false check(ancestors, []string{"Items list"}) check([]atspi.Snapshot{}, []string{"Leaving Items list."}) } func TestDeletedCharacterRetainsCaseAndEchoRouting(t *testing.T) { for _, custom := range []bool{false, true} { settings := config.DefaultSettings() settings.UseCustomEchoVoice = custom live := &liveSettings{} live.Store(settings) queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} state := &eventLoopState{} presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} for _, tc := range []struct { payload, text string character bool }{ {"A", "A", true}, {"a", "a", true}, {"É", "É", true}, {"é", "é", true}, {"!", "!", true}, {" ", "space", false}, {"\n", "blank", false}, {"AB", "AB", false}, } { presenter.speak(atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, tc.payload)) request := <-queue.requests <-queue.spoken if request.Text != tc.text || request.Character != tc.character || request.CustomEcho != (custom && tc.character) { t.Fatalf("delete %q, custom=%v: %+v", tc.payload, custom, request) } } } } type voiceConfigurationRecorder struct { settings config.Settings calls int } func (recorder *voiceConfigurationRecorder) UpdateConfiguration(_ context.Context, settings config.Settings) error { recorder.settings = settings recorder.calls++ return nil } func TestSpecialVoiceApplyPreviewAndCancel(t *testing.T) { settings := config.DefaultSettings() updater := &voiceConfigurationRecorder{} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater) values := preferences.FromSettings(settings) pitch := 3.0 values.HyperlinkVoice.AveragePitch = &pitch values.VoiceType = "hyperlink" if response := worker.preview(context.Background(), preferences.Request{Values: values}); !response.Accepted { t.Fatal(response.Message) } if updater.settings.DefaultVoice.AveragePitch == nil || *updater.settings.DefaultVoice.AveragePitch != pitch || worker.settings.DefaultVoice.AveragePitch != nil { t.Fatal("preview changed committed/default draft voice") } if response := worker.restorePreview(context.Background()); !response.Accepted || updater.settings.DefaultVoice.AveragePitch != nil { t.Fatal("Cancel did not restore committed voice") } if response := worker.apply(context.Background(), preferences.Request{Values: values}); !response.Accepted { t.Fatal(response.Message) } if updater.calls != 3 || *updater.settings.HyperlinkVoice.AveragePitch != pitch || updater.settings.DefaultVoice.AveragePitch != nil { t.Fatal("special voice edit did not reach runtime independently") } } func TestTypedCapitalRetainsCharacterAndCustomEcho(t *testing.T) { settings := config.DefaultSettings() settings.UseCustomEchoVoice = true live := &liveSettings{} live.Store(settings) queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} state := &eventLoopState{} state.presentationOptions = presentationSettingsFromConfig(settings, false) presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue, live: live} snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true} presenter.speak(atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "É")) request := <-queue.requests if request.Text != "É" || !request.Character || !request.CustomEcho { t.Fatalf("capital insertion routing = %+v", request) } // Document spelling must retain case after replacing letters with words. set_review_spelling(&request, "Aa", review.FlatPhonetic) if !reflect.DeepEqual(request.SpellingVoices, []string{"uppercase", ""}) { t.Fatal("phonetic spelling lost uppercase voice") } } func TestBriefNotificationSpeechPreservesDetailedHistory(t *testing.T) { settings := config.DefaultSettings() live := &liveSettings{} queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} history := notification.NewPresenter() announcer := notificationAnnouncer{presenter: history, speech: queue, live: live} entry := notification.Entry{Message: "Update: more details", Summary: "Update", Body: "more details"} for _, detailed := range []bool{false, true} { settings.Reading.MessagesAreDetailed = detailed live.Store(settings) announcer.Announce(entry) request := <-queue.requests want := entry.Summary if detailed { want = entry.Message } if request.Text != want { t.Fatalf("detailed=%v: %q", detailed, request.Text) } } if entries := history.Entries(); len(entries) == 0 || entries[0].Message != entry.Message { t.Fatal("brief speech discarded notification details") } } func TestReadAllDoesNotExpandEveryCellToItsRow(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list"} line := atspi.DocumentLine{Context: []atspi.Snapshot{list}} for _, name := range []string{"One", "Two"} { line.Contents = append(line.Contents, atspi.DocumentContent{Text: name, Snapshot: atspi.Snapshot{ Path: "/" + name, Role: "table cell", TablePath: "/table", TableRowText: "One, Two", }}) } result := review.Result{ReadAllContext: context.Background(), ReadAllLines: []atspi.DocumentLine{line}} presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions()) request := <-queue.requests if len(request.ReadAll) != 2 || request.ReadAll[0].Text != "One" || request.ReadAll[1].Text != "Two" { t.Fatalf("Say All repeated row: %+v", request.ReadAll) } if len(request.ReadAll[0].Context) != 1 || request.ReadAll[0].Context[0].ID() != list.ID() { t.Fatalf("Say All lost line context: %+v", request.ReadAll[0]) } }