Web fixes.

This commit is contained in:
ak47 committed 2026-11-04 21:22:51 -05:00
1 parent 914d174925
commit 453206b85b
11 files changed
+643 -25

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserActionOwnership(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser action fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
var focus atspi.ObjectID
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
t.Fatalf("fixture focus: %v %s", err, out)
}
timer := time.NewTimer(150 * time.Millisecond)
drain:
for {
select {
case event := <-adapter.Events():
if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" {
focus = event.Source.ID()
break drain
}
case <-timer.C:
break drain
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !focus.Valid() {
t.Fatal("fixture heading focus missing")
}
tree, err := adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
root, _ := tree.Root()
document, err := tree.Document(ctx, root)
if err != nil {
t.Fatal(err)
}
anchors := map[string]atspi.ObjectReference{}
labels := map[string]atspi.ObjectReference{}
for _, node := range document.Nodes {
snapshot := node.Snapshot
if snapshot.ElementID == "Basic" || snapshot.ElementID == "Advanced" {
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
if err != nil {
t.Fatal(err)
}
t.Logf("%s anchor actions: %+v", snapshot.ElementID, actions.Actions)
if snapshot.Role != "section" || snapshot.HTMLTag != "a" || snapshot.Focusable || !snapshot.SupportsClick {
t.Fatalf("custom anchor lost activation semantics: %+v", snapshot)
}
anchors[strings.ToUpper(snapshot.ElementID)] = node.Reference
}
if snapshot.Role == "static" && (snapshot.Name == "BASIC" || snapshot.Name == "ADVANCED") {
labels[snapshot.Name] = node.Reference
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
if err != nil {
t.Fatal(err)
}
found := false
for _, action := range actions.Actions {
found = found || action.Name == "clickAncestor"
}
if !found {
t.Fatalf("label does not reproduce Chromium ancestor activation: %+v", actions)
}
}
if snapshot.Role == "static" || snapshot.ElementID == "inactive" || snapshot.Role == "paragraph" || atspi.IsWebDocumentRole(snapshot.Role) {
if snapshot.SupportsClick {
t.Fatalf("passive text/container became a clickable: %+v", snapshot)
}
}
}
if len(anchors) != 2 || len(labels) != 2 {
t.Fatalf("missing mode controls: %v, %v", anchors, labels)
}
cursor, _ := review.NewDocumentCursor(tree)
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
t.Fatal(err)
}
counts := map[string]int{}
for step := 0; step < 30; step++ {
command := review.CaretNextLine
if step == 0 {
command = review.CaretDocumentStart
}
move, err := cursor.MoveCaret(ctx, adapter, command)
if err != nil {
t.Fatal(err)
}
if !move.Moved {
break
}
name := strings.TrimSpace(move.Text)
if _, ok := labels[name]; ok {
counts[name]++
target, err := cursor.ActivationTarget()
if err != nil || target != anchors[name] {
t.Fatalf("label %s activates %v: %v", name, target, err)
}
}
}
if counts["BASIC"] != 1 || counts["ADVANCED"] != 1 {
t.Fatalf("mode reading counts: %v", counts)
}
// Structural clickable navigation discovers the owners, not their labels.
cursor, _ = review.NewDocumentCursor(tree)
cursor.SyncFocus(ctx, focus)
found := map[atspi.ObjectID]bool{}
for step := 0; step < 12; step++ {
move, err := cursor.Move(ctx, review.DocumentClickable, 0, false)
if err != nil {
t.Fatal(err)
}
if !move.Moved || found[move.Position.Object.ID()] {
break
}
found[move.Position.Object.ID()] = true
}
for name, anchor := range anchors {
if !found[anchor.ID()] || found[labels[name].ID()] {
t.Fatalf("clickable discovery for %s: %v", name, found)
}
}
for _, mode := range []string{"Basic", "Advanced"} {
// Reacquire references after each iframe replacement. Enter's target
// still comes from the text leaf, with native focus on the start heading.
tree, err = adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
root, _ = tree.Root()
document, err = tree.Document(ctx, root)
if err != nil {
t.Fatal(err)
}
var label atspi.ObjectID
for _, node := range document.Nodes {
if node.Snapshot.Role == "static" && node.Snapshot.Name == strings.ToUpper(mode) {
label = node.Reference.ID()
}
}
cursor, _ = review.NewDocumentCursor(tree)
if _, err := cursor.SyncFocus(ctx, label); err != nil {
t.Fatal(err)
}
target, err := cursor.ActivationTarget()
if err != nil {
t.Fatal(err)
}
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
t.Fatal(err)
}
activated := false
for !activated {
select {
case event := <-adapter.Events():
activated = event.Type == atspi.AccessibleNameEvent && event.Source.Name == mode+" activated"
case <-ctx.Done():
t.Fatalf("%s action did not reach its delegated handler: %v", mode, ctx.Err())
}
}
// The content iframe changes asynchronously. Capture until its new
// document is available and ensure the old document is gone.
for {
tree, err = adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
root, _ = tree.Root()
document, err = tree.Document(ctx, root)
if err != nil {
t.Fatal(err)
}
ready := false
for _, node := range document.Nodes {
ready = ready || node.Snapshot.Name == mode+" mode content"
}
if ready {
break
}
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
t.Fatal("replacement content missing", ctx.Err())
}
}
readNewContent := false
for step := 0; step < 8; step++ {
move, err := cursor.MoveCaret(ctx, adapter, review.CaretNextLine)
if err != nil {
t.Fatalf("reading after %s activation: %v", mode, err)
}
if strings.TrimSpace(move.Text) == mode+" mode content" {
readNewContent = true
break
}
}
if !readNewContent {
t.Fatalf("browse caret could not reach %s replacement content", mode)
}
}
}
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"math"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
// Exercise real Xorg keys, native focus echoes, the review worker and the
// event-loop presentation filter together. No audio or desktop reader is used.
func TestNativeBrowserCaretLinkSounds(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser sound fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
adapter := atspi.New()
adapter.SetCaptureSoundGeometry(true)
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
listener := xorg.New()
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
if err := commands.Start(ctx); err != nil {
t.Fatal(err)
}
defer commands.Stop(context.Background())
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
browser := newBrowserModeController(listener, commands, adapter)
if err := browser.Start(ctx); err != nil {
t.Fatal(err)
}
defer browser.Stop(context.Background())
output := &ocrTestOutput{speech: make(chan speechRequest, 128)}
options := presentation.Options{EnableSound: true, PlaySoundForRole: true,
SpatializeObjectSounds: true, RoleSoundPresentation: "sound_and_speech"}
loopDone, commandDone := make(chan struct{}), make(chan struct{})
go func() {
defer close(loopDone)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, worker,
20*time.Millisecond, nil, browser, nil, nil, options)
}()
go func() {
defer close(commandDone)
for {
select {
case <-ctx.Done():
return
case event := <-commands.Commands():
browser.observeCommand(event.Command)
if command := documentCaretCommand(event.Command); command != 0 {
worker.MoveDocumentCaret(command)
} else if target, level, backwards := documentReviewCommand(event.Command); target != 0 {
worker.MoveDocument(target, level, backwards)
}
}
}
}()
defer func() { cancel(); <-loopDone; <-commandDone }()
key := func(chord string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
// Wait for a presentation, then retain late native focus echoes through a
// quiet interval. Counting every link cue catches conflicting duplicates.
collect := func(want string) []speechRequest {
t.Helper()
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
quiet := time.NewTimer(200 * time.Millisecond)
defer quiet.Stop()
matched := false
var requests []speechRequest
for {
select {
case request := <-output.speech:
requests = append(requests, request)
matched = matched || strings.Contains(request.Text, want)
quiet.Reset(200 * time.Millisecond)
case <-quiet.C:
if matched {
return requests
}
case <-deadline.C:
t.Fatalf("missing %q; requests=%+v", want, requests)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
heading := false
for attempt := 0; attempt < 25 && !heading; attempt++ {
key("Tab")
timer := time.NewTimer(150 * time.Millisecond)
drain:
for {
select {
case request := <-output.speech:
if strings.Contains(request.Text, "Sound fixture start") {
heading = true
break drain
}
case <-timer.C:
break drain
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !heading {
t.Fatal("fixture heading focus missing")
}
check := func(chord, want string, pan *float64) float64 {
t.Helper()
key(chord)
requests := collect(want)
var pans []float64
for _, request := range requests {
for _, cue := range request.Sound {
if cue.Name == "link" || cue.Name == "link_visited" {
pans = append(pans, cue.Pan)
}
}
}
if len(pans) != 1 {
t.Fatalf("%s produced %d link cues: %+v", chord, len(pans), requests)
}
if pan != nil && math.Abs(pans[0]-*pan) > 0.025 {
t.Fatalf("%s pan=%g, Tab pan=%g", chord, pans[0], *pan)
}
return pans[0]
}
for _, size := range []string{"1200 800", "900 600"} {
args := append([]string{"getwindowfocus", "windowsize"}, strings.Fields(size)...)
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("resize: %v %s", err, out)
}
key("ctrl+Home")
collect("Sound fixture start")
names := []string{"Left link label", "Center link label", "Right link label"}
pans := make([]float64, 3)
for index, name := range names {
pans[index] = check("Tab", name, nil)
}
if pans[0] > -0.5 || math.Abs(pans[1]) > 0.2 || pans[2] < 0.5 {
t.Fatalf("native link positions: %v", pans)
}
key("ctrl+Home")
collect("Sound fixture start")
for index, name := range names {
check("Down", name, &pans[index])
}
check("ctrl+Right", "link", &pans[2])
check("ctrl+Left", "link", &pans[2])
check("Up", names[1], &pans[1])
check("k", names[2], &pans[2])
t.Logf("window %s: Tab, arrows, words and structural links agree at pans %v", size, pans)
}
}
+53 -11
View File
@@ -8,6 +8,56 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot); static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining); static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
// Chromium exposes doDefault even on inert text. Only an anchor with a label
// explicitly offering ancestor activation supplies evidence of a custom action.
// Never turn every descendant of a delegated click handler into a clickable.
static gboolean cthulhu_anchor_has_click_label(AtspiAccessible *source) {
gint count = atspi_accessible_get_child_count(source, NULL);
for (gint index = 0; index < MIN(count, 16); index++) {
AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, NULL);
if (child == NULL) continue;
AtspiRole role = atspi_accessible_get_role(child, NULL);
gboolean label = role == ATSPI_ROLE_STATIC || role == ATSPI_ROLE_TEXT;
gboolean found = FALSE;
AtspiStateSet *states = atspi_accessible_get_state_set(child);
gboolean passive = states != NULL &&
!atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE) &&
!atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
if (states != NULL) g_object_unref(states);
if (passive && label) {
AtspiAction *action = atspi_accessible_get_action_iface(child);
if (action != NULL) {
gint actions = MIN(atspi_action_get_n_actions(action, NULL), 16);
for (gint i = 0; i < actions && !found; i++) {
gchar *name = atspi_action_get_name(action, i, NULL);
found = name != NULL && g_ascii_strcasecmp(name, "clickAncestor") == 0;
g_free(name);
}
g_object_unref(action);
}
}
g_object_unref(child);
if (found) return TRUE;
}
return FALSE;
}
// Shared by capture and activation so a discovered target can actually be
// invoked. Lower numbers retain the existing preference for direct actions.
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor) {
if (name == NULL) return 0;
const gchar *direct[] = {"click", "click-ancestor", "press", "jump", "open", "activate"};
for (guint index = 0; index < G_N_ELEMENTS(direct); index++) {
if (g_ascii_strcasecmp(name, direct[index]) == 0) return index + 1;
}
if (!anchor) return 0;
if (g_ascii_strcasecmp(name, "clickAncestor") == 0) return 7;
if (g_ascii_strcasecmp(name, "doDefault") == 0) {
if (cthulhu_anchor_has_click_label(source)) return 8;
}
return 0;
}
// Some toolkits expose an edit field's label only as a relation, leaving its // Some toolkits expose an edit field's label only as a relation, leaving its
// accessible name empty. Follow direct targets only, with a shared query and // accessible name empty. Follow direct targets only, with a shared query and
// output budget; never infer a label from neighbouring controls or field text. // output budget; never infer a label from neighbouring controls or field text.
@@ -590,22 +640,14 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
if (error != NULL) { if (error != NULL) {
g_clear_error(&error); g_clear_error(&error);
} }
for (gint index = 0; index < actionCount; index++) { for (gint index = 0; index < MIN(actionCount, 64); index++) {
error = NULL; error = NULL;
gchar *actionName = atspi_action_get_name(action, index, &error); gchar *actionName = atspi_action_get_name(action, index, &error);
if (error != NULL) { if (error != NULL) {
g_clear_error(&error); g_clear_error(&error);
} }
// Web providers use different action names for activation. Keep if (cthulhu_document_action_priority(source, actionName,
// this list aligned with activateNativeDocumentObject so browse g_strcmp0(snapshot.htmlTag, "a") == 0) != 0) {
// Enter can reach objects exposing click-ancestor or press.
if (actionName != NULL &&
(g_ascii_strcasecmp(actionName, "click") == 0 ||
g_ascii_strcasecmp(actionName, "click-ancestor") == 0 ||
g_ascii_strcasecmp(actionName, "press") == 0 ||
g_ascii_strcasecmp(actionName, "jump") == 0 ||
g_ascii_strcasecmp(actionName, "open") == 0 ||
g_ascii_strcasecmp(actionName, "activate") == 0)) {
snapshot.supportsClick = TRUE; snapshot.supportsClick = TRUE;
g_free(actionName); g_free(actionName);
break; break;
+11 -9
View File
@@ -13,7 +13,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"unsafe" "unsafe"
@@ -109,7 +108,7 @@ func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position Object
} }
// CaptureDocumentSoundGeometry refreshes only the presented object and its // CaptureDocumentSoundGeometry refreshes only the presented object and its
// optional linked heading target, after navigation has scrolled them into view. // optional semantic link target, after navigation has scrolled them into view.
// It never changes the cached document semantics or retains new native handles. // It never changes the cached document semantics or retains new native handles.
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) { func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
if !object.Valid() { if !object.Valid() {
@@ -231,7 +230,6 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
return errors.New("current document object has no accessible action") return errors.New("current document object has no accessible action")
} }
defer C.g_object_unref(C.gpointer(action)) defer C.g_object_unref(C.gpointer(action))
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
var nativeErr *C.GError var nativeErr *C.GError
count := int(C.atspi_action_get_n_actions(action, &nativeErr)) count := int(C.atspi_action_get_n_actions(action, &nativeErr))
if nativeErr != nil { if nativeErr != nil {
@@ -241,18 +239,22 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
} }
// Read each action name once. Previously each preferred spelling caused // Read each action name once. Previously each preferred spelling caused
// another full round of synchronous accessibility calls. // another full round of synchronous accessibility calls.
actionNames := make([]string, min(max(count, 0), 64)) priorities := make([]int, min(max(count, 0), 64))
for index := range actionNames { anchor := C.gboolean(0)
if snapshot.HTMLTag == "a" {
anchor = 1
}
for index := range priorities {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
name := C.atspi_action_get_name(action, C.gint(index), nil) name := C.atspi_action_get_name(action, C.gint(index), nil)
actionNames[index] = C.GoString(name) priorities[index] = int(C.cthulhu_document_action_priority(source, name, anchor))
C.g_free(C.gpointer(name)) C.g_free(C.gpointer(name))
} }
for _, wanted := range names { for wanted := 1; wanted <= 8; wanted++ {
for index, name := range actionNames { for index, priority := range priorities {
if !strings.EqualFold(name, wanted) { if priority != wanted {
continue continue
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
+1
View File
@@ -93,6 +93,7 @@ typedef struct {
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
gchar **tableName, gchar **tablePath, gchar **tableName, gchar **tablePath,
gint *rowIndex, gint *columnIndex, gboolean *guiTable); gint *rowIndex, gint *columnIndex, gboolean *guiTable);
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor);
cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText, cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText,
gboolean captureCaretCharacter, gboolean captureCaretCharacter,
gboolean captureDocumentMetadata, gboolean captureDocumentMetadata,
+8
View File
@@ -250,6 +250,14 @@ func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusNa
// A visibility change can reveal children omitted by an earlier review // A visibility change can reveal children omitted by an earlier review
// capture, even when the provider emits no children-changed event. // capture, even when the provider emits no children-changed event.
cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid) cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid)
if document && snapshot.Role == "internal frame" {
// Chromium can replace a sibling iframe document without an
// invalidation for its retained frame object. Refresh this boundary
// on recapture so a successful header action cannot leave the old
// content document pinned in either our cache or libatspi's cache.
cachedChildren = false
C.atspi_accessible_clear_cache_single(source)
}
childrenUnavailable := false childrenUnavailable := false
for _, id := range cached.children { for _, id := range cached.children {
if cache.sources[id] == nil { if cache.sources[id] == nil {
+84 -1
View File
@@ -7,11 +7,14 @@ package review
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math"
"reflect" "reflect"
"testing" "testing"
"time" "time"
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
) )
type soundGeometryNavigator struct { type soundGeometryNavigator struct {
@@ -20,13 +23,24 @@ type soundGeometryNavigator struct {
err error err error
reads int reads int
t *testing.T t *testing.T
link atspi.ObjectID
offset int
}
func (navigator *soundGeometryNavigator) SetDocumentCaretPosition(ctx context.Context, object atspi.ObjectReference, focus atspi.ObjectID, offset int) error {
navigator.offset = offset
return navigator.SetDocumentPosition(ctx, object, focus)
} }
func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) { func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) {
navigator.reads++ navigator.reads++
select { select {
case placed := <-navigator.calls: case placed := <-navigator.calls:
if placed.position != object || link.ID() != webID("/topic_link") { wantLink := navigator.link
if !wantLink.Valid() {
wantLink = webID("/topic_link")
}
if placed.position != object || link.ID() != wantLink {
navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link") navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link")
} }
default: default:
@@ -35,6 +49,75 @@ func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.
return navigator.geometry, navigator.err return navigator.geometry, navigator.err
} }
func TestCaretLinkSoundRefreshKeepsLeafAndOffsets(t *testing.T) {
old := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true,
Object: atspi.Rectangle{X: 900, Width: 80, Height: 20}, Reference: atspi.Rectangle{Width: 1000, Height: 600}}
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd} {
for _, state := range []string{"left", "center", "right", "disabled", "failure", "stale"} {
t.Run(fmt.Sprintf("%v/%s", command, state), func(t *testing.T) {
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/link")}},
{Snapshot: atspi.Snapshot{BusName: webID("/link").BusName, Path: "/link", Role: "link", Name: "Wrapped link", Visited: true, SoundGeometry: old}, Children: []atspi.ObjectID{webID("/label")}},
{Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", Name: "Wrapped link", CharacterCount: 12, SoundGeometry: old}},
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
position, err := cursor.SyncFocus(context.Background(), webID("/label"))
if err != nil {
t.Fatal(err)
}
owner, _ := tree.DocumentContext(position.Object)
position.Offset = 8
fresh := old
fresh.Object.X = map[string]int32{"left": 0, "center": 460, "right": 920}[state]
navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t, link: webID("/link")}
want := fresh
switch state {
case "disabled":
want = atspi.SoundGeometry{}
case "failure":
want = atspi.SoundGeometry{}
navigator.err = context.DeadlineExceeded
case "stale":
want = atspi.SoundGeometry{}
tree.Invalidate(webID("/link"))
default:
navigator.geometry = [2]atspi.SoundGeometry{fresh, fresh}
}
worker, _ := NewWorker(navigator, time.Second)
worker.SyncFocus(webID("/doc"))
result := worker.placeDocumentCaret(context.Background(), cursor, Result{
Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"),
DocumentPosition: position, DocumentCaretCommand: command, DocumentCaretContext: owner.Ancestors,
})
if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != position.Object ||
result.DocumentPosition.Offset != 8 || navigator.offset != 8 || result.DocumentPosition.Snapshot.Role != "static" {
t.Fatalf("sound refresh changed caret ownership: %+v", result)
}
link := result.DocumentCaretContext[1]
if link.SoundGeometry != want || !link.Visited || owner.Ancestors[1].SoundGeometry != old {
t.Fatalf("ancestor geometry/semantics: %+v", link)
}
if state == "failure" && !errors.Is(result.DocumentSoundErr, context.DeadlineExceeded) || state == "stale" && result.DocumentSoundErr == nil {
t.Fatalf("missing refresh failure: %v", result.DocumentSoundErr)
}
options := presentation.Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: state != "disabled"}
cues := presentation.SoundCues(link, options)
if len(cues) != 1 || cues[0].Name != "link_visited" {
t.Fatalf("link cue = %+v", cues)
}
wantPan := map[string]float64{"left": -1, "right": 1}[state]
if math.Abs(cues[0].Pan-wantPan) > 0.001 {
t.Fatalf("pan = %g, want %g", cues[0].Pan, wantPan)
}
})
}
}
}
func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) { func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) {
oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}} oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}}
freshGeometry := atspi.SoundGeometry{ freshGeometry := atspi.SoundGeometry{
+31 -4
View File
@@ -330,7 +330,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result { func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
place := func(result Result) Result { place := func(result Result) Result {
if result.DocumentCaretCommand != 0 { if result.DocumentCaretCommand != 0 {
return worker.placeDocumentCaret(ctx, result) return worker.placeDocumentCaret(ctx, cursor, result)
} }
return worker.placeDocument(ctx, cursor, result) return worker.placeDocument(ctx, cursor, result)
} }
@@ -489,7 +489,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
} }
func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result { func (worker *Worker) placeDocumentCaret(ctx context.Context, cursor *DocumentCursor, result Result) Result {
if result.Err != nil || !result.Moved || if result.Err != nil || !result.Moved ||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus { !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
return result return result
@@ -512,6 +512,12 @@ func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Res
nativeOffset = 0 nativeOffset = 0
} }
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset) result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
switch result.DocumentCaretCommand {
case CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd:
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
}
}
return result return result
} }
@@ -755,17 +761,29 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
// Geometry belongs to this presentation, not the document's initial viewport. // Geometry belongs to this presentation, not the document's initial viewport.
// Failed refreshes must not reuse an old position after scrolling or resizing. // Failed refreshes must not reuse an old position after scrolling or resizing.
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{} result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
link := atspi.ObjectReference{} result.DocumentCaretContext = append([]atspi.Snapshot(nil), result.DocumentCaretContext...)
var linkID atspi.ObjectID
for index := range result.DocumentCaretContext {
ancestor := &result.DocumentCaretContext[index]
ancestor.SoundGeometry = atspi.SoundGeometry{}
if !linkID.Valid() && ancestor.Role == "link" && ancestor.ID() != result.DocumentPosition.Object.ID() {
linkID = ancestor.ID()
}
}
if previous := result.DocumentPosition.LinkedHeading; previous != nil { if previous := result.DocumentPosition.LinkedHeading; previous != nil {
copy := *previous copy := *previous
copy.SoundGeometry = atspi.SoundGeometry{} copy.SoundGeometry = atspi.SoundGeometry{}
result.DocumentPosition.LinkedHeading = &copy result.DocumentPosition.LinkedHeading = &copy
linkID = copy.ID()
}
link := atspi.ObjectReference{}
if linkID.Valid() {
if cursor == nil { if cursor == nil {
result.DocumentSoundErr = atspi.ErrStaleObject result.DocumentSoundErr = atspi.ErrStaleObject
return result return result
} }
var err error var err error
link, err = cursor.tree.Reference(copy.ID()) link, err = cursor.tree.Reference(linkID)
if err != nil { if err != nil {
result.DocumentSoundErr = err result.DocumentSoundErr = err
return result return result
@@ -778,6 +796,15 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
if result.DocumentPosition.LinkedHeading != nil { if result.DocumentPosition.LinkedHeading != nil {
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1] result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
} }
for index := range result.DocumentCaretContext {
ancestor := &result.DocumentCaretContext[index]
switch ancestor.ID() {
case result.DocumentPosition.Object.ID():
ancestor.SoundGeometry = geometry[0]
case linkID:
ancestor.SoundGeometry = geometry[1]
}
}
} }
return result return result
} }
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
<h1 tabindex="0">Action fixture start</h1>
<iframe title="Mode header" srcdoc="<!doctype html><body onload='document.onclick=function(event){if(event.target.closest(&quot;a&quot;)) parent.chooseMode(event.target.closest(&quot;a&quot;).id)}'><a id='Basic'>BASIC</a> <a id='Advanced'>ADVANCED</a><p>Plain header text</p></body>"></iframe>
<iframe id="content" title="Mode content" srcdoc="<!doctype html><p>Initial mode content</p>"></iframe>
<p>Plain body text</p>
<div id="custom" onclick="this.setAttribute('aria-label', 'Custom activated')"><span>Custom action</span><button>Nested button</button></div>
<a id="inactive">Anchor without action</a>
<a id="hidden" hidden onclick="chooseMode('Hidden')">Hidden action</a>
<a id="ordinary" href="#destination"><span>Ordinary link</span></a>
<p id="destination">Destination</p>
<script>
function chooseMode(mode) {
document.getElementById('content').srcdoc = '<!doctype html><p>' + mode + ' mode content</p>';
document.querySelector('h1').setAttribute('aria-label', mode + ' activated');
}
</script>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
<style>
body { margin: 8px; }
.row { height: 60px; }
a { display: block; position: relative; width: 140px; }
.center { left: calc(50% - 70px); }
.right { left: calc(100% - 140px); }
@media (max-width: 1000px) { main { margin-top: 1000px; } }
</style>
<h1 tabindex="0">Sound fixture start</h1>
<main>
<div class="row"><a href="#destination"><span>Left link label</span></a></div>
<div class="row"><a class="center" href="#destination"><span>Center link label</span></a></div>
<div class="row"><a class="right" href="#destination"><span>Right link label</span></a></div>
</main>
<p id="destination">Destination</p>
</html>
+2
View File
@@ -12,6 +12,8 @@ from native_test_session import run_private_session
BROWSER_PROOFS = { BROWSER_PROOFS = {
"actions": ("./internal/app", "^TestNativeBrowserActionOwnership$", 35, 40, "browser-actions.html"),
"link-sounds": ("./internal/app", "^TestNativeBrowserCaretLinkSounds$", 40, 45, "browser-link-sounds.html"),
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"), "article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"), "emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50), "structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),