Files
cthulhu/internal/app/editable_document_test.go

138 lines
6.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestEditorParagraphUsesConfiguredEcho(t *testing.T) {
for _, echo := range []string{"disabled", "character", "word", "sentence"} {
t.Run(echo, func(t *testing.T) {
options := presentation.DefaultOptions()
payload, contextText, want := "o", "Hello", "o"
switch echo {
case "disabled":
options.EnableKeyEcho, want = false, ""
case "character":
options.EnableEchoByCharacter = true
case "word":
options.EnableEchoByWord = true
payload, contextText, want = " ", "Hello ", "Hello"
case "sentence":
options.EnableEchoBySentence = true
payload, contextText, want = " ", "Hello. ", "Hello."
}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0, options)
}()
defer func() { cancel(); <-done }()
editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Composer", Role: "entry", Editable: true}
paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: editor.ID(), Text: contextText, TextBeforeCaret: contextText}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, payload)
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/next", Role: "button", Name: "Next"})
// Once focus leaves, delayed events from that editor stay quiet.
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "delayed edit")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"})
wants := []string{"Composer, entry"}
if want != "" {
wants = append(wants, want)
}
wants = append(wants, "Next, button", "End, button")
for _, want := range wants {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
})
}
}
func TestEditableDocumentParagraphEvents(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Message body", Role: "document web", Editable: true}
paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: editor.ID(), Text: "a", TextCaretKnown: true, BrailleLine: "a", BrailleCaret: 1}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "a")
// This caret must not reread the growing line after accepted typing.
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
paragraph.SelectionStart, paragraph.SelectionEnd, paragraph.SelectionText = 0, 1, "a"
adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, paragraph)
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, paragraph, "a")
subject := atspi.Snapshot{BusName: editor.BusName, Path: "/subject", Name: "Subject", Role: "entry", Editable: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, subject)
// A delayed mutation from the old body cannot interrupt a header field.
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "unfocused text")
other := paragraph
other.Path, other.EditableRoot = "/other_paragraph", atspi.NewObjectID(editor.BusName, "/other_editor")
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, other, "other editor")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"})
for _, want := range []string{"Message body, document web", "a", "a selected", "a", "Subject, entry", "End, button"} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
}
func TestEditableDocumentCaretAndSelectionBraille(t *testing.T) {
root := atspi.NewObjectID(":1.7", "/editor")
frames := make(chan braille.Frame, 2)
state := &eventLoopState{}
state.focusedID = root
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), brailleOutput: frameRecorder{frames: frames}}
source := atspi.Snapshot{BusName: root.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: root, Text: "body", TextCaretKnown: true, BrailleLine: "body", BrailleCaret: 2}
for _, eventType := range []string{atspi.CaretEvent, atspi.TextSelectionChangedEvent} {
handler.acceptFocusedEvent(atspi.NewEvent(eventType, source), recentInput(nil))
if state.focusedID != root || state.current.ID() != source.ID() || state.brailleSnapshot.ID() != source.ID() {
t.Fatal("paragraph presentation lost native editor focus or Braille ownership")
}
select {
case frame := <-frames:
if !strings.HasPrefix(frame.Text, "body") || frame.Cursor != 2 || len(frame.Routes) == 0 || frame.Routes[0].Path != source.Path {
t.Fatalf("paragraph Braille frame = %+v", frame)
}
default:
t.Fatal("missing paragraph Braille frame")
}
}
}