// 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/review" ) func TestProcessEventsPresentsFlatReviewThroughSpeechQueue(t *testing.T) { id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) } snapshot := func(path, name, role string) atspi.Snapshot { s := atspi.NewSnapshot(path, name, role, "", "") s.BusName = ":1.42" return s } root := snapshot("/window", "Window", "frame") field := snapshot("/field", "Review field", "text") geometry := atspi.Geometry{Bounds: atspi.Rectangle{Width: 200, Height: 50}, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true} tree, err := atspi.NewAccessibleTree(id("/window"), []atspi.TreeNode{ {Snapshot: root, Geometry: geometry, Children: []atspi.ObjectID{id("/field")}}, {Snapshot: field, Geometry: geometry, Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: 6, Text: "a 猫!oo", Bounds: geometry.Bounds}}}}, }) if err != nil { t.Fatal(err) } adapter := &fakeAdapter{events: make(chan atspi.Event, 1), tree: tree} worker, err := review.NewWorker(adapter, time.Second) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) speech := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)} completed := make(chan error, 1) go func() { completed <- processEventsWithObjectReview(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, worker, 0) }() expect := func(want string) { t.Helper() select { case got := <-speech.spoken: if got != want { t.Fatalf("speech=%q want=%q", got, want) } case <-time.After(time.Second): t.Fatal("speech missing") } } adapter.events <- atspi.NewEvent(atspi.FocusEvent, field) expect("Review field, text") worker.ReadFlat(review.Line, 0) expect("a 猫!oo") worker.ReadFlat(review.Character, 1) expect("space") worker.ReadFlat(review.Character, 1) expect("猫") worker.ReadFlat(review.Word, 0) expect("猫!oo") worker.ReadFlat(review.Character, 1) expect("!") worker.ReadFlat(review.Character, 1) expect("o") worker.ReadFlat(review.Character, 1) expect("o") cancel() select { case err := <-completed: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestFlatReviewResultUsesNativeFocusAndRejectsOldPage(t *testing.T) { worker, err := review.NewWorker(&fakeAdapter{}, time.Second) if err != nil { t.Fatal(err) } document := atspi.NewObjectID(":1.42", "/document") worker.SyncFocus(document) result := review.Result{FlatUnit: review.Line, FlatFocus: document} // A browse caret can be on a child while native focus remains here. if !flatReviewResultCurrent(worker, result) { t.Fatal("flat result from native document focus was discarded") } worker.ResetDocument(document) if flatReviewResultCurrent(worker, result) { t.Fatal("flat result from the previous page was accepted") } result.DocumentEpoch = 1 worker.SyncFocus(atspi.NewObjectID(":1.42", "/address")) if flatReviewResultCurrent(worker, result) { t.Fatal("flat result after focus left the document was accepted") } } func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 2)} logger := slog.New(slog.DiscardHandler) for _, result := range []review.Result{ {FlatUnit: review.Line, FlatStep: 1}, {FlatUnit: review.WordAbove}, {FlatUnit: review.WordBelow}, } { presentFlatReviewResult(logger, queue, result) select { case <-queue.spoken: t.Fatalf("boundary spoke for unit %d", result.FlatUnit) default: } } presentFlatReviewResult(logger, queue, review.Result{FlatUnit: review.Line, Err: review.ErrLimitedReview}) if got := <-queue.spoken; got != "This window exceeds the current flat review text limit." { t.Fatal(got) } } func TestFlatReviewWindowCommands(t *testing.T) { if unit, step := flatReviewCommand(input.ReviewWindowStart); unit != review.WindowStart || step != 0 { t.Fatalf("start = %d, %d", unit, step) } if unit, step := flatReviewCommand(input.ReviewWindowEnd); unit != review.WindowEnd || step != 0 { t.Fatalf("end = %d, %d", unit, step) } if unit, step := flatReviewCommand(input.ReviewWordAbove); unit != review.WordAbove || step != 0 { t.Fatalf("above = %d, %d", unit, step) } if unit, step := flatReviewCommand(input.ReviewWordBelow); unit != review.WordBelow || step != 0 { t.Fatalf("below = %d, %d", unit, step) } if unit, step := flatReviewCommand(input.ReviewLineEnd); unit != review.LineEnd || step != 0 { t.Fatalf("line end = %d, %d", unit, step) } } func TestFlatReviewBlankAndWhitespaceSpeech(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 1)} logger := slog.New(slog.DiscardHandler) for _, tc := range []struct { unit review.FlatUnit text, want string }{ {review.Line, "\n", "blank"}, {review.Character, "\n", "blank"}, {review.WindowEnd, "\n", "blank"}, {review.Line, " ", "white space"}, {review.Word, "\t", "white space"}, {review.Character, " ", "space"}, {review.LineEnd, "\n", "blank"}, } { presentFlatReviewResult(logger, queue, review.Result{FlatUnit: tc.unit, Moved: true, FlatPosition: review.FlatPosition{Text: tc.text}}) if got := <-queue.spoken; got != tc.want { t.Fatalf("unit=%v text=%q: got=%q want=%q", tc.unit, tc.text, got, tc.want) } } } func TestFlatReviewCharacterVoice(t *testing.T) { for _, unit := range []review.FlatUnit{review.Character, review.LineEnd, review.Word, review.Line} { queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, review.Result{Moved: true, FlatUnit: unit}) request := <-queue.requests want := unit == review.Character || unit == review.LineEnd if request.Character != want { t.Fatalf("unit %v: character = %v", unit, request.Character) } } }