GuiTestContext.cs 27 KB

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