GuiTestContext.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. using System.Drawing;
  2. using System.Text;
  3. using Microsoft.Extensions.Logging;
  4. using Terminal.Gui;
  5. namespace TerminalGuiFluentTesting;
  6. /// <summary>
  7. /// Fluent API context for testing a Terminal.Gui application. Create
  8. /// an instance using <see cref="With"/> static class.
  9. /// </summary>
  10. public class GuiTestContext : IDisposable
  11. {
  12. private readonly CancellationTokenSource _cts = new ();
  13. private readonly CancellationTokenSource _hardStop = new (With.Timeout);
  14. private readonly Task _runTask;
  15. private Exception _ex;
  16. private readonly FakeOutput _output = new ();
  17. private readonly FakeWindowsInput _winInput;
  18. private readonly FakeNetInput _netInput;
  19. private View? _lastView;
  20. private readonly StringBuilder _logsSb;
  21. private readonly V2TestDriver _driver;
  22. private bool _finished;
  23. internal GuiTestContext (Func<Toplevel> topLevelBuilder, int width, int height, V2TestDriver driver)
  24. {
  25. IApplication origApp = ApplicationImpl.Instance;
  26. ILogger? origLogger = Logging.Logger;
  27. _logsSb = new ();
  28. _driver = driver;
  29. _netInput = new (_cts.Token);
  30. _winInput = new (_cts.Token);
  31. _output.Size = new (width, height);
  32. var v2 = new ApplicationV2 (
  33. () => _netInput,
  34. () => _output,
  35. () => _winInput,
  36. () => _output);
  37. var booting = new SemaphoreSlim (0, 1);
  38. // Start the application in a background thread
  39. _runTask = Task.Run (
  40. () =>
  41. {
  42. try
  43. {
  44. ApplicationImpl.ChangeInstance (v2);
  45. ILogger logger = LoggerFactory.Create (
  46. builder =>
  47. builder.SetMinimumLevel (LogLevel.Trace)
  48. .AddProvider (new TextWriterLoggerProvider (new StringWriter (_logsSb))))
  49. .CreateLogger ("Test Logging");
  50. Logging.Logger = logger;
  51. v2.Init (null, GetDriverName ());
  52. booting.Release ();
  53. Toplevel t = topLevelBuilder ();
  54. t.Closed += (s, e) => { _finished = true; };
  55. Application.Run (t); // This will block, but it's on a background thread now
  56. t.Dispose ();
  57. Application.Shutdown ();
  58. }
  59. catch (OperationCanceledException)
  60. { }
  61. catch (Exception ex)
  62. {
  63. _ex = ex;
  64. }
  65. finally
  66. {
  67. ApplicationImpl.ChangeInstance (origApp);
  68. Logging.Logger = origLogger;
  69. _finished = true;
  70. }
  71. },
  72. _cts.Token);
  73. // Wait for booting to complete with a timeout to avoid hangs
  74. if (!booting.WaitAsync (TimeSpan.FromSeconds (5)).Result)
  75. {
  76. throw new TimeoutException ("Application failed to start within the allotted time.");
  77. }
  78. WaitIteration ();
  79. }
  80. private string GetDriverName ()
  81. {
  82. return _driver switch
  83. {
  84. V2TestDriver.V2Win => "v2win",
  85. V2TestDriver.V2Net => "v2net",
  86. _ =>
  87. throw new ArgumentOutOfRangeException ()
  88. };
  89. }
  90. /// <summary>
  91. /// Stops the application and waits for the background thread to exit.
  92. /// </summary>
  93. public GuiTestContext Stop ()
  94. {
  95. if (_runTask.IsCompleted)
  96. {
  97. return this;
  98. }
  99. Application.Invoke (() => { Application.RequestStop (); });
  100. // Wait for the application to stop, but give it a 1-second timeout
  101. if (!_runTask.Wait (TimeSpan.FromMilliseconds (1000)))
  102. {
  103. _cts.Cancel ();
  104. // Timeout occurred, force the task to stop
  105. _hardStop.Cancel ();
  106. throw new TimeoutException ("Application failed to stop within the allotted time.");
  107. }
  108. _cts.Cancel ();
  109. if (_ex != null)
  110. {
  111. throw _ex; // Propagate any exception that happened in the background task
  112. }
  113. return this;
  114. }
  115. /// <summary>
  116. /// Hard stops the application and waits for the background thread to exit.
  117. /// </summary>
  118. public void HardStop ()
  119. {
  120. _hardStop.Cancel ();
  121. Stop ();
  122. }
  123. /// <summary>
  124. /// Cleanup to avoid state bleed between tests
  125. /// </summary>
  126. public void Dispose ()
  127. {
  128. Stop ();
  129. if (_hardStop.IsCancellationRequested)
  130. {
  131. throw new (
  132. "Application was hard stopped, typically this means it timed out or did not shutdown gracefully. Ensure you call Stop in your test");
  133. }
  134. _hardStop.Cancel ();
  135. }
  136. /// <summary>
  137. /// Adds the given <paramref name="v"/> to the current top level view
  138. /// and performs layout.
  139. /// </summary>
  140. /// <param name="v"></param>
  141. /// <returns></returns>
  142. public GuiTestContext Add (View v)
  143. {
  144. WaitIteration (
  145. () =>
  146. {
  147. Toplevel top = Application.Top ?? throw new ("Top was null so could not add view");
  148. top.Add (v);
  149. top.Layout ();
  150. _lastView = v;
  151. });
  152. return this;
  153. }
  154. /// <summary>
  155. /// Simulates changing the console size e.g. by resizing window in your operating system
  156. /// </summary>
  157. /// <param name="width">new Width for the console.</param>
  158. /// <param name="height">new Height for the console.</param>
  159. /// <returns></returns>
  160. public GuiTestContext ResizeConsole (int width, int height)
  161. {
  162. _output.Size = new (width, height);
  163. return WaitIteration ();
  164. }
  165. public GuiTestContext ScreenShot (string title, TextWriter writer)
  166. {
  167. writer.WriteLine (title + ":");
  168. var text = Application.ToString ();
  169. writer.WriteLine (text);
  170. return this; //WaitIteration();
  171. }
  172. /// <summary>
  173. /// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
  174. /// </summary>
  175. /// <param name="writer"></param>
  176. /// <returns></returns>
  177. public GuiTestContext WriteOutLogs (TextWriter writer)
  178. {
  179. writer.WriteLine (_logsSb.ToString ());
  180. return this; //WaitIteration();
  181. }
  182. /// <summary>
  183. /// Waits until the end of the current iteration of the main loop. Optionally
  184. /// running a given <paramref name="a"/> action on the UI thread at that time.
  185. /// </summary>
  186. /// <param name="a"></param>
  187. /// <returns></returns>
  188. public GuiTestContext WaitIteration (Action? a = null)
  189. {
  190. // If application has already exited don't wait!
  191. if (_finished || _cts.Token.IsCancellationRequested || _hardStop.Token.IsCancellationRequested)
  192. {
  193. return this;
  194. }
  195. a ??= () => { };
  196. var ctsLocal = new CancellationTokenSource ();
  197. Application.Invoke (
  198. () =>
  199. {
  200. a ();
  201. ctsLocal.Cancel ();
  202. });
  203. // Blocks until either the token or the hardStopToken is cancelled.
  204. WaitHandle.WaitAny (
  205. new []
  206. {
  207. _cts.Token.WaitHandle,
  208. _hardStop.Token.WaitHandle,
  209. ctsLocal.Token.WaitHandle
  210. });
  211. return this;
  212. }
  213. /// <summary>
  214. /// Performs the supplied <paramref name="doAction"/> immediately.
  215. /// Enables running commands without breaking the Fluent API calls.
  216. /// </summary>
  217. /// <param name="doAction"></param>
  218. /// <returns></returns>
  219. public GuiTestContext Then (Action doAction)
  220. {
  221. try
  222. {
  223. doAction ();
  224. }
  225. catch (Exception)
  226. {
  227. HardStop ();
  228. throw;
  229. }
  230. return this;
  231. }
  232. /// <summary>
  233. /// Simulates a right click at the given screen coordinates on the current driver.
  234. /// This is a raw input event that goes through entire processing pipeline as though
  235. /// user had pressed the mouse button physically.
  236. /// </summary>
  237. /// <param name="screenX">0 indexed screen coordinates</param>
  238. /// <param name="screenY">0 indexed screen coordinates</param>
  239. /// <returns></returns>
  240. public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed, screenX, screenY); }
  241. /// <summary>
  242. /// Simulates a left click at the given screen coordinates on the current driver.
  243. /// This is a raw input event that goes through entire processing pipeline as though
  244. /// user had pressed the mouse button physically.
  245. /// </summary>
  246. /// <param name="screenX">0 indexed screen coordinates</param>
  247. /// <param name="screenY">0 indexed screen coordinates</param>
  248. /// <returns></returns>
  249. public GuiTestContext LeftClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); }
  250. public GuiTestContext LeftClick<T> (Func<T, bool> evaluator) where T : View { return Click (WindowsConsole.ButtonState.Button1Pressed, evaluator); }
  251. private GuiTestContext Click<T> (WindowsConsole.ButtonState btn, Func<T, bool> evaluator) where T : View
  252. {
  253. T v = Find (evaluator);
  254. Point screen = v.ViewportToScreen (new Point (0, 0));
  255. return Click (btn, screen.X, screen.Y);
  256. }
  257. private GuiTestContext Click (WindowsConsole.ButtonState btn, int screenX, int screenY)
  258. {
  259. switch (_driver)
  260. {
  261. case V2TestDriver.V2Win:
  262. _winInput.InputBuffer.Enqueue (
  263. new ()
  264. {
  265. EventType = WindowsConsole.EventType.Mouse,
  266. MouseEvent = new ()
  267. {
  268. ButtonState = btn,
  269. MousePosition = new ((short)screenX, (short)screenY)
  270. }
  271. });
  272. _winInput.InputBuffer.Enqueue (
  273. new ()
  274. {
  275. EventType = WindowsConsole.EventType.Mouse,
  276. MouseEvent = new ()
  277. {
  278. ButtonState = WindowsConsole.ButtonState.NoButtonPressed,
  279. MousePosition = new ((short)screenX, (short)screenY)
  280. }
  281. });
  282. break;
  283. case V2TestDriver.V2Net:
  284. int netButton = btn switch
  285. {
  286. WindowsConsole.ButtonState.Button1Pressed => 0,
  287. WindowsConsole.ButtonState.Button2Pressed => 1,
  288. WindowsConsole.ButtonState.Button3Pressed => 2,
  289. WindowsConsole.ButtonState.RightmostButtonPressed => 2,
  290. _ => throw new ArgumentOutOfRangeException (nameof (btn))
  291. };
  292. foreach (ConsoleKeyInfo k in NetSequences.Click (netButton, screenX, screenY))
  293. {
  294. SendNetKey (k);
  295. }
  296. break;
  297. default:
  298. throw new ArgumentOutOfRangeException ();
  299. }
  300. return WaitIteration ();
  301. ;
  302. }
  303. public GuiTestContext Down ()
  304. {
  305. switch (_driver)
  306. {
  307. case V2TestDriver.V2Win:
  308. SendWindowsKey (ConsoleKeyMapping.VK.DOWN);
  309. break;
  310. case V2TestDriver.V2Net:
  311. foreach (ConsoleKeyInfo k in NetSequences.Down)
  312. {
  313. SendNetKey (k);
  314. }
  315. break;
  316. default:
  317. throw new ArgumentOutOfRangeException ();
  318. }
  319. return WaitIteration ();
  320. ;
  321. }
  322. /// <summary>
  323. /// Simulates the Right cursor key
  324. /// </summary>
  325. /// <returns></returns>
  326. /// <exception cref="ArgumentOutOfRangeException"></exception>
  327. public GuiTestContext Right ()
  328. {
  329. switch (_driver)
  330. {
  331. case V2TestDriver.V2Win:
  332. SendWindowsKey (ConsoleKeyMapping.VK.RIGHT);
  333. break;
  334. case V2TestDriver.V2Net:
  335. foreach (ConsoleKeyInfo k in NetSequences.Right)
  336. {
  337. SendNetKey (k);
  338. }
  339. WaitIteration ();
  340. break;
  341. default:
  342. throw new ArgumentOutOfRangeException ();
  343. }
  344. return WaitIteration ();
  345. }
  346. /// <summary>
  347. /// Simulates the Left cursor key
  348. /// </summary>
  349. /// <returns></returns>
  350. /// <exception cref="ArgumentOutOfRangeException"></exception>
  351. public GuiTestContext Left ()
  352. {
  353. switch (_driver)
  354. {
  355. case V2TestDriver.V2Win:
  356. SendWindowsKey (ConsoleKeyMapping.VK.LEFT);
  357. break;
  358. case V2TestDriver.V2Net:
  359. foreach (ConsoleKeyInfo k in NetSequences.Left)
  360. {
  361. SendNetKey (k);
  362. }
  363. break;
  364. default:
  365. throw new ArgumentOutOfRangeException ();
  366. }
  367. return WaitIteration ();
  368. }
  369. /// <summary>
  370. /// Simulates the up cursor key
  371. /// </summary>
  372. /// <returns></returns>
  373. /// <exception cref="ArgumentOutOfRangeException"></exception>
  374. public GuiTestContext Up ()
  375. {
  376. switch (_driver)
  377. {
  378. case V2TestDriver.V2Win:
  379. SendWindowsKey (ConsoleKeyMapping.VK.UP);
  380. break;
  381. case V2TestDriver.V2Net:
  382. foreach (ConsoleKeyInfo k in NetSequences.Up)
  383. {
  384. SendNetKey (k);
  385. }
  386. break;
  387. default:
  388. throw new ArgumentOutOfRangeException ();
  389. }
  390. return WaitIteration ();
  391. }
  392. /// <summary>
  393. /// Simulates pressing the Return/Enter (newline) key.
  394. /// </summary>
  395. /// <returns></returns>
  396. /// <exception cref="ArgumentOutOfRangeException"></exception>
  397. public GuiTestContext Enter ()
  398. {
  399. switch (_driver)
  400. {
  401. case V2TestDriver.V2Win:
  402. SendWindowsKey (
  403. new WindowsConsole.KeyEventRecord
  404. {
  405. UnicodeChar = '\r',
  406. dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed,
  407. wRepeatCount = 1,
  408. wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN,
  409. wVirtualScanCode = 28
  410. });
  411. break;
  412. case V2TestDriver.V2Net:
  413. SendNetKey (new ('\r', ConsoleKey.Enter, false, false, false));
  414. break;
  415. default:
  416. throw new ArgumentOutOfRangeException ();
  417. }
  418. return WaitIteration ();
  419. }
  420. /// <summary>
  421. /// Simulates pressing the Esc (Escape) key.
  422. /// </summary>
  423. /// <returns></returns>
  424. /// <exception cref="ArgumentOutOfRangeException"></exception>
  425. public GuiTestContext Escape ()
  426. {
  427. switch (_driver)
  428. {
  429. case V2TestDriver.V2Win:
  430. SendWindowsKey (
  431. new WindowsConsole.KeyEventRecord
  432. {
  433. UnicodeChar = '\u001b',
  434. dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed,
  435. wRepeatCount = 1,
  436. wVirtualKeyCode = ConsoleKeyMapping.VK.ESCAPE,
  437. wVirtualScanCode = 1
  438. });
  439. break;
  440. case V2TestDriver.V2Net:
  441. // Note that this accurately describes how Esc comes in. Typically, ConsoleKey is None
  442. // even though you would think it would be Escape - it isn't
  443. SendNetKey (new ('\u001b', ConsoleKey.None, false, false, false));
  444. break;
  445. default:
  446. throw new ArgumentOutOfRangeException ();
  447. }
  448. return this;
  449. }
  450. /// <summary>
  451. /// Simulates pressing the Tab key.
  452. /// </summary>
  453. /// <returns></returns>
  454. /// <exception cref="ArgumentOutOfRangeException"></exception>
  455. public GuiTestContext Tab ()
  456. {
  457. switch (_driver)
  458. {
  459. case V2TestDriver.V2Win:
  460. SendWindowsKey (
  461. new WindowsConsole.KeyEventRecord
  462. {
  463. UnicodeChar = '\t',
  464. dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed,
  465. wRepeatCount = 1,
  466. wVirtualKeyCode = 0,
  467. wVirtualScanCode = 0
  468. });
  469. break;
  470. case V2TestDriver.V2Net:
  471. // Note that this accurately describes how Tab comes in. Typically, ConsoleKey is None
  472. // even though you would think it would be Tab - it isn't
  473. SendNetKey (new ('\t', ConsoleKey.None, false, false, false));
  474. break;
  475. default:
  476. throw new ArgumentOutOfRangeException ();
  477. }
  478. return this;
  479. }
  480. /// <summary>
  481. /// Registers a right click handler on the <see cref="LastView"/> added view (or root view) that
  482. /// will open the supplied <paramref name="contextMenu"/>.
  483. /// </summary>
  484. /// <param name="contextMenu"></param>
  485. /// <returns></returns>
  486. public GuiTestContext WithContextMenu (PopoverMenu? contextMenu)
  487. {
  488. LastView.MouseEvent += (s, e) =>
  489. {
  490. if (e.Flags.HasFlag (MouseFlags.Button3Clicked))
  491. {
  492. // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused
  493. // and the context menu is disposed when it is closed.
  494. Application.Popover?.Register (contextMenu);
  495. contextMenu?.MakeVisible (e.ScreenPosition);
  496. }
  497. };
  498. return this;
  499. }
  500. /// <summary>
  501. /// The last view added (e.g. with <see cref="Add"/>) or the root/current top.
  502. /// </summary>
  503. public View LastView => _lastView ?? Application.Top ?? throw new ("Could not determine which view to add to");
  504. /// <summary>
  505. /// Send a full windows OS key including both down and up.
  506. /// </summary>
  507. /// <param name="fullKey"></param>
  508. private void SendWindowsKey (WindowsConsole.KeyEventRecord fullKey)
  509. {
  510. WindowsConsole.KeyEventRecord down = fullKey;
  511. WindowsConsole.KeyEventRecord up = fullKey; // because struct this is new copy
  512. down.bKeyDown = true;
  513. up.bKeyDown = false;
  514. _winInput.InputBuffer.Enqueue (
  515. new ()
  516. {
  517. EventType = WindowsConsole.EventType.Key,
  518. KeyEvent = down
  519. });
  520. _winInput.InputBuffer.Enqueue (
  521. new ()
  522. {
  523. EventType = WindowsConsole.EventType.Key,
  524. KeyEvent = up
  525. });
  526. WaitIteration ();
  527. }
  528. private void SendNetKey (ConsoleKeyInfo consoleKeyInfo) { _netInput.InputBuffer.Enqueue (consoleKeyInfo); }
  529. /// <summary>
  530. /// Sends a special key e.g. cursor key that does not map to a specific character
  531. /// </summary>
  532. /// <param name="specialKey"></param>
  533. private void SendWindowsKey (ConsoleKeyMapping.VK specialKey)
  534. {
  535. _winInput.InputBuffer.Enqueue (
  536. new ()
  537. {
  538. EventType = WindowsConsole.EventType.Key,
  539. KeyEvent = new ()
  540. {
  541. bKeyDown = true,
  542. wRepeatCount = 0,
  543. wVirtualKeyCode = specialKey,
  544. wVirtualScanCode = 0,
  545. UnicodeChar = '\0',
  546. dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed
  547. }
  548. });
  549. _winInput.InputBuffer.Enqueue (
  550. new ()
  551. {
  552. EventType = WindowsConsole.EventType.Key,
  553. KeyEvent = new ()
  554. {
  555. bKeyDown = false,
  556. wRepeatCount = 0,
  557. wVirtualKeyCode = specialKey,
  558. wVirtualScanCode = 0,
  559. UnicodeChar = '\0',
  560. dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed
  561. }
  562. });
  563. WaitIteration ();
  564. }
  565. /// <summary>
  566. /// Sends a key to the application. This goes directly to Application and does not go through
  567. /// a driver.
  568. /// </summary>
  569. /// <param name="key"></param>
  570. /// <returns></returns>
  571. public GuiTestContext RaiseKeyDownEvent (Key key)
  572. {
  573. Application.RaiseKeyDownEvent (key);
  574. return this; //WaitIteration();
  575. }
  576. /// <summary>
  577. /// Sets the input focus to the given <see cref="View"/>.
  578. /// Throws <see cref="ArgumentException"/> if focus did not change due to system
  579. /// constraints e.g. <paramref name="toFocus"/>
  580. /// <see cref="View.CanFocus"/> is <see langword="false"/>
  581. /// </summary>
  582. /// <param name="toFocus"></param>
  583. /// <returns></returns>
  584. /// <exception cref="ArgumentException"></exception>
  585. public GuiTestContext Focus (View toFocus)
  586. {
  587. toFocus.FocusDeepest (NavigationDirection.Forward, TabBehavior.TabStop);
  588. if (!toFocus.HasFocus)
  589. {
  590. throw new ArgumentException ("Failed to set focus, FocusDeepest did not result in HasFocus becoming true. Ensure view is added and focusable");
  591. }
  592. return WaitIteration ();
  593. }
  594. /// <summary>
  595. /// Tabs through the UI until a View matching the <paramref name="evaluator"/>
  596. /// is found (of Type T) or all views are looped through (back to the beginning)
  597. /// in which case triggers hard stop and Exception
  598. /// </summary>
  599. /// <param name="evaluator">Delegate that returns true if the passed View is the one
  600. /// you are trying to focus. Leave <see langword="null"/> to focus the first view of type
  601. /// <typeparamref name="T"/></param>
  602. /// <returns></returns>
  603. /// <exception cref="ArgumentException"></exception>
  604. public GuiTestContext Focus<T> (Func<T, bool>? evaluator = null) where T : View
  605. {
  606. evaluator ??= _ => true;
  607. Toplevel? t = Application.Top;
  608. HashSet<View> seen = new ();
  609. if (t == null)
  610. {
  611. Fail ("Application.Top was null when trying to set focus");
  612. return this;
  613. }
  614. do
  615. {
  616. View? next = t.MostFocused;
  617. // Is view found?
  618. if (next is T v && evaluator (v))
  619. {
  620. return this;
  621. }
  622. // No, try tab to the next (or first)
  623. Tab ();
  624. WaitIteration ();
  625. next = t.MostFocused;
  626. if (next is null)
  627. {
  628. Fail ("Failed to tab to a view which matched the Type and evaluator constraints of the test because MostFocused became or was always null");
  629. return this;
  630. }
  631. // Track the views we have seen
  632. // We have looped around to the start again if it was already there
  633. if (!seen.Add (next))
  634. {
  635. Fail ("Failed to tab to a view which matched the Type and evaluator constraints of the test before looping back to the original View");
  636. return this;
  637. }
  638. }
  639. while (true);
  640. }
  641. private T Find<T> (Func<T, bool> evaluator) where T : View
  642. {
  643. Toplevel? t = Application.Top;
  644. if (t == null)
  645. {
  646. Fail ("Application.Top was null when attempting to find view");
  647. }
  648. T? f = FindRecursive (t!, evaluator);
  649. if (f == null)
  650. {
  651. Fail ("Failed to tab to a view which matched the Type and evaluator constraints in any SubViews of top");
  652. }
  653. return f!;
  654. }
  655. private T? FindRecursive<T> (View current, Func<T, bool> evaluator) where T : View
  656. {
  657. foreach (View subview in current.SubViews)
  658. {
  659. if (subview is T match && evaluator (match))
  660. {
  661. return match;
  662. }
  663. // Recursive call
  664. T? result = FindRecursive (subview, evaluator);
  665. if (result != null)
  666. {
  667. return result;
  668. }
  669. }
  670. return null;
  671. }
  672. private void Fail (string reason)
  673. {
  674. Stop ();
  675. throw new (reason);
  676. }
  677. public GuiTestContext Send (Key key)
  678. {
  679. if (Application.Driver is IConsoleDriverFacade facade)
  680. {
  681. facade.InputProcessor.OnKeyDown (key);
  682. facade.InputProcessor.OnKeyUp (key);
  683. }
  684. else
  685. {
  686. Fail ("Expected Application.Driver to be IConsoleDriverFacade");
  687. }
  688. return this;
  689. }
  690. /// <summary>
  691. /// Returns the last set position of the cursor.
  692. /// </summary>
  693. /// <returns></returns>
  694. public Point GetCursorPosition ()
  695. {
  696. return _output.CursorPosition;
  697. }
  698. }