diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs index e507c97f6..c3afe85de 100644 --- a/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs +++ b/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs @@ -49,11 +49,10 @@ public class NetInputProcessor : InputProcessor private static string FormatConsoleKeyInfoForTestCase (ConsoleKeyInfo input) { string charLiteral = input.KeyChar == '\0' ? @"'\0'" : $"'{input.KeyChar}'"; - var expectedLiteral = "new Rune('todo')"; return $"new ConsoleKeyInfo({charLiteral}, ConsoleKey.{input.Key}, " + $"{input.Modifiers.HasFlag (ConsoleModifiers.Shift).ToString ().ToLower ()}, " + $"{input.Modifiers.HasFlag (ConsoleModifiers.Alt).ToString ().ToLower ()}, " - + $"{input.Modifiers.HasFlag (ConsoleModifiers.Control).ToString ().ToLower ()}), {expectedLiteral}"; + + $"{input.Modifiers.HasFlag (ConsoleModifiers.Control).ToString ().ToLower ()}),"; } } diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index 8fd390881..d8efda92d 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -87,6 +87,7 @@ + diff --git a/Terminal.Gui/Views/TabView/TabView.cs b/Terminal.Gui/Views/TabView/TabView.cs index 5dc14b8bf..98ce20b21 100644 --- a/Terminal.Gui/Views/TabView/TabView.cs +++ b/Terminal.Gui/Views/TabView/TabView.cs @@ -84,6 +84,92 @@ public class TabView : View } ); + AddCommand ( + Command.Up, + () => + { + if (_style.TabsOnBottom) + { + if (_tabsBar is { HasFocus: true } && _containerView.CanFocus) + { + _containerView.SetFocus (); + + return true; + } + } + else + { + if (_containerView is { HasFocus: true }) + { + var mostFocused = _containerView.MostFocused; + + if (mostFocused is { }) + { + for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) - 1; i > -1; i--) + { + var view = mostFocused.SuperView?.SubViews.ElementAt ((int)i); + + if (view is { CanFocus: true, Enabled: true, Visible: true }) + { + // Let toplevel handle it + return false; + } + } + } + + SelectedTab?.SetFocus (); + + return true; + } + } + + return false; + } + ); + + AddCommand ( + Command.Down, + () => + { + if (_style.TabsOnBottom) + { + if (_containerView is { HasFocus: true }) + { + var mostFocused = _containerView.MostFocused; + + if (mostFocused is { }) + { + for (int? i = mostFocused.SuperView?.SubViews.IndexOf (mostFocused) + 1; i < mostFocused.SuperView?.SubViews.Count; i++) + { + var view = mostFocused.SuperView?.SubViews.ElementAt ((int)i); + + if (view is { CanFocus: true, Enabled: true, Visible: true }) + { + // Let toplevel handle it + return false; + } + } + } + + SelectedTab?.SetFocus (); + + return true; + } + } + else + { + if (_tabsBar is { HasFocus: true } && _containerView.CanFocus) + { + _containerView.SetFocus (); + + return true; + } + } + + return false; + } + ); + // Default keybindings for this view KeyBindings.Add (Key.CursorLeft, Command.Left); KeyBindings.Add (Key.CursorRight, Command.Right); @@ -91,6 +177,8 @@ public class TabView : View KeyBindings.Add (Key.End, Command.RightEnd); KeyBindings.Add (Key.PageDown, Command.PageDown); KeyBindings.Add (Key.PageUp, Command.PageUp); + KeyBindings.Add (Key.CursorUp, Command.Up); + KeyBindings.Add (Key.CursorDown, Command.Down); } /// @@ -155,7 +243,7 @@ public class TabView : View private bool TabCanSetFocus () { - return IsInitialized && SelectedTab is { } && (_selectedTabHasFocus || !_containerView.CanFocus); + return IsInitialized && SelectedTab is { } && (HasFocus || (bool)_containerView?.HasFocus) && (_selectedTabHasFocus || !_containerView.CanFocus); } private void ContainerViewCanFocus (object sender, EventArgs eventArgs) @@ -518,7 +606,7 @@ public class TabView : View { SelectedTab?.SetFocus (); } - else + else if (HasFocus) { SelectedTab?.View?.SetFocus (); } diff --git a/Terminal.sln b/Terminal.sln index 0399a634d..e15d8f360 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.2.32427.441 @@ -62,6 +63,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StressTests", "Tests\Stress EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests.Parallelizable", "Tests\UnitTestsParallelizable\UnitTests.Parallelizable.csproj", "{DE780834-190A-8277-51FD-750CC666E82D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTesting", "TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj", "{2DBA7BDC-17AE-474B-A507-00807D087607}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,6 +119,10 @@ Global {DE780834-190A-8277-51FD-750CC666E82D}.Debug|Any CPU.Build.0 = Debug|Any CPU {DE780834-190A-8277-51FD-750CC666E82D}.Release|Any CPU.ActiveCfg = Release|Any CPU {DE780834-190A-8277-51FD-750CC666E82D}.Release|Any CPU.Build.0 = Release|Any CPU + {2DBA7BDC-17AE-474B-A507-00807D087607}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DBA7BDC-17AE-474B-A507-00807D087607}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TerminalGuiFluentTesting/ClassDiagram1.cd b/TerminalGuiFluentTesting/ClassDiagram1.cd new file mode 100644 index 000000000..7fe4c3b91 --- /dev/null +++ b/TerminalGuiFluentTesting/ClassDiagram1.cd @@ -0,0 +1,89 @@ + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAIAAAAAA= + With.cs + + + + + + AQAAAAAAACAAAQEAAAAgAAAAAAAAAAAAAAAAAAAAAAI= + FakeInput.cs + + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + FakeNetInput.cs + + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + FakeWindowsInput.cs + + + + + + + AAAAAAAAgCAAgAAAAAAAAAAAAAAAQAAAMAAAAAEAAAA= + FakeOutput.cs + + + + + + + + + + ABJAAAIAACBACRAAg4IAAAAgAJIEgQQAKACIBACAIgI= + GuiTestContext.cs + + + + + + + + + + + + AAAAAIAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + TextWriterLoggerProvider.cs + + + + + + + AAAAAAAAAAAAAAAAAAEAAAAAAAgAAAAAAAAIAAAAAAA= + TextWriterLogger.cs + + + + + + + AAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAACACAAAAAAgI= + NetSequences.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAACAAA= + V2TestDriver.cs + + + + \ No newline at end of file diff --git a/TerminalGuiFluentTesting/FakeInput.cs b/TerminalGuiFluentTesting/FakeInput.cs new file mode 100644 index 000000000..e90844382 --- /dev/null +++ b/TerminalGuiFluentTesting/FakeInput.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using Terminal.Gui; + +namespace TerminalGuiFluentTesting; + +internal 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 }); + } +} diff --git a/TerminalGuiFluentTesting/FakeNetInput.cs b/TerminalGuiFluentTesting/FakeNetInput.cs new file mode 100644 index 000000000..6ccede491 --- /dev/null +++ b/TerminalGuiFluentTesting/FakeNetInput.cs @@ -0,0 +1,6 @@ +using Terminal.Gui; + +namespace TerminalGuiFluentTesting; + +internal class FakeNetInput (CancellationToken hardStopToken) : FakeInput (hardStopToken), INetInput +{ } diff --git a/TerminalGuiFluentTesting/FakeOutput.cs b/TerminalGuiFluentTesting/FakeOutput.cs new file mode 100644 index 000000000..5f2b43bce --- /dev/null +++ b/TerminalGuiFluentTesting/FakeOutput.cs @@ -0,0 +1,28 @@ +using System.Drawing; +using Terminal.Gui; + +namespace TerminalGuiFluentTesting; + +internal 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) { } +} diff --git a/TerminalGuiFluentTesting/FakeWindowsInput.cs b/TerminalGuiFluentTesting/FakeWindowsInput.cs new file mode 100644 index 000000000..42ccf8098 --- /dev/null +++ b/TerminalGuiFluentTesting/FakeWindowsInput.cs @@ -0,0 +1,6 @@ +using Terminal.Gui; + +namespace TerminalGuiFluentTesting; + +internal class FakeWindowsInput (CancellationToken hardStopToken) : FakeInput (hardStopToken), IWindowsInput +{ } diff --git a/TerminalGuiFluentTesting/GuiTestContext.cs b/TerminalGuiFluentTesting/GuiTestContext.cs new file mode 100644 index 000000000..9a1195df8 --- /dev/null +++ b/TerminalGuiFluentTesting/GuiTestContext.cs @@ -0,0 +1,551 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Terminal.Gui; +using Terminal.Gui.ConsoleDrivers; + +namespace TerminalGuiFluentTesting; + +/// +/// Fluent API context for testing a Terminal.Gui application. Create +/// an instance using static class. +/// +public class GuiTestContext : IDisposable +{ + 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 readonly FakeNetInput _netInput; + private View? _lastView; + private readonly StringBuilder _logsSb; + private readonly V2TestDriver _driver; + + internal GuiTestContext (Func topLevelBuilder, int width, int height, V2TestDriver driver) + { + IApplication origApp = ApplicationImpl.Instance; + ILogger? origLogger = Logging.Logger; + _logsSb = new (); + _driver = driver; + + _netInput = new (_cts.Token); + _winInput = new (_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); + + ILogger logger = LoggerFactory.Create ( + builder => + builder.SetMinimumLevel (LogLevel.Trace) + .AddProvider (new TextWriterLoggerProvider (new StringWriter (_logsSb)))) + .CreateLogger ("Test Logging"); + Logging.Logger = logger; + + v2.Init (null, GetDriverName()); + + booting.Release (); + + Toplevel t = topLevelBuilder (); + + Application.Run (t); // This will block, but it's on a background thread now + + Application.Shutdown (); + } + catch (OperationCanceledException) + { } + catch (Exception ex) + { + _ex = ex; + } + finally + { + ApplicationImpl.ChangeInstance (origApp); + Logging.Logger = origLogger; + } + }, + _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 (); + } + + private string GetDriverName () + { + return _driver switch + { + V2TestDriver.V2Win => "v2win", + V2TestDriver.V2Net => "v2net", + _ => + throw new ArgumentOutOfRangeException () + }; + } + + /// + /// 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 ( + "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 ( + () => + { + Toplevel top = Application.Top ?? throw new ("Top was null so could not add view"); + top.Add (v); + top.Layout (); + _lastView = v; + }); + + return this; + } + + /// + /// Simulates changing the console size e.g. by resizing window in your operating system + /// + /// new Width for the console. + /// new Height for the console. + /// + public GuiTestContext ResizeConsole (int width, int height) + { + _output.Size = new (width, height); + + return WaitIteration (); + } + + public GuiTestContext ScreenShot (string title, TextWriter writer) + { + writer.WriteLine (title + ":"); + var text = Application.ToString (); + + writer.WriteLine (text); + + return WaitIteration (); + } + + /// + /// Writes all Terminal.Gui engine logs collected so far to the + /// + /// + /// + public GuiTestContext WriteOutLogs (TextWriter writer) + { + writer.WriteLine (_logsSb.ToString ()); + + return WaitIteration (); + } + + /// + /// Waits until the end of the current iteration of the main loop. Optionally + /// running a given action on the UI thread at that time. + /// + /// + /// + 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; + } + + /// + /// Performs the supplied immediately. + /// Enables running commands without breaking the Fluent API calls. + /// + /// + /// + public GuiTestContext Then (Action doAction) + { + doAction (); + + return this; + } + + /// + /// Simulates a right click at the given screen coordinates on the current driver. + /// This is a raw input event that goes through entire processing pipeline as though + /// user had pressed the mouse button physically. + /// + /// 0 indexed screen coordinates + /// 0 indexed screen coordinates + /// + public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed, screenX, screenY); } + + /// + /// Simulates a left click at the given screen coordinates on the current driver. + /// This is a raw input event that goes through entire processing pipeline as though + /// user had pressed the mouse button physically. + /// + /// 0 indexed screen coordinates + /// 0 indexed screen coordinates + /// + public GuiTestContext LeftClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); } + + private GuiTestContext Click (WindowsConsole.ButtonState btn, int screenX, int screenY) + { + switch (_driver) + { + case V2TestDriver.V2Win: + + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Mouse, + MouseEvent = new () + { + ButtonState = btn, + MousePosition = new ((short)screenX, (short)screenY) + } + }); + + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Mouse, + MouseEvent = new () + { + ButtonState = WindowsConsole.ButtonState.NoButtonPressed, + MousePosition = new ((short)screenX, (short)screenY) + } + }); + break; + case V2TestDriver.V2Net: + + int netButton = btn switch + { + WindowsConsole.ButtonState.Button1Pressed => 0, + WindowsConsole.ButtonState.Button2Pressed => 1, + WindowsConsole.ButtonState.Button3Pressed => 2, + WindowsConsole.ButtonState.RightmostButtonPressed => 2, + _ => throw new ArgumentOutOfRangeException(nameof(btn)) + }; + foreach (var k in NetSequences.Click(netButton,screenX,screenY)) + { + SendNetKey (k); + } + break; + default: + throw new ArgumentOutOfRangeException (); + } + + WaitIteration (); + + return this; + } + + public GuiTestContext Down () + { + switch (_driver) + { + case V2TestDriver.V2Win: + SendWindowsKey (ConsoleKeyMapping.VK.DOWN); + WaitIteration (); + break; + case V2TestDriver.V2Net: + foreach (var k in NetSequences.Down) + { + SendNetKey (k); + } + break; + default: + throw new ArgumentOutOfRangeException (); + } + + + return this; + } + + /// + /// Simulates the Right cursor key + /// + /// + /// + public GuiTestContext Right () + { + switch (_driver) + { + case V2TestDriver.V2Win: + SendWindowsKey (ConsoleKeyMapping.VK.RIGHT); + WaitIteration (); + break; + case V2TestDriver.V2Net: + foreach (var k in NetSequences.Right) + { + SendNetKey (k); + } + break; + default: + throw new ArgumentOutOfRangeException (); + } + + return this; + } + + /// + /// Simulates the Left cursor key + /// + /// + /// + public GuiTestContext Left () + { + switch (_driver) + { + case V2TestDriver.V2Win: + SendWindowsKey (ConsoleKeyMapping.VK.LEFT); + WaitIteration (); + break; + case V2TestDriver.V2Net: + foreach (var k in NetSequences.Left) + { + SendNetKey (k); + } + break; + default: + throw new ArgumentOutOfRangeException (); + } + + return this; + } + + /// + /// Simulates the up cursor key + /// + /// + /// + public GuiTestContext Up () + { + switch (_driver) + { + case V2TestDriver.V2Win: + SendWindowsKey (ConsoleKeyMapping.VK.UP); + WaitIteration (); + break; + case V2TestDriver.V2Net: + foreach (var k in NetSequences.Up) + { + SendNetKey (k); + } + break; + default: + throw new ArgumentOutOfRangeException (); + } + + return this; + } + + /// + /// Simulates pressing the Return/Enter (newline) key. + /// + /// + /// + public GuiTestContext Enter () + { + switch (_driver) + { + case V2TestDriver.V2Win: + SendWindowsKey ( + new WindowsConsole.KeyEventRecord + { + UnicodeChar = '\r', + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + wRepeatCount = 1, + wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN, + wVirtualScanCode = 28 + }); + break; + case V2TestDriver.V2Net: + SendNetKey (new ('\r', ConsoleKey.Enter, false, false, false)); + break; + default: + throw new ArgumentOutOfRangeException (); + } + + return this; + } + + /// + /// Registers a right click handler on the added view (or root view) that + /// will open the supplied . + /// + /// + /// + /// + public GuiTestContext WithContextMenu (ContextMenu ctx, MenuBarItem menuItems) + { + LastView.MouseEvent += (s, e) => + { + if (e.Flags.HasFlag (MouseFlags.Button3Clicked)) + { + ctx.Show (menuItems); + } + }; + + return this; + } + + /// + /// The last view added (e.g. with ) or the root/current top. + /// + public View LastView => _lastView ?? Application.Top ?? throw new ("Could not determine which view to add to"); + + /// + /// Send a full windows OS key including both down and up. + /// + /// + private void SendWindowsKey (WindowsConsole.KeyEventRecord fullKey) + { + WindowsConsole.KeyEventRecord down = fullKey; + WindowsConsole.KeyEventRecord up = fullKey; // because struct this is new copy + + down.bKeyDown = true; + up.bKeyDown = false; + + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = down + }); + + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = up + }); + + WaitIteration (); + } + + + private void SendNetKey (ConsoleKeyInfo consoleKeyInfo) + { + _netInput.InputBuffer.Enqueue (consoleKeyInfo); + } + + /// + /// Sends a special key e.g. cursor key that does not map to a specific character + /// + /// + private void SendWindowsKey (ConsoleKeyMapping.VK specialKey) + { + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new () + { + bKeyDown = true, + wRepeatCount = 0, + wVirtualKeyCode = specialKey, + wVirtualScanCode = 0, + UnicodeChar = '\0', + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed + } + }); + + _winInput.InputBuffer.Enqueue ( + new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new () + { + bKeyDown = false, + wRepeatCount = 0, + wVirtualKeyCode = specialKey, + wVirtualScanCode = 0, + UnicodeChar = '\0', + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed + } + }); + + WaitIteration (); + } +} diff --git a/TerminalGuiFluentTesting/NetSequences.cs b/TerminalGuiFluentTesting/NetSequences.cs new file mode 100644 index 000000000..10256b126 --- /dev/null +++ b/TerminalGuiFluentTesting/NetSequences.cs @@ -0,0 +1,53 @@ +namespace TerminalGuiFluentTesting; +class NetSequences +{ + public static ConsoleKeyInfo [] Down = new [] + { + new ConsoleKeyInfo('\x1B', ConsoleKey.Enter, false, false, false), + new ConsoleKeyInfo('[', ConsoleKey.None, false, false, false), + new ConsoleKeyInfo('B', ConsoleKey.None, false, false, false), + }; + + public static ConsoleKeyInfo [] Up = new [] + { + new ConsoleKeyInfo('\x1B', ConsoleKey.Enter, false, false, false), + new ConsoleKeyInfo('[', ConsoleKey.None, false, false, false), + new ConsoleKeyInfo('A', ConsoleKey.None, false, false, false), + }; + + public static ConsoleKeyInfo [] Left = new [] + { + new ConsoleKeyInfo('\x1B', ConsoleKey.Enter, false, false, false), + new ConsoleKeyInfo('[', ConsoleKey.None, false, false, false), + new ConsoleKeyInfo('D', ConsoleKey.None, false, false, false), + }; + + public static ConsoleKeyInfo [] Right = new [] + { + new ConsoleKeyInfo('\x1B', ConsoleKey.Enter, false, false, false), + new ConsoleKeyInfo('[', ConsoleKey.None, false, false, false), + new ConsoleKeyInfo('C', ConsoleKey.None, false, false, false), + }; + + public static IEnumerable Click (int button, int screenX, int screenY) + { + // Adjust for 1-based coordinates + int adjustedX = screenX + 1; + int adjustedY = screenY + 1; + + // Mouse press sequence + var sequence = $"\x1B[<{button};{adjustedX};{adjustedY}M"; + foreach (char c in sequence) + { + yield return new ConsoleKeyInfo (c, ConsoleKey.None, false, false, false); + } + + // Mouse release sequence + sequence = $"\x1B[<{button};{adjustedX};{adjustedY}m"; + foreach (char c in sequence) + { + yield return new ConsoleKeyInfo (c, ConsoleKey.None, false, false, false); + } + } + +} diff --git a/TerminalGuiFluentTesting/TerminalGuiFluentTesting.csproj b/TerminalGuiFluentTesting/TerminalGuiFluentTesting.csproj new file mode 100644 index 000000000..21dc13b4d --- /dev/null +++ b/TerminalGuiFluentTesting/TerminalGuiFluentTesting.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + bin\$(Configuration)\$(AssemblyName).xml + + + + + + + \ No newline at end of file diff --git a/TerminalGuiFluentTesting/TextWriterLogger.cs b/TerminalGuiFluentTesting/TextWriterLogger.cs new file mode 100644 index 000000000..7f81a1c70 --- /dev/null +++ b/TerminalGuiFluentTesting/TextWriterLogger.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; + +namespace TerminalGuiFluentTesting; + +internal class TextWriterLogger (TextWriter writer) : ILogger +{ + public IDisposable? BeginScope (TState state) { return null; } + + public bool IsEnabled (LogLevel logLevel) { return true; } + + public void Log ( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? ex, + Func formatter + ) + { + writer.WriteLine (formatter (state, ex)); + } +} diff --git a/TerminalGuiFluentTesting/TextWriterLoggerProvider.cs b/TerminalGuiFluentTesting/TextWriterLoggerProvider.cs new file mode 100644 index 000000000..2a6628329 --- /dev/null +++ b/TerminalGuiFluentTesting/TextWriterLoggerProvider.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Logging; + +namespace TerminalGuiFluentTesting; + +internal class TextWriterLoggerProvider (TextWriter writer) : ILoggerProvider +{ + public ILogger CreateLogger (string category) { return new TextWriterLogger (writer); } + + public void Dispose () { writer.Dispose (); } +} diff --git a/TerminalGuiFluentTesting/V2TestDriver.cs b/TerminalGuiFluentTesting/V2TestDriver.cs new file mode 100644 index 000000000..2366bee0c --- /dev/null +++ b/TerminalGuiFluentTesting/V2TestDriver.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TerminalGuiFluentTesting; + +/// +/// Which v2 driver simulation should be used +/// +public enum V2TestDriver +{ + /// + /// The v2 windows driver with simulation I/O but core driver classes + /// + V2Win, + + /// + /// The v2 net driver with simulation I/O but core driver classes + /// + V2Net +} diff --git a/TerminalGuiFluentTesting/With.cs b/TerminalGuiFluentTesting/With.cs new file mode 100644 index 000000000..b65d83238 --- /dev/null +++ b/TerminalGuiFluentTesting/With.cs @@ -0,0 +1,26 @@ +using Terminal.Gui; + +namespace TerminalGuiFluentTesting; + +/// +/// Entry point to fluent assertions. +/// +public static class With +{ + /// + /// Entrypoint to fluent assertions + /// + /// + /// + /// Which v2 v2TestDriver to use for the test + /// + public static GuiTestContext A (int width, int height, V2TestDriver v2TestDriver) where T : Toplevel, new () + { + return new (() => new T (), width, height,v2TestDriver); + } + + /// + /// The global timeout to allow for any given application to run for before shutting down. + /// + public static TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds (30); +} diff --git a/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs b/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs new file mode 100644 index 000000000..345d7acc1 --- /dev/null +++ b/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs @@ -0,0 +1,138 @@ +using System.Text; +using Terminal.Gui; +using TerminalGuiFluentTesting; +using Xunit.Abstractions; + +namespace IntegrationTests.FluentTests; + +public class BasicFluentAssertionTests +{ + private readonly TextWriter _out; + + public class TestOutputWriter : TextWriter + { + private readonly ITestOutputHelper _output; + + public TestOutputWriter (ITestOutputHelper output) { _output = output; } + + public override void WriteLine (string? value) { _output.WriteLine (value ?? string.Empty); } + + public override Encoding Encoding => Encoding.UTF8; + } + + public BasicFluentAssertionTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); } + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void GuiTestContext_StartsAndStopsWithoutError (V2TestDriver d) + { + using GuiTestContext context = With.A (40, 10,d); + + // No actual assertions are needed — if no exceptions are thrown, it's working + context.Stop (); + } + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void GuiTestContext_ForgotToStop (V2TestDriver d) + { + using GuiTestContext context = With.A (40, 10, d); + } + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void TestWindowsResize (V2TestDriver d) + { + var lbl = new Label + { + Width = Dim.Fill () + }; + + using GuiTestContext c = With.A (40, 10, d) + .Add (lbl) + .Then (() => Assert.Equal (38, lbl.Frame.Width)) // Window has 2 border + .ResizeConsole (20, 20) + .Then (() => Assert.Equal (18, lbl.Frame.Width)) + .WriteOutLogs (_out) + .Stop (); + } + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void ContextMenu_CrashesOnRight (V2TestDriver d) + { + var clicked = false; + + var ctx = new ContextMenu (); + + var menuItems = new MenuBarItem ( + [ + new ("_New File", string.Empty, () => { clicked = true; }) + ] + ); + + using GuiTestContext c = With.A (40, 10, d) + .WithContextMenu (ctx, menuItems) + .ScreenShot ("Before open menu", _out) + + // Click in main area inside border + .RightClick (1, 1) + .ScreenShot ("After open menu", _out) + .LeftClick (3, 3) + .Stop () + .WriteOutLogs (_out); + Assert.True (clicked); + } + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void ContextMenu_OpenSubmenu (V2TestDriver d) + { + var clicked = false; + + var ctx = new ContextMenu (); + + + + var menuItems = new MenuBarItem ( + [ + new MenuItem ("One", "", null), + new MenuItem ("Two", "", null), + new MenuItem ("Three", "", null), + new MenuBarItem ( + "Four", + [ + new MenuItem ("SubMenu1", "", null), + new MenuItem ("SubMenu2", "", ()=>clicked=true), + new MenuItem ("SubMenu3", "", null), + new MenuItem ("SubMenu4", "", null), + new MenuItem ("SubMenu5", "", null), + new MenuItem ("SubMenu6", "", null), + new MenuItem ("SubMenu7", "", null) + ] + ), + new MenuItem ("Five", "", null), + new MenuItem ("Six", "", null) + ] + ); + + using GuiTestContext c = With.A (40, 10,d) + .WithContextMenu (ctx, menuItems) + .ScreenShot ("Before open menu", _out) + + // Click in main area inside border + .RightClick (1, 1) + .ScreenShot ("After open menu", _out) + .Down () + .Down () + .Down () + .Right() + .ScreenShot ("After open submenu", _out) + .Down () + .Enter () + .ScreenShot ("Menu should be closed after selecting", _out) + .Stop () + .WriteOutLogs (_out); + Assert.True (clicked); + } +} diff --git a/Tests/IntegrationTests/FluentTests/V2TestDrivers.cs b/Tests/IntegrationTests/FluentTests/V2TestDrivers.cs new file mode 100644 index 000000000..00509815c --- /dev/null +++ b/Tests/IntegrationTests/FluentTests/V2TestDrivers.cs @@ -0,0 +1,15 @@ +using System.Collections; +using TerminalGuiFluentTesting; + +namespace IntegrationTests.FluentTests; + +public class V2TestDrivers : IEnumerable +{ + public IEnumerator GetEnumerator () + { + yield return new object [] { V2TestDriver.V2Win }; + yield return new object [] { V2TestDriver.V2Net }; + } + + IEnumerator IEnumerable.GetEnumerator () => GetEnumerator (); +} diff --git a/Tests/IntegrationTests/IntegrationTests.csproj b/Tests/IntegrationTests/IntegrationTests.csproj index 94b97b6a0..f279e21df 100644 --- a/Tests/IntegrationTests/IntegrationTests.csproj +++ b/Tests/IntegrationTests/IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/Tests/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs b/Tests/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs index 1a87b6b59..c1006cf51 100644 --- a/Tests/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs +++ b/Tests/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs @@ -6,7 +6,7 @@ using Moq; namespace UnitTests.ConsoleDrivers.V2; public class ApplicationV2Tests { - + private ApplicationV2 NewApplicationV2 () { var netInput = new Mock (); diff --git a/Tests/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs b/Tests/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs index d678c93e9..4c2a32e6f 100644 --- a/Tests/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs @@ -6,7 +6,7 @@ namespace UnitTests.ConsoleDrivers.V2; public class MainLoopCoordinatorTests { [Fact] - public void TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThread () + public async Task TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThread () { var mockLogger = new Mock (); @@ -26,7 +26,7 @@ public class MainLoopCoordinatorTests // StartAsync boots the main loop and the input thread. But if the input class bombs // on startup it is important that the exception surface at the call site and not lost - var ex = Assert.ThrowsAsync(c.StartAsync).Result; + var ex = await Assert.ThrowsAsync(c.StartAsync); Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message); diff --git a/Tests/UnitTests/Views/ContextMenuTests.cs b/Tests/UnitTests/Views/ContextMenuTests.cs index fbb4d4a22..4fc19d229 100644 --- a/Tests/UnitTests/Views/ContextMenuTests.cs +++ b/Tests/UnitTests/Views/ContextMenuTests.cs @@ -2135,4 +2135,84 @@ public class ContextMenuTests (ITestOutputHelper output) top.Dispose (); } + + [Fact] + [AutoInitShutdown] + public void Menu_Opened_In_SuperView_With_TabView_Has_Precedence_On_Key_Press () + { + var win = new Window + { + Title = "My Window", + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + // Tab View + var tabView = new TabView + { + X = 1, + Y = 1, + Width = Dim.Fill () - 2, + Height = Dim.Fill () - 2 + }; + tabView.AddTab (new () { DisplayText = "Tab 1" }, true); + tabView.AddTab (new () { DisplayText = "Tab 2" }, false); + win.Add (tabView); + + // Context Menu + var menuItems = new MenuBarItem ( + [ + new ("Item 1", "First item", () => MessageBox.Query ("Action", "Item 1 Clicked", "OK")), + new MenuBarItem ( + "Submenu", + new List + { + new [] + { + new MenuItem ( + "Sub Item 1", + "Submenu item", + () => { MessageBox.Query ("Action", "Sub Item 1 Clicked", "OK"); }) + } + }) + ]); + + var cm = new ContextMenu (); + + win.MouseClick += (s, e) => + { + if (e.Flags.HasFlag (MouseFlags.Button3Clicked)) // Right-click + { + cm.Position = e.Position; + cm.Show (menuItems); + } + }; + Application.Begin (win); + + cm.Show (menuItems); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorDown)); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorUp)); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorDown)); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorRight)); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.True (cm.MenuBar!.IsMenuOpen); + + Assert.True (Application.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.False (cm.MenuBar!.IsMenuOpen); + Assert.True (tabView.HasFocus); + + win.Dispose (); + } } diff --git a/Tests/UnitTests/Views/TabViewTests.cs b/Tests/UnitTests/Views/TabViewTests.cs index 286045294..b6fb467cc 100644 --- a/Tests/UnitTests/Views/TabViewTests.cs +++ b/Tests/UnitTests/Views/TabViewTests.cs @@ -434,9 +434,8 @@ public class TabViewTests (ITestOutputHelper output) Assert.Equal (btnSubView, top.MostFocused); Assert.True (Application.RaiseKeyDownEvent (Key.CursorUp)); - // TabRow now has TabGroup which only F6 is allowed - Assert.NotEqual (tab2, top.MostFocused); - Assert.Equal (btn, top.MostFocused); + Assert.Equal (tab2, top.MostFocused); + Assert.NotEqual (btn, top.MostFocused); Assert.True (Application.RaiseKeyDownEvent (Key.CursorUp)); Assert.Equal (btnSubView, top.MostFocused); @@ -459,7 +458,8 @@ public class TabViewTests (ITestOutputHelper output) // Press the cursor up key to focus the selected tab which it's the only way to do that Assert.True (Application.RaiseKeyDownEvent (Key.CursorUp)); Assert.Equal (tab2, tv.SelectedTab); - Assert.Equal (btn, top.Focused); + Assert.False (tab2.View.HasFocus); + Assert.Equal (tv, top.Focused); Assert.True (Application.RaiseKeyDownEvent (Key.CursorUp)); Assert.Equal (tv, top.Focused); diff --git a/UICatalog/Properties/launchSettings.json b/UICatalog/Properties/launchSettings.json index 793cef998..858308a30 100644 --- a/UICatalog/Properties/launchSettings.json +++ b/UICatalog/Properties/launchSettings.json @@ -17,7 +17,7 @@ }, "UICatalog --driver v2net": { "commandName": "Project", - "commandLineArgs": "--driver v2net" + "commandLineArgs": "--driver v2net -dl Trace" }, "WSL: UICatalog": { "commandName": "Executable",