Files
cthulhu/internal/app/reading_context_test.go

135 lines
6.2 KiB
Go

// 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"})
})
}
}