349 lines
11 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|