package dcu1 import ( "net" "strconv" "strings" "sync" "testing" "time" ) // fakeRT21 is a controller that accepts ONE session at a time and counts how // many it was asked for, which is the thing under test. type fakeRT21 struct { ln net.Listener mu sync.Mutex sessions int cmds []string } func newFakeRT21(t *testing.T) *fakeRT21 { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } f := &fakeRT21{ln: ln} go f.serve() t.Cleanup(func() { _ = ln.Close() }) return f } func (f *fakeRT21) serve() { for { conn, err := f.ln.Accept() if err != nil { return } f.mu.Lock() f.sessions++ f.mu.Unlock() // Served one at a time, on purpose: a second caller waits in the accept // queue rather than being talked to, which is how these modules behave. f.handle(conn) } } func (f *fakeRT21) handle(conn net.Conn) { defer conn.Close() buf := make([]byte, 64) for { _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) n, err := conn.Read(buf) if n > 0 { for _, cmd := range strings.Split(string(buf[:n]), ";") { if cmd = strings.TrimSpace(cmd); cmd == "" { continue } f.mu.Lock() f.cmds = append(f.cmds, cmd) f.mu.Unlock() if cmd == "AI1" { _, _ = conn.Write([]byte(";123")) } } } if err != nil { return } } } func (f *fakeRT21) port() int { return f.ln.Addr().(*net.TCPAddr).Port } func (f *fakeRT21) seen() (int, []string) { f.mu.Lock() defer f.mu.Unlock() return f.sessions, append([]string(nil), f.cmds...) } // One session for the whole conversation. // // It used to be one per command: the heading is polled twice a second while the // antenna turns and GoTo sends two commands (AP1 then AM1), each with its own // connect and close. An RT-21's Ethernet option — like most embedded serial // servers — commonly accepts a single session and needs a moment to release it, // so the churn alone looked like a controller ignoring half of what it was told. func TestOneSessionServesEveryCommand(t *testing.T) { f := newFakeRT21(t) c := New("127.0.0.1", f.port()) defer c.Close() if az, _, err := c.Heading(); err != nil || az != 123 { t.Fatalf("Heading() = %d, %v; want 123", az, err) } if err := c.GoTo(240); err != nil { t.Fatalf("GoTo: %v", err) } if az, _, err := c.Heading(); err != nil || az != 123 { t.Fatalf("second Heading() = %d, %v", az, err) } sessions, cmds := f.seen() if sessions != 1 { t.Errorf("the controller was asked for %d sessions; four commands must share one", sessions) } want := []string{"AI1", "AP1240", "AM1", "AI1"} if strings.Join(cmds, ",") != strings.Join(want, ",") { t.Errorf("commands %v, want %v", cmds, want) } } // A session the controller has dropped is redialled, and the command that found // it dead is retried rather than reported as a failure — a kept socket's first // write succeeds long after the far end has gone. func TestADroppedSessionIsRedialled(t *testing.T) { f := newFakeRT21(t) c := New("127.0.0.1", f.port()) defer c.Close() if _, _, err := c.Heading(); err != nil { t.Fatalf("first Heading: %v", err) } // The controller power-cycles: close our end the way a dropped session // leaves it, then ask again. c.mu.Lock() if c.conn != nil { _ = c.conn.Close() // closed underneath, but still held — a half-dead socket } c.mu.Unlock() if az, _, err := c.Heading(); err != nil || az != 123 { t.Fatalf("Heading after the session dropped = %d, %v; want 123", az, err) } if sessions, _ := f.seen(); sessions != 2 { t.Errorf("%d sessions; the dropped one should have been redialled exactly once", sessions) } } // Nothing is kept open for a controller that is not there, and the error names // the address so it can be checked. func TestADeadControllerReportsWhereItLooked(t *testing.T) { // Port 1 on loopback: nothing listens, and the refusal is immediate. c := New("127.0.0.1", 1) defer c.Close() _, _, err := c.Heading() if err == nil { t.Fatal("no error from a controller that is not there") } if !strings.Contains(err.Error(), "127.0.0.1:"+strconv.Itoa(1)) { t.Errorf("error %q does not name the address it tried", err) } c.mu.Lock() held := c.conn != nil c.mu.Unlock() if held { t.Error("a failed dial left a connection behind") } }