Files
cthulhu/internal/app/libreoffice_selection_test.go
2026-10-15 01:10:43 -04:00

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")
}
})
}
}