Export and display Remote Braille through the reader
A controlled reader renders accepted application frames in a new virtual-only cthulhu-braille mode at the controller's width and applies its pan and route gestures; reader UI, flashes and secure frames are never exported, and hidden frames are unroutable. A controller reader reports its display width, shows peer cells as an untranslated raw frame and forwards local pan and routing keys. The physical helper now reports its width and edge pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2651b5e9bd
commit
551ef86d5f
16 files changed
+925
-13
No files matched your search
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"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)
|
||||
}
|
||||
Reference in new issue
Block a user