package rigctld import ( "bufio" "fmt" "net" "strings" "sync" "testing" ) // fakeRig stands in for the CAT manager. type fakeRig struct { mu sync.Mutex freq int64 mode string split bool txFreq int64 ptt bool setFreqs []int64 setModes []string failSet bool } func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq } func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode } func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq } func (f *fakeRig) SetFreq(hz int64) error { f.mu.Lock() defer f.mu.Unlock() if f.failSet { return fmt.Errorf("rig refused") } f.freq = hz f.setFreqs = append(f.setFreqs, hz) return nil } func (f *fakeRig) SetMode(m string) error { f.mu.Lock() defer f.mu.Unlock() if f.failSet { return fmt.Errorf("rig refused") } f.mode = m f.setModes = append(f.setModes, m) return nil } func (f *fakeRig) SetPTT(on bool) error { f.mu.Lock() defer f.mu.Unlock() if f.failSet { return fmt.Errorf("rig refused") } f.ptt = on return nil } // The command table. These exact strings are what WSJT-X and MSHV put on the // wire, so they are the contract — a reply in the wrong shape does not degrade // gracefully, the client simply refuses to work with the rig. func TestHandleCommands(t *testing.T) { rig := &fakeRig{freq: 14074000, mode: "FT8", split: true, txFreq: 14100000} s := New(0, rig, nil) cases := []struct{ in, want string }{ {"f", "14074000\n"}, {"\\get_freq", "14074000\n"}, {"m", "PKTUSB\n3000\n"}, // a digital mode reads as PKTUSB {"t", "0\n"}, // PTT always reads RX — see the comment {"v", "VFOA\n"}, {"s", "1\nVFOB\n"}, // split on, TX on B {"i", "14100000\n"}, // split TX frequency {"F 14200000", "RPRT 0\n"}, {"F 14200000.000000", "RPRT 0\n"}, // the float form clients also send {"M USB 2400", "RPRT 0\n"}, {"T 1", "RPRT 0\n"}, {"V VFOB", "RPRT 0\n"}, // accepted and ignored, never an error {"S 1 VFOB", "RPRT 0\n"}, {"\\chk_vfo", "CHKVFO 0\n"}, {"F", "RPRT -1\n"}, // missing argument {"F not_a_number", "RPRT -1\n"}, {"Z", "RPRT -11\n"}, // unknown → answered, never silence {"", ""}, } for _, c := range cases { got, _ := s.handle(c.in) if got != c.want { t.Errorf("handle(%q) = %q, want %q", c.in, got, c.want) } } if q := func() bool { _, q := s.handle("q"); return q }(); !q { t.Error("q must end the session") } } // A rig that refuses must produce an error report, not a success — a client told // "RPRT 0" believes the radio moved and will log the wrong frequency. func TestHandleReportsBackendFailure(t *testing.T) { s := New(0, &fakeRig{failSet: true}, nil) for _, in := range []string{"F 14200000", "M USB 2400", "T 1"} { if got, _ := s.handle(in); got != "RPRT -9\n" { t.Errorf("handle(%q) with a failing rig = %q, want RPRT -9", in, got) } } } // dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first // line as the protocol version and refuses to continue if the block is short or // misshapen. Pinning its shape is what stops a well-meaning edit from silently // breaking every client. func TestDumpStateShape(t *testing.T) { lines := strings.Split(strings.TrimRight(dumpState, "\n"), "\n") if len(lines) < 20 { t.Fatalf("dump_state has %d lines — clients expect the full capability block", len(lines)) } if lines[0] != "0" { t.Errorf("dump_state protocol version = %q, want \"0\"", lines[0]) } // The frequency-range lines must carry seven fields, or the client's parse // slides and every later capability is read from the wrong place. for _, i := range []int{3, 5} { if n := len(strings.Fields(lines[i])); n != 7 { t.Errorf("dump_state line %d has %d fields, want 7: %q", i, n, lines[i]) } } } // End to end over a real socket, because the framing (one reply per line, // flushed immediately) is as much a part of the contract as the text. func TestServerOverTCP(t *testing.T) { rig := &fakeRig{freq: 7074000, mode: "SSB"} s := New(0, rig, nil) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } s.mu.Lock() s.ln = ln s.mu.Unlock() go func() { for { c, err := ln.Accept() if err != nil { return } go s.serve(c) } }() defer s.Stop() c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout) if err != nil { t.Fatalf("dial: %v", err) } defer c.Close() r := bufio.NewReader(c) if _, err := c.Write([]byte("f\n")); err != nil { t.Fatalf("write: %v", err) } line, err := r.ReadString('\n') if err != nil { t.Fatalf("read: %v", err) } if strings.TrimSpace(line) != "7074000" { t.Errorf("get_freq over TCP = %q, want 7074000", strings.TrimSpace(line)) } if _, err := c.Write([]byte("F 14074000\n")); err != nil { t.Fatalf("write: %v", err) } line, _ = r.ReadString('\n') if strings.TrimSpace(line) != "RPRT 0" { t.Errorf("set_freq over TCP = %q, want RPRT 0", strings.TrimSpace(line)) } if got := rig.Freq(); got != 14074000 { t.Errorf("rig frequency = %d, want 14074000 — the command never reached it", got) } } func TestModeMapping(t *testing.T) { for _, c := range []struct{ adif, hamlib string }{ {"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"}, {"FT8", "PKTUSB"}, {"JS8", "PKTUSB"}, {"", "USB"}, } { if got := adifToHamlib(c.adif); got != c.hamlib { t.Errorf("adifToHamlib(%q) = %q, want %q", c.adif, got, c.hamlib) } } // Digital comes back as DATA, never as a specific sub-mode: the CAT backend // applies the operator's own digital default, so a client that switches the // rig to data does not relabel a JS8 operator's QSOs as FT8. for _, c := range []struct{ hamlib, adif string }{ {"PKTUSB", "DATA"}, {"PKTLSB", "DATA"}, {"DIGU", "DATA"}, {"USB", "USB"}, {"CWR", "CW"}, {"FMN", "FM"}, } { if got := hamlibToADIF(c.hamlib); got != c.adif { t.Errorf("hamlibToADIF(%q) = %q, want %q", c.hamlib, got, c.adif) } } }