completer_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. package cli
  2. import (
  3. "bytes"
  4. "strings"
  5. "testing"
  6. "time"
  7. terminal "golang.org/x/term"
  8. )
  9. type mockTerminalWriter struct {
  10. buf bytes.Buffer
  11. }
  12. func (m *mockTerminalWriter) Write(p []byte) (n int, err error) {
  13. return m.buf.Write(p)
  14. }
  15. func TestCompleterRoot(t *testing.T) {
  16. mock := &mockTerminalWriter{}
  17. completer := NewCompleter(mock, nil)
  18. // 1. Single tab on empty line: ambiguous, no advance
  19. newLine, newPos, ok := completer.Callback("", 0, '\t')
  20. if !ok || newLine != "" || newPos != 0 {
  21. t.Fatalf("Expected empty line and ok=true, got newLine=%q, newPos=%d, ok=%v", newLine, newPos, ok)
  22. }
  23. if mock.buf.Len() != 0 {
  24. t.Fatalf("Expected no terminal output on 1st tab, got %q", mock.buf.String())
  25. }
  26. // 2. Second tab immediately after: displays all root options
  27. newLine, newPos, ok = completer.Callback("", 0, '\t')
  28. if !ok || newLine != "" || newPos != 0 {
  29. t.Fatalf("Expected empty line and ok=true, got newLine=%q, newPos=%d, ok=%v", newLine, newPos, ok)
  30. }
  31. out := mock.buf.String()
  32. if !strings.Contains(out, "interface") || !strings.Contains(out, "ip") || !strings.Contains(out, "routing") {
  33. t.Fatalf("Expected root commands in double tab output, got: %s", out)
  34. }
  35. }
  36. func TestCompleterSlashRoot(t *testing.T) {
  37. mock := &mockTerminalWriter{}
  38. completer := NewCompleter(mock, nil)
  39. // 1. Single tab on "/": ambiguous, no advance
  40. newLine, newPos, ok := completer.Callback("/", 1, '\t')
  41. if !ok || newLine != "/" || newPos != 1 {
  42. t.Fatalf("Expected '/' and ok=true, got %q", newLine)
  43. }
  44. // 2. Second tab on "/": displays root choices
  45. newLine, newPos, ok = completer.Callback("/", 1, '\t')
  46. if !ok || newLine != "/" {
  47. t.Fatalf("Expected '/' and ok=true, got %q", newLine)
  48. }
  49. out := mock.buf.String()
  50. if !strings.Contains(out, "interface") || !strings.Contains(out, "ip") {
  51. t.Fatalf("Expected options on double tab, got: %s", out)
  52. }
  53. }
  54. func TestCompleterUniqueMatch(t *testing.T) {
  55. mock := &mockTerminalWriter{}
  56. completer := NewCompleter(mock, nil)
  57. // "/in" should complete to "/interface " on first tab
  58. newLine, newPos, ok := completer.Callback("/in", 3, '\t')
  59. if !ok {
  60. t.Fatalf("Expected ok=true")
  61. }
  62. if newLine != "/interface " {
  63. t.Fatalf("Expected '/interface ', got %q", newLine)
  64. }
  65. if newPos != len("/interface ") {
  66. t.Fatalf("Expected newPos=%d, got %d", len("/interface "), newPos)
  67. }
  68. // "rout" should complete to "routing " on first tab
  69. newLine, newPos, ok = completer.Callback("rout", 4, '\t')
  70. if !ok || newLine != "routing " {
  71. t.Fatalf("Expected 'routing ', got %q", newLine)
  72. }
  73. }
  74. func TestCompleterCommonPrefix(t *testing.T) {
  75. mock := &mockTerminalWriter{}
  76. completer := NewCompleter(mock, nil)
  77. // "/ip dh" matches "dhcp-client" and "dhcp-server" -> LCP is "dhcp-"
  78. newLine, newPos, ok := completer.Callback("/ip dh", 6, '\t')
  79. if !ok || newLine != "/ip dhcp-" {
  80. t.Fatalf("Expected '/ip dhcp-', got %q", newLine)
  81. }
  82. if newPos != len("/ip dhcp-") {
  83. t.Fatalf("Expected newPos=%d, got %d", len("/ip dhcp-"), newPos)
  84. }
  85. // Immediate second tab should show the two options
  86. mock.buf.Reset()
  87. newLine, newPos, ok = completer.Callback(newLine, newPos, '\t')
  88. if !ok || newLine != "/ip dhcp-" {
  89. t.Fatalf("Expected '/ip dhcp-', got %q", newLine)
  90. }
  91. out := mock.buf.String()
  92. if !strings.Contains(out, "dhcp-client") || !strings.Contains(out, "dhcp-server") {
  93. t.Fatalf("Expected dhcp choices, got: %s", out)
  94. }
  95. }
  96. func TestCompleterSubsectionsDoubleTab(t *testing.T) {
  97. mock := &mockTerminalWriter{}
  98. completer := NewCompleter(mock, nil)
  99. subsections := []struct {
  100. input string
  101. expectedIn []string
  102. description string
  103. }{
  104. {
  105. input: "/ip ",
  106. expectedIn: []string{"address", "dhcp-client", "dhcp-server", "dns", "firewall", "route", "service"},
  107. description: "/ip subsections",
  108. },
  109. {
  110. input: "/interface ",
  111. expectedIn: []string{"bridge", "eoip", "pppoe-client", "vlan", "vrrp", "wireguard", "print", "enable", "disable"},
  112. description: "/interface subsections",
  113. },
  114. {
  115. input: "/interface bridge ",
  116. expectedIn: []string{"add", "disable", "enable", "port", "print", "remove"},
  117. description: "/interface bridge actions and port",
  118. },
  119. {
  120. input: "/interface bridge port ",
  121. expectedIn: []string{"add", "disable", "enable", "print", "remove"},
  122. description: "/interface bridge port actions",
  123. },
  124. {
  125. input: "/ip firewall ",
  126. expectedIn: []string{"address-list", "filter", "mangle", "nat"},
  127. description: "/ip firewall subsections",
  128. },
  129. {
  130. input: "/ip firewall filter ",
  131. expectedIn: []string{"add", "disable", "enable", "print", "remove"},
  132. description: "/ip firewall filter actions",
  133. },
  134. {
  135. input: "/ip service ",
  136. expectedIn: []string{"disable", "enable", "print", "set"},
  137. description: "/ip service actions",
  138. },
  139. {
  140. input: "/ip service enable ",
  141. expectedIn: []string{"ssh", "telnet", "www", "www-ssl"},
  142. description: "/ip service enable service choices",
  143. },
  144. {
  145. input: "/certificate ",
  146. expectedIn: []string{"add", "add-ca", "export", "issue-letsencrypt", "print", "remove"},
  147. description: "/certificate subsections",
  148. },
  149. {
  150. input: "/routing ",
  151. expectedIn: []string{"bgp", "filter", "ospf"},
  152. description: "/routing subsections",
  153. },
  154. {
  155. input: "/routing bgp ",
  156. expectedIn: []string{"instance", "network", "peer", "routes"},
  157. description: "/routing bgp subsections",
  158. },
  159. {
  160. input: "/routing ospf ",
  161. expectedIn: []string{"area", "instance", "interface", "neighbor"},
  162. description: "/routing ospf subsections",
  163. },
  164. {
  165. input: "/tool ",
  166. expectedIn: []string{"bandwidth-test", "ping", "traceroute"},
  167. description: "/tool subsections",
  168. },
  169. {
  170. input: "/user ",
  171. expectedIn: []string{"add", "print", "remove", "set"},
  172. description: "/user actions",
  173. },
  174. {
  175. input: "/system ",
  176. expectedIn: []string{"identity", "reboot", "resource"},
  177. description: "/system subsections",
  178. },
  179. {
  180. input: "/system identity ",
  181. expectedIn: []string{"print", "set"},
  182. description: "/system identity actions",
  183. },
  184. }
  185. for _, tc := range subsections {
  186. t.Run(tc.description, func(t *testing.T) {
  187. mock.buf.Reset()
  188. // Tab 1
  189. completer.Callback(tc.input, len(tc.input), '\t')
  190. // Tab 2
  191. completer.Callback(tc.input, len(tc.input), '\t')
  192. out := mock.buf.String()
  193. for _, expected := range tc.expectedIn {
  194. if !strings.Contains(out, expected) {
  195. t.Errorf("In %s, expected %q in output, got: %s", tc.description, expected, out)
  196. }
  197. }
  198. })
  199. }
  200. }
  201. func TestCompleterParameters(t *testing.T) {
  202. mock := &mockTerminalWriter{}
  203. completer := NewCompleter(mock, nil)
  204. // Under /ip firewall filter add, double tab should show parameter options
  205. mock.buf.Reset()
  206. completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
  207. completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
  208. out := mock.buf.String()
  209. expectedParams := []string{"action=", "chain=", "comment=", "dst-address=", "protocol=", "src-address="}
  210. for _, p := range expectedParams {
  211. if !strings.Contains(out, p) {
  212. t.Errorf("Expected param %q in output, got: %s", p, out)
  213. }
  214. }
  215. // Complete "ch" -> "chain=" (no space after =)
  216. newLine, _, ok := completer.Callback("/ip firewall filter add ch", len("/ip firewall filter add ch"), '\t')
  217. if !ok || newLine != "/ip firewall filter add chain=" {
  218. t.Fatalf("Expected parameter completion 'chain=', got %q", newLine)
  219. }
  220. // At "chain=", 2 tabs should show values ("forward", "input", "output")
  221. mock.buf.Reset()
  222. completer.Callback(newLine, len(newLine), '\t')
  223. completer.Callback(newLine, len(newLine), '\t')
  224. out = mock.buf.String()
  225. if !strings.Contains(out, "forward") || !strings.Contains(out, "input") || !strings.Contains(out, "output") {
  226. t.Fatalf("Expected chain values, got: %s", out)
  227. }
  228. // Complete value "chain=f" -> "chain=forward " (with trailing space!)
  229. newLine, _, ok = completer.Callback("/ip firewall filter add chain=f", len("/ip firewall filter add chain=f"), '\t')
  230. if !ok || newLine != "/ip firewall filter add chain=forward " {
  231. t.Fatalf("Expected 'chain=forward ', got %q", newLine)
  232. }
  233. // Once "chain=forward " is used, double tab should NOT suggest "chain=" again
  234. mock.buf.Reset()
  235. completer.Callback(newLine, len(newLine), '\t')
  236. completer.Callback(newLine, len(newLine), '\t')
  237. out = mock.buf.String()
  238. if strings.Contains(out, "chain=") {
  239. t.Fatalf("Expected already used 'chain=' to be omitted, got: %s", out)
  240. }
  241. if !strings.Contains(out, "action=") {
  242. t.Fatalf("Expected 'action=' still present, got: %s", out)
  243. }
  244. }
  245. func TestCompleterDoubleTabTimeout(t *testing.T) {
  246. mock := &mockTerminalWriter{}
  247. completer := NewCompleter(mock, nil)
  248. // Press 1
  249. completer.Callback("/ip ", 4, '\t')
  250. // Artificially advance time past timeout
  251. completer.lastTabTime = time.Now().Add(-1500 * time.Millisecond)
  252. // Press 2 (after timeout): should be treated as new 1st tab, so NO print!
  253. mock.buf.Reset()
  254. completer.Callback("/ip ", 4, '\t')
  255. if mock.buf.Len() != 0 {
  256. t.Fatalf("Expected no output after timeout, got: %s", mock.buf.String())
  257. }
  258. // Press 3 (within 1s of Press 2): should be 2nd tab and print!
  259. completer.Callback("/ip ", 4, '\t')
  260. if mock.buf.Len() == 0 {
  261. t.Fatalf("Expected output on 2nd tab within timeout")
  262. }
  263. }
  264. func TestCompleterIgnoredNonTab(t *testing.T) {
  265. mock := &mockTerminalWriter{}
  266. completer := NewCompleter(mock, nil)
  267. _, _, ok := completer.Callback("/ip ", 4, 'a')
  268. if ok {
  269. t.Fatalf("Expected ok=false for non-tab key 'a'")
  270. }
  271. }
  272. type testTerminalRW struct {
  273. in bytes.Buffer
  274. out bytes.Buffer
  275. }
  276. func (rw *testTerminalRW) Read(p []byte) (n int, err error) {
  277. return rw.in.Read(p)
  278. }
  279. func (rw *testTerminalRW) Write(p []byte) (n int, err error) {
  280. return rw.out.Write(p)
  281. }
  282. func TestCompleterWithRealTerminal(t *testing.T) {
  283. rw := &testTerminalRW{}
  284. term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
  285. completer := NewCompleter(term, nil)
  286. term.AutoCompleteCallback = completer.Callback
  287. // Feed input: "/ip " followed by 2 tabs, then "firewall print\r\n"
  288. rw.in.WriteString("/ip \t\tfirewall print\r\n")
  289. line, err := term.ReadLine()
  290. if err != nil {
  291. t.Fatalf("ReadLine error: %v", err)
  292. }
  293. if line != "/ip firewall print" {
  294. t.Fatalf("Expected '/ip firewall print', got %q", line)
  295. }
  296. out := rw.out.String()
  297. // Output should contain the subsections formatted on double tab
  298. if !strings.Contains(out, "address") || !strings.Contains(out, "firewall") || !strings.Contains(out, "route") {
  299. t.Fatalf("Terminal output missing double-tab options: %s", out)
  300. }
  301. }
  302. func TestCompleterFullInteractiveSession(t *testing.T) {
  303. rw := &testTerminalRW{}
  304. term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
  305. completer := NewCompleter(term, nil)
  306. term.AutoCompleteCallback = completer.Callback
  307. // Type /int<Tab>br<Tab>po<Tab><Tab><Tab>pr<Tab>\r\n
  308. rw.in.WriteString("/int\tbr\tpo\t\t\tpr\t\r\n")
  309. line, err := term.ReadLine()
  310. if err != nil {
  311. t.Fatalf("ReadLine error: %v", err)
  312. }
  313. if line != "/interface bridge port print " {
  314. t.Fatalf("Expected '/interface bridge port print ', got %q", line)
  315. }
  316. out := rw.out.String()
  317. if !strings.Contains(out, "add") || !strings.Contains(out, "disable") || !strings.Contains(out, "print") {
  318. t.Fatalf("Expected bridge port options in output, got: %s", out)
  319. }
  320. }