// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "reflect" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func TestContextAnnouncementsOnlyInBrowseMode(t *testing.T) { logger := slog.New(slog.DiscardHandler) queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} speaker := &spellingTestSpeaker{} worker := newSpeechWorker(logger, speaker) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } browser := &browserModeController{} state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser} table := atspi.Snapshot{BusName: ":1.42", Path: "/table", Role: "table", Name: "Timeline"} control := atspi.Snapshot{BusName: table.BusName, Path: "/item", Role: "text", Name: "Item", SpeechContext: []atspi.Snapshot{table}} check := func(snapshot atspi.Snapshot, want []string) { t.Helper() // Each step represents fresh focus, even if its label is unchanged. state.lastSpokenPath = "" presenter.speak(atspi.NewEvent(atspi.FocusEvent, snapshot)) speaker.parts = nil if len(queue.requests) != 1 { t.Fatalf("queued %d requests", len(queue.requests)) } if err := worker.deliver(context.Background(), <-queue.requests); err != nil { t.Fatal(err) } if !reflect.DeepEqual(speaker.parts, want) { t.Fatalf("speech = %q, want %q", speaker.parts, want) } } check(control, []string{"Item", "text"}) terminal := atspi.Snapshot{BusName: ":1.43", Path: "/terminal", Role: "terminal", Name: "Terminal", SpeechContext: []atspi.Snapshot{}} check(terminal, []string{"Terminal", "terminal"}) if len(worker.speechContext) != 0 { t.Fatal("native context was retained") } browser.ready, browser.mode = true, review.BrowseMode browser.document = atspi.NewObjectID(table.BusName, "/document") check(control, []string{"Timeline table", "Item", "text"}) outside := control outside.Name, outside.SpeechContext = "Outside", []atspi.Snapshot{} check(outside, []string{"Leaving Timeline table.", "Outside", "text"}) check(control, []string{"Timeline table", "Item", "text"}) // A workspace switch must clear browser context without saying its exit, // even while asynchronous browser discovery still holds the old mode. check(terminal, []string{"Terminal", "terminal"}) check(control, []string{"Timeline table", "Item", "text"}) browser.mode = review.FocusMode check(control, []string{"Item", "text"}) browser.mode = review.BrowseMode check(control, []string{"Timeline table", "Item", "text"}) browser.ready = false check(control, []string{"Item", "text"}) } func TestReviewContextSuppressedOutsideBrowseMode(t *testing.T) { logger := slog.New(slog.DiscardHandler) for _, enabled := range []bool{false, true} { queue := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} scoped := contextSpeechQueue{speechEnqueuer: queue, enabled: enabled} list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} speaker := &PauseWaitSpeaker{} worker := newSpeechWorker(logger, speaker) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } worker.speechContext = []atspi.Snapshot{{BusName: ":1.42", Path: "/previous", Role: "table"}} scoped.Enqueue(speechRequest{ReadAll: []readAllPart{{Text: "Inside", Context: []atspi.Snapshot{list}}}}) if err := worker.deliver(context.Background(), <-queue.requests); err != nil { t.Fatal(err) } want := []string{"Inside"} if enabled { want = []string{"Leaving table.", "Items list", "Inside"} } if !reflect.DeepEqual(speaker.parts, want) { t.Fatalf("browse=%v: speech=%q, want %q", enabled, speaker.parts, want) } } } func TestMessageArticleAnnouncedOnceWhenTabbingIntoControls(t *testing.T) { for name, mode := range map[string]review.DocumentMode{"browse": review.BrowseMode, "focus": review.FocusMode, "resolving": review.InactiveMode} { t.Run(name, func(t *testing.T) { logger := slog.New(slog.DiscardHandler) queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} speaker := &spellingTestSpeaker{} worker := newSpeechWorker(logger, speaker) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } browser := &browserModeController{ready: mode != review.InactiveMode, mode: mode, document: atspi.NewObjectID(":1.42", "/document")} state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser} article := atspi.Snapshot{BusName: ":1.42", Path: "/message1", Role: "article", Name: "Author: First message"} check := func(path, name string, ancestors []atspi.Snapshot, want []string) { t.Helper() control := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "button", Name: name, SpeechContext: ancestors} presenter.speak(atspi.NewEvent(atspi.FocusEvent, control)) speaker.parts = nil if len(queue.requests) != 1 { t.Fatalf("queued %d requests", len(queue.requests)) } if err := worker.deliver(context.Background(), <-queue.requests); err != nil { t.Fatal(err) } if !reflect.DeepEqual(speaker.parts, want) { t.Fatalf("speech = %q, want %q", speaker.parts, want) } } check("/reply1", "Reply", []atspi.Snapshot{article}, []string{"Author: First message article", "Reply", "button"}) check("/forward1", "Forward", []atspi.Snapshot{article}, []string{"Forward", "button"}) article.Path, article.Name = "/message2", "Author: Second message" check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"}) check("/outside", "Outside", []atspi.Snapshot{}, []string{"Outside", "button"}) check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"}) }) } }