Share document Where Am I reports with Remote
This commit is contained in:
1 parent
02fb70e81a
commit
103fbfd81a
13 files changed
+249
-19
No files matched your search
@@ -121,7 +121,18 @@ without changing its local text. Layout text and privacy context remain from the
|
|||||||
same capture across tree replacement. Boundaries and errors remain private.
|
same capture across tree replacement. Boundaries and errors remain private.
|
||||||
Existing role chunks, pronunciation, spelling, phonetic expansion and character
|
Existing role chunks, pronunciation, spelling, phonetic expansion and character
|
||||||
delivery retain their normal ordering and original lease/generation admission.
|
delivery retain their normal ordering and original lease/generation admission.
|
||||||
Where Am I and other unreviewed producers remain private.
|
Where Am I at a document position now exports only when that position retains
|
||||||
|
complete captured source ancestry. The event loop stores classification with
|
||||||
|
the current snapshot, document focus and epoch; it stores no lease authority.
|
||||||
|
Every snapshot replacement clears classification, including same-object native
|
||||||
|
updates and Say All position updates without ancestry. Focus/epoch replacement,
|
||||||
|
missing or mismatched ancestry, password/unknown/invalid roles and defunct
|
||||||
|
sources keep the report local. Foreground synchronization runs before this
|
||||||
|
check. Lock and terminal handoff suppress the command. Missing-location status
|
||||||
|
stays private. Native focus reports and other unreviewed producers remain private.
|
||||||
|
Existing text, role chunks, formula reporting, pronunciation, focus, caret and
|
||||||
|
Braille behavior are preserved; the speech worker captures the current lease
|
||||||
|
and generation when the explicit command enters its queue.
|
||||||
|
|
||||||
Browse-mode current-line reporting, spelling and phonetic spelling now export
|
Browse-mode current-line reporting, spelling and phonetic spelling now export
|
||||||
only with the command's captured source ancestry. The review worker retains
|
only with the command's captured source ancestry. The review worker retains
|
||||||
@@ -478,6 +489,20 @@ exclusion and separate role utterance boundaries. An overlay restoring the
|
|||||||
previous unclassified producers reproduces missing ordinary export. No full
|
previous unclassified producers reproduces missing ordinary export. No full
|
||||||
reader suite, native desktop, hardware or audible checks were performed.
|
reader suite, native desktop, hardware or audible checks were performed.
|
||||||
|
|
||||||
|
The document Where Am I increment passed focused application reading/focus/
|
||||||
|
speech/Remote race regressions, complete input/review/AT-SPI and external Remote
|
||||||
|
race suites, ten repetitions of the new source/privacy cases, the speech
|
||||||
|
regression harness, application vet, a separate reader build and Tentacles
|
||||||
|
`make check`. The ordinary-source regression failed before implementation.
|
||||||
|
Owned-tree cases check private/missing/stale ancestry, same-object native updates,
|
||||||
|
Say All position replacement, focus/epoch retirement and lock/handoff, including
|
||||||
|
handoff discovered by foreground synchronization. The source-command/private-relay
|
||||||
|
fixture verifies ordinary exports, private exclusion and separate role boundaries.
|
||||||
|
The full application race suite timed out after 180 seconds in package-upgrade
|
||||||
|
tests; it is not a pass. The source-command race run needed approval for local
|
||||||
|
fixture sockets; its approved rerun passed. No native desktop, hardware or audible
|
||||||
|
checks were performed. See the [live Where Am I steps](../../cthulhu-tentacles/remote/command.md#physical-keyboard-acceptance-still-pending).
|
||||||
|
|
||||||
Next: verify real Speech Dispatcher scheduling and audible local/remote
|
Next: verify real Speech Dispatcher scheduling and audible local/remote
|
||||||
arbitration. Physical input capture/release and ordinary reading while connected
|
arbitration. Physical input capture/release and ordinary reading while connected
|
||||||
still need acceptance.
|
still need acceptance.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Ev
|
|||||||
}
|
}
|
||||||
state.pending.caret.clear()
|
state.pending.caret.clear()
|
||||||
state.focusedID = result.event.Source.ID()
|
state.focusedID = result.event.Source.ID()
|
||||||
state.current = result.event.Source
|
state.setCurrent(result.event.Source)
|
||||||
state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{}
|
state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{}
|
||||||
if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" {
|
if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" {
|
||||||
// A late-created editor has no ordinary focus event to reset
|
// A late-created editor has no ordinary focus event to reset
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The event loop retains classification, not authority, alongside its current
|
||||||
|
// snapshot. Every replacement clears it, including a native update of the same
|
||||||
|
// object. Only complete owned document ancestry can opt Where Am I into export.
|
||||||
|
type currentSpeechSource struct {
|
||||||
|
origin speechOrigin
|
||||||
|
focus atspi.ObjectID
|
||||||
|
epoch uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *eventLoopState) setCurrent(snapshot atspi.Snapshot) {
|
||||||
|
state.current = snapshot
|
||||||
|
state.currentSpeech = currentSpeechSource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *eventLoopState) setCurrentDocument(result review.Result) {
|
||||||
|
state.setCurrent(result.DocumentPosition.Snapshot)
|
||||||
|
sources := result.DocumentCaretContext
|
||||||
|
if result.Err != nil || !state.current.ID().Valid() || len(sources) == 0 || sources[0].ID() != state.current.ID() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := review_speech_origin(sources)
|
||||||
|
if source := accessibility_speech_origin(state.current); source != speechOriginLocal {
|
||||||
|
origin = source
|
||||||
|
}
|
||||||
|
state.currentSpeech = currentSpeechSource{origin: origin, focus: result.DocumentFocus, epoch: result.DocumentEpoch}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *eventReviewHandler) currentSpeechOrigin() speechOrigin {
|
||||||
|
source := handler.state.currentSpeech
|
||||||
|
if handler.objectReview == nil || !source.focus.Valid() || source.focus != handler.objectReview.Focus() ||
|
||||||
|
!handler.objectReview.CurrentDocumentResult(review.Result{DocumentEpoch: source.epoch}) {
|
||||||
|
return speechOriginUnknown
|
||||||
|
}
|
||||||
|
return source.origin
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
|
|||||||
if !state.focusedID.Valid() {
|
if !state.focusedID.Valid() {
|
||||||
state.owner.follow(event)
|
state.owner.follow(event)
|
||||||
state.focusedID = event.Source.ID()
|
state.focusedID = event.Source.ID()
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
|
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
|
||||||
@@ -64,7 +64,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if browser != nil && browser.ready && !event.Source.Editable {
|
if browser != nil && browser.ready && !event.Source.Editable {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
if objectReview != nil {
|
if objectReview != nil {
|
||||||
objectReview.SyncFocus(event.Source.ID())
|
objectReview.SyncFocus(event.Source.ID())
|
||||||
if err := objectReview.SyncDocumentCaret(event.Source.ID(), event.Detail1); err != nil {
|
if err := objectReview.SyncDocumentCaret(event.Source.ID(), event.Detail1); err != nil {
|
||||||
@@ -98,7 +98,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
|
|||||||
// document; focus mode still assembles visual lines across objects.
|
// document; focus mode still assembles visual lines across objects.
|
||||||
if browser != nil && browser.ready && browser.mode == review.FocusMode &&
|
if browser != nil && browser.ready && browser.mode == review.FocusMode &&
|
||||||
intent == input.LineIntent && !event.Source.Editable && objectReview != nil {
|
intent == input.LineIntent && !event.Source.Editable && objectReview != nil {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
objectReview.SyncFocus(event.Source.ID())
|
objectReview.SyncFocus(event.Source.ID())
|
||||||
if request, err := objectReview.ReadDocumentLine(event.Source.ID(), event.Detail1); err == nil {
|
if request, err := objectReview.ReadDocumentLine(event.Source.ID(), event.Detail1); err == nil {
|
||||||
state.documentLineFocus = event.Source.ID()
|
state.documentLineFocus = event.Source.ID()
|
||||||
|
|||||||
@@ -117,13 +117,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
|||||||
// this relationship; no extra mutable ownership state is retained.
|
// this relationship; no extra mutable ownership state is retained.
|
||||||
if belongsToFocusedEditor(event.Source, state.focusedID) &&
|
if belongsToFocusedEditor(event.Source, state.focusedID) &&
|
||||||
(isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent || event.Type == atspi.TextSelectionChangedEvent) {
|
(isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent || event.Type == atspi.TextSelectionChangedEvent) {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
}
|
}
|
||||||
if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) ||
|
if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) ||
|
||||||
event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) &&
|
event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) &&
|
||||||
event.Source.ID() == state.managedDescendantID ||
|
event.Source.ID() == state.managedDescendantID ||
|
||||||
(event.Source.Path != "" && event.Source.ID() == state.focusedID) {
|
(event.Source.Path != "" && event.Source.ID() == state.focusedID) {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
}
|
}
|
||||||
if isTextChangeEvent(event.Type) {
|
if isTextChangeEvent(event.Type) {
|
||||||
if !speaksTextChanges(event.Source) && !event.Source.LiveRegion {
|
if !speaksTextChanges(event.Source) && !event.Source.LiveRegion {
|
||||||
@@ -135,7 +135,7 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable {
|
if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
}
|
}
|
||||||
if brailleOutput != nil && (isFocusGainEvent(event) ||
|
if brailleOutput != nil && (isFocusGainEvent(event) ||
|
||||||
isManagedDescendantEvent(event.Type) || event.Type == atspi.CaretEvent ||
|
isManagedDescendantEvent(event.Type) || event.Type == atspi.CaretEvent ||
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event
|
|||||||
state.pending.clear()
|
state.pending.clear()
|
||||||
focusGate.SetSuspended(true)
|
focusGate.SetSuspended(true)
|
||||||
state.focusedID = event.Source.ID()
|
state.focusedID = event.Source.ID()
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
if objectReview != nil {
|
if objectReview != nil {
|
||||||
objectReview.SyncFocus(event.Source.ID())
|
objectReview.SyncFocus(event.Source.ID())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) boo
|
|||||||
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
||||||
}
|
}
|
||||||
state.owner.follow(event)
|
state.owner.follow(event)
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
state.focusedID = event.Source.ID()
|
state.focusedID = event.Source.ID()
|
||||||
if event.Type == atspi.BusyStateEvent && state.loadedDocument != event.Source.ID() {
|
if event.Type == atspi.BusyStateEvent && state.loadedDocument != event.Source.ID() {
|
||||||
speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true})
|
speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true})
|
||||||
@@ -126,7 +126,7 @@ func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) boo
|
|||||||
state.chromeLoadedDocument = atspi.ObjectID{}
|
state.chromeLoadedDocument = atspi.ObjectID{}
|
||||||
state.chromeLoadedWindow = atspi.ObjectID{}
|
state.chromeLoadedWindow = atspi.ObjectID{}
|
||||||
state.owner.follow(event)
|
state.owner.follow(event)
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
state.focusedID = event.Source.ID()
|
state.focusedID = event.Source.ID()
|
||||||
browser.loaded(event.Source.ID())
|
browser.loaded(event.Source.ID())
|
||||||
if objectReview != nil {
|
if objectReview != nil {
|
||||||
|
|||||||
@@ -33,10 +33,13 @@ type eventReviewHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (handler *eventReviewHandler) whereAmI() {
|
func (handler *eventReviewHandler) whereAmI() {
|
||||||
if handler.focusGate.Locked() {
|
if handler.focusGate.Locked() || handler.focusGate.Suspended() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.syncForeground()
|
handler.syncForeground()
|
||||||
|
if handler.focusGate.Locked() || handler.focusGate.Suspended() {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.refreshPresentation()
|
handler.refreshPresentation()
|
||||||
state := handler.state
|
state := handler.state
|
||||||
state.pending.terminalFocus.clear()
|
state.pending.terminalFocus.clear()
|
||||||
@@ -52,11 +55,13 @@ func (handler *eventReviewHandler) whereAmI() {
|
|||||||
}
|
}
|
||||||
text := spoken.Text
|
text := spoken.Text
|
||||||
role := state.current.Role
|
role := state.current.Role
|
||||||
|
origin := handler.currentSpeechOrigin()
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = "Could not find current location."
|
text = "Could not find current location."
|
||||||
role = "system"
|
role = "system"
|
||||||
|
origin = speechOriginStatus
|
||||||
}
|
}
|
||||||
handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"})
|
handler.speech.Enqueue(speechRequest{origin: origin, Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"})
|
||||||
handler.logger.Debug("queued where am I presentation", "role", role)
|
handler.logger.Debug("queued where am I presentation", "role", role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ func (handler *eventReviewHandler) handle(result review.Result) {
|
|||||||
}
|
}
|
||||||
if result.ReadAllPosition {
|
if result.ReadAllPosition {
|
||||||
if objectReview.CurrentDocumentResult(result) && result.DocumentFocus == objectReview.Focus() {
|
if objectReview.CurrentDocumentResult(result) && result.DocumentFocus == objectReview.Focus() {
|
||||||
state.current = result.DocumentPosition.Snapshot
|
state.setCurrentDocument(result)
|
||||||
state.focusedID = state.current.ID()
|
state.focusedID = state.current.ID()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -173,7 +178,7 @@ func (handler *eventReviewHandler) handle(result review.Result) {
|
|||||||
if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute {
|
if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute {
|
||||||
state.documentSelection = textSelection{}
|
state.documentSelection = textSelection{}
|
||||||
}
|
}
|
||||||
state.current = result.DocumentPosition.Snapshot
|
state.setCurrentDocument(result)
|
||||||
if brailleOutput != nil {
|
if brailleOutput != nil {
|
||||||
state.brailleSnapshot = state.current
|
state.brailleSnapshot = state.current
|
||||||
brailleOptions := config.DefaultSettings().Braille
|
brailleOptions := config.DefaultSettings().Braille
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type eventForegroundState struct {
|
|||||||
type eventFocusState struct {
|
type eventFocusState struct {
|
||||||
libreOfficeSelection libreOfficeSelectionState
|
libreOfficeSelection libreOfficeSelectionState
|
||||||
current atspi.Snapshot
|
current atspi.Snapshot
|
||||||
|
currentSpeech currentSpeechSource
|
||||||
brailleSnapshot atspi.Snapshot
|
brailleSnapshot atspi.Snapshot
|
||||||
focusedID atspi.ObjectID
|
focusedID atspi.ObjectID
|
||||||
managedContainerID atspi.ObjectID
|
managedContainerID atspi.ObjectID
|
||||||
@@ -165,7 +166,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
|
|||||||
state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0
|
state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0
|
||||||
state.managedContainerID = atspi.ObjectID{}
|
state.managedContainerID = atspi.ObjectID{}
|
||||||
state.managedDescendantID = atspi.ObjectID{}
|
state.managedDescendantID = atspi.ObjectID{}
|
||||||
state.current = atspi.Snapshot{}
|
state.setCurrent(atspi.Snapshot{})
|
||||||
state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0
|
state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0
|
||||||
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
||||||
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event)
|
|||||||
}
|
}
|
||||||
// Like NVDA, refresh braille only if this is already the displayed object.
|
// Like NVDA, refresh braille only if this is already the displayed object.
|
||||||
if event.Source.ID() == state.current.ID() {
|
if event.Source.ID() == state.current.ID() {
|
||||||
state.current = event.Source
|
state.setCurrent(event.Source)
|
||||||
state.brailleSnapshot = event.Source
|
state.brailleSnapshot = event.Source
|
||||||
if handler.brailleOutput != nil {
|
if handler.brailleOutput != nil {
|
||||||
handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, settings.Braille, settings.TextAttributes))
|
handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, settings.Braille, settings.TextAttributes))
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"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 where_am_i_observation_handler(t *testing.T, privacy string) (*eventReviewHandler, *fakeSpeechQueue) {
|
||||||
|
t.Helper()
|
||||||
|
// Use owned-tree ancestry, as document movement does, rather than treating
|
||||||
|
// the presentation's filtered SpeechContext as a complete source capture.
|
||||||
|
position := review_observation_result(t, "object", privacy).Position
|
||||||
|
focus := atspi.NewObjectID(":1.42", "/document")
|
||||||
|
worker, err := review.NewWorker(&fakeAdapter{}, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
worker.SyncFocus(focus)
|
||||||
|
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 4), spoken: make(chan string, 4)}
|
||||||
|
handler := &eventReviewHandler{state: &eventLoopState{}, objectReview: worker, speech: queue,
|
||||||
|
logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}, syncForeground: func() {}}
|
||||||
|
handler.state.current = atspi.Snapshot{BusName: focus.BusName, Path: focus.Path, Role: "document web"}
|
||||||
|
handler.state.presentationOptions = presentation.DefaultOptions()
|
||||||
|
handler.handle(review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine, DocumentFocus: focus,
|
||||||
|
DocumentPosition: review.DocumentPosition{Snapshot: position.Current}, DocumentCaretContext: position.SourceContext,
|
||||||
|
DocumentCaretText: position.Current.Name})
|
||||||
|
<-queue.requests
|
||||||
|
<-queue.spoken
|
||||||
|
return handler, queue
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteWhereAmIInvalidatesSource(t *testing.T) {
|
||||||
|
for _, change := range []string{"same-object-update", "say-all-position", "focus", "epoch", "foreground", "missing-owner", "no-location"} {
|
||||||
|
t.Run(change, func(t *testing.T) {
|
||||||
|
handler, queue := where_am_i_observation_handler(t, "ordinary")
|
||||||
|
switch change {
|
||||||
|
case "same-object-update":
|
||||||
|
// Even identical native text cannot reuse older capture ancestry.
|
||||||
|
events := accessibilityEventHandler{state: handler.state, objectReview: handler.objectReview, logger: handler.logger}
|
||||||
|
events.acceptFocusedEvent(atspi.NewEvent(atspi.FocusEvent, handler.state.current), input.Activity{})
|
||||||
|
case "say-all-position":
|
||||||
|
handler.handle(review.Result{ReadAllPosition: true, DocumentFocus: handler.objectReview.Focus(),
|
||||||
|
DocumentPosition: review.DocumentPosition{Snapshot: handler.state.current}})
|
||||||
|
case "focus":
|
||||||
|
handler.objectReview.SyncFocus(atspi.NewObjectID(":1.42", "/other"))
|
||||||
|
case "epoch":
|
||||||
|
handler.objectReview.ResetDocument(handler.objectReview.Focus())
|
||||||
|
case "foreground":
|
||||||
|
handler.syncForeground = func() { handler.state.setCurrent(handler.state.current) }
|
||||||
|
case "missing-owner":
|
||||||
|
handler.objectReview = nil
|
||||||
|
case "no-location":
|
||||||
|
handler.state.current.Name, handler.state.current.Role = "", ""
|
||||||
|
}
|
||||||
|
handler.whereAmI()
|
||||||
|
request := <-queue.requests
|
||||||
|
if exportable_speech(request) {
|
||||||
|
t.Fatal("Where Am I reused retired source classification")
|
||||||
|
}
|
||||||
|
if change != "no-location" && !strings.Contains(request.Text, "Review colour.") {
|
||||||
|
t.Fatal("source invalidation changed local reading", request.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteWhereAmIRejectsMismatchedSource(t *testing.T) {
|
||||||
|
handler, queue := where_am_i_observation_handler(t, "ordinary")
|
||||||
|
current := handler.state.current
|
||||||
|
handler.state.setCurrentDocument(review.Result{DocumentFocus: handler.objectReview.Focus(),
|
||||||
|
DocumentPosition: review.DocumentPosition{Snapshot: current},
|
||||||
|
DocumentCaretContext: []atspi.Snapshot{{BusName: current.BusName, Path: "/other", Role: "static"}}})
|
||||||
|
handler.whereAmI()
|
||||||
|
if exportable_speech(<-queue.requests) {
|
||||||
|
t.Fatal("unrelated ancestry authorized Where Am I export")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteWhereAmISecureFocus(t *testing.T) {
|
||||||
|
for _, mode := range []string{"lock", "handoff", "foreground-handoff"} {
|
||||||
|
handler, queue := where_am_i_observation_handler(t, "ordinary")
|
||||||
|
if mode == "lock" {
|
||||||
|
handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
||||||
|
} else if mode == "foreground-handoff" {
|
||||||
|
handler.syncForeground = func() { handler.focusGate.SetSuspended(true) }
|
||||||
|
} else {
|
||||||
|
handler.focusGate.SetSuspended(true)
|
||||||
|
}
|
||||||
|
handler.whereAmI()
|
||||||
|
if len(queue.requests) != 0 {
|
||||||
|
t.Fatal("Where Am I spoke during lock or handoff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteWhereAmIResolvedOutput(t *testing.T) {
|
||||||
|
worker, session, _, _ := observation_fixture(t)
|
||||||
|
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||||
|
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} {
|
||||||
|
request := where_am_i_observation_request(t, privacy)
|
||||||
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for len(worker.speaker.(*observationSpeaker).spoken) > 0 {
|
||||||
|
want := <-worker.speaker.(*observationSpeaker).spoken
|
||||||
|
if privacy == "ordinary" {
|
||||||
|
value := observed_speech(t, session)
|
||||||
|
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End {
|
||||||
|
t.Fatal("Where Am I export differed from resolved local chunks", value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worker.InterruptForUser()
|
||||||
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||||
|
t.Fatal("private Where Am I reached the child", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func where_am_i_observation_request(t *testing.T, privacy string) speechRequest {
|
||||||
|
t.Helper()
|
||||||
|
handler, queue := where_am_i_observation_handler(t, privacy)
|
||||||
|
handler.whereAmI()
|
||||||
|
return <-queue.requests
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteWhereAmISourcePrivacy(t *testing.T) {
|
||||||
|
for _, privacy := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "unknown-ancestor", "unknown-snapshot-ancestor", "missing", "stale"} {
|
||||||
|
t.Run(privacy, func(t *testing.T) {
|
||||||
|
request := where_am_i_observation_request(t, privacy)
|
||||||
|
if exportable_speech(request) != (privacy == "ordinary") {
|
||||||
|
t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request))
|
||||||
|
}
|
||||||
|
if privacy != "password" && !strings.Contains(request.Text, "Review colour.") {
|
||||||
|
t.Fatal("source privacy changed local Where Am I", request.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -580,6 +580,11 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} {
|
||||||
|
if err := worker.EnqueueAndWait(t.Context(), where_am_i_observation_request(t, privacy)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
worker.InterruptForUser()
|
worker.InterruptForUser()
|
||||||
var received strings.Builder
|
var received strings.Builder
|
||||||
ends, frames := 0, 0
|
ends, frames := 0, 0
|
||||||
@@ -615,7 +620,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
|||||||
t.Fatal("reader output did not reach the relay in order")
|
t.Fatal("reader output did not reach the relay in order")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if received.String() != "color "+remoteText+"First color.Second color.Current color.Review color.staticFlat color. Review color." || frames < 8 || ends != 7 {
|
if received.String() != "color "+remoteText+"First color.Second color.Current color.Review color.staticFlat color. Review color.Review color.static" || frames < 10 || ends != 9 {
|
||||||
t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):])
|
t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func (c *wineReviewFocusController) changed() {
|
|||||||
c.worker.SyncFocus(selected.ID())
|
c.worker.SyncFocus(selected.ID())
|
||||||
}
|
}
|
||||||
c.active, c.epoch = selected, fg.Epoch
|
c.active, c.epoch = selected, fg.Epoch
|
||||||
c.state.current = atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"}
|
c.state.setCurrent(atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"})
|
||||||
if selected.Protected {
|
if selected.Protected {
|
||||||
c.state.current.Role = "password text"
|
c.state.current.Role = "password text"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user