Files
cthulhu/internal/app/braille_presentation_test.go

426 lines
19 KiB
Go

package app
import (
"context"
"log/slog"
"reflect"
"strings"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/review"
)
type frameRecorder struct{ frames chan braille.Frame }
type flashRecorder struct{ messages []string }
func (*flashRecorder) EnqueueFrame(braille.Frame) {}
func (recorder *flashRecorder) Flash(message string, _ config.BrailleOptions) bool {
recorder.messages = append(recorder.messages, message)
return true
}
func TestSystemMessageFlashesOnBraille(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
braille := &flashRecorder{}
queue := outputQueue{speech: &remoteSpeechQueue{}, braille: braille, live: live}
queue.Enqueue(speechRequest{Text: "Copied", Role: "system", Cause: "clipboard"})
queue.Enqueue(speechRequest{Text: "Current line", Role: "system", Cause: "document-caret-line"})
if len(braille.messages) != 1 || braille.messages[0] != "Copied" {
t.Fatalf("flash messages = %v", braille.messages)
}
}
func TestBrailleOptionsChangePresentation(t *testing.T) {
options := config.DefaultSettings().Braille
options.DisableEOL = true
options.WordWrap = true
options.SelectionIndicator = 0x40
entry := atspi.Snapshot{Role: "entry", Name: "Title", BrailleLine: "abcd", BrailleLineStart: 10,
BrailleCaret: 12, TextCaretKnown: true, SelectionStart: 11, SelectionEnd: 13}
frame := brailleFocusFrameWithOptions(entry, options)
if frame.Text != "Title abcd" || !frame.WordWrap || len(frame.Dots) != 10 || frame.Dots[7] != 0x40 || frame.Dots[8] != 0x40 {
t.Fatalf("entry frame = %+v", frame)
}
options.AbbreviateRoles = true
if got := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "button", Name: "Save"}, options).Text; got != "Save btn" {
t.Fatalf("abbreviated button = %q", got)
}
options.Verbosity = 0
if got := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "button", Name: "Save"}, options).Text; got != "Save" {
t.Fatalf("brief button = %q", got)
}
options.LinkIndicator = 0x80
link := brailleFocusFrameWithOptions(atspi.Snapshot{Role: "link", Name: "Help"}, options)
if len(link.Dots) != 4 || link.Dots[0] != 0x80 || link.Dots[3] != 0x80 {
t.Fatalf("link dots = %v", link.Dots)
}
}
func (recorder frameRecorder) EnqueueFrame(frame braille.Frame) { recorder.frames <- frame }
func TestBrailleFocusFrameTracksUnicodeCaret(t *testing.T) {
snapshot := atspi.NewSnapshot("/entry", "Message", "entry", "", "é猫x")
snapshot.TextCaretKnown = true
snapshot.BrailleLine = "é猫x"
snapshot.TextBeforeCaret = "é猫"
snapshot.BrailleCaret = 2
if got := brailleFocusFrame(snapshot); !reflect.DeepEqual(got, braille.Frame{Text: "Message é猫x $l", Cursor: 10}) {
t.Fatalf("braille frame = %+v", got)
}
snapshot.BrailleLine = "a é"
snapshot.TextBeforeCaret = "a "
snapshot.BrailleCaret = 3
if got := brailleFocusFrame(snapshot); !reflect.DeepEqual(got, braille.Frame{Text: "Message a é $l", Cursor: 11}) {
t.Fatalf("braille spacing/caret = %+v", got)
}
snapshot.BrailleLine = "é猫x"
snapshot.TextBeforeCaret = "other"
snapshot.BrailleCaret = 5
if got := brailleFocusFrame(snapshot); got.Cursor != -1 {
t.Fatalf("invalid caret should be hidden: %+v", got)
}
snapshot.Role = "password text"
if got := brailleFocusFrame(snapshot); got.Cursor != 0 || got.Text != "Message password text" {
t.Fatalf("protected text leaked into braille: %+v", got)
}
}
func TestBrailleFramesFollowAcceptedFocusAndCaretWithoutSpeech(t *testing.T) {
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
frames := make(chan braille.Frame, 8)
queue := outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}}
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 }()
entry := atspi.NewSnapshot("/entry", "Message", "entry", "", "é猫")
entry.Editable = true
entry.TextCaretKnown = true
entry.BrailleLine = "é猫"
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
wantFrame(t, frames, braille.Frame{Text: "Message é猫 $l", Cursor: 8})
entry.TextBeforeCaret = "é"
entry.BrailleCaret = 1
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
wantFrame(t, frames, braille.Frame{Text: "Message é猫 $l", Cursor: 9})
entry.Text = "é猫x"
entry.BrailleLine = "é猫x"
entry.TextBeforeCaret = "é猫x"
entry.BrailleCaret = 3
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "x")
wantFrame(t, frames, braille.Frame{Text: "Message é猫x $l", Cursor: 11})
// State snapshots may omit text capture. They must retain the displayed
// line while updating its state suffix.
entry.TextCaretKnown = false
entry.BrailleLine = ""
entry.Required = true
adapter.events <- atspi.NewEvent("object:state-changed:required", entry)
wantFrame(t, frames, braille.Frame{Text: "Message é猫x $l required", Cursor: 11})
check := atspi.NewSnapshot("/check", "Save", "check box", "", "")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, check)
wantFrame(t, frames, braille.Frame{Text: "< > Save check box", Cursor: 0})
check.Checked = true
adapter.events <- atspi.NewEvent("object:state-changed:checked", check)
wantFrame(t, frames, braille.Frame{Text: "<x> Save check box", Cursor: 0})
}
func wantFrame(t *testing.T, frames <-chan braille.Frame, want braille.Frame) {
t.Helper()
select {
case got := <-frames:
if !reflect.DeepEqual(got, want) {
t.Fatalf("braille frame = %+v, want %+v", got, want)
}
case <-time.After(time.Second):
t.Fatalf("timed out waiting for braille frame %+v", want)
}
}
func TestBraillePythonControlFormats(t *testing.T) {
for _, tc := range []struct {
name string
snapshot atspi.Snapshot
text string
cursor int
}{
{"unchecked", atspi.Snapshot{Name: "Save", Role: "check box"}, "< > Save check box", 0},
{"checked", atspi.Snapshot{Name: "Save", Role: "check box", Checked: true}, "<x> Save check box", 0},
{"mixed", atspi.Snapshot{Name: "Save", Role: "check box", Indeterminate: true}, "<-> Save check box", 0},
{"radio", atspi.Snapshot{Name: "First", Role: "radio button", Checked: true}, "&=y First radio button", 0},
{"toggle", atspi.Snapshot{Name: "Bold", Role: "toggle button", Checked: true}, "&=y Bold toggle button", 0},
{"button", atspi.Snapshot{Name: "More", Role: "push button", Expandable: true}, "More collapsed push button", 0},
{"menu", atspi.Snapshot{Name: "Save", Role: "menu item", SensitiveKnown: true}, "Save grayed", 0},
{"unchecked generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true}, "< > Updates check menu item", 0},
{"checked generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Checked: true}, "<x> Updates check menu item", 0},
{"mixed generic menu", atspi.Snapshot{Name: "Updates", Role: "menu item", Indeterminate: true}, "<-> Updates check menu item", 0},
{"combo", atspi.Snapshot{Name: "Color", Role: "combo box", ValueText: "Blue"}, "Color Blue combo box", 6},
{"slider", atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 1.25, MinimumIncrement: 0.01}, "Volume 1.25 slider", 0},
{"progress", atspi.Snapshot{Name: "Copy", Role: "progress bar", ValueKnown: true, CurrentValue: 25, MaximumValue: 100}, "Copy 25% progress bar", 0},
{"progress range", atspi.Snapshot{Name: "Copy", Role: "progress bar", ValueKnown: true, MinimumValue: 10, CurrentValue: 20, MaximumValue: 30}, "Copy 50% progress bar", 0},
{"tree", atspi.Snapshot{Name: "Files", Role: "tree item", Expandable: true, Expanded: true}, "Files expanded", 0},
} {
t.Run(tc.name, func(t *testing.T) {
frame := brailleFocusFrame(tc.snapshot)
if frame.Text != tc.text || frame.Cursor != tc.cursor {
t.Fatalf("got %+v, want %q cursor %d", frame, tc.text, tc.cursor)
}
})
}
}
func TestBrailleTextRoutesExcludeLabelsAndStates(t *testing.T) {
s := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Name: "Message", Role: "entry", Editable: true, TextCaretKnown: true,
BrailleLine: "é猫\n", BrailleLineStart: 20, BrailleCaret: 22, Invalid: true, Required: true, ReadOnly: true}
f := brailleFocusFrame(s)
if f.Text != "Message é猫 $l invalid required rdonly" || f.Cursor != 10 || len(f.Routes) != 1 {
t.Fatalf("frame %+v", f)
}
r := f.Routes[0]
if r.Start != 8 || r.End != 13 || r.SourceStart != 20 || r.TextLength != 2 || r.SourceText != "é猫\n" {
t.Fatalf("route %+v", r)
}
s.Role = "terminal"
f = brailleFocusFrame(s)
if f.Text != "é猫 " || f.Cursor != 2 {
t.Fatalf("terminal frame %+v", f)
}
s.BrailleCaret = 19
if f = brailleFocusFrame(s); f.Cursor != -1 {
t.Fatalf("caret outside captured suffix: %+v", f)
}
}
type tableBrailleCapturer struct{ *documentLineCapturer }
type browseBrailleCapturer struct {
*documentLineCapturer
text string
}
func (capturer *browseBrailleCapturer) ReadDocumentCaret(_ context.Context, _ atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
characters := []rune(capturer.text)
if offset < 0 || offset >= len(characters) {
return atspi.DocumentCaretText{CharacterCount: len(characters)}, nil
}
start, end := offset, offset+1
if unit != atspi.DocumentCaretCharacter {
for start > 0 && characters[start-1] != '\n' {
start--
}
for end < len(characters) && characters[end-1] != '\n' {
end++
}
}
return atspi.DocumentCaretText{Start: start, End: end, Text: string(characters[start:end]), CharacterCount: len(characters)}, nil
}
func TestBrowseCaretUpdatesBrailleWithoutNativeCaretEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
capturer := &browseBrailleCapturer{documentLineCapturer: &documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}, text: "é猫 word\nSecond line"}
worker, err := review.NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
settings := config.DefaultSettings()
settings.EnableBraille = false
prefsWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
prefsWorker.braille = &recordingBrailleSettings{}
prefsWorker.documentReview = worker
values := preferences.FromSettings(settings)
values.EnableBraille = true
if response := prefsWorker.apply(ctx, preferences.Request{Values: values}); !response.Accepted {
t.Fatalf("enabling braille for the running session failed: %+v", response)
}
if err = worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
frames := make(chan braille.Frame, 16)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}}, adapter, nil, worker, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
doc := atspi.Snapshot{BusName: ":1.42", Path: "/doc", Role: "document web", StatesKnown: true}
text := atspi.Snapshot{BusName: ":1.42", Path: "/text", Role: "static", StatesKnown: true, CharacterCount: utf8.RuneCountInString(capturer.text)}
tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{text.ID()}}, {Snapshot: text}})
if err != nil {
t.Fatal(err)
}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, doc)
wantFrame(t, frames, brailleFocusFrame(doc))
for index, step := range []struct {
command review.DocumentCaretCommand
line string
cursor int
}{
{review.CaretDocumentStart, "é猫 word", 0},
{review.CaretNextCharacter, "é猫 word", 1},
{review.CaretLineEnd, "é猫 word", 8},
{review.CaretNextLine, "Second line", 0},
{review.CaretPreviousLine, "é猫 word", 0},
{review.CaretDocumentEnd, "Second line", 10},
} {
if err = worker.MoveDocumentCaret(step.command); err != nil {
t.Fatal(err)
}
if index == 0 {
select {
case call := <-capturer.calls:
call.result <- tree
case <-time.After(time.Second):
t.Fatal("document capture missing")
}
}
select {
case frame := <-frames:
if strings.TrimRight(frame.Text, " ") != step.line || frame.Cursor != step.cursor || len(frame.Routes) != 1 || frame.Document == nil || frame.Routes[0].Path != text.Path {
t.Fatalf("command %v: frame %+v, want %q cursor %d with document routing", step.command, frame, step.line, step.cursor)
}
case <-time.After(time.Second):
t.Fatalf("command %v did not update braille", step.command)
}
}
}
func TestBrowseBrailleUsesVirtualOffsetAndAncestorIndicators(t *testing.T) {
options := config.DefaultSettings().Braille
options.LinkIndicator = 0x80
options.WordWrap = true
result := review.Result{DocumentPosition: review.DocumentPosition{Offset: 12, Snapshot: atspi.Snapshot{Role: "static"}},
DocumentBrailleLine: &atspi.DocumentCaretText{Start: 10, Text: "é猫 word\n"},
DocumentCaretContext: []atspi.Snapshot{{Role: "link"}, {Role: "heading", HeadingLevel: 2}}}
frame := brailleDocumentFrame(result, options)
if frame.Text != "é猫 word h2" || frame.Cursor != 2 || !frame.WordWrap || len(frame.Routes) != 0 || len(frame.Dots) != 11 || frame.Dots[0] != 0x80 || frame.Dots[8] != 0 {
t.Fatalf("browse frame = %+v", frame)
}
result.DocumentPosition.Snapshot = atspi.Snapshot{Role: "password text", Name: "Secret"}
frame = brailleDocumentFrame(result, options)
if frame.Text != "Secret password text" || len(frame.Routes) != 0 {
t.Fatalf("protected document text leaked: %+v", frame)
}
}
func TestBrowseBrailleNamedLinkUsesLabelCursorWithoutNativeRoutes(t *testing.T) {
result := review.Result{DocumentPosition: review.DocumentPosition{Offset: 4,
Snapshot: atspi.Snapshot{Role: "link", ExplicitName: true, Name: "É猫 Info", CharacterCount: 1}}}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if !strings.HasPrefix(frame.Text, "É猫 Info") || frame.Cursor != 4 || len(frame.Routes) != 0 {
t.Fatalf("named link lost label cursor or exposed synthetic native routes: %+v", frame)
}
}
func TestBrowseBrailleFragmentLineKeepsNativeCursor(t *testing.T) {
first := atspi.NewSnapshot("/first", "", "static", "", "")
first.BusName = ":1.999"
second := atspi.NewSnapshot("/second", "", "static", "", "")
second.BusName = first.BusName
result := review.Result{
DocumentPosition: review.DocumentPosition{Snapshot: second, Offset: 8},
DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{
{Snapshot: first, Start: 0, End: 2, RawText: "é猫"},
{Snapshot: second, Start: 7, End: 11, RawText: "word", Separator: " "},
}},
DocumentCaretContext: []atspi.Snapshot{{Role: "heading", HeadingLevel: 2}},
}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if frame.Text != "é猫 word h2" || frame.Cursor != 4 || len(frame.Routes) != 0 {
t.Fatalf("fragment frame = %+v", frame)
}
}
func TestLinkedHeadingBrailleKeepsHeadingAndLinkIndicators(t *testing.T) {
options := config.DefaultSettings().Braille
options.LinkIndicator = 0x80
heading := atspi.NewSnapshot("/heading", "Linked heading", "heading", "", "")
heading.HeadingLevel = 2
link := atspi.NewSnapshot("/link", "Linked heading", "link", "", "")
link.Visited = true
result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: heading, LinkedHeading: &link}}
frame := brailleDocumentFrame(result, options)
if !strings.Contains(frame.Text, "h2") || !strings.Contains(frame.Text, "vlnk") || len(frame.Dots) != utf8.RuneCountInString(frame.Text) || frame.Dots[0] != 0x80 || frame.Dots[len([]rune(heading.Name))] != 0 {
t.Fatalf("linked heading frame = %+v", frame)
}
result.DocumentBrailleLine = &atspi.DocumentCaretText{Text: "Linked heading"}
frame = brailleDocumentFrame(result, options)
if frame.Text != "Linked heading h2 vlnk" || frame.Dots[0] != 0x80 || frame.Dots[len([]rune(heading.Name))] != 0 {
t.Fatalf("linked heading line frame = %+v", frame)
}
}
func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
return atspi.DocumentCaretText{}, nil
}
func TestTableNavigationUpdatesBrailleWithoutNativeCaretEvent(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
capturer := &tableBrailleCapturer{&documentLineCapturer{calls: make(chan documentLineCaptureCall, 2)}}
worker, err := review.NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
if err = worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
frames := make(chan braille.Frame, 8)
queue := outputQueue{speech: discardSpeech{}, braille: frameRecorder{frames: frames}}
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, worker, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
doc := atspi.Snapshot{BusName: ":1.42", Path: "/doc", Role: "document web", StatesKnown: true}
table := atspi.Snapshot{BusName: ":1.42", Path: "/table", Role: "table", Name: "Probe", TableRowCount: 1, TableColumnCount: 1, StatesKnown: true}
cell := atspi.Snapshot{BusName: ":1.42", Path: "/cell", Role: "table cell", Name: "Cell contents", TablePositionKnown: true, TableRowSpan: 1, TableColumnSpan: 1, StatesKnown: true}
tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{table.ID()}}, {Snapshot: table, Children: []atspi.ObjectID{cell.ID()}}, {Snapshot: cell}})
if err != nil {
t.Fatal(err)
}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, table)
wantFrame(t, frames, brailleFocusFrame(table))
if err = worker.MoveDocumentCaret(review.TableFirst); err != nil {
t.Fatal(err)
}
select {
case call := <-capturer.calls:
call.result <- tree
case <-time.After(time.Second):
t.Fatal("table capture missing")
}
wantFrame(t, frames, brailleFocusFrame(cell))
}
func TestBrowseBrailleRepeatedParentKeepsNativeCursor(t *testing.T) {
parent := atspi.NewSnapshot("/body", "", "paragraph", "", "")
parent.BusName = ":1.1"
child := atspi.NewSnapshot("/span", "", "section", "", "")
child.BusName = ":1.1"
result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: parent, Offset: 0}, DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{
{Snapshot: parent, Start: 0, End: 7, RawText: "Before "},
{Snapshot: child, Start: 0, End: 9, RawText: "Read more"},
{Snapshot: parent, Start: 8, End: 15, RawText: " after."},
}}}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if frame.Cursor != 0 {
t.Fatalf("text=%q cursor=%d; expected cursor=0", frame.Text, frame.Cursor)
}
}