135 lines
6.2 KiB
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"})
|
|
})
|
|
}
|
|
}
|