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