// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/presentation" ) func TestEditorParagraphUsesConfiguredEcho(t *testing.T) { for _, echo := range []string{"disabled", "character", "word", "sentence"} { t.Run(echo, func(t *testing.T) { options := presentation.DefaultOptions() payload, contextText, want := "o", "Hello", "o" switch echo { case "disabled": options.EnableKeyEcho, want = false, "" case "character": options.EnableEchoByCharacter = true case "word": options.EnableEchoByWord = true payload, contextText, want = " ", "Hello ", "Hello" case "sentence": options.EnableEchoBySentence = true payload, contextText, want = " ", "Hello. ", "Hello." } queue := &fakeSpeechQueue{spoken: make(chan string, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0, options) }() defer func() { cancel(); <-done }() editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Composer", Role: "entry", Editable: true} paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true, EditableRoot: editor.ID(), Text: contextText, TextBeforeCaret: contextText} adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor) adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, payload) adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/next", Role: "button", Name: "Next"}) // Once focus leaves, delayed events from that editor stay quiet. adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "delayed edit") adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"}) wants := []string{"Composer, entry"} if want != "" { wants = append(wants, want) } wants = append(wants, "Next, button", "End, button") for _, want := range wants { select { case got := <-queue.spoken: if got != want { t.Fatalf("speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing %q", want) } } }) } } func TestEditableDocumentParagraphEvents(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} 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 }() editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Message body", Role: "document web", Editable: true} paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true, EditableRoot: editor.ID(), Text: "a", TextCaretKnown: true, BrailleLine: "a", BrailleCaret: 1} adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor) adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "a") // This caret must not reread the growing line after accepted typing. adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) paragraph.SelectionStart, paragraph.SelectionEnd, paragraph.SelectionText = 0, 1, "a" adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, paragraph) adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, paragraph, "a") subject := atspi.Snapshot{BusName: editor.BusName, Path: "/subject", Name: "Subject", Role: "entry", Editable: true} adapter.events <- atspi.NewEvent(atspi.FocusEvent, subject) // A delayed mutation from the old body cannot interrupt a header field. adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "unfocused text") other := paragraph other.Path, other.EditableRoot = "/other_paragraph", atspi.NewObjectID(editor.BusName, "/other_editor") adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, other, "other editor") adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"}) for _, want := range []string{"Message body, document web", "a", "a selected", "a", "Subject, entry", "End, button"} { select { case got := <-queue.spoken: if got != want { t.Fatalf("speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing %q", want) } } } func TestEditableDocumentCaretAndSelectionBraille(t *testing.T) { root := atspi.NewObjectID(":1.7", "/editor") frames := make(chan braille.Frame, 2) state := &eventLoopState{} state.focusedID = root handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), brailleOutput: frameRecorder{frames: frames}} source := atspi.Snapshot{BusName: root.BusName, Path: "/paragraph", Role: "paragraph", Editable: true, EditableRoot: root, Text: "body", TextCaretKnown: true, BrailleLine: "body", BrailleCaret: 2} for _, eventType := range []string{atspi.CaretEvent, atspi.TextSelectionChangedEvent} { handler.acceptFocusedEvent(atspi.NewEvent(eventType, source), recentInput(nil)) if state.focusedID != root || state.current.ID() != source.ID() || state.brailleSnapshot.ID() != source.ID() { t.Fatal("paragraph presentation lost native editor focus or Braille ownership") } select { case frame := <-frames: if !strings.HasPrefix(frame.Text, "body") || frame.Cursor != 2 || len(frame.Routes) == 0 || frame.Routes[0].Path != source.Path { t.Fatalf("paragraph Braille frame = %+v", frame) } default: t.Fatal("missing paragraph Braille frame") } } }