308 lines
13 KiB
Go
308 lines
13 KiB
Go
// 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])
|
|
}
|
|
}
|