252 lines
11 KiB
Go
252 lines
11 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 (
|
|
"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: "toggle button", Checked: true}, []string{"button", "togglebutton_checked"}},
|
|
{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{"button", "togglebutton_checked"}},
|
|
} {
|
|
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 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{"button", "togglebutton_checked"}},
|
|
{"sound_only", true, true, "Power", []string{"button", "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, check box, 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 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)
|
|
}
|
|
}
|