Reset master to testing content
This commit is contained in:
1 parent
0f2587b719
commit
42afa2a56c
1483 files changed
+145308
-938069
No files matched your search
@@ -0,0 +1,395 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user