Merge branch 'v2_develop' of tig:tig/Terminal.Gui into v2_develop

This commit is contained in:
Tig
2025-03-21 08:22:01 +01:00
24 changed files with 1202 additions and 12 deletions

View File

@@ -49,11 +49,10 @@ public class NetInputProcessor : InputProcessor<ConsoleKeyInfo>
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 ()}),";
}
}

View File

@@ -87,6 +87,7 @@
<InternalsVisibleTo Include="StressTests" />
<InternalsVisibleTo Include="IntegrationTests" />
<InternalsVisibleTo Include="TerminalGuiDesigner" />
<InternalsVisibleTo Include="TerminalGuiFluentTesting" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<!-- =================================================================== -->

View File

@@ -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);
}
/// <summary>
@@ -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 ();
}

View File

@@ -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

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<ClassDiagram MajorVersion="1" MinorVersion="1">
<Class Name="TerminalGuiFluentTesting.With" Collapsed="true">
<Position X="0.5" Y="1.5" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAIAAAAAA=</HashCode>
<FileName>With.cs</FileName>
</TypeIdentifier>
</Class>
<Class Name="TerminalGuiFluentTesting.FakeInput&lt;T&gt;" Collapsed="true">
<Position X="7" Y="1.5" Width="1.5" />
<TypeIdentifier>
<HashCode>AQAAAAAAACAAAQEAAAAgAAAAAAAAAAAAAAAAAAAAAAI=</HashCode>
<FileName>FakeInput.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.FakeNetInput" Collapsed="true">
<Position X="8.25" Y="2.75" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode>
<FileName>FakeNetInput.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.FakeWindowsInput" Collapsed="true">
<Position X="6" Y="2.75" Width="1.75" />
<TypeIdentifier>
<HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode>
<FileName>FakeWindowsInput.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.FakeOutput" Collapsed="true" BaseTypeListCollapsed="true">
<Position X="5.5" Y="0.5" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAAAgCAAgAAAAAAAAAAAAAAAQAAAMAAAAAEAAAA=</HashCode>
<FileName>FakeOutput.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.GuiTestContext" BaseTypeListCollapsed="true">
<Position X="2.25" Y="0.5" Width="2.25" />
<Compartments>
<Compartment Name="Fields" Collapsed="true" />
</Compartments>
<TypeIdentifier>
<HashCode>ABJAAAIAACBACRAAg4IAAAAgAJIEgQQAKACIBACAIgI=</HashCode>
<FileName>GuiTestContext.cs</FileName>
</TypeIdentifier>
<ShowAsAssociation>
<Field Name="_output" />
<Field Name="_winInput" />
<Field Name="_netInput" />
</ShowAsAssociation>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.TextWriterLoggerProvider" Collapsed="true">
<Position X="10" Y="2.75" Width="2" />
<TypeIdentifier>
<HashCode>AAAAAIAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode>
<FileName>TextWriterLoggerProvider.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.TextWriterLogger" Collapsed="true">
<Position X="10" Y="1.75" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAAAAAAAAAAAAAEAAAAAAAgAAAAAAAAIAAAAAAA=</HashCode>
<FileName>TextWriterLogger.cs</FileName>
</TypeIdentifier>
<Lollipop Position="0.2" />
</Class>
<Class Name="TerminalGuiFluentTesting.NetSequences">
<Position X="11" Y="4.5" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAACACAAAAAAgI=</HashCode>
<FileName>NetSequences.cs</FileName>
</TypeIdentifier>
</Class>
<Enum Name="TerminalGuiFluentTesting.V2TestDriver">
<Position X="9.25" Y="4.5" Width="1.5" />
<TypeIdentifier>
<HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAACAAA=</HashCode>
<FileName>V2TestDriver.cs</FileName>
</TypeIdentifier>
</Enum>
<Font Name="Segoe UI" Size="9" />
</ClassDiagram>

View File

@@ -0,0 +1,34 @@
using System.Collections.Concurrent;
using Terminal.Gui;
namespace TerminalGuiFluentTesting;
internal class FakeInput<T> : IConsoleInput<T>
{
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);
}
/// <inheritdoc/>
public void Dispose () { }
/// <inheritdoc/>
public void Initialize (ConcurrentQueue<T> inputBuffer) { InputBuffer = inputBuffer; }
public ConcurrentQueue<T> InputBuffer { get; set; }
/// <inheritdoc/>
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 });
}
}

View File

@@ -0,0 +1,6 @@
using Terminal.Gui;
namespace TerminalGuiFluentTesting;
internal class FakeNetInput (CancellationToken hardStopToken) : FakeInput<ConsoleKeyInfo> (hardStopToken), INetInput
{ }

View File

@@ -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; }
/// <inheritdoc/>
public void Dispose () { }
/// <inheritdoc/>
public void Write (ReadOnlySpan<char> text) { }
/// <inheritdoc/>
public void Write (IOutputBuffer buffer) { LastBuffer = buffer; }
/// <inheritdoc/>
public Size GetWindowSize () { return Size; }
/// <inheritdoc/>
public void SetCursorVisibility (CursorVisibility visibility) { }
/// <inheritdoc/>
public void SetCursorPosition (int col, int row) { }
}

View File

@@ -0,0 +1,6 @@
using Terminal.Gui;
namespace TerminalGuiFluentTesting;
internal class FakeWindowsInput (CancellationToken hardStopToken) : FakeInput<WindowsConsole.InputRecord> (hardStopToken), IWindowsInput
{ }

View File

@@ -0,0 +1,551 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Terminal.Gui;
using Terminal.Gui.ConsoleDrivers;
namespace TerminalGuiFluentTesting;
/// <summary>
/// Fluent API context for testing a Terminal.Gui application. Create
/// an instance using <see cref="With"/> static class.
/// </summary>
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<Toplevel> 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 ()
};
}
/// <summary>
/// Stops the application and waits for the background thread to exit.
/// </summary>
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;
}
/// <summary>
/// Cleanup to avoid state bleed between tests
/// </summary>
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 ();
}
/// <summary>
/// Adds the given <paramref name="v"/> to the current top level view
/// and performs layout.
/// </summary>
/// <param name="v"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Simulates changing the console size e.g. by resizing window in your operating system
/// </summary>
/// <param name="width">new Width for the console.</param>
/// <param name="height">new Height for the console.</param>
/// <returns></returns>
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 ();
}
/// <summary>
/// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
/// </summary>
/// <param name="writer"></param>
/// <returns></returns>
public GuiTestContext WriteOutLogs (TextWriter writer)
{
writer.WriteLine (_logsSb.ToString ());
return WaitIteration ();
}
/// <summary>
/// Waits until the end of the current iteration of the main loop. Optionally
/// running a given <paramref name="a"/> action on the UI thread at that time.
/// </summary>
/// <param name="a"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Performs the supplied <paramref name="doAction"/> immediately.
/// Enables running commands without breaking the Fluent API calls.
/// </summary>
/// <param name="doAction"></param>
/// <returns></returns>
public GuiTestContext Then (Action doAction)
{
doAction ();
return this;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="screenX">0 indexed screen coordinates</param>
/// <param name="screenY">0 indexed screen coordinates</param>
/// <returns></returns>
public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed, screenX, screenY); }
/// <summary>
/// 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.
/// </summary>
/// <param name="screenX">0 indexed screen coordinates</param>
/// <param name="screenY">0 indexed screen coordinates</param>
/// <returns></returns>
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;
}
/// <summary>
/// Simulates the Right cursor key
/// </summary>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
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;
}
/// <summary>
/// Simulates the Left cursor key
/// </summary>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
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;
}
/// <summary>
/// Simulates the up cursor key
/// </summary>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
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;
}
/// <summary>
/// Simulates pressing the Return/Enter (newline) key.
/// </summary>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
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;
}
/// <summary>
/// Registers a right click handler on the <see cref="LastView"/> added view (or root view) that
/// will open the supplied <paramref name="menuItems"/>.
/// </summary>
/// <param name="ctx"></param>
/// <param name="menuItems"></param>
/// <returns></returns>
public GuiTestContext WithContextMenu (ContextMenu ctx, MenuBarItem menuItems)
{
LastView.MouseEvent += (s, e) =>
{
if (e.Flags.HasFlag (MouseFlags.Button3Clicked))
{
ctx.Show (menuItems);
}
};
return this;
}
/// <summary>
/// The last view added (e.g. with <see cref="Add"/>) or the root/current top.
/// </summary>
public View LastView => _lastView ?? Application.Top ?? throw new ("Could not determine which view to add to");
/// <summary>
/// Send a full windows OS key including both down and up.
/// </summary>
/// <param name="fullKey"></param>
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);
}
/// <summary>
/// Sends a special key e.g. cursor key that does not map to a specific character
/// </summary>
/// <param name="specialKey"></param>
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 ();
}
}

View File

@@ -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<ConsoleKeyInfo> 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);
}
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;
namespace TerminalGuiFluentTesting;
internal class TextWriterLogger (TextWriter writer) : ILogger
{
public IDisposable? BeginScope<TState> (TState state) { return null; }
public bool IsEnabled (LogLevel logLevel) { return true; }
public void Log<TState> (
LogLevel logLevel,
EventId eventId,
TState state,
Exception? ex,
Func<TState, Exception?, string> formatter
)
{
writer.WriteLine (formatter (state, ex));
}
}

View File

@@ -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 (); }
}

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TerminalGuiFluentTesting;
/// <summary>
/// Which v2 driver simulation should be used
/// </summary>
public enum V2TestDriver
{
/// <summary>
/// The v2 windows driver with simulation I/O but core driver classes
/// </summary>
V2Win,
/// <summary>
/// The v2 net driver with simulation I/O but core driver classes
/// </summary>
V2Net
}

View File

@@ -0,0 +1,26 @@
using Terminal.Gui;
namespace TerminalGuiFluentTesting;
/// <summary>
/// Entry point to fluent assertions.
/// </summary>
public static class With
{
/// <summary>
/// Entrypoint to fluent assertions
/// </summary>
/// <param name="width"></param>
/// <param name="height"></param>
/// <param name="v2TestDriver">Which v2 v2TestDriver to use for the test</param>
/// <returns></returns>
public static GuiTestContext A<T> (int width, int height, V2TestDriver v2TestDriver) where T : Toplevel, new ()
{
return new (() => new T (), width, height,v2TestDriver);
}
/// <summary>
/// The global timeout to allow for any given application to run for before shutting down.
/// </summary>
public static TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds (30);
}

View File

@@ -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<Window> (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<Window> (40, 10, d);
}
[Theory]
[ClassData (typeof (V2TestDrivers))]
public void TestWindowsResize (V2TestDriver d)
{
var lbl = new Label
{
Width = Dim.Fill ()
};
using GuiTestContext c = With.A<Window> (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<Window> (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<Window> (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);
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections;
using TerminalGuiFluentTesting;
namespace IntegrationTests.FluentTests;
public class V2TestDrivers : IEnumerable<object []>
{
public IEnumerator<object []> GetEnumerator ()
{
yield return new object [] { V2TestDriver.V2Win };
yield return new object [] { V2TestDriver.V2Net };
}
IEnumerator IEnumerable.GetEnumerator () => GetEnumerator ();
}

View File

@@ -26,6 +26,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
<ProjectReference Include="..\..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" />
<ProjectReference Include="..\..\UICatalog\UICatalog.csproj" />
<ProjectReference Include="..\UnitTests\UnitTests.csproj" />
</ItemGroup>

View File

@@ -6,7 +6,7 @@ using Moq;
namespace UnitTests.ConsoleDrivers.V2;
public class ApplicationV2Tests
{
private ApplicationV2 NewApplicationV2 ()
{
var netInput = new Mock<INetInput> ();

View File

@@ -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<ILogger> ();
@@ -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<AggregateException>(c.StartAsync).Result;
var ex = await Assert.ThrowsAsync<AggregateException>(c.StartAsync);
Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message);

View File

@@ -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<MenuItem []>
{
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 ();
}
}

View File

@@ -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);

View File

@@ -17,7 +17,7 @@
},
"UICatalog --driver v2net": {
"commandName": "Project",
"commandLineArgs": "--driver v2net"
"commandLineArgs": "--driver v2net -dl Trace"
},
"WSL: UICatalog": {
"commandName": "Executable",