// 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/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" ) func TestFlatReviewPartialDiagnosticsPreserveSpellingWithoutExtraMessages(t *testing.T) { live := &liveSettings{} live.Store(config.DefaultSettings()) braille := &flashRecorder{} queue := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} output := outputQueue{speech: queue, braille: braille, live: live} result := review.Result{FlatUnit: review.Word, FlatSpeech: review.FlatSpell, FlatWarning: true, FlatCompleteness: review.FlatCompleteness{OmittedObjects: 1}, FlatPosition: review.FlatPosition{Text: "cat"}} presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result) content := <-queue.requests if len(content.Spelling) != 3 || content.Text != "cat" || len(queue.requests) != 0 { t.Fatalf("content metadata or extra message: %+v, queued=%d", content, len(queue.requests)) } if len(braille.messages) != 0 { t.Fatalf("unexpected Braille warning: %v", braille.messages) } result.FlatUnit, result.FlatStep, result.Moved = review.Line, -1, false presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result) if boundary := <-queue.requests; boundary.Text != "Top of screen." { t.Fatalf("partial top boundary: %+v", boundary) } result.FlatStep = 1 presentFlatReviewResult(slog.New(slog.DiscardHandler), output, result) if boundary := <-queue.requests; boundary.Text != "Bottom of screen." || len(queue.requests) != 0 { t.Fatalf("partial bottom boundary or extra message: %+v", boundary) } } 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.Line, -1) expect("Top of screen.") worker.ReadFlat(review.Line, 0) expect("a 猫!oo") worker.ReadFlat(review.Line, 1) expect("Bottom of screen.") 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 TestFlatReviewLineBoundaryFeedback(t *testing.T) { for _, tc := range []struct { name string step int want string }{ {"top", -1, "Top of screen."}, {"bottom", 1, "Bottom of screen."}, } { t.Run(tc.name, func(t *testing.T) { queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)} result := review.Result{ FlatUnit: review.Line, FlatStep: tc.step, FlatSpeech: review.FlatSpell, FlatPosition: review.FlatPosition{Text: " Edge line"}, } for range 2 { presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, result) select { case request := <-queue.requests: if request.Text != tc.want || request.Cause != "flat-review" { t.Fatalf("boundary request = %+v", request) } if request.Line != nil || request.Character || len(request.Spelling) != 0 { t.Fatalf("boundary retained content speech metadata: %+v", request) } default: t.Fatal("boundary was silent") } } }) } } func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 2)} logger := slog.New(slog.DiscardHandler) for _, result := range []review.Result{ {FlatUnit: review.Word, FlatStep: -1}, {FlatUnit: review.Word, FlatStep: 1}, {FlatUnit: review.Character, FlatStep: -1}, {FlatUnit: review.Character, 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) } } }