Files
cthulhu/internal/app/tentacle_remote_braille_test.go

301 lines
12 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"reflect"
"strings"
"sync"
"testing"
"time"
"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/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type fakeVirtualBraille struct {
mu sync.Mutex
requests []braille.VirtualRequest
responses chan braille.VirtualResponse
closed chan struct{}
once sync.Once
}
func (fake *fakeVirtualBraille) Send(request braille.VirtualRequest) error {
fake.mu.Lock()
defer fake.mu.Unlock()
fake.requests = append(fake.requests, request)
return nil
}
func (fake *fakeVirtualBraille) Responses() <-chan braille.VirtualResponse { return fake.responses }
func (fake *fakeVirtualBraille) Close() { fake.once.Do(func() { close(fake.closed) }) }
func (fake *fakeVirtualBraille) seen() []braille.VirtualRequest {
fake.mu.Lock()
defer fake.mu.Unlock()
return append([]braille.VirtualRequest(nil), fake.requests...)
}
type peerRouteRecorder struct{ routes chan atspi.BrailleRoute }
func (router peerRouteRecorder) RouteBraille(_ context.Context, route atspi.BrailleRoute) error {
router.routes <- route
return nil
}
func braille_fixture(t *testing.T, grants []string) (*tentacle.Session, *tentacle.RemotePolicy, *brailleWorker, *fakeVirtualBraille, peerRouteRecorder) {
t.Helper()
policy := tentacle.NewRemotePolicy()
session := keyboard_owner_session(t, policy, grants)
router := peerRouteRecorder{routes: make(chan atspi.BrailleRoute, 4)}
worker := &brailleWorker{logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {},
connected: true, cells: 20, router: router, frame: braille.Frame{Cursor: -1}, baseFrame: braille.Frame{Cursor: -1}}
fake := &fakeVirtualBraille{responses: make(chan braille.VirtualResponse, 4), closed: make(chan struct{})}
runtime := &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, announcements: make(chan string, 4),
output: &tentacleOutput{braille: worker, gate: &focusGate{}}, virtualBraille: func() (virtualBraille, error) { return fake, nil }}
ctx, cancel := context.WithCancel(t.Context())
done := runtime.start_remote_braille(ctx, session)
t.Cleanup(func() {
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("Braille owner did not join")
}
})
return session, policy, worker, fake, router
}
func braille_open(t *testing.T, session *tentacle.Session, role string, grants []string) tentaclewire.RemoteLease {
t.Helper()
approval, err := session.BeginRemoteApproval(role, grants)
if err != nil {
t.Fatal(err)
}
if _, err := session.AuthorizeRemoteWithApproval(approval, role, grants); err != nil {
t.Fatal(err)
}
var lease tentaclewire.RemoteLease
if json.Unmarshal(keyboard_owner_invoke(t, session, "open", nil), &lease) != nil || lease.Epoch == 0 {
t.Fatal("lease did not open")
}
return lease
}
func braille_call(t *testing.T, session *tentacle.Session, lease tentaclewire.RemoteLease, sequence uint64, value tentaclewire.RemoteBraille) {
t.Helper()
request := tentaclewire.RemoteBrailleRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Braille: value}
keyboard_owner_invoke(t, session, "rpc", map[string]any{"Method": "remote.braille", "Params": request})
}
func wait_until(t *testing.T, what string, predicate func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !predicate() {
if time.Now().After(deadline) {
t.Fatal("timed out waiting for", what)
}
time.Sleep(2 * time.Millisecond)
}
}
func TestRemoteBrailleExportsApplicationFramesOnly(t *testing.T) {
session, policy, worker, fake, router := braille_fixture(t, []string{"braille-stream"})
route := braille.TextRoute{Start: 0, End: 5, SourceStart: 0, TextLength: 5, BusName: ":1.2", Path: "/text", SourceText: "hello"}
worker.EnqueueFrame(braille.Frame{Text: "hello", Cursor: -1, Routes: []braille.TextRoute{route}})
lease := braille_open(t, session, "controlled", []string{"braille-stream"})
braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "size", Width: 20})
wait_until(t, "width and initial frame", func() bool { return len(fake.seen()) >= 2 })
seen := fake.seen()
if seen[0].Width == nil || *seen[0].Width != 20 || seen[1].Frame == nil || seen[1].Frame.Text != "hello" || seen[1].Frame.ID == 0 || seen[1].Frame.Routes != nil {
t.Fatalf("initial export = %+v", seen)
}
exportedID := seen[1].Frame.ID
fake.responses <- braille.VirtualResponse{Cells: []int{1, 3, 9}}
var event tentaclewire.SessionEvent
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Kind != "remote.braille" || !reflect.DeepEqual(event.Braille.Cells, []int{1, 3, 9}) {
t.Fatal("cells not published", event)
}
braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "route", Index: 3})
braille_call(t, session, lease, 3, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "pan-right"})
wait_until(t, "gestures", func() bool { return len(fake.seen()) >= 4 })
if seen = fake.seen(); seen[2].Route == nil || *seen[2].Route != 3 || seen[3].Pan != "right" {
t.Fatalf("gestures = %+v", seen[2:])
}
fake.responses <- braille.VirtualResponse{Route: &braille.RoutingEvent{ID: exportedID, Offset: 3}}
select {
case routed := <-router.routes:
if routed.Offset != 3 || routed.Line != "hello" {
t.Fatal("route changed", routed)
}
case <-time.After(5 * time.Second):
t.Fatal("peer route did not reach the application")
}
// Reader UI is never exported; application frames under it are, unroutable.
worker.SetInternalUIFrame(&braille.Frame{Text: "private form", Cursor: -1})
worker.EnqueueFrame(braille.Frame{Text: "world", Cursor: -1})
wait_until(t, "frame under reader UI", func() bool { return len(fake.seen()) >= 5 })
for _, request := range fake.seen() {
if request.Frame != nil && request.Frame.Text == "private form" {
t.Fatal("reader UI frame exported")
}
}
if last := fake.seen()[4]; last.Frame == nil || last.Frame.Text != "world" || last.Frame.ID != 0 {
t.Fatalf("frame under UI = %+v", last)
}
policy.SetAvailable(false)
select {
case <-fake.closed:
case <-time.After(5 * time.Second):
t.Fatal("revocation did not stop export")
}
policy.SetAvailable(true)
worker.mu.Lock()
exporter := worker.remoteExport
worker.mu.Unlock()
if exporter != nil {
t.Fatal("exporter left installed")
}
}
func TestRemoteBrailleControllerShowsPeerCellsAndReportsKeys(t *testing.T) {
session, policy, worker, _, _ := braille_fixture(t, []string{"braille-stream", "remote-present"})
worker.EnqueueFrame(braille.Frame{Text: "local", Cursor: -1})
worker.SetContraction(true, "en-ueb-g2.ctb")
lease := braille_open(t, session, "controller", []string{"braille-stream"})
braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "geometry"})
var event tentaclewire.SessionEvent
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || event.Braille.Op != "geometry" || event.Braille.Width != 20 {
t.Fatal("geometry not reported", event)
}
braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "cells", Cells: []int{1, 2, 255}})
wait_until(t, "peer cells", func() bool { return worker.current().Raw })
frame := worker.current()
if frame.Text != "⠁⠂⣿" || frame.Contract || frame.Table != "" || frame.WordWrap {
t.Fatalf("peer frame = %+v", frame)
}
// Local application frames continue updating underneath.
worker.EnqueueFrame(braille.Frame{Text: "newer", Cursor: -1})
if !worker.current().Raw {
t.Fatal("application frame replaced peer cells")
}
frame = worker.current()
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: 1})
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: -1, Pan: "right"})
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID + 99, Offset: 0}) // Stale.
for _, want := range []tentaclewire.RemoteBraille{{Op: "gesture", Gesture: "route", Index: 1}, {Op: "gesture", Gesture: "pan-right"}} {
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || !reflect.DeepEqual(*event.Braille, want) {
t.Fatal("gesture changed", event)
}
}
policy.SetAvailable(false)
wait_until(t, "local frame restored", func() bool { return !worker.current().Raw })
if text := worker.current().Text; text != "newer" {
t.Fatal("restored frame", text)
}
policy.SetAvailable(true)
}
func TestRemoteBrailleInitialExportDuringFlashIsUnroutable(t *testing.T) {
worker := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)}
worker.EnqueueFrame(braille.Frame{Text: "application", Cursor: -1})
baseID := worker.current().ID
var exported braille.Frame
worker.SetRemoteExport(func(frame braille.Frame) { exported = frame })
if exported.ID != baseID {
t.Fatal("initial application lost its routing identity")
}
worker.FlashConfirmation("message", config.BrailleOptions{FlashMessages: true, FlashPersistent: true})
worker.SetRemoteExport(func(frame braille.Frame) { exported = frame })
if exported.Text != "application" || exported.ID != 0 {
t.Fatalf("flash export = %+v", exported)
}
worker.DismissConfirmation()
worker.SetRemoteExport(func(frame braille.Frame) { exported = frame })
if exported.ID == 0 || exported.ID != worker.current().ID {
t.Fatal("restored application lost its routing identity")
}
}
func TestRemoteBrailleInitialExportSerializedWithUpdates(t *testing.T) {
worker := &brailleWorker{cancel: func() {}, wake: make(chan struct{}, 1)}
worker.EnqueueFrame(braille.Frame{Text: "initial", Cursor: -1})
entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
var frames []string
go func() {
worker.SetRemoteExport(func(frame braille.Frame) {
if frame.Text == "initial" {
close(entered)
<-release
}
frames = append(frames, frame.Text)
})
close(finished)
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("initial export was not offered")
}
if worker.mu.TryLock() {
worker.mu.Unlock()
close(release)
<-finished
t.Fatal("initial export was offered outside the frame lock")
}
updated := make(chan struct{})
go func() { worker.EnqueueFrame(braille.Frame{Text: "newer", Cursor: -1}); close(updated) }()
close(release)
<-finished
<-updated
if !reflect.DeepEqual(frames, []string{"initial", "newer"}) {
t.Fatal(frames)
}
}
func TestRemoteBrailleCancelledRouteHasNoEffect(t *testing.T) {
router := peerRouteRecorder{routes: make(chan atspi.BrailleRoute, 1)}
worker := &brailleWorker{cancel: func() {}, wake: make(chan struct{}, 1), router: router}
worker.EnqueueFrame(braille.Frame{Text: "test", Cursor: -1, Routes: []braille.TextRoute{{Start: 0, End: 4, TextLength: 4}}})
ctx, cancel := context.WithCancel(t.Context())
cancel()
worker.RouteExported(ctx, braille.RoutingEvent{ID: worker.current().ID, Offset: 1})
select {
case <-router.routes:
t.Fatal("cancelled route reached application")
default:
}
}
func TestRemoteBrailleWedgedHelperWriteIsBounded(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
helper := &processVirtualBraille{stdin: writer, encoder: json.NewEncoder(writer)}
done := make(chan error, 1)
go func() {
done <- helper.Send(braille.VirtualRequest{Frame: &braille.Frame{Text: strings.Repeat("x", 1024*1024)}})
}()
select {
case err := <-done:
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("blocked write = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("wedged helper blocked its owner")
}
}