// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" ) func TestLibreOfficeHeaderCommandsRespectOwnershipAndGates(t *testing.T) { event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3") event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1 event.Source.LibreOffice.Document = atspi.NewObjectID(event.Source.BusName, "/document") event.Source.LibreOffice.SheetName = "Example" state := eventLoopState{} state.presentationOptions = presentation.DefaultOptions() state.current = event.Source state.owner.follow(event) speech := &fakeSpeechQueue{spoken: make(chan string, 8)} gate := &focusGate{} frames := make(chan braille.Frame, 8) handler := eventReviewHandler{state: &state, speech: speech, focusGate: gate, calcHeaders: atspi.New(), syncForeground: func() {}, brailleOutput: calcFrameQueue{frames}} for _, test := range []struct { command input.Command want string }{ {input.SetColumnHeadersRow, "Row 3 set as column headers."}, {input.SetRowHeadersColumn, "Column B set as row headers."}, {input.ClearColumnHeadersRow, "Column headers cleared."}, {input.ClearRowHeadersColumn, "Row headers cleared."}, } { handler.configureCalcHeader(test.command) if got := <-speech.spoken; got != test.want { t.Fatalf("header command = %q", got) } <-frames } // Clearing immediately refreshes the Braille base frame as well as the // copied Where Am I snapshot, without waiting for another cell event. state.current.TableColumnHeaders, state.current.TableRowHeaders = "Total", "Monday" handler.configureCalcHeader(input.ClearColumnHeadersRow) <-speech.spoken if frame := <-frames; frame.Text != "Monday 10 B3" { t.Fatalf("cleared frame = %+v", frame) } if state.current.TableColumnHeaders != "" || state.brailleSnapshot.TableColumnHeaders != "" { t.Fatal("clear retained column heading") } gate.secure.Store(true) handler.configureCalcHeader(input.SetColumnHeadersRow) gate.secure.Store(false) gate.suspended.Store(true) handler.configureCalcHeader(input.SetColumnHeadersRow) gate.suspended.Store(false) if len(speech.spoken) != 0 || len(frames) != 0 { t.Fatal("header command bypassed lock/suspension") } state.owner.focus = atspi.NewObjectID(event.Source.BusName, "/other") handler.configureCalcHeader(input.SetColumnHeadersRow) if got := <-speech.spoken; got != "Not in a Calc cell." { t.Fatal(got) } handler.syncForeground = func() { state.current = atspi.Snapshot{} } handler.configureCalcHeader(input.SetColumnHeadersRow) if got := <-speech.spoken; got != "Not in a Calc cell." { t.Fatal(got) } } func TestLibreOfficeHeaderSpeechHistoryAndBraille(t *testing.T) { event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3") event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1 event.Source.TableRowHeaders, event.Source.TableColumnHeaders = "Monday", "Total" state := eventLoopState{} state.lastTable, state.lastTableCell = calcSheet(event.Source), event.Source.ID() state.lastTableRow, state.lastTableColumn = 2, 0 filtered := state.calcHeaderSpeechEvent(event) if filtered.Source.TableRowHeaders != "" || filtered.Source.TableColumnHeaders != "Total" { t.Fatalf("horizontal: %+v", filtered.Source) } state.lastTableRow, state.lastTableColumn = 1, 1 filtered = state.calcHeaderSpeechEvent(event) if filtered.Source.TableRowHeaders != "Monday" || filtered.Source.TableColumnHeaders != "" { t.Fatalf("vertical: %+v", filtered.Source) } // The accepted snapshot retains both for Where Am I and Braille. if got := presentation.FocusText(event.Source); got != "Monday row header, Total column header, 10, B3" { t.Fatal(got) } if frame := brailleFocusFrame(event.Source); frame.Text != "Monday Total 10 B3" { t.Fatalf("frame = %+v", frame) } reading := config.DefaultSettings().Reading reading.OnlySpeakDisplayedText = true options := presentation.DefaultOptions() options.Reading = &reading if got := presentation.FocusTextWithOptions(event.Source, options); got != "10" { t.Fatal(got) } state.current = event.Source state.clearObjectFocus(nil, nil, nil) filtered = state.calcHeaderSpeechEvent(event) if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" { t.Fatal("focus return suppressed headers") } state.lastTable = atspi.NewObjectID(event.Source.BusName, "/other-sheet") filtered = state.calcHeaderSpeechEvent(event) if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" { t.Fatal("sheet return suppressed headers") } }