Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func TestNativeBrowserNamedLinks(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil {
|
||||
t.Fatalf("fixture focus: %s, %v", output, err)
|
||||
}
|
||||
var focus atspi.ObjectID
|
||||
for !focus.Valid() {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Probe start" {
|
||||
focus = event.Source.ID()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
tree, err := adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, err := review.NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||
for _, step := range []struct {
|
||||
command review.DocumentCaretCommand
|
||||
text string
|
||||
}{
|
||||
{review.CaretNextLine, "Example Game 2 copies"},
|
||||
{review.CaretNextLine, "Ordinary multiline link"},
|
||||
{review.CaretNextCharacter, "r"},
|
||||
{review.CaretNextWord, "Ordinary"},
|
||||
{review.CaretNextWord, "multiline"},
|
||||
{review.CaretNextLine, "Useful image label"},
|
||||
{review.CaretPreviousLine, "Ordinary multiline link"},
|
||||
{review.CaretPreviousLine, "Example Game 2 copies"},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(ctx, adapter, step.command)
|
||||
t.Logf("%v: path=%s role=%s text=%q offset=%d range=%d:%d count=%d", step.command, move.Position.Object.ID().Path, move.Position.Snapshot.Role, move.Text, move.Position.Offset, move.Start, move.End, move.Position.Snapshot.CharacterCount)
|
||||
if err != nil || !move.Moved {
|
||||
t.Fatalf("%v: %+v, %v", step.command, move, err)
|
||||
}
|
||||
if step.text == "Example Game 2 copies" && !move.Position.Snapshot.ExplicitName {
|
||||
t.Fatal("native explicit-name attribute was not captured")
|
||||
}
|
||||
context, err := tree.DocumentContext(move.Position.Object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := review.Result{DocumentCaretCommand: step.command, DocumentPosition: move.Position,
|
||||
DocumentCaretText: move.Text, DocumentCaretContext: context.Ancestors, Moved: move.Moved}
|
||||
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions())
|
||||
select {
|
||||
case spoken := <-queue.spoken:
|
||||
if !strings.Contains(spoken, step.text) {
|
||||
t.Fatalf("%v spoke %q, want %q", step.command, spoken, step.text)
|
||||
}
|
||||
default:
|
||||
t.Fatal("native browse result was silent")
|
||||
}
|
||||
}
|
||||
reference, err := tree.Reference(focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, err := tree.Document(ctx, reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var username, emoji atspi.ObjectReference
|
||||
for _, node := range document.Nodes {
|
||||
if node.Snapshot.Role != "static" {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(node.Snapshot.Name) {
|
||||
case "Username":
|
||||
username = node.Reference
|
||||
case "🧻":
|
||||
emoji = node.Reference
|
||||
}
|
||||
}
|
||||
if !username.Valid() || !emoji.Valid() {
|
||||
t.Fatal("separate display-name and emoji text objects missing")
|
||||
}
|
||||
word, err := adapter.ReadDocumentCaret(ctx, emoji, 0, atspi.DocumentCaretWord)
|
||||
t.Logf("native emoji word: %+v, %v", word, err)
|
||||
if _, err := cursor.SyncFocus(ctx, username.ID()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, step := range []struct {
|
||||
command review.DocumentCaretCommand
|
||||
text string
|
||||
}{
|
||||
{review.CaretNextWord, "Username"},
|
||||
{review.CaretNextWord, "🧻"},
|
||||
{review.CaretNextWord, "After"},
|
||||
{review.CaretPreviousWord, "After"},
|
||||
{review.CaretPreviousWord, "🧻"},
|
||||
{review.CaretPreviousWord, "Username"},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(ctx, adapter, step.command)
|
||||
if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.text {
|
||||
t.Fatalf("word navigation across emoji, %v: %+v, %v; want %q", step.command, move, err, step.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user