// 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/logging" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func TestProcessEventsDoesNotBlockOnDiagnosticsWriter(t *testing.T) { writer := &eventDiagnosticsWriter{ started: make(chan struct{}), canceled: make(chan struct{}), } diagnostics := logging.NewAsync(writer, logging.AsyncOptions{ Options: logging.Options{Level: slog.LevelDebug}, Capacity: 2, StopTimeout: 100 * time.Millisecond, CancelGrace: 10 * time.Millisecond, }) speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, diagnostics.Logger(), speech, adapter, 0) close(completed) }() adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/test", "Test", "label", "", "")) select { case <-writer.started: case <-time.After(time.Second): t.Fatal("diagnostics worker did not start") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event consumer blocked on diagnostics writer") } writer.Cancel() if err := diagnostics.Stop(context.Background()); err != nil { t.Fatalf("diagnostics stop: %v", err) } } func TestProcessEventsSuppressesBrowserAccessibleNameEvents(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} browser := newBrowserModeController(nil, &modeKeys{}, nil) documentContext := modeContext(t, "heading", false) browser.navigated(review.DocumentPosition{ Document: documentContext.TopLevel, Snapshot: documentContext.Ancestors[0], }, documentContext.Ancestors[0].ID()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, browser, nil, nil) close(completed) }() browserName := atspi.NewSnapshot("/browser/static", "https://example.com/", "static", "", "") browserName.BusName = documentContext.TopLevel.ID().BusName adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, browserName) select { case text := <-speech.spoken: t.Fatalf("browser name event spoke %q", text) case <-time.After(50 * time.Millisecond): } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestProcessEventsAppliesDescriptionSetting(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents( ctx, slog.New(slog.DiscardHandler), speech, adapter, 0, presentation.Options{SpeakDescription: false}, ) close(completed) }() adapter.events <- atspi.NewEvent( "focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "Saves the document", ""), ) select { case text := <-speech.spoken: if text != "Save, push button" { t.Fatalf("spoken text = %q, want %q", text, "Save, push button") } case <-time.After(time.Second): t.Fatal("timed out waiting for configured focus speech") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestProcessEventsSpeaksChangedTextImmediately(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() snapshot := atspi.NewSnapshot("/accessible/editor", "", "text", "", "prompt ls") snapshot.Editable = true adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, snapshot, "s") adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, "s") adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, snapshot, " ") for _, expected := range []string{"s", "s", "space"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for changed text speech %q", expected) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } }