Files
cthulhu/internal/app/libreoffice_native_test.go
2026-10-15 01:10:43 -04:00

250 lines
7.1 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(), 35*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")
}
}
}
// Focus once. Refocusing before every key sequence can hide failures
// to retain or recover Calc's document focus during normal keyboard use.
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
t.Fatal(err)
}
press("Escape", "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.")
}