286 lines
11 KiB
Go
286 lines
11 KiB
Go
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")
|
|
}
|
|
}
|