Files
cthulhu/internal/app/reading_preferences_test.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])
}
}