NestedRunTimeoutTests.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. #nullable enable
  2. using Xunit.Abstractions;
  3. namespace ApplicationTests.Timeout;
  4. /// <summary>
  5. /// Tests for timeout behavior with nested Application.Run() calls.
  6. /// These tests verify that timeouts scheduled in a parent run loop continue to fire
  7. /// correctly when a nested modal dialog is shown via Application.Run().
  8. /// </summary>
  9. public class NestedRunTimeoutTests (ITestOutputHelper output)
  10. {
  11. [Fact]
  12. public void Timeout_Fires_With_Single_Session ()
  13. {
  14. // Arrange
  15. using IApplication? app = Application.Create (example: false);
  16. app.Init ("FakeDriver");
  17. // Create a simple window for the main run loop
  18. var mainWindow = new Window { Title = "Main Window" };
  19. // Schedule a timeout that will ensure the app quits
  20. var requestStopTimeoutFired = false;
  21. app.AddTimeout (
  22. TimeSpan.FromMilliseconds (100),
  23. () =>
  24. {
  25. output.WriteLine ($"RequestStop Timeout fired!");
  26. requestStopTimeoutFired = true;
  27. app.RequestStop ();
  28. return false;
  29. }
  30. );
  31. // Act - Start the main run loop
  32. app.Run (mainWindow);
  33. // Assert
  34. Assert.True (requestStopTimeoutFired, "RequestStop Timeout should have fired");
  35. mainWindow.Dispose ();
  36. }
  37. [Fact]
  38. public void Timeout_Fires_In_Nested_Run ()
  39. {
  40. // Arrange
  41. using IApplication? app = Application.Create (example: false);
  42. app.Init ("FakeDriver");
  43. var timeoutFired = false;
  44. var nestedRunStarted = false;
  45. var nestedRunEnded = false;
  46. // Create a simple window for the main run loop
  47. var mainWindow = new Window { Title = "Main Window" };
  48. // Create a dialog for the nested run loop
  49. var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
  50. // Schedule a safety timeout that will ensure the app quits if test hangs
  51. var requestStopTimeoutFired = false;
  52. app.AddTimeout (
  53. TimeSpan.FromMilliseconds (5000),
  54. () =>
  55. {
  56. output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
  57. requestStopTimeoutFired = true;
  58. app.RequestStop ();
  59. return false;
  60. }
  61. );
  62. // Schedule a timeout that will fire AFTER the nested run starts and stop the dialog
  63. app.AddTimeout (
  64. TimeSpan.FromMilliseconds (200),
  65. () =>
  66. {
  67. output.WriteLine ($"DialogRequestStop Timeout fired! TopRunnable: {app.TopRunnableView?.Title ?? "null"}");
  68. timeoutFired = true;
  69. // Close the dialog when timeout fires
  70. if (app.TopRunnableView == dialog)
  71. {
  72. app.RequestStop (dialog);
  73. }
  74. return false;
  75. }
  76. );
  77. // After 100ms, start the nested run loop
  78. app.AddTimeout (
  79. TimeSpan.FromMilliseconds (100),
  80. () =>
  81. {
  82. output.WriteLine ("Starting nested run...");
  83. nestedRunStarted = true;
  84. // This blocks until the dialog is closed (by the timeout at 200ms)
  85. app.Run (dialog);
  86. output.WriteLine ("Nested run ended");
  87. nestedRunEnded = true;
  88. // Stop the main window after nested run completes
  89. app.RequestStop ();
  90. return false;
  91. }
  92. );
  93. // Act - Start the main run loop
  94. app.Run (mainWindow);
  95. // Assert
  96. Assert.True (nestedRunStarted, "Nested run should have started");
  97. Assert.True (timeoutFired, "Timeout should have fired during nested run");
  98. Assert.True (nestedRunEnded, "Nested run should have ended");
  99. Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
  100. dialog.Dispose ();
  101. mainWindow.Dispose ();
  102. }
  103. [Fact]
  104. public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run ()
  105. {
  106. // Arrange
  107. using IApplication? app = Application.Create (example: false);
  108. app.Init ("FakeDriver");
  109. var executionOrder = new List<string> ();
  110. var mainWindow = new Window { Title = "Main Window" };
  111. var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
  112. // Schedule a safety timeout that will ensure the app quits if test hangs
  113. var requestStopTimeoutFired = false;
  114. app.AddTimeout (
  115. TimeSpan.FromMilliseconds (10000),
  116. () =>
  117. {
  118. output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
  119. requestStopTimeoutFired = true;
  120. app.RequestStop ();
  121. return false;
  122. }
  123. );
  124. // Schedule multiple timeouts
  125. app.AddTimeout (
  126. TimeSpan.FromMilliseconds (100),
  127. () =>
  128. {
  129. executionOrder.Add ("Timeout1-100ms");
  130. output.WriteLine ("Timeout1 fired at 100ms");
  131. return false;
  132. }
  133. );
  134. app.AddTimeout (
  135. TimeSpan.FromMilliseconds (200),
  136. () =>
  137. {
  138. executionOrder.Add ("Timeout2-200ms-StartNestedRun");
  139. output.WriteLine ("Timeout2 fired at 200ms - Starting nested run");
  140. // Start nested run
  141. app.Run (dialog);
  142. executionOrder.Add ("Timeout2-NestedRunEnded");
  143. output.WriteLine ("Nested run ended");
  144. return false;
  145. }
  146. );
  147. app.AddTimeout (
  148. TimeSpan.FromMilliseconds (300),
  149. () =>
  150. {
  151. executionOrder.Add ("Timeout3-300ms-InNestedRun");
  152. output.WriteLine ($"Timeout3 fired at 300ms - TopRunnable: {app.TopRunnableView?.Title}");
  153. // This should fire while dialog is running
  154. Assert.Equal (dialog, app.TopRunnableView);
  155. return false;
  156. }
  157. );
  158. app.AddTimeout (
  159. TimeSpan.FromMilliseconds (400),
  160. () =>
  161. {
  162. executionOrder.Add ("Timeout4-400ms-CloseDialog");
  163. output.WriteLine ("Timeout4 fired at 400ms - Closing dialog");
  164. // Close the dialog
  165. app.RequestStop (dialog);
  166. return false;
  167. }
  168. );
  169. app.AddTimeout (
  170. TimeSpan.FromMilliseconds (500),
  171. () =>
  172. {
  173. executionOrder.Add ("Timeout5-500ms-StopMain");
  174. output.WriteLine ("Timeout5 fired at 500ms - Stopping main window");
  175. // Stop main window
  176. app.RequestStop (mainWindow);
  177. return false;
  178. }
  179. );
  180. // Act
  181. app.Run (mainWindow);
  182. // Assert - Verify all timeouts fired in the correct order
  183. output.WriteLine ($"Execution order: {string.Join (", ", executionOrder)}");
  184. Assert.Equal (6, executionOrder.Count); // 5 timeouts + 1 nested run end marker
  185. Assert.Equal ("Timeout1-100ms", executionOrder [0]);
  186. Assert.Equal ("Timeout2-200ms-StartNestedRun", executionOrder [1]);
  187. Assert.Equal ("Timeout3-300ms-InNestedRun", executionOrder [2]);
  188. Assert.Equal ("Timeout4-400ms-CloseDialog", executionOrder [3]);
  189. Assert.Equal ("Timeout2-NestedRunEnded", executionOrder [4]);
  190. Assert.Equal ("Timeout5-500ms-StopMain", executionOrder [5]);
  191. Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
  192. dialog.Dispose ();
  193. mainWindow.Dispose ();
  194. }
  195. [Fact]
  196. public void Timeout_Scheduled_Before_Nested_Run_Fires_During_Nested_Run ()
  197. {
  198. // This test specifically reproduces the ESC key issue scenario:
  199. // - Timeouts are scheduled upfront (like demo keys)
  200. // - A timeout fires and triggers a nested run (like Enter opening MessageBox)
  201. // - A subsequent timeout should still fire during the nested run (like ESC closing MessageBox)
  202. // Arrange
  203. using IApplication? app = Application.Create (example: false);
  204. app.Init ("FakeDriver");
  205. var enterFired = false;
  206. var escFired = false;
  207. var messageBoxShown = false;
  208. var messageBoxClosed = false;
  209. var mainWindow = new Window { Title = "Login Window" };
  210. var messageBox = new Dialog { Title = "Success", Buttons = [new Button { Text = "Ok" }] };
  211. // Schedule a safety timeout that will ensure the app quits if test hangs
  212. var requestStopTimeoutFired = false;
  213. app.AddTimeout (
  214. TimeSpan.FromMilliseconds (10000),
  215. () =>
  216. {
  217. output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
  218. requestStopTimeoutFired = true;
  219. app.RequestStop ();
  220. return false;
  221. }
  222. );
  223. // Schedule "Enter" timeout at 100ms
  224. app.AddTimeout (
  225. TimeSpan.FromMilliseconds (100),
  226. () =>
  227. {
  228. output.WriteLine ("Enter timeout fired - showing MessageBox");
  229. enterFired = true;
  230. // Simulate Enter key opening MessageBox
  231. messageBoxShown = true;
  232. app.Run (messageBox);
  233. messageBoxClosed = true;
  234. output.WriteLine ("MessageBox closed");
  235. return false;
  236. }
  237. );
  238. // Schedule "ESC" timeout at 200ms (should fire while MessageBox is running)
  239. app.AddTimeout (
  240. TimeSpan.FromMilliseconds (200),
  241. () =>
  242. {
  243. output.WriteLine ($"ESC timeout fired - TopRunnable: {app.TopRunnableView?.Title}");
  244. escFired = true;
  245. // Simulate ESC key closing MessageBox
  246. if (app.TopRunnableView == messageBox)
  247. {
  248. output.WriteLine ("Closing MessageBox with ESC");
  249. app.RequestStop (messageBox);
  250. }
  251. return false;
  252. }
  253. );
  254. // Stop main window after MessageBox closes
  255. app.AddTimeout (
  256. TimeSpan.FromMilliseconds (300),
  257. () =>
  258. {
  259. output.WriteLine ("Stopping main window");
  260. app.RequestStop (mainWindow);
  261. return false;
  262. }
  263. );
  264. // Act
  265. app.Run (mainWindow);
  266. // Assert
  267. Assert.True (enterFired, "Enter timeout should have fired");
  268. Assert.True (messageBoxShown, "MessageBox should have been shown");
  269. Assert.True (escFired, "ESC timeout should have fired during MessageBox"); // THIS WAS THE BUG - NOW FIXED!
  270. Assert.True (messageBoxClosed, "MessageBox should have been closed");
  271. Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
  272. messageBox.Dispose ();
  273. mainWindow.Dispose ();
  274. }
  275. [Fact]
  276. public void Timeout_Queue_Persists_Across_Nested_Runs ()
  277. {
  278. // Verify that the timeout queue is not cleared when nested runs start/end
  279. // Arrange
  280. using IApplication? app = Application.Create (example: false);
  281. app.Init ("FakeDriver");
  282. // Schedule a safety timeout that will ensure the app quits if test hangs
  283. var requestStopTimeoutFired = false;
  284. app.AddTimeout (
  285. TimeSpan.FromMilliseconds (10000),
  286. () =>
  287. {
  288. output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
  289. requestStopTimeoutFired = true;
  290. app.RequestStop ();
  291. return false;
  292. }
  293. );
  294. var mainWindow = new Window { Title = "Main Window" };
  295. var dialog = new Dialog { Title = "Dialog", Buttons = [new Button { Text = "Ok" }] };
  296. int initialTimeoutCount = 0;
  297. int timeoutCountDuringNestedRun = 0;
  298. int timeoutCountAfterNestedRun = 0;
  299. // Schedule 5 timeouts at different times
  300. for (int i = 0; i < 5; i++)
  301. {
  302. int capturedI = i;
  303. app.AddTimeout (
  304. TimeSpan.FromMilliseconds (100 * (i + 1)),
  305. () =>
  306. {
  307. output.WriteLine ($"Timeout {capturedI} fired at {100 * (capturedI + 1)}ms");
  308. if (capturedI == 0)
  309. {
  310. initialTimeoutCount = app.TimedEvents!.Timeouts.Count;
  311. output.WriteLine ($"Initial timeout count: {initialTimeoutCount}");
  312. }
  313. if (capturedI == 1)
  314. {
  315. // Start nested run
  316. output.WriteLine ("Starting nested run");
  317. app.Run (dialog);
  318. output.WriteLine ("Nested run ended");
  319. timeoutCountAfterNestedRun = app.TimedEvents!.Timeouts.Count;
  320. output.WriteLine ($"Timeout count after nested run: {timeoutCountAfterNestedRun}");
  321. }
  322. if (capturedI == 2)
  323. {
  324. // This fires during nested run
  325. timeoutCountDuringNestedRun = app.TimedEvents!.Timeouts.Count;
  326. output.WriteLine ($"Timeout count during nested run: {timeoutCountDuringNestedRun}");
  327. // Close dialog
  328. app.RequestStop (dialog);
  329. }
  330. if (capturedI == 4)
  331. {
  332. // Stop main window
  333. app.RequestStop (mainWindow);
  334. }
  335. return false;
  336. }
  337. );
  338. }
  339. // Act
  340. app.Run (mainWindow);
  341. // Assert
  342. output.WriteLine ($"Final counts - Initial: {initialTimeoutCount}, During: {timeoutCountDuringNestedRun}, After: {timeoutCountAfterNestedRun}");
  343. // The timeout queue should have pending timeouts throughout
  344. Assert.True (initialTimeoutCount >= 0, "Should have timeouts in queue initially");
  345. Assert.True (timeoutCountDuringNestedRun >= 0, "Should have timeouts in queue during nested run");
  346. Assert.True (timeoutCountAfterNestedRun >= 0, "Should have timeouts in queue after nested run");
  347. Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
  348. dialog.Dispose ();
  349. mainWindow.Dispose ();
  350. }
  351. }