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