Files
cthulhu/internal/app/libreoffice_native_test.go

396 lines
12 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bytes"
"context"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
type calcFrameQueue struct{ frames chan braille.Frame }
func (queue calcFrameQueue) EnqueueFrame(frame braille.Frame) { queue.frames <- frame }
// This runs the production native adapter and event loop against real Calc.
// Speech/Braille are recorded queues, not audible or physical display proof.
func TestNativeLibreOfficeCalc(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_LIBREOFFICE") != "1" {
t.Skip("requires tests/native_libreoffice.py")
}
window := os.Getenv("CTHULHU_CALC_WINDOW")
if window == "" {
t.Fatal("Calc window missing")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Errorf("stop adapter: %v", err)
}
}()
speech := &fakeSpeechQueue{spoken: make(chan string, 256)}
frames := make(chan braille.Frame, 256)
done := make(chan error, 1)
commands := make(chan input.Command, 1)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
options := presentation.DefaultOptions()
options.EnableEchoByCharacter = true
go func() {
done <- processEventsWithReview(ctx, logger, outputQueue{speech: speech, braille: calcFrameQueue{frames}}, adapter, commands, 0, options)
}()
defer func() {
cancel()
<-done
if t.Failed() {
t.Log(logBuffer.String())
}
}()
press := func(keys ...string) {
t.Helper()
args := append([]string{"key", "--clearmodifiers"}, keys...)
if err := exec.CommandContext(ctx, "xdotool", args...).Run(); err != nil {
t.Fatal(err)
}
}
waitSpeech := func(want string) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case got := <-speech.spoken:
t.Logf("Calc speech: %q", got)
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
t.Fatalf("Calc capacity/state chatter: %q", got)
}
if got == want || strings.HasSuffix(got, ". "+want) {
return
}
case <-timeout.C:
t.Fatalf("missing Calc speech %q", want)
}
}
}
waitFrame := func(suffix string) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case frame := <-frames:
if strings.HasSuffix(frame.Text, suffix) {
return
}
case <-timeout.C:
t.Fatalf("missing Calc Braille ending %q", suffix)
}
}
}
enterEditor := func(coordinate string) {
t.Helper()
drain:
for {
select {
case <-frames:
default:
break drain
}
}
press("F2")
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case frame := <-frames:
if strings.Contains(frame.Text, "Cell "+coordinate) {
return
}
case <-timeout.C:
t.Fatal("Calc did not expose its temporary cell editor")
}
}
}
activate := func(window string) {
t.Helper()
if err := exec.CommandContext(ctx, "xdotool", "windowactivate", "--sync", window).Run(); err != nil {
t.Fatal(err)
}
}
// Focus once. Refocusing before every key sequence can hide failures
// to retain or recover Calc's document focus during normal keyboard use.
activate(window)
press("Escape", "F6", "ctrl+F6", "ctrl+Home", "Right")
waitSpeech("42, B1")
waitFrame("42 B1")
press("Down")
waitSpeech("Beta, B2")
press("Left")
waitSpeech("blank, A2")
waitFrame("A2")
enterEditor("A2")
press("x")
waitSpeech("x")
press("Escape")
waitSpeech("blank, A2")
enterEditor("A2")
press("y")
waitSpeech("y")
press("Return")
waitSpeech("blank, A3")
press("Up")
waitSpeech("y, A2")
press("ctrl+Page_Down", "ctrl+Home")
waitSpeech("Gamma, A1")
press("ctrl+Page_Up", "ctrl+Home")
waitSpeech("Alpha, A1")
press("Right")
waitSpeech("42, B1")
enterEditor("B1")
press("Left")
waitSpeech("2")
press("Escape")
waitSpeech("42, B1")
press("F6")
press("ctrl+F6", "Down")
waitSpeech("Beta, B2")
waitFrame("Beta B2")
press("ctrl+Home", "Right", "Right")
waitSpeech("43, C1, has formula")
waitFrame("43 C1")
commands <- input.PresentCellFormula
waitSpeech("B1+1")
commands <- input.WhereAmI
waitSpeech("43, C1, has formula, B1+1")
press("Right")
waitSpeech("blank, D1, has formula")
commands <- input.PresentCellFormula
waitSpeech(`""`)
// Replacing a formula must refresh metadata for the same cell identity.
press("7", "Return", "Up")
waitSpeech("7, D1")
commands <- input.PresentCellFormula
waitSpeech("7")
press("Right")
waitSpeech("two spaces, E1, has formula")
commands <- input.PresentCellFormula
waitSpeech(`"two spaces"`)
press("Right")
waitSpeech("blank, F1")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("ctrl+Page_Down", "ctrl+Home", "Right", "Right")
waitSpeech("blank, C1")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("ctrl+Home")
waitSpeech("Gamma, A1")
press("shift+Right")
waitSpeech("Cell B1 selected.")
press("shift+Down")
waitSpeech("Cells A2 to B2 selected.")
press("shift+Up")
waitSpeech("Cells A2 to B2 unselected.")
// Formula commands must still use the active cell, not the range's
// first selected child or the sheet that emitted the selection event.
commands <- input.PresentCellFormula
waitSpeech("Gamma")
press("Right")
waitSpeech("Selection cleared.")
press("ctrl+Home", "shift+space")
waitSpeech("Row 1 selected.")
press("shift+Down")
waitSpeech("Row 2 selected.")
press("ctrl+Home", "ctrl+space")
waitSpeech("Column A selected.")
press("shift+Right")
waitSpeech("Column B selected.")
press("ctrl+Home", "ctrl+a", "ctrl+a")
waitSpeech("All cells selected.")
// Calc omits events while shrinking full-column ranges. Growing the
// shrunken selection again supplies an event for a capped partial sheet.
press("shift+Left", "shift+Left", "shift+Right")
waitSpeech("At least 2147483647 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
press("Down")
waitSpeech("blank, B2")
// Use the provider's selection API to make actual disjoint selections;
// these are distinct from the synthetic event/presentation unit checks.
if output, err := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_libreoffice.py", "--select-disjoint").CombinedOutput(); err != nil {
t.Fatalf("disjoint Calc selection: %v: %s", err, output)
}
waitSpeech("Cell C1 selected.")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("Right")
waitSpeech("Selection cleared.")
// A large non-column selection has an exact count, without enumerating
// its cells or inferring a rectangle from boundary samples.
press("ctrl+Home", "shift+Right", "ctrl+shift+Down", "shift+Up")
waitSpeech("2097150 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
// Dynamic headers are real provider cell reads, isolated per sheet.
press("ctrl+Page_Up", "ctrl+Home")
waitSpeech("Alpha, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
commands <- input.SetRowHeadersColumn
waitSpeech("Column A set as row headers.")
press("Right")
waitSpeech("42, B1")
press("Down")
waitSpeech("y row header, Beta, B2")
waitFrame("y 42 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 42 column header, Beta, B2")
press("Right")
waitSpeech("43 column header, blank, C2")
press("Down")
waitSpeech("blank, C3")
press("Up")
waitSpeech("y row header, blank, C2")
press("ctrl+Page_Down", "ctrl+Home")
waitSpeech("Gamma, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2") // Same column: only Where Am I repeats its header.
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
press("ctrl+Page_Up", "ctrl+Home", "Right", "Down")
waitSpeech("y row header, Beta, B2")
// A heading edit is visible on the next cell snapshot, without a cache.
press("Up")
waitSpeech("Alpha row header, 42, B1")
press("8", "Return")
waitSpeech("y row header, Beta, B2")
waitFrame("y 8 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 8 column header, Beta, B2")
commands <- input.ClearColumnHeadersRow
waitSpeech("Column headers cleared.")
commands <- input.WhereAmI
waitSpeech("y row header, Beta, B2")
commands <- input.ClearRowHeadersColumn
waitSpeech("Row headers cleared.")
commands <- input.WhereAmI
waitSpeech("Beta, B2")
press("Up", "Down")
waitSpeech("Beta, B2")
press("ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
// A second workbook in the same provider bus must not inherit choices.
profile, document := os.Getenv("CTHULHU_CALC_PROFILE"), os.Getenv("CTHULHU_CALC_SECOND_DOCUMENT")
if profile == "" || document == "" {
t.Fatal("private second-workbook fixture missing")
}
if output, err := exec.CommandContext(ctx, "libreoffice", "-env:UserInstallation="+profile, "--calc", document).CombinedOutput(); err != nil {
t.Fatalf("open second private workbook: %v: %s", err, output)
}
secondWindow := ""
deadline := time.Now().Add(5 * time.Second)
for secondWindow == "" && time.Now().Before(deadline) {
output, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc").Output()
for _, candidate := range strings.Fields(string(output)) {
if candidate != window {
secondWindow = candidate
}
}
if secondWindow == "" {
time.Sleep(20 * time.Millisecond)
}
}
if secondWindow == "" {
t.Fatal("second private workbook window missing")
}
activate(secondWindow)
press("ctrl+F6", "ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("blank, A2")
press("Up", "d", "Return")
waitSpeech("blank, A2")
press("Up")
waitSpeech("d, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("d column header, blank, A2")
// Return to the first workbook, then back again: identical sheet names
// must retain independent choices and fresh cells in both directions.
// Use normal navigation here, without the Ctrl+F6 recovery shortcut.
activate(window)
press("ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
waitFrame("Gamma A2")
activate(secondWindow)
press("ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("d column header, blank, A2")
waitFrame("d A2")
// Dialog activation still resolves its focused control. Cancelling it
// must leave the document navigable with the same header choices.
press("ctrl+h")
dialogWindow := ""
deadline = time.Now().Add(5 * time.Second)
for dialogWindow == "" && time.Now().Before(deadline) {
output, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Find and Replace$").Output()
if candidates := strings.Fields(string(output)); len(candidates) == 1 {
dialogWindow = candidates[0]
} else {
time.Sleep(20 * time.Millisecond)
}
}
if dialogWindow == "" {
t.Fatal("private Find and Replace dialog window missing")
}
activate(dialogWindow)
press("x")
waitSpeech("x")
press("Escape")
deadline = time.Now().Add(5 * time.Second)
for {
output, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Find and Replace$").Output()
if len(strings.Fields(string(output))) == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("private Find and Replace dialog did not close")
}
time.Sleep(20 * time.Millisecond)
}
activate(secondWindow)
press("ctrl+F6", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("d column header, blank, A2")
}