Files

323 lines
14 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package presentation
import (
"fmt"
"math"
"reflect"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/sound"
)
func TestRoleAndStateSoundPolicy(t *testing.T) {
options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true}
for _, test := range []struct {
snapshot atspi.Snapshot
want []string
}{
{atspi.Snapshot{Role: "push button"}, []string{"button"}},
{atspi.Snapshot{Role: "check box", Checked: true}, []string{"checkbox_checked"}},
{atspi.Snapshot{Role: "check box", Indeterminate: true}, []string{"checkbox_mixed"}},
{atspi.Snapshot{Role: "check box"}, []string{"checkbox_unchecked"}},
{atspi.Snapshot{Role: "menu item", Checkable: true}, []string{"checkbox_unchecked"}},
{atspi.Snapshot{Role: "menu item", Checked: true}, []string{"checkbox_checked"}},
{atspi.Snapshot{Role: "menu item", Indeterminate: true}, []string{"checkbox_mixed"}},
{atspi.Snapshot{Role: "menu item"}, nil},
{atspi.Snapshot{Role: "toggle button", Checked: true}, []string{"togglebutton_checked"}},
{atspi.Snapshot{Role: "toggle button"}, []string{"togglebutton_unchecked"}},
{atspi.Snapshot{Role: "switch", Checked: true}, []string{"switch_on"}},
{atspi.Snapshot{Role: "switch"}, []string{"switch_off"}},
{atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}},
{atspi.Snapshot{Role: "entry"}, nil},
{atspi.Snapshot{Role: "terminal"}, nil},
} {
if got := cueNames(SoundCues(test.snapshot, options)); !reflect.DeepEqual(got, test.want) {
t.Fatalf("cues = %#v, want %#v", got, test.want)
}
}
options.RoleSoundPresentation = "sound_only"
if got := FocusTextWithOptions(atspi.Snapshot{Name: "Save", Role: "push button"}, options); got != "Save" {
t.Fatal(got)
}
options.EnableSound = false
if got := FocusTextWithOptions(atspi.Snapshot{Name: "Save", Role: "push button"}, options); got != "Save, push button" {
t.Fatal(got)
}
options.EnableSound = true
options.RoleSoundPresentation = "speech_only"
if got := SoundCues(atspi.Snapshot{Role: "push button"}, options); got != nil {
t.Fatalf("speech-only role presentation produced cues %#v", got)
}
options.RoleSoundPresentation = "sound_and_speech"
options.PlaySoundForRole = false
if got := cueNames(SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options)); !reflect.DeepEqual(got, []string{"togglebutton_checked"}) {
t.Fatalf("disabled role sound produced cues %#v", got)
}
options.PlaySoundForRole = true
options.PlaySoundForState = false
if got := cueNames(SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options)); !reflect.DeepEqual(got, []string{"button"}) {
t.Fatalf("disabled state sound produced cues %#v", got)
}
options.PlaySoundForRole = false
if got := SoundCues(atspi.Snapshot{Role: "toggle button", Checked: true}, options); got != nil {
t.Fatalf("disabled role and state sounds produced cues %#v", got)
}
}
func TestFocusedEventSoundCues(t *testing.T) {
options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_and_speech"}
for _, test := range []struct {
name string
snapshot atspi.Snapshot
want []string
}{
{"button", atspi.Snapshot{Role: "button"}, []string{"button"}},
{"link", atspi.Snapshot{Role: "link"}, []string{"link"}},
{"visited link", atspi.Snapshot{Role: "link", Visited: true}, []string{"link_visited"}},
{"checked toggle", atspi.Snapshot{Role: "toggle button", Checked: true}, []string{"togglebutton_checked"}},
{"unchecked toggle", atspi.Snapshot{Role: "toggle button"}, []string{"togglebutton_unchecked"}},
} {
t.Run(test.name, func(t *testing.T) {
for _, eventType := range []string{"focus:", "object:state-changed:focused"} {
event := atspi.NewEvent(eventType, test.snapshot)
if got := cueNames(EventSoundCues(event, options)); !reflect.DeepEqual(got, test.want) {
t.Errorf("%s cues = %v, want %v", eventType, got, test.want)
}
}
})
}
checked := atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Role: "check box", Checked: true})
if got := cueNames(EventSoundCues(checked, options)); !reflect.DeepEqual(got, []string{"checkbox_checked"}) {
t.Errorf("checked state cues = %v", got)
}
autocompleteState := atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Role: "entry", Autocomplete: true})
if got := EventSoundCues(autocompleteState, options); got != nil {
t.Errorf("autocomplete state replayed role cue: %v", got)
}
}
func TestToggleSoundPreservesUnavailableStateFeedback(t *testing.T) {
options := Options{
EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true,
RoleSoundPresentation: "sound_only",
SoundCueAvailable: func(name string) bool { return name == "button" },
}
for _, checked := range []bool{false, true} {
snapshot := atspi.Snapshot{Name: "Power", Role: "toggle button", Checked: checked}
if got := cueNames(SoundCues(snapshot, options)); !reflect.DeepEqual(got, []string{"button"}) {
t.Fatalf("unavailable state cue, checked=%v: %v", checked, got)
}
wantText := "Power, not pressed"
if checked {
wantText = "Power, pressed"
}
if got := FocusTextWithOptions(snapshot, options); got != wantText {
t.Fatalf("unavailable state speech = %q, want %q", got, wantText)
}
}
}
func cueNames(cues []sound.Cue) []string {
if cues == nil {
return nil
}
names := make([]string, len(cues))
for index, cue := range cues {
names[index] = cue.Name
}
return names
}
func TestProgressValueSound(t *testing.T) {
snapshot := atspi.Snapshot{Role: "progress bar", ValueKnown: true, MinimumValue: 0, CurrentValue: 50, MaximumValue: 100}
if got := ProgressValueSound(snapshot); got != "generated-progress-tone:50" {
t.Fatalf("progress sound = %q", got)
}
snapshot.CurrentValue = 200
if got := ProgressValueSound(snapshot); got != "generated-progress-tone:100" {
t.Fatalf("clamped progress sound = %q", got)
}
snapshot.Role = "slider"
if got := ProgressValueSound(snapshot); got != "" {
t.Fatalf("slider generated progress sound %q", got)
}
}
func TestProgressPercentagesRetainFractionsAndRejectInvalidValues(t *testing.T) {
for _, step := range []struct {
snapshot atspi.Snapshot
value float64
valid bool
}{
{atspi.Snapshot{Role: "progress bar", ValueKnown: true, MinimumValue: 10, CurrentValue: 20, MaximumValue: 30}, 50, true},
{atspi.Snapshot{Role: "progress bar", ValueText: "12.5%"}, 12.5, true},
{atspi.Snapshot{Role: "progress bar", ValueText: "101%"}, 100, true},
{atspi.Snapshot{Role: "progress bar", ValueText: "NaN"}, 0, false},
{atspi.Snapshot{Role: "progress bar", ValueText: "+Inf"}, 0, false},
{atspi.Snapshot{Role: "progress bar", ValueText: "working"}, 0, false},
{atspi.Snapshot{Role: "progress bar", ValueKnown: true, MaximumValue: 0}, 0, false},
{atspi.Snapshot{Role: "progress bar", ValueText: "50%", Indeterminate: true}, 0, false},
} {
value, valid := ProgressPercentage(step.snapshot)
if value != step.value || valid != step.valid {
t.Fatalf("progress %+v: %g, %t", step.snapshot, value, valid)
}
}
}
func TestElementPresentationStrategiesKeepRoleAndStateIndependent(t *testing.T) {
toggle := atspi.Snapshot{Name: "Power", Role: "toggle button", Checked: true}
for _, test := range []struct {
strategy string
role bool
state bool
text string
cues []string
}{
{"speech_only", true, true, "Power, toggle button, pressed", nil},
{"sound_and_speech", true, true, "Power, toggle button, pressed", []string{"togglebutton_checked"}},
{"sound_only", true, true, "Power", []string{"togglebutton_checked"}},
{"sound_only", false, true, "Power, toggle button", []string{"togglebutton_checked"}},
{"sound_only", true, false, "Power, pressed", []string{"button"}},
{"sound_only", false, false, "Power, toggle button, pressed", nil},
} {
options := Options{EnableSound: true, PlaySoundForRole: test.role, PlaySoundForState: test.state, RoleSoundPresentation: test.strategy}
if got := FocusTextWithOptions(toggle, options); got != test.text {
t.Errorf("%s role=%v state=%v text=%q, want %q", test.strategy, test.role, test.state, got, test.text)
}
if got := cueNames(SoundCues(toggle, options)); !reflect.DeepEqual(got, test.cues) {
t.Errorf("%s role=%v state=%v cues=%v, want %v", test.strategy, test.role, test.state, got, test.cues)
}
}
checkbox := atspi.Snapshot{Name: "Accept", Role: "check box", Checked: true, Required: true, Invalid: true}
options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_only"}
if got := FocusTextWithOptions(checkbox, options); got != "Accept, required, invalid entry" {
t.Fatalf("sound-only checkbox text = %q", got)
}
unsupported := atspi.Snapshot{Name: "Document", Role: "text frame", Expandable: true, Expanded: true}
if got := FocusTextWithOptions(unsupported, options); got != "Document, text frame, expanded" {
t.Fatalf("unsupported state text = %q", got)
}
options.SoundCueAvailable = func(string) bool { return false }
if got := FocusTextWithOptions(toggle, options); got != "Power, toggle button, pressed" {
t.Fatalf("unavailable cue fallback text = %q", got)
}
}
func TestStateCueReplacesControlRoleInSoundOnlyFocus(t *testing.T) {
for _, role := range []string{"check box", "check menu item", "radio button", "radio menu item", "switch", "menu item"} {
for _, checked := range []bool{false, true} {
for _, strategy := range []string{"sound_only", "sound_and_speech", "speech_only"} {
t.Run(fmt.Sprintf("%s/%t/%s", role, checked, strategy), func(t *testing.T) {
snapshot := atspi.Snapshot{Name: "Option", Role: role, Checked: checked, Checkable: role == "menu item"}
options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: strategy}
want := FocusText(snapshot)
if strategy == "sound_only" {
want = "Option"
}
if got := FocusTextWithOptions(snapshot, options); got != want {
t.Fatalf("focus speech = %q, want %q", got, want)
}
if strategy != "sound_only" {
return
}
for _, disabled := range []string{"sound", "state", "unavailable"} {
fallback := options
switch disabled {
case "sound":
fallback.EnableSound = false
case "state":
fallback.PlaySoundForState = false
case "unavailable":
fallback.SoundCueAvailable = func(string) bool { return false }
}
if got := FocusTextWithOptions(snapshot, fallback); got != FocusText(snapshot) {
t.Errorf("%s fallback = %q, want %q", disabled, got, FocusText(snapshot))
}
}
options.PlaySoundForRole = false
if got, want := FocusTextWithOptions(snapshot, options), "Option, "+snapshot.ControlRole(); got != want {
t.Errorf("disabled role sound speech = %q, want %q", got, want)
}
})
}
}
}
}
func TestStateChangeSoundOnlySuppressesOnlyReplacedState(t *testing.T) {
options := Options{EnableSound: true, PlaySoundForRole: true, PlaySoundForState: true, RoleSoundPresentation: "sound_only"}
event := atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Role: "check box", Checked: true})
if got := EventTextWithOptions(event, options); got != "" {
t.Fatalf("checked state remained spoken: %q", got)
}
unsupported := atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Role: "text frame", Expandable: true, Expanded: true})
if got := EventTextWithOptions(unsupported, options); got != "expanded" {
t.Fatalf("unsupported expanded state = %q", got)
}
toggleExpanded := atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Role: "toggle button", Checked: true, Expandable: true, Expanded: true})
if got := EventSoundCues(toggleExpanded, options); got != nil {
t.Fatalf("expanded event replayed unrelated cues %#v", got)
}
options.PlaySoundForState = false
if got := EventTextWithOptions(event, options); got != "checked" {
t.Fatalf("disabled state fallback = %q", got)
}
}
func TestObjectSoundPanUsesCapturedGeometry(t *testing.T) {
base := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true, Reference: atspi.Rectangle{X: 100, Y: 20, Width: 1000, Height: 600}}
for _, test := range []struct {
name string
object atspi.Rectangle
want float64
}{
{"left", atspi.Rectangle{X: 100, Y: 100, Width: 10, Height: 10}, -1},
{"center", atspi.Rectangle{X: 595, Y: 100, Width: 10, Height: 10}, 0},
{"right", atspi.Rectangle{X: 1090, Y: 100, Width: 10, Height: 10}, 1},
{"intermediate", atspi.Rectangle{X: 350, Y: 100, Width: 10, Height: 10}, -0.5444444444444445},
} {
geometry := base
geometry.Object = test.object
snapshot := atspi.Snapshot{Role: "push button", SoundGeometry: geometry}
options := Options{EnableSound: true, PlaySoundForRole: true, RoleSoundPresentation: "sound_only", SpatializeObjectSounds: true}
cues := SoundCues(snapshot, options)
if len(cues) != 1 || math.Abs(cues[0].Pan-test.want) > 1e-9 {
t.Errorf("%s pan = %#v, want %v", test.name, cues, test.want)
}
}
invalid := base
invalid.Object = atspi.Rectangle{X: 0, Y: 0, Width: 0, Height: 10}
if got := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: invalid}, Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true}); got[0].Pan != 0 {
t.Fatalf("invalid geometry pan = %v", got[0].Pan)
}
clipped := base
clipped.Object = atspi.Rectangle{X: 50, Y: 100, Width: 10, Height: 10}
if got := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: clipped}, Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true}); got[0].Pan != 0 {
t.Fatalf("clipped geometry pan = %v", got[0].Pan)
}
centered := SoundCues(atspi.Snapshot{Role: "push button", SoundGeometry: base}, Options{EnableSound: true, PlaySoundForRole: true})
if centered[0].Pan != 0 {
t.Fatalf("disabled spatialization pan = %v", centered[0].Pan)
}
}