diff --git a/internal/app/radio_sound_test.go b/internal/app/radio_sound_test.go index a1e0387..69fea4e 100644 --- a/internal/app/radio_sound_test.go +++ b/internal/app/radio_sound_test.go @@ -22,6 +22,7 @@ func radio_sound_fixture(t *testing.T) (*eventPresenter, *fakeSpeechQueue, func( state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() state.presentationOptions.EnableSound = true + state.presentationOptions.PlaySoundForRole = true state.presentationOptions.PlaySoundForState = true state.presentationOptions.RoleSoundPresentation = "sound_only" queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} @@ -40,7 +41,7 @@ func TestRadioFocusSoundCoalescesSelectionWithoutDelayingLabel(t *testing.T) { option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Laptop", Mnemonic: "l"} present(atspi.NewEvent(atspi.FocusEvent, option)) request := <-queue.requests - if !strings.Contains(request.Text, "Laptop") || len(request.Sound) != 0 || presenter.state.pending.radioSound.ready == nil { + if !strings.Contains(request.Text, "Laptop") || strings.Contains(request.Text, "radio button") || len(request.Sound) != 0 || presenter.state.pending.radioSound.ready == nil { t.Fatalf("focus label/sound = %+v", request) } // The old member loses selection before GTK checks the focused member. diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index 84b5114..1890f94 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -127,7 +127,10 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech { role = "" } _, replaceRole := roleSoundCue(snapshot, options) - if options.RoleSoundPresentation == "sound_only" && replaceRole { + _, replaceState := stateSoundCue(snapshot, options) + // A control-specific state cue also identifies its role. Keep the role + // spoken when role sounds are disabled or no replacement cue is available. + if options.RoleSoundPresentation == "sound_only" && (replaceRole || options.PlaySoundForRole && replaceState) { role = "" } if options.SpeechVerbosityLevel != 0 && snapshot.ChildCountKnown && snapshot.ChildCount > 0 { @@ -147,7 +150,6 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech { } fields = append(fields, snapshot.Name, role) states := controlStates(snapshot) - _, replaceState := stateSoundCue(snapshot, options) if options.RoleSoundPresentation == "sound_only" && replaceState { states = removeReplacedState(states) } diff --git a/internal/presentation/sound_cues_test.go b/internal/presentation/sound_cues_test.go index 6a593db..18cf810 100644 --- a/internal/presentation/sound_cues_test.go +++ b/internal/presentation/sound_cues_test.go @@ -18,6 +18,7 @@ package presentation import ( + "fmt" "math" "reflect" "testing" @@ -209,7 +210,7 @@ func TestElementPresentationStrategiesKeepRoleAndStateIndependent(t *testing.T) 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" { + 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} @@ -222,6 +223,47 @@ func TestElementPresentationStrategiesKeepRoleAndStateIndependent(t *testing.T) } } +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}) diff --git a/internal/version/version.go b/internal/version/version.go index 1093261..8feec91 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.10.25" +const Version = "2026.10.26" const Channel = "testing" var Build = Version diff --git a/tests/native_echo_preview.py b/tests/native_echo_preview.py index 7fb6997..09bd6ec 100644 --- a/tests/native_echo_preview.py +++ b/tests/native_echo_preview.py @@ -143,7 +143,9 @@ def check_radio_buttons(controls, tabs, server, readerLog): time.sleep(0.35) cues = radio_cues()[cueOffset:] assert cues == ["radiobutton_checked"], (keyName, target, cues) - assert any(target in record["text"] for record in server.snapshot()[speechOffset:]), target + " label was lost" + spoken = [record["text"] for record in server.snapshot()[speechOffset:]] + assert any(target in text for text in spoken), target + " label was lost" + assert not any("radio button" in text for text in spoken), (target, spoken) records = [json.loads(line) for line in readerLog.read_text().splitlines() if line.startswith("{")] errors = [record for record in records if record.get("message") == "play sound" and record.get("cue", "").startswith("radiobutton_") and record.get("error") != "context canceled"]