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") } }