358 lines
8.2 KiB
Go
358 lines
8.2 KiB
Go
package mosh
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestPredictorBasicEcho(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
|
|
// Type "abc"
|
|
p.Keystroke([]byte("a"))
|
|
p.Keystroke([]byte("b"))
|
|
p.Keystroke([]byte("c"))
|
|
|
|
if !p.Active() {
|
|
t.Fatal("predictor should be active after typing")
|
|
}
|
|
if len(p.pending) != 3 {
|
|
t.Fatalf("expected 3 pending, got %d", len(p.pending))
|
|
}
|
|
if p.pending[0].r != 'a' || p.pending[0].x != 0 || p.pending[0].y != 0 {
|
|
t.Fatalf("prediction 0: got %+v", p.pending[0])
|
|
}
|
|
if p.pending[1].r != 'b' || p.pending[1].x != 1 {
|
|
t.Fatalf("prediction 1: got %+v", p.pending[1])
|
|
}
|
|
if p.pending[2].r != 'c' || p.pending[2].x != 2 {
|
|
t.Fatalf("prediction 2: got %+v", p.pending[2])
|
|
}
|
|
}
|
|
|
|
func TestPredictorOverlay(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("hi"))
|
|
|
|
fb := NewFramebuffer(80, 24)
|
|
p.Overlay(fb)
|
|
|
|
c0 := fb.CellAt(0, 0)
|
|
c1 := fb.CellAt(1, 0)
|
|
if c0.Rune != 'h' || !c0.Attr.Under {
|
|
t.Fatalf("cell 0: got %+v, want 'h' underlined", c0)
|
|
}
|
|
if c1.Rune != 'i' || !c1.Attr.Under {
|
|
t.Fatalf("cell 1: got %+v, want 'i' underlined", c1)
|
|
}
|
|
if fb.CurX != 2 || fb.CurY != 0 {
|
|
t.Fatalf("cursor: got (%d,%d), want (2,0)", fb.CurX, fb.CurY)
|
|
}
|
|
}
|
|
|
|
func TestPredictorConfirmAll(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("ab"))
|
|
|
|
// Simulate server confirming "ab" at positions 0,1
|
|
fb := NewFramebuffer(80, 24)
|
|
fb.CellAt(0, 0).Rune = 'a'
|
|
fb.CellAt(1, 0).Rune = 'b'
|
|
fb.CurX = 2
|
|
fb.CurY = 0
|
|
|
|
p.Confirm(fb)
|
|
if p.Active() {
|
|
t.Fatal("predictor should be inactive after full confirmation")
|
|
}
|
|
if len(p.pending) != 0 {
|
|
t.Fatalf("expected 0 pending, got %d", len(p.pending))
|
|
}
|
|
}
|
|
|
|
func TestPredictorPartialConfirm(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("abc"))
|
|
|
|
// Server has confirmed "a" but not "b" or "c" yet
|
|
fb := NewFramebuffer(80, 24)
|
|
fb.CellAt(0, 0).Rune = 'a'
|
|
fb.CurX = 1
|
|
fb.CurY = 0
|
|
|
|
p.Confirm(fb)
|
|
if !p.Active() {
|
|
t.Fatal("predictor should still be active with pending predictions")
|
|
}
|
|
if len(p.pending) != 2 {
|
|
t.Fatalf("expected 2 pending, got %d", len(p.pending))
|
|
}
|
|
if p.pending[0].r != 'b' {
|
|
t.Fatalf("first remaining prediction: got %c, want b", p.pending[0].r)
|
|
}
|
|
}
|
|
|
|
func TestPredictorDivergence(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("abc"))
|
|
|
|
// Server shows 'x' where we predicted 'a' — tab completion, etc.
|
|
fb := NewFramebuffer(80, 24)
|
|
fb.CellAt(0, 0).Rune = 'x'
|
|
fb.CurX = 5
|
|
fb.CurY = 0
|
|
|
|
p.Confirm(fb)
|
|
if p.Active() {
|
|
t.Fatal("predictor should reset on divergence")
|
|
}
|
|
if p.curX != 5 {
|
|
t.Fatalf("cursor should sync to server: got %d, want 5", p.curX)
|
|
}
|
|
}
|
|
|
|
func TestPredictorControlCharResets(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("ab"))
|
|
|
|
if !p.Active() {
|
|
t.Fatal("should be active")
|
|
}
|
|
|
|
// Send a control character (enter)
|
|
p.Keystroke([]byte("\n"))
|
|
if p.Active() {
|
|
t.Fatal("control char should reset predictions")
|
|
}
|
|
}
|
|
|
|
func TestPredictorEscapeSequenceResets(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("ab"))
|
|
|
|
// Escape byte
|
|
p.Keystroke([]byte{0x1b})
|
|
if p.Active() {
|
|
t.Fatal("escape should reset predictions")
|
|
}
|
|
}
|
|
|
|
func TestPredictorExpireStale(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("a"))
|
|
|
|
// Manually backdate the prediction
|
|
p.pending[0].at = time.Now().Add(-600 * time.Millisecond)
|
|
|
|
p.ExpireStale(time.Now())
|
|
if p.Active() {
|
|
t.Fatal("stale prediction should be expired")
|
|
}
|
|
if len(p.pending) != 0 {
|
|
t.Fatalf("expected 0 pending, got %d", len(p.pending))
|
|
}
|
|
}
|
|
|
|
func TestPredictorSpaceConfirm(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("hi there"))
|
|
|
|
if len(p.pending) != 8 {
|
|
t.Fatalf("expected 8 predictions, got %d", len(p.pending))
|
|
}
|
|
|
|
// Server shows "hi " — space at position 2 should confirm, not stall
|
|
fb := NewFramebuffer(80, 24)
|
|
fb.CellAt(0, 0).Rune = 'h'
|
|
fb.CellAt(1, 0).Rune = 'i'
|
|
fb.CellAt(2, 0).Rune = ' '
|
|
fb.CurX = 3
|
|
fb.CurY = 0
|
|
|
|
p.Confirm(fb)
|
|
if len(p.pending) != 5 {
|
|
t.Fatalf("expected 5 pending after confirming 'hi ', got %d", len(p.pending))
|
|
}
|
|
if p.pending[0].r != 't' {
|
|
t.Fatalf("first remaining should be 't', got %c", p.pending[0].r)
|
|
}
|
|
}
|
|
|
|
func TestPredictorNoActivityNoCursorOverride(t *testing.T) {
|
|
p := NewPredictor()
|
|
|
|
// Without any predictions, SetCursor should track server
|
|
p.SetCursor(10, 5)
|
|
if p.curX != 10 || p.curY != 5 {
|
|
t.Fatalf("cursor not tracking: got (%d,%d)", p.curX, p.curY)
|
|
}
|
|
|
|
// After typing, SetCursor should not override predicted position
|
|
p.Keystroke([]byte("x"))
|
|
p.SetCursor(0, 0)
|
|
if p.curX != 11 {
|
|
t.Fatalf("predicted cursor should not be overridden: got %d, want 11", p.curX)
|
|
}
|
|
}
|
|
|
|
func TestPredictorMultibyteUTF8(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(0, 0)
|
|
p.Keystroke([]byte("é")) // 2 bytes
|
|
|
|
if len(p.pending) != 1 {
|
|
t.Fatalf("expected 1 prediction, got %d", len(p.pending))
|
|
}
|
|
if p.pending[0].r != 'é' {
|
|
t.Fatalf("got %c, want é", p.pending[0].r)
|
|
}
|
|
}
|
|
|
|
func TestPredictorOverlayDoesNotAffectUnpredictedCells(t *testing.T) {
|
|
p := NewPredictor()
|
|
p.SetCursor(5, 0)
|
|
p.Keystroke([]byte("x"))
|
|
|
|
fb := NewFramebuffer(80, 24)
|
|
fb.CellAt(0, 0).Rune = 'A'
|
|
fb.CellAt(1, 0).Rune = 'B'
|
|
|
|
p.Overlay(fb)
|
|
|
|
// Unpredicted cells should be untouched
|
|
if fb.CellAt(0, 0).Rune != 'A' {
|
|
t.Fatal("cell 0 should be unchanged")
|
|
}
|
|
if fb.CellAt(1, 0).Rune != 'B' {
|
|
t.Fatal("cell 1 should be unchanged")
|
|
}
|
|
// Predicted cell
|
|
if fb.CellAt(5, 0).Rune != 'x' {
|
|
t.Fatal("cell 5 should be 'x'")
|
|
}
|
|
}
|
|
|
|
// TestPredictorE2EGoServer tests prediction with real mosh transport.
|
|
func TestPredictorE2EGoServer(t *testing.T) {
|
|
srv, err := NewServer("/bin/sh", 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go srv.Serve()
|
|
defer srv.Close()
|
|
<-srv.started
|
|
|
|
client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close()
|
|
|
|
// Wait for shell prompt.
|
|
for i := 0; i < 40; i++ {
|
|
if out := client.Recv(500 * time.Millisecond); len(out) > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
pred := NewPredictor()
|
|
pred.SetCursor(0, 0)
|
|
|
|
// Type printable chars — predictions should appear immediately.
|
|
keys := []byte("echo hello")
|
|
pred.Keystroke(keys)
|
|
client.Send(keys)
|
|
|
|
if !pred.Active() {
|
|
t.Fatal("predictor should be active after typing")
|
|
}
|
|
if len(pred.pending) != 10 {
|
|
t.Fatalf("expected 10 predictions, got %d", len(pred.pending))
|
|
}
|
|
|
|
// The predictions can be overlaid on a framebuffer immediately —
|
|
// this is the "instant echo" the user sees.
|
|
fb := NewFramebuffer(80, 24)
|
|
pred.Overlay(fb)
|
|
if fb.CellAt(0, 0).Rune != 'e' || fb.CellAt(1, 0).Rune != 'c' {
|
|
t.Fatal("overlay should show predicted characters immediately")
|
|
}
|
|
|
|
// Wait for server to confirm. The server will echo the characters
|
|
// and predictions should get confirmed.
|
|
deadline := time.After(10 * time.Second)
|
|
for pred.Active() {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("predictions not confirmed. %d still pending", len(pred.pending))
|
|
default:
|
|
}
|
|
out := client.Recv(200 * time.Millisecond)
|
|
if out != nil {
|
|
// Parse server output into a framebuffer to confirm predictions.
|
|
// For simplicity, build a minimal framebuffer from ANSI output.
|
|
serverFB := NewFramebuffer(80, 24)
|
|
applyANSIToFB(serverFB, out)
|
|
pred.Confirm(serverFB)
|
|
}
|
|
pred.ExpireStale(time.Now())
|
|
}
|
|
|
|
t.Log("prediction E2E with Go server passed")
|
|
}
|
|
|
|
// applyANSIToFB is a minimal helper that writes characters from ANSI
|
|
// output into a framebuffer. It doesn't parse escape sequences — just
|
|
// extracts printable characters at sequential positions. Good enough
|
|
// for testing prediction confirmation.
|
|
func applyANSIToFB(fb *Framebuffer, data []byte) {
|
|
x, y := 0, 0
|
|
i := 0
|
|
for i < len(data) {
|
|
if data[i] == 0x1b {
|
|
// Skip escape sequence
|
|
i++
|
|
if i < len(data) && data[i] == '[' {
|
|
i++
|
|
for i < len(data) && !((data[i] >= 'A' && data[i] <= 'Z') || (data[i] >= 'a' && data[i] <= 'z')) {
|
|
i++
|
|
}
|
|
if i < len(data) {
|
|
i++ // skip final byte
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if data[i] == '\r' {
|
|
x = 0
|
|
i++
|
|
continue
|
|
}
|
|
if data[i] == '\n' {
|
|
y++
|
|
i++
|
|
continue
|
|
}
|
|
r, size := utf8.DecodeRune(data[i:])
|
|
i += size
|
|
if r >= 0x20 && x < fb.W && y < fb.H {
|
|
cell := fb.CellAt(x, y)
|
|
cell.Rune = r
|
|
cell.Width = 1
|
|
x++
|
|
}
|
|
}
|
|
fb.CurX = x
|
|
fb.CurY = y
|
|
}
|