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