// 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 app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" ) func TestProcessInputEventsInterruptsSpeechOnKeyPress(t *testing.T) { speech := &fakeSpeechQueue{ spoken: make(chan string, 1), interrupt: make(chan struct{}, 3), } listener := &fakeInputListener{ events: make(chan input.Event, 4), modifierKeycodes: input.NewModifierKeycodes(37), } listener.events <- input.Event{Keycode: 37, Pressed: false} listener.events <- input.NewKeyEvent(111, true, "Up") listener.events <- input.NewKeyEvent(111, false, "Up") listener.events <- input.Event{Keycode: 37, Pressed: true} close(listener.events) processInputEvents(context.Background(), slog.New(slog.DiscardHandler), speech, listener, input.EchoSettings{}, nil, nil) if got := len(speech.interrupt); got != 2 { t.Fatalf("interrupts = %d, want 2 for Up and Control presses", got) } } func TestProcessInputEventsEchoesConfiguredNonPrintableKeys(t *testing.T) { speech := &fakeSpeechQueue{ spoken: make(chan string, 3), interrupt: make(chan struct{}, 4), } listener := &fakeInputListener{ events: make(chan input.Event, 5), modifierKeycodes: input.NewModifierKeycodes(37), } ctx, cancel := context.WithCancel(context.Background()) completed := make(chan struct{}) go func() { processInputEvents(ctx, slog.New(slog.DiscardHandler), speech, listener, input.EchoSettings{ EnableKeyEcho: true, EnableModifierKeys: true, EnableFunctionKeys: true, CthulhuModifierKeys: []string{"Insert", "KP_Insert"}, }, nil, nil) close(completed) }() listener.events <- input.NewKeyEvent(37, true, "Control_L") listener.events <- input.NewKeyEvent(118, true, "Insert") listener.events <- input.NewKeyEvent(67, true, "F1") listener.events <- input.NewKeyEvent(118, false, "Insert") listener.events <- input.NewKeyEvent(68, true, "F2") select { case text := <-speech.spoken: if text != "left control" { t.Fatalf("modifier echo = %q", text) } case <-time.After(time.Second): t.Fatal("timed out waiting for modifier echo") } select { case text := <-speech.spoken: if text != "f2" { t.Fatalf("Cthulhu chord key echo leaked as %q before f2", text) } case <-time.After(time.Second): t.Fatal("timed out waiting for key echo after Cthulhu modifier release") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for input event loop to stop") } } func TestProcessEventsAppliesPrintableKeyEchoSettings(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan struct{}) options := presentation.DefaultOptions() options.EnableAlphabeticKeys = false go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0, options) close(completed) }() snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "") snapshot.Editable = true adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "a") adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "pasted text") select { case text := <-speech.spoken: if text != "pasted text" { t.Fatalf("disabled alphabetic key echo spoke %q before multi-character insertion", text) } case <-time.After(time.Second): t.Fatal("timed out waiting for multi-character insertion") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestProcessEventsRetainsPriorLineForSentenceEchoOnNewline(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan struct{}) options := presentation.DefaultOptions() options.EnableEchoBySentence = true go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0, options) close(completed) }() snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "Line without punctuation") snapshot.Editable = true snapshot.TextBeforeCaret = "Line without punctuation" adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "n") blankLine := atspi.NewSnapshot("/accessible/editor", "", "text", "", "") blankLine.Editable = true adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, blankLine, "\n") for _, want := range []string{"n", "Line without punctuation"} { select { case text := <-speech.spoken: if text != want { t.Fatalf("typing echo = %q, want %q", text, want) } case <-time.After(time.Second): t.Fatalf("timed out waiting for typing echo %q", want) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } }