// 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.") }