using System.Collections.Concurrent; using System.Drawing; using FluentAssertions; using FluentAssertions.Numeric; using Terminal.Gui; using Terminal.Gui.ConsoleDrivers; using static Unix.Terminal.Curses; namespace TerminalGuiFluentAssertions; class FakeInput : IConsoleInput { private readonly CancellationToken _hardStopToken; private readonly CancellationTokenSource _timeoutCts; public FakeInput (CancellationToken hardStopToken) { _hardStopToken = hardStopToken; // Create a timeout-based cancellation token too to prevent tests ever fully hanging _timeoutCts = new (With.Timeout); } /// public void Dispose () { } /// public void Initialize (ConcurrentQueue inputBuffer) { InputBuffer = inputBuffer;} public ConcurrentQueue InputBuffer { get; set; } /// public void Run (CancellationToken token) { // Blocks until either the token or the hardStopToken is cancelled. WaitHandle.WaitAny (new [] { token.WaitHandle, _hardStopToken.WaitHandle, _timeoutCts.Token.WaitHandle }); } } class FakeNetInput (CancellationToken hardStopToken) : FakeInput (hardStopToken), INetInput { } class FakeWindowsInput (CancellationToken hardStopToken) : FakeInput (hardStopToken), IWindowsInput { } class FakeOutput : IConsoleOutput { public IOutputBuffer LastBuffer { get; set; } public Size Size { get; set; } /// public void Dispose () { } /// public void Write (ReadOnlySpan text) { } /// public void Write (IOutputBuffer buffer) { LastBuffer = buffer; } /// public Size GetWindowSize () { return Size; } /// public void SetCursorVisibility (CursorVisibility visibility) { } /// public void SetCursorPosition (int col, int row) { } } /// /// Entry point to fluent assertions. /// public static class With { /// /// Entrypoint to fluent assertions /// /// /// /// public static GuiTestContext A (int width, int height) where T : Toplevel, new () { return new (width,height); } /// /// The global timeout to allow for any given application to run for before shutting down. /// public static TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds (30); } public class GuiTestContext : IDisposable where T : Toplevel, new() { private readonly CancellationTokenSource _cts = new (); private readonly CancellationTokenSource _hardStop = new (With.Timeout); private readonly Task _runTask; private Exception _ex; private readonly FakeOutput _output = new (); private readonly FakeWindowsInput winInput; private View _lastView; internal GuiTestContext (int width, int height) { IApplication origApp = ApplicationImpl.Instance; var netInput = new FakeNetInput (_cts.Token); winInput = new FakeWindowsInput (_cts.Token); _output.Size = new (width, height); var v2 = new ApplicationV2( () => netInput, ()=>_output, () => winInput, () => _output); var booting = new SemaphoreSlim (0, 1); // Start the application in a background thread _runTask = Task.Run (() => { try { ApplicationImpl.ChangeInstance (v2); v2.Init (null,"v2win"); booting.Release (); Application.Run (); // This will block, but it's on a background thread now Application.Shutdown (); } catch (OperationCanceledException) { } catch (Exception ex) { _ex = ex; } finally { ApplicationImpl.ChangeInstance (origApp); } }, _cts.Token); // Wait for booting to complete with a timeout to avoid hangs if (!booting.WaitAsync (TimeSpan.FromSeconds (5)).Result) { throw new TimeoutException ("Application failed to start within the allotted time."); } WaitIteration (); } /// /// Stops the application and waits for the background thread to exit. /// public GuiTestContext Stop () { if (_runTask.IsCompleted) { return this; } Application.Invoke (()=> Application.RequestStop ()); // Wait for the application to stop, but give it a 1-second timeout if (!_runTask.Wait (TimeSpan.FromMilliseconds (1000))) { _cts.Cancel (); // Timeout occurred, force the task to stop _hardStop.Cancel (); throw new TimeoutException ("Application failed to stop within the allotted time."); } _cts.Cancel (); if (_ex != null) { throw _ex; // Propagate any exception that happened in the background task } return this; } // Cleanup to avoid state bleed between tests public void Dispose () { Stop (); if (_hardStop.IsCancellationRequested) { throw new Exception ( "Application was hard stopped, typically this means it timed out or did not shutdown gracefully. Ensure you call Stop in your test"); } _hardStop.Cancel(); } /// /// Adds the given to the current top level view /// and performs layout. /// /// /// public GuiTestContext Add (View v) { WaitIteration ( () => { var top = Application.Top ?? throw new Exception("Top was null so could not add view"); top.Add (v); top.Layout (); _lastView = v; }); return this; } public GuiTestContext ResizeConsole (int width, int height) { _output.Size = new Size (width,height); return WaitIteration (); } public GuiTestContext ScreenShot (string title, TextWriter writer) { writer.WriteLine(title +":"); var text = Application.ToString (); writer.WriteLine(text); return WaitIteration (); } public GuiTestContext WaitIteration (Action? a = null) { a ??= () => { }; var ctsLocal = new CancellationTokenSource (); Application.Invoke (()=> { a(); ctsLocal.Cancel (); }); // Blocks until either the token or the hardStopToken is cancelled. WaitHandle.WaitAny (new [] { _cts.Token.WaitHandle, _hardStop.Token.WaitHandle, ctsLocal.Token.WaitHandle }); return this; } public GuiTestContext Assert (AndConstraint be) { return this; } public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed,screenX, screenY); } public GuiTestContext LeftClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); } private GuiTestContext Click (WindowsConsole.ButtonState btn, int screenX, int screenY) { winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Mouse, MouseEvent = new WindowsConsole.MouseEventRecord () { ButtonState = btn, MousePosition = new WindowsConsole.Coord ((short)screenX, (short)screenY) } }); winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Mouse, MouseEvent = new WindowsConsole.MouseEventRecord () { ButtonState = WindowsConsole.ButtonState.NoButtonPressed, MousePosition = new WindowsConsole.Coord ((short)screenX, (short)screenY) } }); WaitIteration (); return this; } public GuiTestContext Down () { winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Key, KeyEvent = new WindowsConsole.KeyEventRecord { bKeyDown = true, wRepeatCount = 0, wVirtualKeyCode = ConsoleKeyMapping.VK.DOWN, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Key, KeyEvent = new WindowsConsole.KeyEventRecord { bKeyDown = false, wRepeatCount = 0, wVirtualKeyCode = ConsoleKeyMapping.VK.DOWN, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); WaitIteration (); return this; } public GuiTestContext Enter () { winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Key, KeyEvent = new WindowsConsole.KeyEventRecord { bKeyDown = true, wRepeatCount = 0, wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); winInput.InputBuffer.Enqueue (new WindowsConsole.InputRecord () { EventType = WindowsConsole.EventType.Key, KeyEvent = new WindowsConsole.KeyEventRecord { bKeyDown = false, wRepeatCount = 0, wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); WaitIteration (); return this; } public GuiTestContext WithContextMenu (ContextMenu ctx, MenuBarItem menuItems) { LastView.MouseEvent += (s, e) => { if (e.Flags.HasFlag (MouseFlags.Button3Clicked)) { ctx.Show (menuItems); } }; return this; } public View LastView => _lastView ?? Application.Top ?? throw new Exception ("Could not determine which view to add to"); }