// 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" "git.stormux.org/storm/cthulhu/internal/review" ) func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) { speech := &fakeSpeechQueue{ spoken: make(chan string, 2), interrupt: make(chan struct{}, 1), } adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} reviewRequests := make(chan input.Command, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan error, 1) options := presentation.DefaultOptions() options.EnableSound = true options.PlaySoundForRole = true options.RoleSoundPresentation = "sound_only" go func() { completed <- processEventsWithReview( ctx, slog.New(slog.DiscardHandler), speech, adapter, reviewRequests, 0, options, ) }() adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.NewSnapshot( "/accessible/example", "Example", "button", "Runs the example", "", )) select { case text := <-speech.spoken: if text != "Example, Runs the example" { t.Fatalf("focus speech = %q", text) } case <-time.After(time.Second): t.Fatal("focus was not presented") } reviewRequests <- input.WhereAmI select { case <-speech.interrupt: case <-time.After(time.Second): t.Fatal("where am I did not interrupt stale speech") } select { case text := <-speech.spoken: if text != "Example, button, Runs the example" { t.Fatalf("where am I speech = %q", text) } case <-time.After(time.Second): t.Fatal("where am I was not presented") } cancel() select { case err := <-completed: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event)} reviewRequests := make(chan input.Command, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan error, 1) go func() { completed <- processEventsWithReview( ctx, slog.New(slog.DiscardHandler), speech, adapter, reviewRequests, 0, ) }() reviewRequests <- input.WhereAmI select { case text := <-speech.spoken: if text != "Could not find current location." { t.Fatalf("where am I without focus = %q", text) } case <-time.After(time.Second): t.Fatal("missing-focus message was not presented") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsSynchronizesAndPresentsObjectReview(t *testing.T) { rootID := atspi.NewObjectID(":1.42", "/root") firstID := atspi.NewObjectID(":1.42", "/first") secondID := atspi.NewObjectID(":1.42", "/second") root := atspi.NewSnapshot("/root", "Window", "frame", "", "") root.BusName = ":1.42" first := atspi.NewSnapshot("/first", "First", "push button", "", "") first.BusName = ":1.42" second := atspi.NewSnapshot("/second", "Second", "check box", "", "") second.BusName = ":1.42" second.Checked = true tree, err := atspi.NewAccessibleTree(rootID, []atspi.TreeNode{ {Snapshot: root, Children: []atspi.ObjectID{firstID, secondID}}, {Snapshot: first}, {Snapshot: second}, }) if err != nil { t.Fatal(err) } adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree} objectReview, err := review.NewWorker(adapter, time.Second) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := objectReview.Start(ctx); err != nil { t.Fatal(err) } speech := &fakeSpeechQueue{spoken: make(chan string, 3), interrupt: make(chan struct{}, 2)} completed := make(chan error, 1) go func() { completed <- processEventsWithObjectReview( ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, objectReview, 0, ) }() adapter.events <- atspi.NewEvent(atspi.FocusEvent, first) select { case text := <-speech.spoken: if text != "First, push button" { t.Fatalf("focus speech = %q", text) } case <-time.After(time.Second): t.Fatal("focus was not presented") } if err := objectReview.Move(review.NextSibling); err != nil { t.Fatal(err) } select { case text := <-speech.spoken: if text != "Second, check box, checked" { t.Fatalf("object review speech = %q", text) } case <-time.After(time.Second): t.Fatal("object review movement was not presented") } if err := objectReview.Move(review.FirstChild); err != nil { t.Fatal(err) } select { case text := <-speech.spoken: if text != "No children." { t.Fatalf("object review boundary = %q", text) } case <-time.After(time.Second): t.Fatal("object review boundary was not presented") } cancel() select { case err := <-completed: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("event loop did not stop") } if err := objectReview.Stop(context.Background()); err != nil { t.Fatal(err) } } func TestObjectReviewBoundaryMessagesMatchPython(t *testing.T) { tests := map[review.Direction]string{ review.Parent: "No parent.", review.FirstChild: "No children.", review.PreviousSibling: "No previous.", review.NextSibling: "No next.", } for direction, expected := range tests { if got := objectReviewBoundaryMessage(direction); got != expected { t.Fatalf("direction %d = %q, want %q", direction, got, expected) } } }