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") } }