// 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 . 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) } }