247 lines
8.5 KiB
Go
247 lines
8.5 KiB
Go
// 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)
|
|
}
|
|
}
|