Files

349 lines
11 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestNativeOCRReviewInput(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_OCR_REVIEW") != "1" {
t.Skip("requires private Xorg display")
}
for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} {
name := "desktop"
if layout == input.LaptopKeyboardLayout {
name = "laptop"
}
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/ocr_review_fixture.py")
stdin, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err = fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { stdin.Close(); fixture.Wait() }()
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() {
t.Fatal("fixture did not start")
}
var windows map[string]uint64
if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil {
t.Fatal(err)
}
type deliveredKey struct {
Pressed bool
Symbol uint32
Modifiers uint16
Window uint64
}
type state struct {
Focus uint64
Keys []deliveredKey
}
action := func(value map[string]any) state {
t.Helper()
data, _ := json.Marshal(value)
if _, err := stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
if !scanner.Scan() {
t.Fatal("fixture did not respond")
}
var result state
if err := json.Unmarshal(scanner.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
clear := func() { action(map[string]any{"action": "clear"}) }
snapshot := func() state { return action(map[string]any{"action": "state"}) }
listener := NewCommands([]string{"Insert"}, layout)
if err = listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
key := func(args ...string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
defer func() {
exec.Command("xdotool", "keyup", "Insert", "Control_L", "Shift_L", "Down", "Escape", "Tab", "o").Run()
}()
ready := func(predicate func() bool) {
t.Helper()
for !predicate() {
select {
case err := <-listener.Failures():
t.Fatal(err)
case <-ctx.Done():
t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load())
case <-time.After(time.Millisecond):
}
}
}
command := func(want input.Command) input.CommandEvent {
t.Helper()
select {
case got := <-listener.Commands():
if got.Command != want {
t.Fatalf("got %s want %s", got.Command, want)
}
return got
case err := <-listener.Failures():
t.Fatal(err)
case <-ctx.Done():
t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load())
}
return input.CommandEvent{}
}
drained := func() { ready(func() bool { return !listener.CommandGrabActive() }) }
noLeak := func() {
t.Helper()
drained()
got := snapshot()
balance := make(map[uint32]int)
for _, event := range got.Keys {
if event.Symbol != 0xffe1 && event.Symbol != 0xffe3 {
t.Fatalf("review leaked navigation: %+v", got)
}
if event.Pressed {
balance[event.Symbol]++
} else {
balance[event.Symbol]--
}
}
for _, count := range balance {
if count != 0 {
t.Fatalf("unbalanced native modifier: %+v", got)
}
}
if got.Focus != windows["window"] {
t.Fatalf("review leaked/changed focus: %+v", got)
}
}
listener.SetBrowseNavigation(true)
ready(listener.BrowseNavigationEnabled)
listener.SetNativeButtonFocus(true)
// A conflict must reject entry and preserve the existing browser context.
action(map[string]any{"action": "conflict"})
if err = listener.SetOCRReview(ctx, true); err == nil {
t.Fatal("conflicting Ctrl+C grab accepted")
}
if listener.OCRReviewEnabled() || !listener.BrowseNavigationEnabled() {
t.Fatal("failed entry lost old context")
}
key("key", "h")
command(input.DocumentNextHeading)
drained()
action(map[string]any{"action": "unconflict"})
clear()
// Recognition claims Escape only, even when the application's Ctrl+C
// grab prevents full review. Arrows and structural letters pass.
action(map[string]any{"action": "conflict"})
if err = listener.SetOCRContext(ctx, windows["other"], true); err == nil {
t.Fatal("pending entry accepted another foreground")
}
if err = listener.SetOCRContext(ctx, windows["window"], true); err != nil {
t.Fatal(err)
}
if listener.OCRReviewEnabled() || listener.BrowseNavigationEnabled() {
t.Fatal("pending context exposed full review/browser navigation")
}
key("key", "Down")
key("key", "h")
gotPending := snapshot()
if len(gotPending.Keys) != 4 || gotPending.Keys[0].Symbol != 0xff54 || gotPending.Keys[2].Symbol != 'h' {
t.Fatalf("pending navigation: %+v", gotPending)
}
clear()
key("key", "Escape")
command(input.OCRDismiss)
noLeak()
ready(listener.BrowseNavigationEnabled)
action(map[string]any{"action": "unconflict"})
clear()
// A timed-out entry must never activate when the held key later releases.
key("keydown", "Insert")
ready(listener.CommandGrabActive)
expired, cancelEntry := context.WithTimeout(ctx, 25*time.Millisecond)
if err = listener.SetOCRReview(expired, true); err != context.DeadlineExceeded {
t.Fatalf("waiting entry: %v", err)
}
cancelEntry()
key("keyup", "Insert")
if err = listener.SetOCRReview(ctx, false); err != nil {
t.Fatal(err)
}
if listener.OCRReviewEnabled() {
t.Fatal("expired entry activated later")
}
// Entry waits for the full initiating chord, including modifier release.
key("keydown", "Insert")
ready(listener.CommandGrabActive)
applied := make(chan error, 1)
go func() { applied <- listener.SetOCRReview(ctx, true) }()
select {
case err := <-applied:
t.Fatalf("entered on held modifier: %v", err)
case <-time.After(25 * time.Millisecond):
}
key("keyup", "Insert")
if err = <-applied; err != nil {
t.Fatal(err)
}
ready(listener.OCRReviewEnabled)
clear()
for _, test := range []struct {
chord string
want input.Command
}{
{"Down", input.OCRNextLine}, {"Up", input.OCRPreviousLine}, {"Left", input.OCRPreviousCharacter},
{"ctrl+Right", input.OCRNextWord}, {"Home", input.OCRLineStart}, {"ctrl+End", input.OCRDocumentEnd},
{"shift+Down", input.OCRNextLine}, {"ctrl+a", input.OCRSelectAll}, {"ctrl+c", input.OCRCopySelection},
{"Return", input.OCRActivate}, {"space", input.OCRActivate},
} {
key("key", test.chord)
event := command(test.want)
if test.chord == "shift+Down" && event.Chord.Modifiers&1 == 0 {
t.Fatal("selection modifier lost")
}
noLeak()
clear()
}
if layout == input.LaptopKeyboardLayout {
key("key", "Insert+o")
command(input.ReviewNextLine)
noLeak()
clear()
}
// Alt+Tab remains available to the application/window manager.
key("key", "alt+Tab")
got := snapshot()
tabPresses, tabReleases := 0, 0
for _, event := range got.Keys {
if event.Symbol == 0xff09 {
if event.Pressed {
tabPresses++
} else {
tabReleases++
}
}
}
if tabPresses != 1 || tabReleases != 1 {
t.Fatalf("Alt+Tab did not pass: %+v", got)
}
clear()
// Structural letters reach the app rather than inventing OCR headings.
key("key", "h")
got = snapshot()
if len(got.Keys) != 2 || got.Keys[0].Symbol != 'h' {
t.Fatalf("structural letter: %+v", got)
}
clear()
key("key", "ctrl+z")
got = snapshot()
if len(got.Keys) != 4 {
t.Fatalf("unbound Control chord not replayed whole: %+v", got)
}
clear()
// Exit also waits for the complete navigation key release.
key("keydown", "Down")
command(input.OCRNextLine)
go func() { applied <- listener.SetOCRReview(ctx, false) }()
select {
case err := <-applied:
t.Fatalf("exited on held arrow: %v", err)
case <-time.After(25 * time.Millisecond):
}
key("keyup", "Down")
if err = <-applied; err != nil {
t.Fatal(err)
}
noLeak()
ready(listener.BrowseNavigationEnabled)
key("key", "h")
command(input.DocumentNextHeading)
drained()
clear()
if err = listener.SetOCRReview(ctx, true); err != nil {
t.Fatal(err)
}
key("key", "Escape")
command(input.OCRDismiss)
ready(func() bool { return !listener.OCRReviewEnabled() })
noLeak()
for _, chord := range []string{"Tab", "shift+Tab"} {
if err = listener.SetOCRReview(ctx, true); err != nil {
t.Fatal(err)
}
clear()
key("key", chord)
command(input.OCRDismissAndPass)
ready(func() bool { return !listener.OCRReviewEnabled() })
drained()
got = snapshot()
presses, releases := 0, 0
for _, event := range got.Keys {
if event.Symbol == 0xff09 {
if event.Pressed {
presses++
} else {
releases++
}
}
}
if got.Focus != windows["window"] || presses != 1 || releases != 1 {
t.Fatalf("Tab replay: %+v", got)
}
}
// Focus loss retires temporary input; a following arrow reaches the new app.
if err = listener.SetOCRReview(ctx, true); err != nil {
t.Fatal(err)
}
listener.SetBrowseNavigation(false)
action(map[string]any{"action": "activate", "other": true})
command(input.OCRDismiss)
ready(func() bool { return !listener.OCRReviewEnabled() })
clear()
key("key", "Down")
got = snapshot()
if len(got.Keys) != 2 || got.Keys[0].Window != windows["other"] {
t.Fatalf("focus-loss routing: %+v", got)
}
action(map[string]any{"action": "activate"})
if err = listener.SetOCRReview(ctx, true); err != nil {
t.Fatal(err)
}
listener.SetSecureInputActive(true)
ready(func() bool { return !listener.ocrApplied.Load() && !listener.CommandGrabsEnabled() })
listener.SetSecureInputActive(false)
ready(listener.CommandGrabsEnabled)
if listener.OCRReviewEnabled() {
t.Fatal("unlock revived OCR")
}
})
}
}