203 lines
8.3 KiB
Go
203 lines
8.3 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
)
|
|
|
|
func calcTestEvent(eventType, path, value, coordinate string) atspi.Event {
|
|
source := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "table cell", Name: coordinate,
|
|
Text: value, ApplicationProcess: "soffice.bin", Focused: true,
|
|
TablePath: "/sheet", TablePositionKnown: true,
|
|
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: coordinate}}
|
|
event := atspi.NewEvent(eventType, source)
|
|
event.NativeType = eventType
|
|
event.Window = atspi.NewObjectID(source.BusName, "/window")
|
|
event.OriginPath = "/sheet"
|
|
return event
|
|
}
|
|
|
|
func TestLibreOfficeFormulaCommandUsesOwnedFocusAndSecurityGates(t *testing.T) {
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
state := eventLoopState{}
|
|
state.current = calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1").Source
|
|
state.current.LibreOffice.Formula = "B1+1"
|
|
gate := &focusGate{}
|
|
display := &flashRecorder{}
|
|
live := &liveSettings{}
|
|
live.Store(config.DefaultSettings())
|
|
queue := outputQueue{speech: speech, braille: display, live: live}
|
|
handler := eventReviewHandler{state: &state, speech: queue, focusGate: gate,
|
|
syncForeground: func() {}}
|
|
handler.presentCellFormula()
|
|
if got := <-speech.spoken; got != "B1+1" {
|
|
t.Fatalf("formula = %q", got)
|
|
}
|
|
if len(display.messages) != 1 || display.messages[0] != "B1+1" {
|
|
t.Fatalf("formula Braille flash = %v", display.messages)
|
|
}
|
|
gate.secure.Store(true)
|
|
handler.presentCellFormula()
|
|
gate.secure.Store(false)
|
|
gate.suspended.Store(true)
|
|
handler.presentCellFormula()
|
|
gate.suspended.Store(false)
|
|
if len(speech.spoken) != 0 || len(display.messages) != 1 {
|
|
t.Fatal("formula command bypassed lock or suspension")
|
|
}
|
|
// Synchronizing foreground ownership can retire the old Calc focus.
|
|
handler.syncForeground = func() { state.current = atspi.Snapshot{} }
|
|
handler.presentCellFormula()
|
|
if got := <-speech.spoken; got != "Not in a table." {
|
|
t.Fatalf("stale foreground formula = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) {
|
|
state := eventLoopState{}
|
|
state.presentationOptions = presentation.DefaultOptions()
|
|
state.presentationOptions.EnableEchoByCharacter = true
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 32)}
|
|
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
|
|
speech: speech, adapter: &fakeAdapter{}, clearOwnedFocus: func() {}, syncForeground: func() {}}
|
|
presenter := eventPresenter{state: &state, logger: handler.logger, speech: speech}
|
|
handler.speak = presenter.speak
|
|
handler.titles = &windowTitleController{state: &state, speech: speech, logger: handler.logger}
|
|
handler.webScripts = &webScriptController{speech: speech}
|
|
expectSpeech := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != want {
|
|
t.Fatalf("Calc speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing Calc speech %q", want)
|
|
}
|
|
}
|
|
first := calcTestEvent(atspi.ActiveDescendantEvent, "/cell1", "42", "B1")
|
|
first.Source.TableColumnIndex = 1
|
|
handler.handle(first)
|
|
expectSpeech("42, B1")
|
|
ancestor := first
|
|
ancestor.Type = atspi.FocusEvent
|
|
ancestor.Source.Path, ancestor.Source.Role, ancestor.Source.Name = "/root_pane", "root pane", "Example"
|
|
ancestor.Source.LibreOffice = nil
|
|
state.current.BrailleContext = []atspi.Snapshot{ancestor.Source}
|
|
handler.handle(ancestor)
|
|
if state.current.ID() != first.Source.ID() || len(speech.spoken) != 0 {
|
|
t.Fatal("ancestor focus displaced the active cell")
|
|
}
|
|
handler.handle(first)
|
|
if len(speech.spoken) != 0 {
|
|
t.Fatal("repeated cell descendant was spoken")
|
|
}
|
|
blank := calcTestEvent(atspi.ActiveDescendantEvent, "/cell2", "", "A2")
|
|
blank.Source.TableRowIndex = 1
|
|
handler.handle(blank)
|
|
expectSpeech("blank, A2")
|
|
backgroundCaret := blank
|
|
backgroundCaret.Type = atspi.CaretEvent
|
|
backgroundCaret.Source.Path = "/input_line"
|
|
backgroundCaret.Source.Role = "paragraph"
|
|
backgroundCaret.Source.Text = "unrelated"
|
|
backgroundCaret.Source.LibreOffice = nil
|
|
handler.handle(backgroundCaret)
|
|
if state.current.ID() != blank.Source.ID() || len(speech.spoken) != 0 {
|
|
t.Fatal("formula-bar caret displaced cell focus")
|
|
}
|
|
editor := blank
|
|
editor.Type = atspi.TextInsertedEvent
|
|
editor.Source.Path, editor.Source.Role, editor.Source.Name = "/editor", "paragraph", ""
|
|
editor.Source.Editable = true
|
|
editor.Source.Text, editor.Payload = "x", "x"
|
|
editor.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, CellEditor: true}
|
|
handler.handle(editor)
|
|
if state.current.ID() != editor.Source.ID() || state.focusedID != editor.Source.ID() {
|
|
t.Fatal("first editor mutation was filtered before focus adoption")
|
|
}
|
|
expectSpeech("x")
|
|
table := editor
|
|
table.Type = atspi.FocusEvent
|
|
table.Source.Path, table.Source.Role, table.Source.Name = "/sheet", "table", "Sheet Example"
|
|
table.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true}
|
|
handler.handle(table)
|
|
if state.current.ID() != table.Source.ID() || len(speech.spoken) != 0 {
|
|
t.Fatal("post-edit table focus was not owned silently")
|
|
}
|
|
handler.handle(blank)
|
|
expectSpeech("blank, A2")
|
|
otherSheet := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "Gamma", "A1")
|
|
otherSheet.OriginPath = "/other_sheet"
|
|
otherSheet.Source.TablePath = "/other_sheet"
|
|
handler.handle(otherSheet)
|
|
expectSpeech("Gamma, A1")
|
|
selection := blank
|
|
selection.Type = atspi.SelectionChangedEvent
|
|
handler.handle(selection)
|
|
if state.current.ID() != otherSheet.Source.ID() || len(speech.spoken) != 0 {
|
|
t.Fatal("range selection displaced the active cell")
|
|
}
|
|
}
|
|
|
|
func TestLibreOfficePolicyCannotAdoptAnotherWindowOrProcess(t *testing.T) {
|
|
current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1")
|
|
owner := windowOwner{window: current.Window, applicationBus: current.Source.BusName}
|
|
for _, change := range []func(*atspi.Event){
|
|
func(event *atspi.Event) { event.Window.Path = "/other_window" },
|
|
func(event *atspi.Event) { event.Source.ApplicationProcess = "chromium" },
|
|
func(event *atspi.Event) { event.Source.Focused = false },
|
|
func(event *atspi.Event) { event.Source.Defunct = true },
|
|
} {
|
|
event := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "wrong", "C1")
|
|
change(&event)
|
|
result := prepareLibreOfficeEvent(event, current.Source, owner)
|
|
if result.event.Type != atspi.ActiveDescendantEvent || result.adoptFocus {
|
|
t.Fatalf("unowned cell became focus: %+v", result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLibreOfficeBraillePreservesSpacingAndHasNoEditorRoute(t *testing.T) {
|
|
event := calcTestEvent(atspi.FocusEvent, "/cell", "é 猫", "B3")
|
|
frame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille)
|
|
if frame.Text != "é 猫 B3" || frame.Cursor != 0 || len(frame.Routes) != 0 {
|
|
t.Fatalf("Calc Braille = %+v", frame)
|
|
}
|
|
}
|
|
|
|
func TestLibreOfficeRoutingHonorsLockAndForegroundBeforePolicy(t *testing.T) {
|
|
for _, locked := range []bool{true, false} {
|
|
t.Run(map[bool]string{true: "locked", false: "stale foreground"}[locked], func(t *testing.T) {
|
|
current := calcTestEvent(atspi.FocusEvent, "/current", "42", "B1")
|
|
state := eventLoopState{}
|
|
state.current, state.focusedID = current.Source, current.Source.ID()
|
|
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
|
|
state.foregroundState = atspi.ForegroundState{Epoch: 2}
|
|
state.lastSpokenText = "unchanged"
|
|
gate := newFocusGate(nil)
|
|
gate.secure.Store(locked)
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
handler := accessibilityEventHandler{state: &state, focusGate: gate, speech: speech,
|
|
logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
|
|
foreground: &ocrTestForeground{state: state.foregroundState},
|
|
syncForeground: func() {}, webScripts: &webScriptController{}}
|
|
event := calcTestEvent(atspi.ActiveDescendantEvent, "/new_cell", "wrong", "C1")
|
|
event.Foreground.Epoch = 1
|
|
handler.handle(event)
|
|
if state.current.ID() != current.Source.ID() || state.lastSpokenText != "unchanged" || len(speech.spoken) != 0 {
|
|
t.Fatal("LibreOffice policy ran before security/foreground rejection")
|
|
}
|
|
})
|
|
}
|
|
}
|