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: " 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}, " 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) } }