157 lines
6.7 KiB
Go
157 lines
6.7 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"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
)
|
|
|
|
func TestLibreOfficeSelectionChangesKeepActiveCellAndBraille(t *testing.T) {
|
|
current := calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1")
|
|
current.Source.TableColumnIndex = 2
|
|
current.Source.LibreOffice.Formula = "B1+1"
|
|
state := eventLoopState{}
|
|
state.current, state.focusedID = current.Source, current.Source.ID()
|
|
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
|
|
state.presentationOptions = presentation.DefaultOptions()
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
|
display := &flashRecorder{}
|
|
live := &liveSettings{}
|
|
live.Store(config.DefaultSettings())
|
|
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
|
|
adapter: &fakeAdapter{}, speech: outputQueue{speech: speech, braille: display, live: live}, syncForeground: func() {}, webScripts: &webScriptController{}}
|
|
event := current
|
|
event.Type = atspi.SelectionChangedEvent
|
|
event.Source.Path, event.Source.Role = "/sheet", "table"
|
|
selection := func(cells ...atspi.LibreOfficeCell) {
|
|
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true,
|
|
Selection: &atspi.LibreOfficeSelection{Kind: "cells", Count: len(cells), Complete: true, Cells: cells}}
|
|
handler.handle(event)
|
|
}
|
|
expect := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != want {
|
|
t.Fatalf("got %q, want %q", got, want)
|
|
}
|
|
default:
|
|
t.Fatalf("missing %q", want)
|
|
}
|
|
if state.current.ID() != current.Source.ID() || state.focusedID != current.Source.ID() || presentation.LibreOfficeCellFormula(state.current) != "B1+1" {
|
|
t.Fatal("selection displaced active cell/formula")
|
|
}
|
|
if display.messages[len(display.messages)-1] != want {
|
|
t.Fatal("selection did not use system Braille flash")
|
|
}
|
|
}
|
|
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2})
|
|
expect("Cell A1 selected.")
|
|
handler.handle(event)
|
|
if len(speech.spoken) != 0 {
|
|
t.Fatal("duplicate selection repeated")
|
|
}
|
|
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
|
|
expect("Cell E1 selected.")
|
|
selection(atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
|
|
expect("Cell A1 unselected.")
|
|
selection()
|
|
expect("Selection cleared.")
|
|
// A count-only change reports the new total, never a fabricated
|
|
// unselection of the whole old range when selections overlap.
|
|
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000}
|
|
handler.handle(event)
|
|
expect("1000000 cells selected.")
|
|
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001}
|
|
handler.handle(event)
|
|
expect("1000001 cells selected.")
|
|
// The same total with different boundary samples is a changed selection.
|
|
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001, First: atspi.LibreOfficeCell{Row: 2, Column: 2}}
|
|
handler.handle(event)
|
|
expect("1000001 cells selected.")
|
|
// Whole-row/column shrinkage has the same delta policy when the
|
|
// provider supplies an event (the real Calc harness documents gaps).
|
|
for _, kind := range []string{"rows", "columns"} {
|
|
state.libreOfficeSelection = libreOfficeSelectionState{}
|
|
positions := []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}}
|
|
label := "Row 2 unselected."
|
|
selectedLabel := "Rows 1 to 2 selected."
|
|
if kind == "columns" {
|
|
positions[1] = atspi.LibreOfficeCell{Row: 0, Column: 1}
|
|
label = "Column B unselected."
|
|
selectedLabel = "Columns A to B selected."
|
|
}
|
|
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 2, Complete: true, Cells: positions}
|
|
handler.handle(event)
|
|
expect(selectedLabel)
|
|
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 1, Complete: true, Cells: positions[:1]}
|
|
handler.handle(event)
|
|
expect(label)
|
|
}
|
|
state.clearObjectFocus(nil, nil, nil)
|
|
if state.libreOfficeSelection.previous != nil {
|
|
t.Fatal("selection survived owned-focus reset")
|
|
}
|
|
}
|
|
|
|
func TestLibreOfficeSelectionRejectsUnownedAndSuppressedOutput(t *testing.T) {
|
|
for _, name := range []string{"other sheet", "other bus", "other window", "wrong process", "defunct", "locked", "suspended", "stale foreground", "displayed text"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
current := calcTestEvent(atspi.FocusEvent, "/cell", "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.presentationOptions = presentation.DefaultOptions()
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
|
gate := newFocusGate(nil)
|
|
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, speech: speech, focusGate: gate,
|
|
foreground: &ocrTestForeground{state: state.foregroundState}, syncForeground: func() {}, webScripts: &webScriptController{}}
|
|
event := current
|
|
event.Type = atspi.SelectionChangedEvent
|
|
event.Foreground.Epoch = 2
|
|
event.Source.Path, event.Source.Role = "/sheet", "table"
|
|
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, Selection: &atspi.LibreOfficeSelection{Kind: "all", Complete: true}}
|
|
switch name {
|
|
case "other sheet":
|
|
event.Source.Path = "/other_sheet"
|
|
case "other bus":
|
|
event.Source.BusName = ":1.99"
|
|
case "other window":
|
|
event.Window.Path = "/other_window"
|
|
case "wrong process":
|
|
event.Source.ApplicationProcess = "example"
|
|
case "defunct":
|
|
event.Source.Defunct = true
|
|
case "locked":
|
|
gate.secure.Store(true)
|
|
case "suspended":
|
|
gate.suspended.Store(true)
|
|
case "stale foreground":
|
|
event.Foreground.Epoch = 1
|
|
case "displayed text":
|
|
reading := config.DefaultSettings().Reading
|
|
reading.OnlySpeakDisplayedText = true
|
|
state.presentationOptions.Reading = &reading
|
|
}
|
|
// Test the application hook after the ordinary foreground/security
|
|
// gates; wrong-process events belong to the generic application path.
|
|
if name == "wrong process" {
|
|
handler.handleLibreOfficeSelection(event)
|
|
} else {
|
|
handler.handle(event)
|
|
}
|
|
if len(speech.spoken) != 0 || state.current.ID() != current.Source.ID() {
|
|
t.Fatal("unowned/suppressed selection changed output or focus")
|
|
}
|
|
})
|
|
}
|
|
}
|