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>
193 lines
6.6 KiB
Go
193 lines
6.6 KiB
Go
package braille
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type clientBackend struct {
|
|
fakeBackend
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (backend *clientBackend) Write(text string, cursor int) error {
|
|
if err := backend.fakeBackend.Write(text, cursor); err != nil {
|
|
return err
|
|
}
|
|
backend.cancel()
|
|
return nil
|
|
}
|
|
|
|
func TestRunClientShowsLiveFrameAndReleasesDisplay(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
backend := &clientBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, cancel: cancel}
|
|
input := strings.NewReader("{\"text\":\"Save, push button\",\"cursor\":-1}\n")
|
|
var ready bytes.Buffer
|
|
if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ready.String() != "ready 20\n" || len(backend.writes) != 1 ||
|
|
backend.writes[0] != "Save, push button " || backend.opens != 1 || backend.closes != 1 {
|
|
t.Fatalf("client lifecycle: ready=%q backend=%+v", ready.String(), backend)
|
|
}
|
|
}
|
|
|
|
type flakyOpenBackend struct {
|
|
fakeBackend
|
|
failures int
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (backend *flakyOpenBackend) Open() (Geometry, error) {
|
|
if backend.failures > 0 {
|
|
backend.failures--
|
|
backend.opens++
|
|
return Geometry{}, errors.New("display not ready")
|
|
}
|
|
return backend.fakeBackend.Open()
|
|
}
|
|
|
|
func (backend *flakyOpenBackend) Write(text string, cursor int) error {
|
|
if err := backend.fakeBackend.Write(text, cursor); err != nil {
|
|
return err
|
|
}
|
|
backend.cancel()
|
|
return nil
|
|
}
|
|
|
|
// BRLTTY refuses or half-answers connection attempts while a display is
|
|
// still negotiating (observed live: query failures, then zero geometry,
|
|
// then success). The helper must keep trying in-process so the display is
|
|
// claimed as soon as BRLTTY is ready, without a supervisor respawn cycle.
|
|
func TestRunClientRetriesOpenUntilDisplayReady(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 2, cancel: cancel}
|
|
input := strings.NewReader("{\"text\":\"Hello\",\"cursor\":-1}\n")
|
|
var ready bytes.Buffer
|
|
if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ready.String() != "ready 20\n" || backend.opens != 3 || backend.closes != 1 || len(backend.writes) != 1 {
|
|
t.Fatalf("ready=%q backend=%+v", ready.String(), backend)
|
|
}
|
|
}
|
|
|
|
// Cancellation while the display has not appeared yet ends the wait
|
|
// cleanly and promptly.
|
|
func TestRunClientOpenWaitHonorsCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 1 << 30, cancel: cancel}
|
|
var ready bytes.Buffer
|
|
done := make(chan error, 1)
|
|
go func() { done <- RunClient(ctx, backend, io.NopCloser(strings.NewReader("")), &ready) }()
|
|
time.Sleep(100 * time.Millisecond)
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("cancelled open wait error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("open wait ignored cancellation")
|
|
}
|
|
if ready.Len() != 0 {
|
|
t.Fatalf("ready written without a display: %q", ready.String())
|
|
}
|
|
}
|
|
|
|
// A display that never becomes ready surfaces the last open error at the
|
|
// give-up bound instead of waiting silently forever.
|
|
func TestRunClientOpenWaitGivesUpWithLastError(t *testing.T) {
|
|
delay, giveUp := openRetryDelay, openRetryGiveUp
|
|
openRetryDelay, openRetryGiveUp = 10*time.Millisecond, 55*time.Millisecond
|
|
defer func() { openRetryDelay, openRetryGiveUp = delay, giveUp }()
|
|
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 1 << 30}
|
|
err := RunClient(context.Background(), backend, io.NopCloser(strings.NewReader("")), &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "display not ready") {
|
|
t.Fatalf("give-up error = %v", err)
|
|
}
|
|
if backend.opens < 2 {
|
|
t.Fatalf("open attempts = %d, want retries", backend.opens)
|
|
}
|
|
}
|
|
|
|
func TestRunClientPlacesCursorOnUnicodeCaret(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
backend := &clientBackend{fakeBackend: fakeBackend{geometry: Geometry{4, 1}}, cancel: cancel}
|
|
input := strings.NewReader("{\"text\":\"é界abcde\",\"cursor\":5}\n")
|
|
if err := RunClient(ctx, backend, io.NopCloser(input), &bytes.Buffer{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(backend.writes) != 1 || backend.writes[0] != "cde " ||
|
|
len(backend.cursors) != 1 || backend.cursors[0] != 2 {
|
|
t.Fatalf("writes=%q cursors=%v", backend.writes, backend.cursors)
|
|
}
|
|
}
|
|
|
|
func TestRunClientRejectsInvalidCursorAndCloses(t *testing.T) {
|
|
backend := &fakeBackend{geometry: Geometry{4, 1}}
|
|
err := RunClient(context.Background(), backend,
|
|
io.NopCloser(strings.NewReader("{\"text\":\"abc\",\"cursor\":4}\n")), &bytes.Buffer{})
|
|
if err == nil || backend.opens != 1 || backend.closes != 1 || len(backend.writes) != 0 {
|
|
t.Fatalf("error=%v backend=%+v", err, backend)
|
|
}
|
|
}
|
|
|
|
func TestOfferLatestFrameReplacesPendingFrame(t *testing.T) {
|
|
frames := make(chan Frame, 1)
|
|
offerLatestFrame(frames, Frame{Text: "old"})
|
|
offerLatestFrame(frames, Frame{Text: "new"})
|
|
if frame := <-frames; frame.Text != "new" {
|
|
t.Fatalf("retained %q", frame.Text)
|
|
}
|
|
offerLatestFrame(frames, Frame{Text: "newer"})
|
|
if frame := <-frames; frame.Text != "newer" {
|
|
t.Fatalf("lost empty-queue update: %q", frame.Text)
|
|
}
|
|
}
|
|
|
|
type routingBackend struct{ fakeBackend }
|
|
|
|
func (b *routingBackend) ReadKey() (Key, error) {
|
|
if len(b.writes) == 0 {
|
|
return NoKey, nil
|
|
}
|
|
return b.fakeBackend.ReadKey()
|
|
}
|
|
|
|
func TestRunClientRoutesDisplayedFrameAfterPanningAndJoinsReader(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
input, writer := io.Pipe()
|
|
defer writer.Close()
|
|
backend := &routingBackend{fakeBackend{geometry: Geometry{4, 1}, keys: []Key{PanRight, RouteBase + 1}, cancel: cancel}}
|
|
var output bytes.Buffer
|
|
done := make(chan error, 1)
|
|
go func() { done <- RunClient(ctx, backend, input, &output) }()
|
|
if _, err := io.WriteString(writer, "{\"text\":\"é猫abcdef\",\"cursor\":-1,\"id\":42}\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if output.String() != "ready 4\n{\"id\":42,\"offset\":5}\n" {
|
|
t.Fatalf("helper output %q", output.String())
|
|
}
|
|
if backend.closes != 1 {
|
|
t.Fatalf("close count %d", backend.closes)
|
|
}
|
|
// The writer has deliberately remained open; return requires the session to
|
|
// close the blocked input reader and join it.
|
|
if _, err := writer.Write([]byte("x")); err == nil {
|
|
t.Fatal("reader still open")
|
|
}
|
|
}
|