Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,285 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
)
|
||||
|
||||
func TestWineEventFocusBurstAndSelection(t *testing.T) {
|
||||
var spoken []string
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
spoken = append(spoken, message)
|
||||
return true
|
||||
})
|
||||
defer owner.Close()
|
||||
if !owner.Present(remote.AccessibleEvent{SourceID: "window:1", EventType: "focus", Name: "Loading", Role: "client", WindowTitle: "Game"}) {
|
||||
t.Fatal("focus was rejected")
|
||||
}
|
||||
if !owner.Present(remote.AccessibleEvent{SourceID: "window:2", EventType: "focus", Name: "Play", Role: "button", Position: 2, Count: 3, WindowTitle: "Game"}) {
|
||||
t.Fatal("replacement focus was rejected")
|
||||
}
|
||||
if len(spoken) != 0 {
|
||||
t.Fatalf("focus burst spoke early: %v", spoken)
|
||||
}
|
||||
owner.flush()
|
||||
if len(spoken) != 1 || spoken[0] != "Play, button, 2 of 3, Game" {
|
||||
t.Fatalf("focus presentation = %v", spoken)
|
||||
}
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: "other", EventType: "value", Value: "unfocused", WindowTitle: "Game"}) {
|
||||
t.Fatal("unfocused value was accepted")
|
||||
}
|
||||
if !owner.Present(remote.AccessibleEvent{SourceID: "list:item", EventType: "selection", Name: "Race", Role: "list item", WindowTitle: "Game"}) {
|
||||
t.Fatal("same-window selection was rejected")
|
||||
}
|
||||
if len(spoken) != 2 || spoken[1] != "Race, list item" {
|
||||
t.Fatalf("selection presentation = %v", spoken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineEventClosedAndEmptyFiltering(t *testing.T) {
|
||||
owner := newWineEventPresenter(func(string) bool { return true })
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "unknown", Name: "ignored"}) {
|
||||
t.Fatal("unknown event accepted")
|
||||
}
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "focus", Role: "client"}) {
|
||||
t.Fatal("empty client focus accepted")
|
||||
}
|
||||
owner.Close()
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: "source", EventType: "focus", Name: "after stop"}) {
|
||||
t.Fatal("event accepted after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineButtonFocusLossDoesNotRepeatPreviousControl(t *testing.T) {
|
||||
speech := &remoteSpeechQueue{}
|
||||
braille := &remoteBrailleQueue{running: true}
|
||||
presenter := remoteSpeech{speech: speech, braille: braille}
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
return presenter.presentMessage(message, "wine-accessible-event")
|
||||
})
|
||||
defer owner.Close()
|
||||
buttons := []string{"Yes", "No", "Cancel", "No"}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: buttons[0], EventType: "focus", Name: buttons[0], Role: "client"})
|
||||
owner.flush()
|
||||
for index := 1; index < len(buttons); index++ {
|
||||
previous, current := buttons[index-1], buttons[index]
|
||||
// Native Wine sends the new button's state, the previous button's
|
||||
// focus-loss state, then the new focus after a pause between Tabs.
|
||||
owner.mu.Lock()
|
||||
owner.lastTime = time.Now().Add(-time.Second)
|
||||
owner.mu.Unlock()
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: current, EventType: "state", Name: current, Role: "client"}) {
|
||||
t.Fatal("next button's state accepted before focus")
|
||||
}
|
||||
if owner.Present(remote.AccessibleEvent{SourceID: previous, EventType: "state", Name: previous, Role: "client"}) {
|
||||
t.Fatal("unchanged previous button repeated on focus loss")
|
||||
}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: current, EventType: "focus", Name: current, Role: "client"})
|
||||
owner.flush()
|
||||
}
|
||||
var spoken []string
|
||||
for _, request := range speech.requests {
|
||||
spoken = append(spoken, request.Text)
|
||||
}
|
||||
if !slices.Equal(spoken, buttons) || !slices.Equal(braille.text, buttons) {
|
||||
t.Fatalf("spoken=%v braille=%v; want %v", spoken, braille.text, buttons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineEditorActionsReachSpeechAndBrailleInOrder(t *testing.T) {
|
||||
speech := &remoteSpeechQueue{}
|
||||
braille := &remoteBrailleQueue{running: true}
|
||||
var log bytes.Buffer
|
||||
presenter := remoteSpeech{speech: speech, braille: braille}
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
return presenter.presentMessage(message, "wine-accessible-event")
|
||||
})
|
||||
owner.logger = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
defer owner.Close()
|
||||
presenter.wine = owner
|
||||
if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{
|
||||
SourceID: "42:text", EventType: "focus", Role: "edit", Value: "hello beta",
|
||||
}) {
|
||||
t.Fatal("editor focus rejected")
|
||||
}
|
||||
for _, text := range []string{"e", "l", "l", "beta", "second line", "s selected", "s unselected", "s deleted", "😀"} {
|
||||
if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{
|
||||
SourceID: "42:text", EventType: "text", Name: text,
|
||||
States: "start=1 end=2 kind=5",
|
||||
}) {
|
||||
t.Fatalf("editor action %q rejected", text)
|
||||
}
|
||||
}
|
||||
owner.flush()
|
||||
want := []string{"edit, hello beta", "e", "l", "l", "beta", "second line", "s selected", "s unselected", "s deleted", "😀"}
|
||||
var spoken []string
|
||||
for _, request := range speech.requests {
|
||||
spoken = append(spoken, request.Text)
|
||||
if !request.Append || request.Cause != "wine-accessible-event" {
|
||||
t.Fatalf("unexpected editor speech policy: %+v", request)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(spoken, want) || !slices.Equal(braille.text, want) {
|
||||
t.Fatalf("spoken=%v braille=%v; want %v", spoken, braille.text, want)
|
||||
}
|
||||
for _, diagnostic := range []string{"Wine editor feedback", "42:text", "start=1 end=2 kind=5", "s deleted"} {
|
||||
if !strings.Contains(log.String(), diagnostic) {
|
||||
t.Fatalf("missing debug diagnostic %q: %s", diagnostic, log.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineEditorRejectsStaleAndPasswordActions(t *testing.T) {
|
||||
var spoken []string
|
||||
var log bytes.Buffer
|
||||
owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true })
|
||||
owner.logger = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
defer owner.Close()
|
||||
text := remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "private text"}
|
||||
if owner.Present(text) {
|
||||
t.Fatal("text accepted without focus")
|
||||
}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit"})
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "menu", EventType: "focus", Name: "File", Role: "submenu"})
|
||||
if owner.Present(text) {
|
||||
t.Fatal("text from editor accepted while menu owns focus")
|
||||
}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "private text", Description: "password"})
|
||||
if owner.Present(text) {
|
||||
t.Fatal("password action accepted")
|
||||
}
|
||||
owner.flush()
|
||||
if !slices.Equal(spoken, []string{"edit, password"}) {
|
||||
t.Fatalf("password focus/output = %v", spoken)
|
||||
}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", States: "password"})
|
||||
if owner.Present(text) {
|
||||
t.Fatal("MSAA password action accepted")
|
||||
}
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "blank"})
|
||||
if !owner.Present(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "h"}) {
|
||||
t.Fatal("ordinary editor did not recover after password focus")
|
||||
}
|
||||
if strings.Contains(log.String(), "private text") {
|
||||
t.Fatalf("rejected/password text reached debug log: %s", log.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineFocusedNameAndStateChanges(t *testing.T) {
|
||||
var spoken []string
|
||||
owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true })
|
||||
defer owner.Close()
|
||||
event := remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"}
|
||||
owner.Present(event)
|
||||
event.EventType, event.Name = "name", "Renamed option"
|
||||
if !owner.Present(event) {
|
||||
t.Fatal("focused name change rejected")
|
||||
}
|
||||
event.EventType, event.States = "state", "checked, unavailable"
|
||||
if !owner.Present(event) {
|
||||
t.Fatal("focused state change rejected")
|
||||
}
|
||||
owner.flush()
|
||||
event.States = "not checked"
|
||||
if !owner.Present(event) {
|
||||
t.Fatal("unchecked state rejected")
|
||||
}
|
||||
for _, kind := range []string{"name", "state", "text"} {
|
||||
event.SourceID, event.EventType = "background", kind
|
||||
if owner.Present(event) {
|
||||
t.Fatalf("background %s change accepted", kind)
|
||||
}
|
||||
}
|
||||
want := []string{"Renamed option, check box, checked, unavailable", "Renamed option, check box, not checked"}
|
||||
if !slices.Equal(spoken, want) {
|
||||
t.Fatalf("spoken=%v want=%v", spoken, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineEditorFlushInvalidatesOldTimer(t *testing.T) {
|
||||
var spoken []string
|
||||
owner := newWineEventPresenter(func(message string) bool { spoken = append(spoken, message); return true })
|
||||
defer owner.Close()
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "editor", EventType: "focus", Role: "edit"})
|
||||
owner.mu.Lock()
|
||||
oldGeneration := owner.timerGeneration
|
||||
owner.timer.Stop()
|
||||
owner.mu.Unlock()
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "editor", EventType: "text", Name: "a"})
|
||||
owner.Present(remote.AccessibleEvent{SourceID: "menu", EventType: "focus", Name: "File"})
|
||||
owner.mu.Lock()
|
||||
owner.timer.Stop()
|
||||
owner.mu.Unlock()
|
||||
// Model a timer that fired before Stop but was waiting for delivery.
|
||||
owner.flush_generation(oldGeneration)
|
||||
if !slices.Equal(spoken, []string{"edit", "a"}) {
|
||||
t.Fatalf("stale timer delivered new focus: %v", spoken)
|
||||
}
|
||||
owner.flush()
|
||||
if !slices.Equal(spoken, []string{"edit", "a", "File"}) {
|
||||
t.Fatalf("menu focus lost: %v", spoken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineEditorCharacterSpeechRouting(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.UseCustomEchoVoice = true
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
speech := &remoteSpeechQueue{}
|
||||
braille := &remoteBrailleQueue{running: true}
|
||||
presenter := remoteSpeech{speech: speech, braille: braille, live: live}
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
return presenter.presentMessage(message, "wine-accessible-event")
|
||||
})
|
||||
defer owner.Close()
|
||||
presenter.wine = owner
|
||||
presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit"})
|
||||
owner.flush()
|
||||
for _, tc := range []struct {
|
||||
text, states string
|
||||
character, custom bool
|
||||
}{
|
||||
{"A", "kind=8", true, true},
|
||||
{"a", "start=1 end=1 kind=8", true, true},
|
||||
{"É", "kind=8", true, true},
|
||||
{"𐐀", "kind=8", true, true},
|
||||
{"😀", "kind=8", true, true},
|
||||
{"space", "kind=8", true, true},
|
||||
{"new line", "kind=8", true, true},
|
||||
{"tab", "kind=8", true, true},
|
||||
{"A", "kind=5", true, false},
|
||||
{"A", "kind=7", true, true},
|
||||
{"ABC", "kind=5", false, false},
|
||||
{"A selected", "kind=2", false, false},
|
||||
{"A deleted", "kind=4", false, false},
|
||||
{"A", "kind=80", true, false},
|
||||
} {
|
||||
if !presenter.PresentAccessibleEvent(remote.AccessibleEvent{
|
||||
SourceID: "edit", EventType: "text", Name: tc.text, States: tc.states,
|
||||
}) {
|
||||
t.Fatalf("action rejected: %+v", tc)
|
||||
}
|
||||
request := speech.requests[len(speech.requests)-1]
|
||||
if request.Text != tc.text || request.Character != tc.character || request.CustomEcho != tc.custom ||
|
||||
!request.Append || request.Cause != "wine-accessible-event" {
|
||||
t.Fatalf("action %+v: request=%+v", tc, request)
|
||||
}
|
||||
if braille.text[len(braille.text)-1] != tc.text {
|
||||
t.Fatalf("braille changed: %v", braille.text)
|
||||
}
|
||||
}
|
||||
settings.UseCustomEchoForCharacter = false
|
||||
live.Store(settings)
|
||||
presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "text", Name: "A", States: "kind=8"})
|
||||
if speech.requests[len(speech.requests)-1].CustomEcho {
|
||||
t.Fatal("disabled custom character echo voice was used")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user