Files
cthulhu/internal/app/braille_presentation_test.go
deedra edd6c560b8 Read Thunderbird message text and preserve link announcements
Thunderbird HTML messages expose readable text on containers around embedded
objects. The browse cursor previously considered only text leaves, and native
document capture omitted heading/link character counts. This skipped message
content, headings and link text. A caret event following Tab could also replace
a complete link announcement with its first character.

Capture owned hypertext child identities and native placeholder offsets on the
GLib thread, releasing native interfaces before returning. Require every child
to occupy one embedded-object character so providers exposing duplicate text
through static descendants retain their ordinary traversal.

Traverse native text ranges before and after embedded children without changing
their offset domains. Preserve character, word, line and document-boundary
navigation; normalize native caret and Braille routing positions at adjacent
embedded links. Keep the Braille cursor on the correct fragment when a parent
contributes text more than once to an assembled line.

Ignore same-input link caret echoes after focus while retaining subsequent
arrow and selection feedback. Add regression tests, extend the private Firefox
fixture, and record behavior and remaining acceptance in the parity ledger.

Validation:
- Full Go test suite with count=1; focused app/atspi/review race checks.
- go vet ./...; reader and brlapi helper builds.
- Private Firefox baseline, mixed-parent offsets and tab navigation.
- Private Chromium fragments, named links, document navigation and selection.
- Parity ledger validation and whitespace checks.

The existing Firefox tab test intermittently timed out on both an untouched
HEAD copy and the changed tree; the final run passed. The unchanged
TestRunReachesSoundStopAfterEarlierServiceStopError also failed once during
startup cancellation and passed in subsequent full-suite and race runs.

Both installed binaries were verified and the reader reloaded in its original
desktop session. The maintainer confirmed readable message text and later
working links after an earlier intermittent omission. That omission remains
untraced; repeat live acceptance and physical Braille verification remain open.
2026-10-20 10:41:38 -07:00

414 lines
18 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},
{"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 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)
}
}