// 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 . 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) } }) } } }