Preserve native offsets after braille text expansion
Expanding embedded-object placeholders changes the displayed braille text
without changing native AT-SPI offsets. Several consumers still validated
native positions against the expanded string, which could reject a valid
line end after contraction, accept an offset beyond the native line after
expansion, or drop selection and text-attribute indicators on a plain-text
fragment following an embedded object.
Use RawText for native-offset validation and fragment comparisons whenever
a braille line is expanded. Apply the same rule to parent/child native-caret
handoff: identical displayed text must not authorize an offset transfer
when the underlying native strings differ. Expanded spans retain their
existing conservative presentation without native cell routes.
Restrict the assembled-line displayed-text fallback to lines from which
embedded-object placeholders were actually removed. Ordinary whitespace-only
lines now retain their spaces, cursor position, and native routing instead
of being replaced with the object's text or accessible name.
Add regressions for contracted and expanded native offset bounds, suffix
selection and attribute preservation, immutable line enrichment, ambiguous
caret transfer, and whitespace-only line routing. The regressions reproduced
the review findings before the fixes and pass afterward.
Validation:
- Full Go suite before fixes; full race and BrlAPI suites after fixes.
- Focused Braille and native-caret regressions, including race checks.
- go vet ./...; reader and BrlAPI helper builds; parity-ledger validation.
- Private Chromium linked-heading, selection, and browse-mode checks.
- Private Firefox baseline and linked-heading checks.
- git diff --check.
The Chromium fragment harness still fails its embedded-editor navigation
step, and the Firefox harness still times out in its New Tab test. Both
failures reproduce on the parent of the reviewed commit, 8ce5e5c, as well.
These failures are not claimed as passing checks.
The active reader was not replaced. Physical keyboard, audible speech, and
Braille acceptance remain for maintainer testing: check linked headings,
mixed inline-link text, selection indicators after links, and ordinary or
blank-line cursor routing in both browser families before submission.
This commit is contained in:
1 parent
8ce5e5cdad
commit
7ec6e4ccaf
7 files changed
+94
-5
No files matched your search
@@ -79,6 +79,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
|
||||
}
|
||||
}
|
||||
if assembled != nil {
|
||||
strippedObjects := false
|
||||
for index, content := range assembled.Contents {
|
||||
raw := content.RawText
|
||||
if index == len(assembled.Contents)-1 {
|
||||
@@ -91,11 +92,12 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
|
||||
// as their own contents; drop it instead of duplicating them.
|
||||
native := !strings.ContainsRune(raw, '\ufffc')
|
||||
if !native {
|
||||
strippedObjects = true
|
||||
raw = stripEmbeddedObjects(raw)
|
||||
}
|
||||
appendContent(content.Snapshot, raw, content.Separator, content.Start, native)
|
||||
}
|
||||
if strings.TrimSpace(frame.Text) == "" {
|
||||
if strippedObjects && strings.TrimSpace(frame.Text) == "" {
|
||||
// Nothing but placeholders survived assembly; fall back to
|
||||
// the displayed text speech would use for this object.
|
||||
if text := brailleDisplayedText(snapshot); text != "" {
|
||||
|
||||
@@ -481,6 +481,24 @@ func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseBrailleAssembledWhitespaceKeepsNativeMapping(t *testing.T) {
|
||||
snapshot := atspi.Snapshot{BusName: ":1.999", Path: "/text", Role: "static", Name: "Accessible label", Text: "Other text"}
|
||||
tree, err := atspi.NewAccessibleTree(snapshot.ID(), []atspi.TreeNode{{Snapshot: snapshot}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object, _ := tree.Reference(snapshot.ID())
|
||||
result := review.Result{DocumentEpoch: 1, DocumentFocus: snapshot.ID(),
|
||||
DocumentPosition: review.DocumentPosition{Document: object, Snapshot: snapshot, Offset: 1},
|
||||
DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{{
|
||||
Snapshot: snapshot, Start: 0, End: 2, RawText: " ",
|
||||
}}}}
|
||||
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
|
||||
if frame.Text != " " || frame.Cursor != 1 || len(frame.Routes) != 1 || frame.Routes[0].SourceText != " " {
|
||||
t.Fatalf("ordinary whitespace replaced by object fallback: %+v", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
return atspi.DocumentCaretText{}, nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
|
||||
return result
|
||||
}
|
||||
if line.Start < 0 || result.DocumentPosition.Offset < line.Start ||
|
||||
result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(line.Text) || line.Text == "" {
|
||||
result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(documentBrailleNativeText(line)) || line.Text == "" {
|
||||
result.DocumentBrailleErr = atspi.ErrNoDocumentCaret
|
||||
return result
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
|
||||
}
|
||||
}
|
||||
start, end := content.Start-selected.Start, content.Start-selected.Start+utf8.RuneCountInString(content.RawText)
|
||||
runes := []rune(selected.Text)
|
||||
runes := []rune(documentBrailleNativeText(selected))
|
||||
if start < 0 || end > len(runes) || string(runes[start:end]) != content.RawText {
|
||||
continue
|
||||
}
|
||||
@@ -73,3 +73,11 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Braille expansion changes display characters, never the native offset domain.
|
||||
func documentBrailleNativeText(line atspi.DocumentCaretText) string {
|
||||
if line.Expanded {
|
||||
return line.RawText
|
||||
}
|
||||
return line.Text
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package review
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -79,3 +80,45 @@ func TestDocumentBrailleReadsOnlyEnabledAcceptedNavigation(t *testing.T) {
|
||||
t.Fatalf("braille read failure changed successful navigation: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentBrailleExpandedLineUsesNativeOffsets(t *testing.T) {
|
||||
reader := &documentBrailleReader{fakeCapturer: &fakeCapturer{}, line: atspi.DocumentCaretText{
|
||||
Start: 0, End: 5, RawText: "a\ufffc bc", Text: "a bc", Expanded: true,
|
||||
}}
|
||||
worker, err := NewWorker(reader, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): reader.line.RawText})
|
||||
object, _ := cursor.tree.Reference(webID("/text"))
|
||||
worker.SyncFocus(object.ID())
|
||||
worker.SetDocumentBrailleEnabled(true)
|
||||
result := Result{Moved: true, DocumentFocus: object.ID(),
|
||||
DocumentPosition: DocumentPosition{Object: object, Offset: 5}}
|
||||
got := worker.readDocumentBraille(context.Background(), result)
|
||||
if got.DocumentBrailleLine == nil || got.DocumentBrailleErr != nil {
|
||||
t.Fatalf("valid native line end rejected after contraction: %+v", got)
|
||||
}
|
||||
|
||||
reader.line.Text = "aLong link bc"
|
||||
reader.line.SelectionStart, reader.line.SelectionEnd = 3, 5
|
||||
reader.line.TextAttributeRuns = []atspi.TextAttributeRun{{Start: 3, End: 5, Values: map[string]string{"weight": "700"}}}
|
||||
result.DocumentPosition.Offset = 3
|
||||
result.DocumentCaretLine = &atspi.DocumentLine{Contents: []atspi.DocumentContent{{
|
||||
Reference: object, Snapshot: atspi.Snapshot{BusName: object.ID().BusName, Path: object.ID().Path},
|
||||
Start: 3, End: 5, RawText: "bc",
|
||||
}}}
|
||||
got = worker.readDocumentBraille(context.Background(), result)
|
||||
snapshot := got.DocumentCaretLine.Contents[0].Snapshot
|
||||
if snapshot.SelectionStart != 3 || snapshot.SelectionEnd != 5 || !reflect.DeepEqual(snapshot.TextAttributeRuns, reader.line.TextAttributeRuns) {
|
||||
t.Fatalf("native suffix indicators lost after display expansion: %+v", snapshot)
|
||||
}
|
||||
if result.DocumentCaretLine.Contents[0].Snapshot.SelectionEnd != 0 {
|
||||
t.Fatal("enrichment mutated the original line")
|
||||
}
|
||||
result.DocumentPosition.Offset = 6
|
||||
got = worker.readDocumentBraille(context.Background(), result)
|
||||
if got.DocumentBrailleLine != nil || !errors.Is(got.DocumentBrailleErr, atspi.ErrNoDocumentCaret) {
|
||||
t.Fatalf("display length accepted an invalid native offset: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@ func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atsp
|
||||
if err != nil {
|
||||
return atspi.DocumentNode{}, 0, err
|
||||
}
|
||||
if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text {
|
||||
parentNative, childNative := documentBrailleNativeText(parentText), documentBrailleNativeText(childText)
|
||||
if parentText.Start != childText.Start || parentText.End != childText.End || parentNative == "" || parentNative != childNative {
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
node = child
|
||||
|
||||
@@ -90,6 +90,23 @@ func TestNativeCaretRejectsAmbiguousOffsetDomains(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type expandedCaretReader map[atspi.ObjectID]atspi.DocumentCaretText
|
||||
|
||||
func (reader expandedCaretReader) ReadDocumentCaret(_ context.Context, object atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
return reader[object.ID()], nil
|
||||
}
|
||||
|
||||
func TestNativeCaretRejectsMatchingExpandedTextWithDifferentNativeOffsets(t *testing.T) {
|
||||
cursor := mirroredCaretCursor(t, caretTextReader{webID("/paragraph"): "\ufffcxy", webID("/text"): "axy"}, false)
|
||||
reader := expandedCaretReader{
|
||||
webID("/paragraph"): {Start: 0, End: 3, RawText: "\ufffcxy", Text: "axy", Expanded: true},
|
||||
webID("/text"): {Start: 0, End: 3, RawText: "axy", Text: "axy"},
|
||||
}
|
||||
if err := cursor.syncNativeCaret(context.Background(), reader, webID("/paragraph"), 1); !errors.Is(err, atspi.ErrNoDocumentCaret) {
|
||||
t.Fatalf("expanded display text authorized native offset transfer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerNativeCaretHandoffPrecedesNextArrow(t *testing.T) {
|
||||
text := caretTextReader{webID("/text"): "When autocomplete"}
|
||||
navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text}
|
||||
|
||||
@@ -21,7 +21,7 @@ import "strings"
|
||||
|
||||
const Name = "Cthulhu"
|
||||
|
||||
const Version = "2026.11.02"
|
||||
const Version = "2026.11.03"
|
||||
const Channel = "hunter"
|
||||
|
||||
var Build = Version
|
||||
|
||||
Reference in new issue
Block a user