completer_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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"},
  142. description: "/ip service enable service choices",
  143. },
  144. {
  145. input: "/routing ",
  146. expectedIn: []string{"bgp", "filter", "ospf"},
  147. description: "/routing subsections",
  148. },
  149. {
  150. input: "/routing bgp ",
  151. expectedIn: []string{"instance", "network", "peer", "routes"},
  152. description: "/routing bgp subsections",
  153. },
  154. {
  155. input: "/routing ospf ",
  156. expectedIn: []string{"area", "instance", "interface", "neighbor"},
  157. description: "/routing ospf subsections",
  158. },
  159. {
  160. input: "/tool ",
  161. expectedIn: []string{"bandwidth-test", "ping", "traceroute"},
  162. description: "/tool subsections",
  163. },
  164. {
  165. input: "/user ",
  166. expectedIn: []string{"add", "print", "remove", "set"},
  167. description: "/user actions",
  168. },
  169. {
  170. input: "/system ",
  171. expectedIn: []string{"identity", "reboot", "resource"},
  172. description: "/system subsections",
  173. },
  174. {
  175. input: "/system identity ",
  176. expectedIn: []string{"print", "set"},
  177. description: "/system identity actions",
  178. },
  179. }
  180. for _, tc := range subsections {
  181. t.Run(tc.description, func(t *testing.T) {
  182. mock.buf.Reset()
  183. // Tab 1
  184. completer.Callback(tc.input, len(tc.input), '\t')
  185. // Tab 2
  186. completer.Callback(tc.input, len(tc.input), '\t')
  187. out := mock.buf.String()
  188. for _, expected := range tc.expectedIn {
  189. if !strings.Contains(out, expected) {
  190. t.Errorf("In %s, expected %q in output, got: %s", tc.description, expected, out)
  191. }
  192. }
  193. })
  194. }
  195. }
  196. func TestCompleterParameters(t *testing.T) {
  197. mock := &mockTerminalWriter{}
  198. completer := NewCompleter(mock, nil)
  199. // Under /ip firewall filter add, double tab should show parameter options
  200. mock.buf.Reset()
  201. completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
  202. completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
  203. out := mock.buf.String()
  204. expectedParams := []string{"action=", "chain=", "comment=", "dst-address=", "protocol=", "src-address="}
  205. for _, p := range expectedParams {
  206. if !strings.Contains(out, p) {
  207. t.Errorf("Expected param %q in output, got: %s", p, out)
  208. }
  209. }
  210. // Complete "ch" -> "chain=" (no space after =)
  211. newLine, _, ok := completer.Callback("/ip firewall filter add ch", len("/ip firewall filter add ch"), '\t')
  212. if !ok || newLine != "/ip firewall filter add chain=" {
  213. t.Fatalf("Expected parameter completion 'chain=', got %q", newLine)
  214. }
  215. // At "chain=", 2 tabs should show values ("forward", "input", "output")
  216. mock.buf.Reset()
  217. completer.Callback(newLine, len(newLine), '\t')
  218. completer.Callback(newLine, len(newLine), '\t')
  219. out = mock.buf.String()
  220. if !strings.Contains(out, "forward") || !strings.Contains(out, "input") || !strings.Contains(out, "output") {
  221. t.Fatalf("Expected chain values, got: %s", out)
  222. }
  223. // Complete value "chain=f" -> "chain=forward " (with trailing space!)
  224. newLine, _, ok = completer.Callback("/ip firewall filter add chain=f", len("/ip firewall filter add chain=f"), '\t')
  225. if !ok || newLine != "/ip firewall filter add chain=forward " {
  226. t.Fatalf("Expected 'chain=forward ', got %q", newLine)
  227. }
  228. // Once "chain=forward " is used, double tab should NOT suggest "chain=" again
  229. mock.buf.Reset()
  230. completer.Callback(newLine, len(newLine), '\t')
  231. completer.Callback(newLine, len(newLine), '\t')
  232. out = mock.buf.String()
  233. if strings.Contains(out, "chain=") {
  234. t.Fatalf("Expected already used 'chain=' to be omitted, got: %s", out)
  235. }
  236. if !strings.Contains(out, "action=") {
  237. t.Fatalf("Expected 'action=' still present, got: %s", out)
  238. }
  239. }
  240. func TestCompleterDoubleTabTimeout(t *testing.T) {
  241. mock := &mockTerminalWriter{}
  242. completer := NewCompleter(mock, nil)
  243. // Press 1
  244. completer.Callback("/ip ", 4, '\t')
  245. // Artificially advance time past timeout
  246. completer.lastTabTime = time.Now().Add(-1500 * time.Millisecond)
  247. // Press 2 (after timeout): should be treated as new 1st tab, so NO print!
  248. mock.buf.Reset()
  249. completer.Callback("/ip ", 4, '\t')
  250. if mock.buf.Len() != 0 {
  251. t.Fatalf("Expected no output after timeout, got: %s", mock.buf.String())
  252. }
  253. // Press 3 (within 1s of Press 2): should be 2nd tab and print!
  254. completer.Callback("/ip ", 4, '\t')
  255. if mock.buf.Len() == 0 {
  256. t.Fatalf("Expected output on 2nd tab within timeout")
  257. }
  258. }
  259. func TestCompleterIgnoredNonTab(t *testing.T) {
  260. mock := &mockTerminalWriter{}
  261. completer := NewCompleter(mock, nil)
  262. _, _, ok := completer.Callback("/ip ", 4, 'a')
  263. if ok {
  264. t.Fatalf("Expected ok=false for non-tab key 'a'")
  265. }
  266. }
  267. type testTerminalRW struct {
  268. in bytes.Buffer
  269. out bytes.Buffer
  270. }
  271. func (rw *testTerminalRW) Read(p []byte) (n int, err error) {
  272. return rw.in.Read(p)
  273. }
  274. func (rw *testTerminalRW) Write(p []byte) (n int, err error) {
  275. return rw.out.Write(p)
  276. }
  277. func TestCompleterWithRealTerminal(t *testing.T) {
  278. rw := &testTerminalRW{}
  279. term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
  280. completer := NewCompleter(term, nil)
  281. term.AutoCompleteCallback = completer.Callback
  282. // Feed input: "/ip " followed by 2 tabs, then "firewall print\r\n"
  283. rw.in.WriteString("/ip \t\tfirewall print\r\n")
  284. line, err := term.ReadLine()
  285. if err != nil {
  286. t.Fatalf("ReadLine error: %v", err)
  287. }
  288. if line != "/ip firewall print" {
  289. t.Fatalf("Expected '/ip firewall print', got %q", line)
  290. }
  291. out := rw.out.String()
  292. // Output should contain the subsections formatted on double tab
  293. if !strings.Contains(out, "address") || !strings.Contains(out, "firewall") || !strings.Contains(out, "route") {
  294. t.Fatalf("Terminal output missing double-tab options: %s", out)
  295. }
  296. }
  297. func TestCompleterFullInteractiveSession(t *testing.T) {
  298. rw := &testTerminalRW{}
  299. term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
  300. completer := NewCompleter(term, nil)
  301. term.AutoCompleteCallback = completer.Callback
  302. // Type /int<Tab>br<Tab>po<Tab><Tab><Tab>pr<Tab>\r\n
  303. rw.in.WriteString("/int\tbr\tpo\t\t\tpr\t\r\n")
  304. line, err := term.ReadLine()
  305. if err != nil {
  306. t.Fatalf("ReadLine error: %v", err)
  307. }
  308. if line != "/interface bridge port print " {
  309. t.Fatalf("Expected '/interface bridge port print ', got %q", line)
  310. }
  311. out := rw.out.String()
  312. if !strings.Contains(out, "add") || !strings.Contains(out, "disable") || !strings.Contains(out, "print") {
  313. t.Fatalf("Expected bridge port options in output, got: %s", out)
  314. }
  315. }