Files
cthulhu/internal/app/libreoffice_test.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")
}
})
}
}