Thunderbird's message composer emits U+FFFC object replacement characters when creating or removing paragraph objects. Treating these structural updates as inserted or deleted text sent raw placeholders to speech, including multi-character updates that bypass ordinary key-echo settings. Remove object markers from insertion and deletion presentation while preserving surrounding text. Normalize typing-echo payloads before deciding the echo kind or applying character, word, and sentence echo policies. Return no text or echo kind for updates containing only object markers. Add regression cases for single and repeated markers, mixed marker/text payloads, and ordinary text through both event presentation and typing echo. Validation: - go test ./... -count=1, with the local cache and dependency network disabled. - Built the reader and the BrlAPI-tagged Braille helper. - A disposable Thunderbird profile on a private X11 display and session bus produced one marker-containing presentation before the fix and none after. - Private Thunderbird checks preserved character, word, and line navigation after typing several paragraphs. - The user confirmed main-arrow reading worked with the restarted build. The original silent-arrow failure was not reproduced in isolation. Restarting restored reading in the user's session; this commit does not claim to fix its underlying cause or change arrow-key handling. Physical Braille output was not tested. Preserve the original and fresh diagnostic logs locally.
190 lines
7.3 KiB
Go
190 lines
7.3 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package presentation
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestEventTextAppliesPrintableKeyEchoSettings(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
payload string
|
|
update func(*Options)
|
|
want string
|
|
}{
|
|
{name: "alphabetic enabled", payload: "a", want: "a"},
|
|
{name: "alphabetic disabled", payload: "a", update: func(options *Options) { options.EnableAlphabeticKeys = false }},
|
|
{name: "numeric disabled", payload: "7", update: func(options *Options) { options.EnableNumericKeys = false }},
|
|
{name: "punctuation disabled", payload: "!", update: func(options *Options) { options.EnablePunctuationKeys = false }},
|
|
{name: "symbol uses punctuation setting", payload: "€", update: func(options *Options) { options.EnablePunctuationKeys = false }},
|
|
{name: "space enabled", payload: " ", want: "space"},
|
|
{name: "space disabled", payload: " ", update: func(options *Options) { options.EnableSpace = false }},
|
|
{name: "diacritical enabled", payload: "́", update: func(options *Options) { options.EnableDiacriticalKeys = true }, want: "́"},
|
|
{name: "character echo overrides category", payload: "a", update: func(options *Options) {
|
|
options.EnableAlphabeticKeys = false
|
|
options.EnableEchoByCharacter = true
|
|
}, want: "a"},
|
|
{name: "key echo disables character echo", payload: "a", update: func(options *Options) {
|
|
options.EnableKeyEcho = false
|
|
options.EnableEchoByCharacter = true
|
|
}},
|
|
{name: "multi-character insertion is not a key echo", payload: "pasted text", update: func(options *Options) {
|
|
options.EnableKeyEcho = false
|
|
}, want: "pasted text"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
options := DefaultOptions()
|
|
if test.update != nil {
|
|
test.update(&options)
|
|
}
|
|
event := atspi.NewEventWithPayload(
|
|
atspi.TextInsertedEvent,
|
|
atspi.NewSnapshot("/accessible/editor", "", "text", "", ""),
|
|
test.payload,
|
|
)
|
|
if got := EventTextWithOptions(event, options); got != test.want {
|
|
t.Fatalf("EventTextWithOptions() = %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSentenceEchoStopsAtPreviousLine(t *testing.T) {
|
|
options := DefaultOptions()
|
|
options.EnableEchoBySentence = true
|
|
for _, context := range []string{"First line\nSecond line. ", "First line\rSecond line. "} {
|
|
event := atspi.NewEvent(atspi.TextInsertedEvent, atspi.Snapshot{TextBeforeCaret: context})
|
|
event.Payload = " "
|
|
if got := TypingEchoText(event, "", options); got != "Second line." {
|
|
t.Fatalf("sentence echo = %q, want only the current sentence", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTypingEchoTextAppliesWordAndSentenceSettings(t *testing.T) {
|
|
options := DefaultOptions()
|
|
options.EnableEchoByWord = true
|
|
snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "hello ")
|
|
snapshot.Editable = true
|
|
snapshot.TextBeforeCaret = "hello "
|
|
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ")
|
|
if got := TypingEchoText(event, "", options); got != "hello" {
|
|
t.Fatalf("word echo = %q, want hello", got)
|
|
}
|
|
|
|
snapshot.TextBeforeCaret = "hello,"
|
|
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, ",")
|
|
if got := TypingEchoText(event, "", options); got != "hello" {
|
|
t.Fatalf("punctuation word echo = %q, want hello", got)
|
|
}
|
|
|
|
snapshot.TextBeforeCaret = "hello "
|
|
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ")
|
|
if got := TypingEchoText(event, "", options); got != "space" {
|
|
t.Fatalf("repeated delimiter echo = %q, want space", got)
|
|
}
|
|
|
|
options.EnableEchoBySentence = true
|
|
snapshot.TextBeforeCaret = "First. Second. "
|
|
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, " ")
|
|
if got := TypingEchoText(event, "", options); got != "Second." {
|
|
t.Fatalf("sentence echo = %q, want Second.", got)
|
|
}
|
|
|
|
snapshot.TextBeforeCaret = ""
|
|
snapshot.Text = ""
|
|
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "\n")
|
|
if got := TypingEchoText(event, "Line without punctuation", options); got != "Line without punctuation" {
|
|
t.Fatalf("newline sentence echo = %q", got)
|
|
}
|
|
|
|
options.EnableKeyEcho = false
|
|
if got := TypingEchoText(event, "Line without punctuation", options); got != "" {
|
|
t.Fatalf("disabled key echo sentence = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTypingEchoInEditFieldsOnly(t *testing.T) {
|
|
options := DefaultOptions()
|
|
options.KeyEchoInEditFieldsOnly = true
|
|
options.EnableEchoByWord = true
|
|
options.EnableEchoBySentence = true
|
|
for _, tc := range []struct {
|
|
name string
|
|
source atspi.Snapshot
|
|
want bool
|
|
}{
|
|
{"entry", atspi.Snapshot{Role: "text", Editable: true}, true},
|
|
{"contenteditable", atspi.Snapshot{Role: "section", Editable: true}, true},
|
|
{"readonly", atspi.Snapshot{Role: "text", Editable: true, ReadOnly: true}, false},
|
|
{"terminal", atspi.Snapshot{Role: "terminal"}, false},
|
|
{"unknown", atspi.Snapshot{Role: "text"}, false},
|
|
{"password", atspi.Snapshot{Role: "password text", Editable: true}, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
for _, payload := range []string{"a", " "} {
|
|
event := atspi.Event{Type: atspi.TextInsertedEvent, Source: tc.source, Payload: payload}
|
|
event.Source.TextBeforeCaret = "Hello. "
|
|
text, _ := TypingEcho(event, "", options)
|
|
if (text != "") != tc.want {
|
|
t.Fatalf("payload=%q echo=%q wanted enabled=%v", payload, text, tc.want)
|
|
}
|
|
}
|
|
if tc.source.Role != "password text" {
|
|
event := atspi.Event{Type: atspi.TextInsertedEvent, Source: tc.source, Payload: "pasted or application text"}
|
|
if got := TypingEchoText(event, "", options); got != event.Payload {
|
|
t.Fatalf("ordinary text lost: %q", got)
|
|
}
|
|
event.Type = atspi.TextDeletedEvent
|
|
event.Payload = "a"
|
|
if got := EventTextWithOptions(event, options); got != "a" {
|
|
t.Fatalf("deletion lost: %q", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEditorStructuralChangesDoNotSpeakObjectMarkers(t *testing.T) {
|
|
for _, eventType := range []string{atspi.TextInsertedEvent, atspi.TextDeletedEvent} {
|
|
for _, tc := range []struct{ payload, want string }{
|
|
{"\ufffc", ""},
|
|
{"\ufffc\ufffc", ""},
|
|
{"\ufffcDraft\ufffc", "Draft"},
|
|
{"ordinary text", "ordinary text"},
|
|
} {
|
|
t.Run(eventType+tc.payload, func(t *testing.T) {
|
|
event := atspi.NewEventWithPayload(eventType, atspi.Snapshot{
|
|
Role: "document web", Editable: true,
|
|
}, tc.payload)
|
|
if got := EventTextWithOptions(event, DefaultOptions()); got != tc.want {
|
|
t.Fatalf("structural change speech = %q, want %q", got, tc.want)
|
|
}
|
|
if got := TypingEchoText(event, "", DefaultOptions()); got != tc.want {
|
|
t.Fatalf("structural change echo = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|