250 lines
7.1 KiB
Go
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.")
|
|
}
|