Some echo tests added.
This commit is contained in:
1 parent
8a32895457
commit
eb5b2038d6
38 files changed
+1739
-31
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/session"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -62,3 +63,9 @@ func readClipboardMessage(ctx context.Context) (string, error) {
|
||||
}
|
||||
return result.Message, nil
|
||||
}
|
||||
|
||||
func writeClipboardText(ctx context.Context, value string) error {
|
||||
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in")
|
||||
command.Stdin = strings.NewReader(value)
|
||||
return command.Run()
|
||||
}
|
||||
@@ -208,6 +208,46 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
||||
t.Fatalf("key echo custom=%v: %+v", custom, got)
|
||||
}
|
||||
}
|
||||
// Drive the insertion presenter through the real SSIP clients for every
|
||||
// echo kind. Per-kind voice selection must not affect other echo kinds.
|
||||
for _, kind := range []string{"key", "character", "word", "sentence"} {
|
||||
for _, selected := range []bool{false, true} {
|
||||
current := settings
|
||||
current.EnableEchoByCharacter = kind == "character"
|
||||
current.EnableEchoByWord = kind == "word"
|
||||
current.EnableEchoBySentence = kind == "sentence"
|
||||
current.UseCustomEchoForKey = selected
|
||||
current.UseCustomEchoForCharacter = selected
|
||||
current.UseCustomEchoForWord = selected
|
||||
current.UseCustomEchoForSentence = selected
|
||||
live := &liveSettings{}
|
||||
live.Store(current)
|
||||
queue := &remoteSpeechQueue{}
|
||||
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
|
||||
payload, contextText, want := "é", "Café", "é"
|
||||
if kind == "word" {
|
||||
payload, contextText, want = " ", "Café ", "Café"
|
||||
} else if kind == "sentence" {
|
||||
payload, contextText, want = " ", "First. Second. ", "Second."
|
||||
}
|
||||
presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: payload,
|
||||
Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: contextText}})
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatalf("%s: requests=%+v", kind, queue.requests)
|
||||
}
|
||||
if err := keySpeech.deliver(ctx, queue.requests[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := <-spoken
|
||||
wantModule, wantRate := "", ""
|
||||
if selected {
|
||||
wantModule, wantRate = "echo-module", "62"
|
||||
}
|
||||
if got.text != want || got.module != wantModule || got.rate != wantRate {
|
||||
t.Fatalf("%s custom=%v: %+v", kind, selected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preview unsaved echo selections through the real SSIP clients. Only the
|
||||
// numeric request uses that endpoint; subsequent dialog speech stays main.
|
||||
previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
|
||||
|
||||
@@ -40,6 +40,7 @@ type accessibilityEventHandler struct {
|
||||
syncForeground func()
|
||||
flushTerminalDeletion func()
|
||||
speak func(atspi.Event, ...input.Intent)
|
||||
messaging *messagingController
|
||||
webScripts *webScriptController
|
||||
wineReview *wineReviewFocusController
|
||||
}
|
||||
@@ -110,6 +111,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
"selection_end", event.Source.SelectionEnd)
|
||||
}
|
||||
syncForeground()
|
||||
if handler.messaging.handle(event) {
|
||||
return
|
||||
}
|
||||
notification := eventBypassesXTermHandoff(event)
|
||||
if handler.wineReview != nil && handler.wineReview.owns(state.current.ID()) && !notification {
|
||||
return
|
||||
|
||||
@@ -137,6 +137,11 @@ func processEventsWithAttributes(
|
||||
Flash(string, config.BrailleOptions) bool
|
||||
})
|
||||
defer webScripts.reset()
|
||||
messages := newMessagingController(ctx, speech, focusGate, live, writeClipboardText)
|
||||
messages.braille, _ = brailleOutput.(interface {
|
||||
Flash(string, config.BrailleOptions) bool
|
||||
})
|
||||
defer messages.stop()
|
||||
logger.Info("listening for focus events")
|
||||
browser, _ := inputSource.(*browserModeController)
|
||||
var modeResults <-chan browserModeResult
|
||||
@@ -182,6 +187,7 @@ func processEventsWithAttributes(
|
||||
speak(event)
|
||||
}
|
||||
clearObjectFocus := func() {
|
||||
messages.cancelCopyRequest()
|
||||
ocrSession.Retire()
|
||||
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
|
||||
}
|
||||
@@ -225,7 +231,7 @@ func processEventsWithAttributes(
|
||||
brailleOutput: brailleOutput, foreground: foreground, debounce: debounce,
|
||||
clearOwnedFocus: clearOwnedFocus, syncForeground: syncForeground,
|
||||
flushTerminalDeletion: flushTerminalDeletion, speak: speak, webScripts: webScripts,
|
||||
wineReview: wineReview,
|
||||
wineReview: wineReview, messaging: messages,
|
||||
}
|
||||
|
||||
var lockChanges <-chan struct{}
|
||||
@@ -241,10 +247,14 @@ func processEventsWithAttributes(
|
||||
wineReview.retire()
|
||||
}
|
||||
webScripts.reset()
|
||||
messages.reset()
|
||||
state.owner.clear()
|
||||
clearOwnedFocus()
|
||||
case <-wineReviewChanges:
|
||||
syncForeground()
|
||||
case result := <-messages.results:
|
||||
syncForeground()
|
||||
messages.copied(result)
|
||||
case <-webScripts.due:
|
||||
webScripts.flush()
|
||||
case <-foregroundChanges:
|
||||
@@ -335,7 +345,12 @@ func processEventsWithAttributes(
|
||||
case input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
|
||||
reviewHandler.configureCalcHeader(command)
|
||||
default:
|
||||
reviewHandler.whereAmI()
|
||||
if _, _, ok := input.MessageCommandIndex(command); ok {
|
||||
syncForeground()
|
||||
messages.review(command, state.current)
|
||||
} else {
|
||||
reviewHandler.whereAmI()
|
||||
}
|
||||
}
|
||||
case <-attributeRequests:
|
||||
if !focusGate.Locked() {
|
||||
|
||||
@@ -125,7 +125,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
|
||||
if speaksTextChanges(event.Source) && event.Source.Path != "" && !event.Source.LiveRegion {
|
||||
state.typingPath = event.Source.Path
|
||||
state.typingText = event.Source.TextBeforeCaret
|
||||
if state.typingText == "" && event.Payload != "\n" && event.Payload != "\t" {
|
||||
if state.typingText == "" && !event.Source.TextCaretKnown && event.Payload != "\n" && event.Payload != "\t" {
|
||||
state.typingText = event.Source.Text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/version"
|
||||
)
|
||||
|
||||
const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape, Cthulhu H, or Cthulhu F1 to exit."
|
||||
const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape or Cthulhu H to exit."
|
||||
|
||||
type learnModePresenter struct {
|
||||
tentacles *tentacleRuntime
|
||||
@@ -46,7 +46,7 @@ func (presenter *learnModePresenter) handle(event input.CommandEvent) {
|
||||
presenter.lastTime = 0
|
||||
presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"})
|
||||
presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"})
|
||||
presenter.braille.EnqueueMessage("Learn mode. Press Escape, Cthulhu H, or Cthulhu F1 to exit.")
|
||||
presenter.braille.EnqueueMessage("Learn mode. Press Escape or Cthulhu H to exit.")
|
||||
return
|
||||
}
|
||||
presenter.key(event)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestLearnModePresentsKeyCommandsWithoutExecutingThem(t *testing.T) {
|
||||
braille := &learnBrailleRecorder{}
|
||||
presenter := &learnModePresenter{ctx: context.Background(), logger: slog.New(slog.DiscardHandler), speech: speech, braille: braille, live: live}
|
||||
presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, LearnModeStarted: true})
|
||||
if !presenter.active || len(speech.texts) != 2 || !strings.Contains(speech.texts[1], "Cthulhu F1") || len(braille.texts) != 1 {
|
||||
if !presenter.active || len(speech.texts) != 2 || !strings.Contains(speech.texts[1], "Escape or Cthulhu H") || strings.Contains(speech.texts[1], "Cthulhu F1") || len(braille.texts) != 1 {
|
||||
t.Fatalf("start speech=%q braille=%q", speech.texts, braille.texts)
|
||||
}
|
||||
presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: input.CommandChord{Symbol: 'q', CthulhuModifier: true}, KeyName: "q", Timestamp: 100})
|
||||
@@ -66,7 +66,7 @@ func TestLearnShortcutListUsesActiveBindings(t *testing.T) {
|
||||
foundPreferences := false
|
||||
for _, row := range list.Rows {
|
||||
if row.Label == "Enter or exit learn mode." {
|
||||
foundLearn = strings.Contains(row.Keys, "Cthulhu+h") && strings.Contains(row.Keys, "Cthulhu+F1")
|
||||
foundLearn = row.Keys == "Cthulhu+h"
|
||||
}
|
||||
if row.Label == "Open Cthulhu preferences." {
|
||||
foundPreferences = true
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/messaging"
|
||||
)
|
||||
|
||||
type messageCopyRequest struct {
|
||||
ctx context.Context
|
||||
serial uint64
|
||||
text string
|
||||
}
|
||||
|
||||
type messageCopyResult struct {
|
||||
messageCopyRequest
|
||||
err error
|
||||
}
|
||||
|
||||
// messagingController belongs to the accessibility event loop. Histories survive
|
||||
// focus changes, retire with their native window/transcript, and clear on lock.
|
||||
// Its one clipboard worker has a bounded queue and never performs native queries.
|
||||
type messagingController struct {
|
||||
history messaging.History
|
||||
speech speechEnqueuer
|
||||
braille interface {
|
||||
Flash(string, config.BrailleOptions) bool
|
||||
}
|
||||
live *liveSettings
|
||||
gate *focusGate
|
||||
ctx context.Context
|
||||
cancelWorker context.CancelFunc
|
||||
cancelCopy context.CancelFunc
|
||||
serial uint64
|
||||
requests chan messageCopyRequest
|
||||
results chan messageCopyResult
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newMessagingController(ctx context.Context, speech speechEnqueuer, gate *focusGate, live *liveSettings, write func(context.Context, string) error) *messagingController {
|
||||
ctx, cancelWorker := context.WithCancel(ctx)
|
||||
controller := &messagingController{ctx: ctx, cancelWorker: cancelWorker, speech: speech, gate: gate, live: live,
|
||||
requests: make(chan messageCopyRequest, 1), results: make(chan messageCopyResult, 1), done: make(chan struct{})}
|
||||
go func() {
|
||||
defer close(controller.done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case request := <-controller.requests:
|
||||
if request.ctx.Err() != nil || gate.Locked() {
|
||||
continue
|
||||
}
|
||||
err := write(request.ctx, request.text)
|
||||
if errors.Is(request.ctx.Err(), context.Canceled) {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case controller.results <- messageCopyResult{request, err}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *messagingController) handle(event atspi.Event) bool {
|
||||
if controller == nil {
|
||||
return false
|
||||
}
|
||||
if event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent && event.Detail1 != 0 {
|
||||
id := event.Source.ID()
|
||||
if event.Type == atspi.WindowDestroyedEvent && event.Window.Valid() {
|
||||
id = event.Window
|
||||
}
|
||||
controller.history.Retire(id.BusName, id.Path)
|
||||
}
|
||||
message := event.ChatMessage
|
||||
if message == nil || event.Type != atspi.TextInsertedEvent || event.Source.ApplicationProcess != "pidgin" ||
|
||||
event.Source.Defunct || event.Source.Role != "text" ||
|
||||
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
|
||||
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
|
||||
return false
|
||||
}
|
||||
if !controller.gate.Locked() && controller.history.Add(*message) {
|
||||
stored, _ := controller.history.Previous(message.Provider, 0)
|
||||
controller.present(stored.Text, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (controller *messagingController) present(text string, background bool) {
|
||||
if controller.gate.Locked() {
|
||||
return
|
||||
}
|
||||
if controller.braille != nil {
|
||||
options := config.DefaultSettings().Braille
|
||||
if controller.live != nil {
|
||||
options = controller.live.Load().Braille
|
||||
}
|
||||
controller.braille.Flash(text, options)
|
||||
}
|
||||
// The controller owns the flash; avoid a duplicate system-message flash.
|
||||
controller.speech.Enqueue(speechRequest{Text: text, Role: "notification", Cause: "chat-message", BypassFocusGate: background})
|
||||
}
|
||||
|
||||
func (controller *messagingController) review(command input.Command, current atspi.Snapshot) {
|
||||
index, copyMessage, ok := input.MessageCommandIndex(command)
|
||||
if !ok || controller.gate.Locked() || controller.gate.Suspended() || current.ApplicationProcess != "pidgin" {
|
||||
return
|
||||
}
|
||||
message, exists := controller.history.Previous(current.BusName, index)
|
||||
if !exists {
|
||||
return
|
||||
} // Legacy leaves empty history slots silent.
|
||||
if !copyMessage {
|
||||
controller.present(message.Text, false)
|
||||
return
|
||||
}
|
||||
controller.cancelCopyRequest()
|
||||
copyCtx, cancel := context.WithTimeout(controller.ctx, clipboardReadTimeout)
|
||||
controller.cancelCopy = cancel
|
||||
request := messageCopyRequest{ctx: copyCtx, serial: controller.serial, text: message.Text}
|
||||
// Only the event loop produces requests. Replace an obsolete queued copy
|
||||
// rather than dropping the newest command while the worker cancels a write.
|
||||
select {
|
||||
case <-controller.requests:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case controller.requests <- request:
|
||||
default:
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *messagingController) copied(result messageCopyResult) {
|
||||
if result.serial != controller.serial || errors.Is(result.ctx.Err(), context.Canceled) || controller.gate.Locked() || controller.gate.Suspended() {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("Copied %s to clipboard.", result.text)
|
||||
if result.err != nil {
|
||||
text = "Error accessing clipboard"
|
||||
}
|
||||
controller.present(text, false)
|
||||
controller.cancelCopyRequest()
|
||||
}
|
||||
|
||||
func (controller *messagingController) cancelCopyRequest() {
|
||||
controller.serial++
|
||||
if controller.cancelCopy != nil {
|
||||
controller.cancelCopy()
|
||||
controller.cancelCopy = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *messagingController) reset() {
|
||||
controller.cancelCopyRequest()
|
||||
controller.history.Reset()
|
||||
}
|
||||
|
||||
func (controller *messagingController) stop() {
|
||||
controller.reset()
|
||||
controller.cancelWorker()
|
||||
<-controller.done
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/messaging"
|
||||
)
|
||||
|
||||
func pidginMessageEvent(text string) atspi.Event {
|
||||
event := atspi.NewEvent(atspi.TextInsertedEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Role: "text", ApplicationProcess: "pidgin"})
|
||||
event.Window = atspi.NewObjectID(":1.42", "/window")
|
||||
event.ChatMessage = &messaging.Message{Provider: ":1.42", Conversation: "/transcript", Window: "/window", RoomName: "Conversation", Text: text}
|
||||
event.ForegroundMismatch = true
|
||||
return event
|
||||
}
|
||||
|
||||
func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||
requests := make(chan input.Command)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- processEventsWithReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, requests, 0)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/entry")
|
||||
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = "pidgin", "entry", "Message input"
|
||||
focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1
|
||||
adapter.events <- focus
|
||||
expectActivationSpeech(t, queue.spoken, "Message input, entry")
|
||||
adapter.events <- pidginMessageEvent("First message")
|
||||
expectActivationSpeech(t, queue.spoken, "First message")
|
||||
adapter.events <- pidginMessageEvent("Second message")
|
||||
expectActivationSpeech(t, queue.spoken, "Second message")
|
||||
requests <- input.MessageCommand(0, false)
|
||||
expectActivationSpeech(t, queue.spoken, "Second message")
|
||||
requests <- input.MessageCommand(1, false)
|
||||
expectActivationSpeech(t, queue.spoken, "First message")
|
||||
requests <- input.WhereAmI
|
||||
expectActivationSpeech(t, queue.spoken, "Message input, entry")
|
||||
// Background chat never becomes current focus, and another provider cannot
|
||||
// borrow a trusted message's copied metadata.
|
||||
forged := pidginMessageEvent("Do not announce")
|
||||
forged.Source.ApplicationProcess = "chromium"
|
||||
adapter.events <- forged
|
||||
requests <- input.WhereAmI
|
||||
expectActivationSpeech(t, queue.spoken, "Message input, entry")
|
||||
select {
|
||||
case text := <-queue.spoken:
|
||||
t.Fatalf("unexpected output: %q", text)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingCopyResultsAndSecurityCancellation(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) {
|
||||
queue := &learnSpeechRecorder{}
|
||||
copied := make(chan string, 1)
|
||||
controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error {
|
||||
copied <- text
|
||||
if fail {
|
||||
return errors.New("clipboard failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
defer controller.stop()
|
||||
event := pidginMessageEvent("Unicode 界\nsecond line")
|
||||
controller.handle(event)
|
||||
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
|
||||
select {
|
||||
case result := <-controller.results:
|
||||
controller.copied(result)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("copy did not finish")
|
||||
}
|
||||
if text := <-copied; text != event.ChatMessage.Text {
|
||||
t.Fatalf("clipboard text = %q", text)
|
||||
}
|
||||
want := "Copied Unicode 界\nsecond line to clipboard."
|
||||
if fail {
|
||||
want = "Error accessing clipboard"
|
||||
}
|
||||
if got := queue.texts[len(queue.texts)-1]; got != want {
|
||||
t.Fatalf("copy response = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
gate := &focusGate{}
|
||||
queue := &learnSpeechRecorder{}
|
||||
started := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
controller := newMessagingController(context.Background(), queue, gate, nil, func(ctx context.Context, text string) error {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(finished)
|
||||
return ctx.Err()
|
||||
})
|
||||
defer controller.stop()
|
||||
controller.handle(pidginMessageEvent("Pending copy"))
|
||||
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("copy worker blocked event loop")
|
||||
}
|
||||
controller.reset()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("copy did not cancel")
|
||||
}
|
||||
if len(queue.texts) != 1 {
|
||||
t.Fatal("cancelled copy announced completion")
|
||||
}
|
||||
controller.review(input.MessageCommand(0, false), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
|
||||
if len(queue.texts) != 1 {
|
||||
t.Fatal("cleared history was replayed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingSuppressesTypingAndBypassesOnlySuspension(t *testing.T) {
|
||||
queue := &learnSpeechRecorder{}
|
||||
gate := &focusGate{}
|
||||
gate.SetSuspended(true)
|
||||
braille := &flashRecorder{}
|
||||
controller := newMessagingController(context.Background(), gatedSpeech{speech: queue, gate: gate}, gate, nil, writeClipboardText)
|
||||
controller.braille = braille
|
||||
defer controller.stop()
|
||||
event := pidginMessageEvent("Incoming message")
|
||||
if !controller.handle(event) || len(queue.texts) != 1 || len(braille.messages) != 1 {
|
||||
t.Fatal("background message did not reach speech and Braille")
|
||||
}
|
||||
event.ChatMessage.Typing = true
|
||||
controller.handle(event)
|
||||
if len(queue.texts) != 1 {
|
||||
t.Fatal("typing status was announced")
|
||||
}
|
||||
if _, ok := controller.history.Previous(":1.42", 1); ok {
|
||||
t.Fatal("typing status entered history")
|
||||
}
|
||||
gate.secure.Store(true)
|
||||
event.ChatMessage.Typing = false
|
||||
controller.handle(event)
|
||||
if len(queue.texts) != 1 || len(braille.messages) != 1 {
|
||||
t.Fatal("locked message escaped output gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeMessagingCopyPreservesClipboardText(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_MESSAGING") != "1" {
|
||||
t.Skip("requires isolated Xorg clipboard")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
speech := &learnSpeechRecorder{}
|
||||
controller := newMessagingController(ctx, speech, nil, nil, writeClipboardText)
|
||||
defer controller.stop()
|
||||
text := "Username: one\ntwo 界"
|
||||
controller.handle(pidginMessageEvent(text))
|
||||
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
|
||||
select {
|
||||
case result := <-controller.results:
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
controller.copied(result)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
got, err := readClipboardTarget(ctx, "UTF8_STRING")
|
||||
if err != nil || got != text {
|
||||
t.Fatalf("clipboard = %q, %v", got, err)
|
||||
}
|
||||
if speech.texts[len(speech.texts)-1] != "Copied "+text+" to clipboard." {
|
||||
t.Fatal("successful copy was not confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingOutputQueueFlashesOnce(t *testing.T) {
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
speech, braille := &learnSpeechRecorder{}, &flashRecorder{}
|
||||
controller := newMessagingController(context.Background(), outputQueue{speech: speech, braille: braille, live: live}, nil, live, writeClipboardText)
|
||||
controller.braille = braille
|
||||
defer controller.stop()
|
||||
controller.handle(pidginMessageEvent("One message"))
|
||||
if len(braille.messages) != 1 || len(speech.texts) != 1 {
|
||||
t.Fatal("message was duplicated by shared output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingQueuedCopyKeepsNewestMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
started := make(chan string, 3)
|
||||
release := make(chan struct{})
|
||||
controller := newMessagingController(ctx, &learnSpeechRecorder{}, nil, nil, func(writeCtx context.Context, text string) error {
|
||||
started <- text
|
||||
if text == "First" {
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return writeCtx.Err()
|
||||
})
|
||||
defer controller.stop()
|
||||
current := atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"}
|
||||
controller.handle(pidginMessageEvent("First"))
|
||||
controller.review(input.MessageCommand(0, true), current)
|
||||
select {
|
||||
case <-started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
for _, text := range []string{"Second", "Third"} {
|
||||
controller.handle(pidginMessageEvent(text))
|
||||
controller.review(input.MessageCommand(0, true), current)
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case result := <-controller.results:
|
||||
if result.text != "Third" {
|
||||
t.Fatalf("completed obsolete copy %q", result.text)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("newest queued copy was lost")
|
||||
}
|
||||
if text := <-started; text != "Third" {
|
||||
t.Fatalf("copied obsolete queued message %q", text)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,12 @@ func (worker commandRuntime) run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
default:
|
||||
if target, level, backwards := documentReviewCommand(command); target != 0 {
|
||||
if _, _, ok := input.MessageCommandIndex(command); ok {
|
||||
select {
|
||||
case reviewRequests <- command:
|
||||
default:
|
||||
}
|
||||
} else if target, level, backwards := documentReviewCommand(command); target != 0 {
|
||||
if err := objectReview.MoveDocument(target, level, backwards); err != nil {
|
||||
logger.Warn("queue document navigation", "error", err)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
|
||||
t.Error("command worker did not stop")
|
||||
}
|
||||
}()
|
||||
for _, command := range []input.Command{input.PresentCurrentLine, input.SpellCurrentLine, input.PhoneticCurrentLine, input.WhereAmI, input.PresentWindowTitle, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn} {
|
||||
for _, command := range []input.Command{input.PresentCurrentLine, input.SpellCurrentLine, input.PhoneticCurrentLine, input.WhereAmI, input.PresentWindowTitle, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn, input.MessageCommand(0, false), input.MessageCommand(8, true)} {
|
||||
commands.commands <- input.CommandEvent{Command: command}
|
||||
select {
|
||||
case got := <-requests:
|
||||
|
||||
@@ -130,8 +130,3 @@ func clipboardPNG(ctx context.Context) (tentaclewire.Item, error) {
|
||||
keep = true
|
||||
return tentaclewire.Item{Path: path, Temporary: true}, nil
|
||||
}
|
||||
func copyTentacleText(ctx context.Context, value string) error {
|
||||
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in")
|
||||
command.Stdin = strings.NewReader(value)
|
||||
return command.Run()
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestNativeTentacleClipboardTargetsAndTemporaryImage(t *testing.T) {
|
||||
t.Fatal("accepted multiple file-manager clipboard items")
|
||||
}
|
||||
text := "one\ntwo é猫"
|
||||
if err = copyTentacleText(ctx, text); err != nil {
|
||||
if err = writeClipboardText(ctx, text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err = readTentacleClipboard(ctx)
|
||||
|
||||
@@ -55,7 +55,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
||||
}
|
||||
if value.Clipboard != nil {
|
||||
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
|
||||
err = copyTentacleText(copyCtx, *value.Clipboard)
|
||||
err = writeClipboardText(copyCtx, *value.Clipboard)
|
||||
stopCopy()
|
||||
if err != nil {
|
||||
value.Presentation = clipboardWriteFailure(value.Presentation)
|
||||
@@ -207,7 +207,7 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te
|
||||
return internalUIResult{Cancelled: true}, nil
|
||||
}
|
||||
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
|
||||
copyErr := copyTentacleText(copyCtx, *status.Clipboard)
|
||||
copyErr := writeClipboardText(copyCtx, *status.Clipboard)
|
||||
stopCopy()
|
||||
if copyErr != nil {
|
||||
status.Presentation = clipboardWriteFailure(status.Presentation)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/logging"
|
||||
)
|
||||
|
||||
type echoRoutingSpeaker struct{ orderedSpeaker }
|
||||
|
||||
func (speaker *echoRoutingSpeaker) SpeakVoice(_ context.Context, text, voice string) error {
|
||||
speaker.calls = append(speaker.calls, voice+":"+text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (speaker *echoRoutingSpeaker) SpeakCharacter(ctx context.Context, text string) error {
|
||||
return speaker.SpeakVoice(ctx, text, "character")
|
||||
}
|
||||
|
||||
func (speaker *echoRoutingSpeaker) SpeakEcho(ctx context.Context, text string) error {
|
||||
return speaker.SpeakVoice(ctx, text, "echo")
|
||||
}
|
||||
|
||||
func (speaker *echoRoutingSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error {
|
||||
return speaker.SpeakVoice(ctx, text, "echo-character")
|
||||
}
|
||||
|
||||
func TestTypingEchoVoiceCombinations(t *testing.T) {
|
||||
for modes := 0; modes < 8; modes++ {
|
||||
for voices := 0; voices < 16; voices++ {
|
||||
for _, custom := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("modes=%03b/voices=%04b/custom=%v", modes, voices, custom), func(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.EnableEchoByCharacter = modes&1 != 0
|
||||
settings.EnableEchoByWord = modes&2 != 0
|
||||
settings.EnableEchoBySentence = modes&4 != 0
|
||||
settings.UseCustomEchoVoice = custom
|
||||
settings.UseCustomEchoForKey = voices&1 != 0
|
||||
settings.UseCustomEchoForCharacter = voices&2 != 0
|
||||
settings.UseCustomEchoForWord = voices&4 != 0
|
||||
settings.UseCustomEchoForSentence = voices&8 != 0
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
queue := &remoteSpeechQueue{}
|
||||
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
|
||||
for _, tc := range []struct {
|
||||
payload, context, basic, word, sentence string
|
||||
}{
|
||||
{"é", "Café", "é", "", ""},
|
||||
{" ", "Café ", "space", "Café", ""},
|
||||
{",", "Café,", ",", "Café", ""},
|
||||
{" ", "First. Second. ", "space", "", "Second."},
|
||||
{"pasted text", "pasted text", "pasted text", "", ""},
|
||||
} {
|
||||
want, voiceBit, character := tc.basic, 1, true
|
||||
if settings.EnableEchoByCharacter {
|
||||
voiceBit = 2
|
||||
}
|
||||
if settings.EnableEchoByWord && tc.word != "" {
|
||||
want, voiceBit, character = tc.word, 4, false
|
||||
}
|
||||
if settings.EnableEchoBySentence && tc.sentence != "" {
|
||||
want, voiceBit, character = tc.sentence, 8, false
|
||||
}
|
||||
if tc.payload == "pasted text" {
|
||||
voiceBit, character = 0, false
|
||||
}
|
||||
queue.requests = nil
|
||||
presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: tc.payload,
|
||||
Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: tc.context}})
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatalf("payload=%q: requests=%+v", tc.payload, queue.requests)
|
||||
}
|
||||
request := queue.requests[0]
|
||||
wantCustom := custom && voices&voiceBit != 0
|
||||
if request.Text != want || request.CustomEcho != wantCustom || request.Character != character {
|
||||
t.Fatalf("payload=%q: request=%+v; want text=%q custom=%v character=%v", tc.payload, request, want, wantCustom, character)
|
||||
}
|
||||
speaker := &echoRoutingSpeaker{}
|
||||
var output bytes.Buffer
|
||||
worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker)
|
||||
if err := worker.deliver(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
voice := ""
|
||||
if wantCustom {
|
||||
voice = "echo"
|
||||
}
|
||||
if character {
|
||||
if voice != "" {
|
||||
voice += "-"
|
||||
}
|
||||
voice += "character"
|
||||
}
|
||||
if got := fmt.Sprint(speaker.calls); got != "["+voice+":"+want+"]" {
|
||||
t.Fatalf("backend calls=%s", got)
|
||||
}
|
||||
var record struct {
|
||||
Utterance string `json:"utterance"`
|
||||
CustomEcho *bool `json:"custom_echo"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &record); err != nil {
|
||||
t.Fatalf("delivery log: %v: %s", err, output.String())
|
||||
}
|
||||
if record.Utterance != want || record.CustomEcho == nil || *record.CustomEcho != wantCustom {
|
||||
t.Fatalf("incorrect voice in delivery log: %s", output.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypingEchoLineBreakKeepsPriorCaretContext(t *testing.T) {
|
||||
for _, mode := range []string{"word", "sentence"} {
|
||||
for _, before := range []string{"Hello", ""} {
|
||||
t.Run(mode+"/before="+before, func(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.EnableEchoByWord = mode == "word"
|
||||
settings.EnableEchoBySentence = mode == "sentence"
|
||||
settings.UseCustomEchoVoice = true
|
||||
settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = true, true
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
queue := &remoteSpeechQueue{}
|
||||
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
|
||||
source := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true,
|
||||
Text: before + "following text", TextBeforeCaret: before, TextCaretKnown: true}
|
||||
presenter.speak(atspi.NewEvent(atspi.FocusEvent, source))
|
||||
queue.requests = nil
|
||||
source.Text, source.TextBeforeCaret = "following text", ""
|
||||
presenter.speak(atspi.NewEventWithPayload(atspi.TextInsertedEvent, source, "\n"))
|
||||
if before == "" {
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatalf("following text echoed at start of line: %+v", queue.requests)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != before || !queue.requests[0].CustomEcho {
|
||||
t.Fatalf("completed %s: %+v", mode, queue.requests)
|
||||
}
|
||||
if strings.Contains(presenter.state.typingText, "following") {
|
||||
t.Fatalf("following line entered prior-caret cache: %q", presenter.state.typingText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
func TestNativeTypingEchoLineSplit(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_TYPING_ECHO") != "1" {
|
||||
t.Skip("requires native_test_session.py private GTK session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
previousBus := ""
|
||||
for _, mode := range []string{"word", "sentence"} {
|
||||
for _, offset := range []string{"5", "0"} {
|
||||
t.Run(mode+"/offset="+offset, func(t *testing.T) {
|
||||
fixture := exec.CommandContext(ctx, "/usr/bin/python3", filepath.Join("..", "..", "tests", "gtk_typing_echo.py"), offset)
|
||||
output, err := fixture.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.Stderr = os.Stderr
|
||||
if err := fixture.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { fixture.Process.Kill(); fixture.Wait() }()
|
||||
if line, err := bufio.NewReader(output).ReadString('\n'); err != nil || line != "READY\n" {
|
||||
t.Fatalf("GTK fixture readiness: %q %v", line, err)
|
||||
}
|
||||
keys := func(args ...string) {
|
||||
t.Helper()
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
keys("search", "--sync", "--onlyvisible", "--pid", strconv.Itoa(fixture.Process.Pid), "--name", "^Typing echo probe$", "windowfocus", "--sync")
|
||||
var focus atspi.Event
|
||||
for focus.Source.Name != "Echo editor" || focus.Source.BusName == previousBus || !isFocusGainEvent(focus) {
|
||||
select {
|
||||
case focus = <-adapter.Events():
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
previousBus = focus.Source.BusName
|
||||
want := "Hello"
|
||||
if offset == "0" {
|
||||
want = ""
|
||||
}
|
||||
if !focus.Source.TextCaretKnown || focus.Source.TextBeforeCaret != want {
|
||||
t.Fatalf("initial native caret context: %+v", focus.Source)
|
||||
}
|
||||
settings := config.DefaultSettings()
|
||||
settings.EnableEchoByWord = mode == "word"
|
||||
settings.EnableEchoBySentence = mode == "sentence"
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
queue := &remoteSpeechQueue{}
|
||||
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
|
||||
presenter.speak(focus)
|
||||
queue.requests = nil
|
||||
keys("key", "--clearmodifiers", "Return")
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Source.ID() != focus.Source.ID() || event.Type != atspi.TextInsertedEvent || event.Payload != "\n" {
|
||||
continue
|
||||
}
|
||||
presenter.speak(event)
|
||||
if want == "" {
|
||||
if len(queue.requests) != 0 {
|
||||
t.Fatalf("start of line echoed following text: %+v", queue.requests)
|
||||
}
|
||||
} else if len(queue.requests) != 1 || queue.requests[0].Text != want {
|
||||
t.Fatalf("line split echo=%+v, want %q; native line=%q before=%q", queue.requests, want, event.Source.Text, event.Source.TextBeforeCaret)
|
||||
}
|
||||
return
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user