Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,369 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
"git.stormux.org/storm/cthulhu/internal/wine"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type wineNativeOutput struct {
|
||||
requests chan speechRequest
|
||||
frames chan braille.Frame
|
||||
}
|
||||
|
||||
func (o *wineNativeOutput) Enqueue(request speechRequest) { o.requests <- request }
|
||||
func (o *wineNativeOutput) Interrupt() {}
|
||||
func (o *wineNativeOutput) EnqueueFrame(frame braille.Frame) { o.frames <- frame }
|
||||
|
||||
func TestNativeWineReviewPhysicalKeys(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_WINE_REVIEW_BUILD") == "" {
|
||||
t.Skip("requires isolated Wine build/display/session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
|
||||
defer cancel()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
registry := wine.NewReviewRegistry(logger)
|
||||
if err := registry.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registry.Stop(context.Background())
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
||||
if err := commands.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer commands.Stop(context.Background())
|
||||
gate := newFocusGate(commands)
|
||||
worker, err := review.NewWorker(&reviewCaptureRouter{native: adapter, wine: registry}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = worker.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer worker.Stop(context.Background())
|
||||
live := &liveSettings{wineReview: registry}
|
||||
live.Store(config.DefaultSettings())
|
||||
output := &wineNativeOutput{requests: make(chan speechRequest, 256), frames: make(chan braille.Frame, 256)}
|
||||
queue := outputQueue{speech: output, braille: output, live: live}
|
||||
helperEvents := newWineEventPresenter(func(text string) bool {
|
||||
output.Enqueue(speechRequest{Text: text, Cause: "wine-accessible-event"})
|
||||
return true
|
||||
})
|
||||
defer helperEvents.Close()
|
||||
service := remote.New(logger, remoteSpeech{speech: output, wine: helperEvents, wineReview: registry})
|
||||
if err = service.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Stop(context.Background())
|
||||
eventDone, commandDone := make(chan error, 1), make(chan error, 1)
|
||||
go func() {
|
||||
eventDone <- processEventsWithAttributes(ctx, logger, queue, adapter, nil, nil, worker, 0, live, nil, gate, nil, nil)
|
||||
}()
|
||||
go func() {
|
||||
commandDone <- (commandRuntime{logger: logger, commands: commands, focusChanges: commands.FocusChanges(), focusGate: gate, speechOutput: queue, objectReview: worker, live: live, stopRun: cancel}).run(ctx)
|
||||
}()
|
||||
defer func() {
|
||||
cancel()
|
||||
if err := <-eventDone; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := <-commandDone; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
driver := exec.CommandContext(ctx, "python3", "-u", "../../tests/wine_review_x11.py")
|
||||
stdin, err := driver.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout, err := driver.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver.Stderr = os.Stderr
|
||||
if err = driver.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
stdin.Close()
|
||||
if err := driver.Wait(); err != nil && ctx.Err() == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
ready := func() {
|
||||
t.Helper()
|
||||
if !scanner.Scan() || scanner.Text() != "{\"ready\": true}" {
|
||||
t.Fatalf("physical fixture not ready: %s", scanner.Text())
|
||||
}
|
||||
}
|
||||
ready()
|
||||
send := func(command map[string]any) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(command)
|
||||
if _, err := stdin.Write(append(data, '\n')); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready()
|
||||
}
|
||||
waitFocus := func() {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for {
|
||||
matched := false
|
||||
probe := &wineReviewFocusController{}
|
||||
for _, notice := range registry.Focuses() {
|
||||
if notice.ID() == worker.Focus() && probe.eligible(notice, adapter.Foreground(), adapter.ForegroundTitle()) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Wine focus unavailable; notices=%+v foreground=%+v title=%+v", registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle())
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
waitFocus()
|
||||
waitCaptureReady := func() {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
id := worker.Focus()
|
||||
if _, err := registry.Capture(ctx, id); err == nil && registry.Current(id) && worker.Focus() == id {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("new native provider never reached a consistent capture")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
check := func(symbols []uint32, want string, spelling bool) {
|
||||
t.Helper()
|
||||
// Normal Linux focus frames can remain queued after switching back to
|
||||
// Wine. Inspect only frames produced for this physical command.
|
||||
for len(output.frames) > 0 {
|
||||
<-output.frames
|
||||
}
|
||||
started := time.Now()
|
||||
send(map[string]any{"action": "keys", "symbols": symbols})
|
||||
for {
|
||||
select {
|
||||
case request := <-output.requests:
|
||||
if request.Cause != "flat-review" {
|
||||
continue
|
||||
}
|
||||
if request.Text != want || (spelling && len(request.Spelling) == 0) {
|
||||
t.Fatalf("keys %x: speech=%+v want=%q", symbols, request, want)
|
||||
}
|
||||
if spelling && want == "b" && request.Spelling[0] != "bravo" {
|
||||
t.Fatalf("phonetic letter: %+v", request.Spelling)
|
||||
}
|
||||
t.Logf("keys %x: %q queued in %s", symbols, want, time.Since(started))
|
||||
if strings.Contains(request.Text, "private-review-secret") || strings.Contains(request.Text, "hidden-review-secret") {
|
||||
t.Fatal("excluded text reached speech")
|
||||
}
|
||||
goto frame
|
||||
case <-time.After(2 * time.Second):
|
||||
tree, captureErr := registry.Capture(ctx, worker.Focus())
|
||||
t.Logf("capture=%+v error=%v focus=%+v notices=%+v foreground=%+v title=%+v", tree, captureErr, worker.Focus(), registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle())
|
||||
t.Fatalf("physical keys %x did not produce %q", symbols, want)
|
||||
}
|
||||
}
|
||||
frame:
|
||||
if !wine.IsReviewID(worker.Focus()) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case f := <-output.frames:
|
||||
if f.Text == "" {
|
||||
continue
|
||||
}
|
||||
if len(f.Routes) != 0 || f.Document != nil || strings.Contains(f.Text, "private-review-secret") {
|
||||
t.Fatalf("unsafe native routing: %+v", f)
|
||||
}
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("review braille output missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
check([]uint32{0xff97}, "alpha beta", false)
|
||||
check([]uint32{0xff9a}, "second line", false)
|
||||
check([]uint32{0xff95}, "alpha beta", false)
|
||||
check([]uint32{0xff9d}, "alpha ", false)
|
||||
check([]uint32{0xff9d}, "alpha ", true)
|
||||
check([]uint32{0xff9d}, "alpha ", true)
|
||||
check([]uint32{0xff98}, "beta", false)
|
||||
check([]uint32{0xff99}, "b", false)
|
||||
check([]uint32{0xff99}, "b", true)
|
||||
check([]uint32{0xff9b}, "e", false)
|
||||
check([]uint32{0xff9c}, "b", false)
|
||||
check([]uint32{0xff63, 0xff9c}, "a", false)
|
||||
check([]uint32{0xff63, 0xff98}, "line", false)
|
||||
check([]uint32{0xff63, 0xff96}, "beta", false)
|
||||
check([]uint32{0xff63, 0xff95}, "Command:", false)
|
||||
check([]uint32{0xff63, 0xff9a}, "Open & close", false)
|
||||
check([]uint32{0xff63, 0xff95}, "Command:", false)
|
||||
check([]uint32{0xff9a}, "alpha beta", false)
|
||||
check([]uint32{0xff98}, "beta", false)
|
||||
send(map[string]any{"action": "check"})
|
||||
if os.Getenv("CTHULHU_WINE_REVIEW_WM") == "i3" {
|
||||
send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}})
|
||||
send(map[string]any{"action": "check", "check": "--check-right"})
|
||||
return
|
||||
}
|
||||
send(map[string]any{"action": "configure", "mode": 6})
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
check([]uint32{0xff9d}, "zeta", false)
|
||||
// Seek the first text line from the window's caption, then verify an update
|
||||
// within the same control without a native caret change.
|
||||
check([]uint32{0xff63, 0xff95}, "Command:", false)
|
||||
check([]uint32{0xff9a}, "omega zeta native & item", false)
|
||||
send(map[string]any{"action": "check"})
|
||||
send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}})
|
||||
send(map[string]any{"action": "check", "check": "--check-right"})
|
||||
send(map[string]any{"action": "configure", "mode": 8})
|
||||
positionDeadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
moved := false
|
||||
for _, notice := range registry.Focuses() {
|
||||
if notice.Bounds.X < 0 {
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
break
|
||||
}
|
||||
if time.Now().After(positionDeadline) {
|
||||
t.Fatal("native window movement was not published")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
waitFocus()
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
oldFocus := worker.Focus()
|
||||
send(map[string]any{"action": "restart-helper"})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for worker.Focus() == oldFocus || !wine.IsReviewID(worker.Focus()) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("helper restart kept old review lifetime")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
waitFocus()
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
// Restart the actual service owner and registry connections. The helper must
|
||||
// register again and authorize the newly owned connection for capture.
|
||||
if err := service.Stop(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Stop(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.ClearFocus()
|
||||
if err := registry.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFocus()
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
// A native Linux foreground change retires a queued Wine capture, while its
|
||||
// blocked MSAA worker is still waiting in the application's window procedure.
|
||||
send(map[string]any{"action": "configure", "mode": 10})
|
||||
waitFocus()
|
||||
send(map[string]any{"action": "keys", "symbols": []uint32{0xff97}})
|
||||
send(map[string]any{"action": "linux"})
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for !worker.Focus().Valid() || wine.IsReviewID(worker.Focus()) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Linux focus not restored: %+v", worker.Focus())
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
check([]uint32{0xff97}, "Linux review ready", false)
|
||||
lateDeadline := time.After(2 * time.Second)
|
||||
late:
|
||||
for {
|
||||
select {
|
||||
case request := <-output.requests:
|
||||
if request.Cause == "flat-review" {
|
||||
t.Fatalf("late Wine review result: %+v", request)
|
||||
}
|
||||
case <-lateDeadline:
|
||||
break late
|
||||
}
|
||||
}
|
||||
send(map[string]any{"action": "wine"})
|
||||
waitFocus()
|
||||
firstPrefix := worker.Focus()
|
||||
send(map[string]any{"action": "second-prefix"})
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for worker.Focus().BusName == firstPrefix.BusName || !wine.IsReviewID(worker.Focus()) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("identical titles shared a provider across prefixes")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
waitFocus()
|
||||
if worker.Focus().BusName == firstPrefix.BusName {
|
||||
t.Fatal("identical titles shared a provider across prefixes")
|
||||
}
|
||||
// The 32-bit hook host can publish initial lifetime events after its X11
|
||||
// window appears. Handshake on a consistent snapshot before this physical
|
||||
// key; every key still captures again. Do not warm the stalled-call phase.
|
||||
waitCaptureReady()
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
send(map[string]any{"action": "wine"})
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for worker.Focus() != firstPrefix {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("return to first prefix lost its native lifetime")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
waitFocus()
|
||||
if worker.Focus() != firstPrefix {
|
||||
t.Fatal("return to first prefix lost its native lifetime")
|
||||
}
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
oldDesktopFocus := worker.Focus()
|
||||
send(map[string]any{"action": "virtual-desktop"})
|
||||
deadline = time.Now().Add(6 * time.Second)
|
||||
for !wine.IsReviewID(worker.Focus()) || worker.Focus().BusName == oldDesktopFocus.BusName {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("virtual desktop restart kept the old helper lifetime")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
waitFocus()
|
||||
waitCaptureReady()
|
||||
check([]uint32{0xff97}, "alpha beta native & item", false)
|
||||
}
|
||||
Reference in new issue
Block a user