// 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" ) func TestAlertMessageFinishesBeforeInitialButton(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 5)} 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) }() window := atspi.NewObjectID(":1.4", "/question") alert := atspi.NewSnapshot(window.Path, "Question", "alert", "", "Version 0.6.0 is available. Open the releases page in your browser?") alert.BusName = window.BusName created := atspi.NewEvent(atspi.WindowCreateEvent, alert) created.Window = window adapter.events <- created activated := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.NewSnapshot(window.Path, "Question", "alert", "", "")) activated.Source.BusName = window.BusName activated.Window = window adapter.events <- activated for _, item := range []struct{ path, name string }{{"/question/no", "No"}, {"/question/yes", "Yes"}} { button := atspi.NewSnapshot(item.path, item.name, "button", "", "") button.BusName = window.BusName focused := atspi.NewEvent(atspi.FocusedStateEvent, button) focused.Window = window focused.Detail1 = 1 adapter.events <- focused } for index, expected := range []struct { text string append bool }{ {"Question, alert, Version 0.6.0 is available. Open the releases page in your browser?", false}, {"No, button", true}, {"Yes, button", false}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestSimpleFramePromptMessageFinishesBeforeButton(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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) }() window := atspi.NewObjectID(":1.5", "/prompt") frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "Microsoft Windows has been found! Would you like to remove it?") frame.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) activated.Window = window adapter.events <- activated for _, item := range []struct{ path, name string }{{"/prompt/yes", "gtk-yes"}, {"/prompt/no", "gtk-no"}} { button := atspi.NewSnapshot(item.path, item.name, "button", "", "") button.BusName = window.BusName focused := atspi.NewEvent(atspi.FocusedStateEvent, button) focused.Window = window focused.Detail1 = 1 adapter.events <- focused } for index, expected := range []struct { text string append bool }{ {"Alert, frame, Microsoft Windows has been found! Would you like to remove it?", false}, {"gtk-yes, button", true}, {"gtk-no, button", false}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestSimplePromptRecoversMessageAtFirstButton(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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) }() window := atspi.NewObjectID(":1.6", "/prompt") frame := atspi.NewSnapshot(window.Path, "Alert", "frame", "", "") frame.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) activated.Window = window adapter.events <- activated button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") button.BusName = window.BusName focused := atspi.NewEvent(atspi.FocusedStateEvent, button) focused.Window = window focused.Detail1 = 1 focused.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" adapter.events <- focused for index, expected := range []struct { text string append bool }{ {"Alert, frame", false}, {"Microsoft Windows has been found! Would you like to remove it?, gtk-yes, button", true}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestListPromptAnnouncesQuestionBeforeChoices(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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) }() window := atspi.NewObjectID(":1.9", "/list") list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "") list.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, list) activated.Window = window activated.WindowTitle = "Select Sound Type" activated.WindowMessage = "Which sound type would you like to use?" adapter.events <- activated focused := atspi.NewEvent(atspi.FocusedStateEvent, list) focused.Window = window focused.Detail1 = 1 adapter.events <- focused for index, expected := range []struct { text string append bool }{ {"Select Sound Type, Which sound type would you like to use?", false}, {"tree table", true}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestListPromptAnnouncesQuestionWhenActivationTargetsFirstCell(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} 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) close(completed) }() window := atspi.NewObjectID(":1.9", "/power") cell := atspi.NewSnapshot("/power/list/first", "Power off", "table cell", "", "") cell.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell) activated.Window = window activated.WindowTitle = "I38" activated.WindowMessage = "Power options" adapter.events <- activated select { case request := <-speech.requests: if request.Text != "I38, Power options" { t.Fatalf("activation speech = %q", request.Text) } case <-time.After(time.Second): t.Fatal("timed out waiting for list prompt") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestListPromptRecoversQuestionOnFocus(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} 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) }() window := atspi.NewObjectID(":1.10", "/list") frame := atspi.NewSnapshot(window.Path, "Select Sound Type", "frame", "", "") frame.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame) activated.Window = window adapter.events <- activated list := atspi.NewSnapshot("/list/choices", "", "tree table", "", "") list.BusName = window.BusName focused := atspi.NewEvent(atspi.FocusedStateEvent, list) focused.Window = window focused.Detail1 = 1 focused.WindowMessage = "Which sound type would you like to use?" adapter.events <- focused for index, expected := range []struct { text string append bool }{ {"Select Sound Type, frame", false}, {"Which sound type would you like to use?, tree table", true}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestFocusedButtonActivationAnnouncesPromptBeforeButton(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 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) }() window := atspi.NewObjectID(":1.7", "/prompt") button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") button.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, button) activated.Window = window activated.WindowTitle = "Alert" activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" adapter.events <- activated focused := atspi.NewEvent(atspi.FocusedStateEvent, button) focused.Window = window focused.Detail1 = 1 adapter.events <- focused other := atspi.NewSnapshot("/prompt/no", "gtk-no", "button", "", "") other.BusName = window.BusName focusedOther := atspi.NewEvent(atspi.FocusedStateEvent, other) focusedOther.Window = window focusedOther.Detail1 = 1 adapter.events <- focusedOther for index, expected := range []struct { text string append bool }{ {"Alert, Microsoft Windows has been found! Would you like to remove it?", false}, {"gtk-yes, button", true}, {"gtk-no, button", false}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestFocusedButtonActivationAfterFocusKeepsPrompt(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2), requests: make(chan speechRequest, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} 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) }() window := atspi.NewObjectID(":1.8", "/prompt") button := atspi.NewSnapshot("/prompt/yes", "gtk-yes", "button", "", "") button.BusName = window.BusName button.Focused = true focused := atspi.NewEvent(atspi.FocusedStateEvent, button) focused.Window = window focused.Detail1 = 1 adapter.events <- focused activated := atspi.NewEvent(atspi.WindowActivatedEvent, button) activated.Window = window activated.WindowTitle = "Alert" activated.WindowMessage = "Microsoft Windows has been found! Would you like to remove it?" adapter.events <- activated for index, expected := range []string{ "gtk-yes, button", "Alert, Microsoft Windows has been found! Would you like to remove it?", } { select { case request := <-speech.requests: if request.Text != expected { t.Fatalf("request %d = %q, want %q", index, request.Text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("timed out waiting for event loop to stop") } } func TestTerminalTextChangeSuppressesPromptReplayButPreservesNavigation(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} 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) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt") adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Output") if got := <-speech.spoken; got != "Output" { t.Fatalf("text = %q", got) } adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) // The caret timer must have fired before the next navigation event. select { case got := <-speech.spoken: t.Fatalf("prompt replayed after insertion: %q", got) case <-time.After(30 * time.Millisecond): } terminal.CaretCharacter = "P" adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) select { case got := <-speech.spoken: if got != "P" { t.Fatalf("caret navigation = %q", got) } case <-time.After(time.Second): t.Fatal("caret navigation was suppressed") } terminal.CaretCharacter = "" terminal.Text = "Another line" adapter.events <- atspi.NewEvent(atspi.CaretEvent, terminal) select { case got := <-speech.spoken: if got != "Another line" { t.Fatalf("line navigation = %q", got) } case <-time.After(time.Second): t.Fatal("line navigation was suppressed") } cancel() <-completed }