Browse Source

added mainloop tests and updated others

Charlie Kindel 5 years ago
parent
commit
1edf8189e1

+ 2 - 62
Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs

@@ -14,7 +14,7 @@ namespace Terminal.Gui {
 	/// <summary>
 	/// Implements a mock ConsoleDriver for unit testing
 	/// </summary>
-	public class FakeDriver : ConsoleDriver, IMainLoopDriver {
+	public class FakeDriver : ConsoleDriver {
 		int cols, rows;
 		/// <summary>
 		/// 
@@ -421,7 +421,7 @@ namespace Terminal.Gui {
 		public override void PrepareToRun (MainLoop mainLoop, Action<KeyEvent> keyHandler, Action<KeyEvent> keyDownHandler, Action<KeyEvent> keyUpHandler, Action<MouseEvent> mouseHandler)
 		{
 			// Note: Net doesn't support keydown/up events and thus any passed keyDown/UpHandlers will never be called
-			(mainLoop.Driver as FakeDriver).WindowsKeyPressed = delegate (ConsoleKeyInfo consoleKey) {
+			(mainLoop.Driver as NetMainLoop).KeyPressed = delegate (ConsoleKeyInfo consoleKey) {
 				var map = MapKey (consoleKey);
 				if (map == (Key)0xffffffff)
 					return;
@@ -463,65 +463,5 @@ namespace Terminal.Gui {
 		public override void UncookMouse ()
 		{
 		}
-
-		AutoResetEvent keyReady = new AutoResetEvent (false);
-		AutoResetEvent waitForProbe = new AutoResetEvent (false);
-		ConsoleKeyInfo? windowsKeyResult = null;
-
-		/// <summary>
-		/// 
-		/// </summary>
-		public Action<ConsoleKeyInfo> WindowsKeyPressed;
-		MainLoop mainLoop;
-
-		void WindowsKeyReader ()
-		{
-			while (true) {
-				waitForProbe.WaitOne ();
-				windowsKeyResult = FakeConsole.ReadKey (true);
-				keyReady.Set ();
-			}
-		}
-
-		void IMainLoopDriver.Setup (MainLoop mainLoop)
-		{
-			this.mainLoop = mainLoop;
-			Thread readThread = new Thread (WindowsKeyReader);
-			readThread.Start ();
-		}
-
-		void IMainLoopDriver.Wakeup ()
-		{
-		}
-
-		bool IMainLoopDriver.EventsPending (bool wait)
-		{
-			long now = DateTime.UtcNow.Ticks;
-
-			int waitTimeout;
-			if (mainLoop.timeouts.Count > 0) {
-				waitTimeout = (int)((mainLoop.timeouts.Keys [0] - now) / TimeSpan.TicksPerMillisecond);
-				if (waitTimeout < 0)
-					return true;
-			} else
-				waitTimeout = -1;
-
-			if (!wait)
-				waitTimeout = 0;
-
-			windowsKeyResult = null;
-			waitForProbe.Set ();
-			keyReady.WaitOne (waitTimeout);
-			return windowsKeyResult.HasValue;
-		}
-
-		void IMainLoopDriver.MainIteration ()
-		{
-			if (windowsKeyResult.HasValue) {
-				if (WindowsKeyPressed != null)
-					WindowsKeyPressed (windowsKeyResult.Value);
-				windowsKeyResult = null;
-			}
-		}
 	}
 }

+ 31 - 13
Terminal.Gui/ConsoleDrivers/NetDriver.cs

@@ -354,7 +354,7 @@ namespace Terminal.Gui {
 		public override void PrepareToRun (MainLoop mainLoop, Action<KeyEvent> keyHandler, Action<KeyEvent> keyDownHandler, Action<KeyEvent> keyUpHandler, Action<MouseEvent> mouseHandler)
 		{
 			// Note: Net doesn't support keydown/up events and thus any passed keyDown/UpHandlers will never be called
-			(mainLoop.Driver as NetMainLoop).WindowsKeyPressed = delegate (ConsoleKeyInfo consoleKey) {
+			(mainLoop.Driver as NetMainLoop).KeyPressed = delegate (ConsoleKeyInfo consoleKey) {
 				var map = MapKey (consoleKey);
 				if (map == (Key)0xffffffff)
 					return;
@@ -393,22 +393,41 @@ namespace Terminal.Gui {
 	/// be used on Windows and Unix, it is cross platform but lacks things like
 	/// file descriptor monitoring.
 	/// </summary>
-	class NetMainLoop : IMainLoopDriver {
+	/// <remarks>
+	/// This implementation is used for both NetDriver and FakeDriver. 
+	/// </remarks>
+	public class NetMainLoop : IMainLoopDriver {
 		AutoResetEvent keyReady = new AutoResetEvent (false);
 		AutoResetEvent waitForProbe = new AutoResetEvent (false);
-		ConsoleKeyInfo? windowsKeyResult = null;
-		public Action<ConsoleKeyInfo> WindowsKeyPressed;
+		ConsoleKeyInfo? keyResult = null;
 		MainLoop mainLoop;
-
-		public NetMainLoop ()
+		Func<ConsoleKeyInfo> consoleKeyReaderFn = null;
+
+		/// <summary>
+		/// Invoked when a Key is pressed.
+		/// </summary>
+		public Action<ConsoleKeyInfo> KeyPressed;
+
+		/// <summary>
+		/// Initializes the class.
+		/// </summary>
+		/// <remarks>
+		///   Passing a consoleKeyReaderfn is provided to support unit test sceanrios.
+		/// </remarks>
+		/// <param name="consoleKeyReaderFn">The method to be called to get a key from the console.</param>
+		public NetMainLoop (Func<ConsoleKeyInfo> consoleKeyReaderFn = null)
 		{
+			if (consoleKeyReaderFn == null) {
+				throw new ArgumentNullException ("key reader function must be provided.");
+			}
+			this.consoleKeyReaderFn = consoleKeyReaderFn;
 		}
 
 		void WindowsKeyReader ()
 		{
 			while (true) {
 				waitForProbe.WaitOne ();
-				windowsKeyResult = Console.ReadKey (true);
+				keyResult = consoleKeyReaderFn();
 				keyReady.Set ();
 			}
 		}
@@ -439,18 +458,17 @@ namespace Terminal.Gui {
 			if (!wait)
 				waitTimeout = 0;
 
-			windowsKeyResult = null;
+			keyResult = null;
 			waitForProbe.Set ();
 			keyReady.WaitOne (waitTimeout);
-			return windowsKeyResult.HasValue;
+			return keyResult.HasValue;
 		}
 
 		void IMainLoopDriver.MainIteration ()
 		{
-			if (windowsKeyResult.HasValue) {
-				if (WindowsKeyPressed != null)
-					WindowsKeyPressed (windowsKeyResult.Value);
-				windowsKeyResult = null;
+			if (keyResult.HasValue) {
+				KeyPressed?.Invoke (keyResult.Value);
+				keyResult = null;
 			}
 		}
 	}

+ 8 - 6
Terminal.Gui/Core/Application.cs

@@ -157,30 +157,32 @@ namespace Terminal.Gui {
 		/// Creates a <see cref="Toplevel"/> and assigns it to <see cref="Top"/> and <see cref="CurrentView"/>
 		/// </para>
 		/// </remarks>
-		public static void Init (ConsoleDriver driver = null) => Init (() => Toplevel.Create (), driver);
+		public static void Init (ConsoleDriver driver = null, IMainLoopDriver mainLoopDriver = null) => Init (() => Toplevel.Create (), driver, mainLoopDriver);
 
 		internal static bool _initialized = false;
 
 		/// <summary>
 		/// Initializes the Terminal.Gui application
 		/// </summary>
-		static void Init (Func<Toplevel> topLevelFactory, ConsoleDriver driver = null)
+		static void Init (Func<Toplevel> topLevelFactory, ConsoleDriver driver = null, IMainLoopDriver mainLoopDriver = null)
 		{
 			if (_initialized) return;
 
 			// This supports Unit Tests and the passing of a mock driver/loopdriver
 			if (driver != null) {
+				if (mainLoopDriver == null) {
+					throw new ArgumentNullException ("mainLoopDriver cannot be null if driver is provided.");
+				}
 				Driver = driver;
 				Driver.Init (TerminalResized);
-				MainLoop = new MainLoop ((IMainLoopDriver)driver);
+				MainLoop = new MainLoop (mainLoopDriver);
 				SynchronizationContext.SetSynchronizationContext (new MainLoopSyncContext (MainLoop));
 			}
 
 			if (Driver == null) {
 				var p = Environment.OSVersion.Platform;
-				IMainLoopDriver mainLoopDriver;
 				if (UseSystemConsole) {
-					mainLoopDriver = new NetMainLoop ();
+					mainLoopDriver = new NetMainLoop (() => Console.ReadKey (true));
 					Driver = new NetDriver ();
 				} else if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) {
 					var windowsDriver = new WindowsDriver ();
@@ -487,7 +489,7 @@ namespace Terminal.Gui {
 		}
 
 		/// <summary>
-		/// Shutdown an application initialized with <see cref="Init(ConsoleDriver)"/>
+		/// Shutdown an application initialized with <see cref="Init(ConsoleDriver, IMainLoopDriver)"/>
 		/// </summary>
 		/// /// <param name="closeDriver"><c>true</c>Closes the application.<c>false</c>Closes toplevels only.</param>
 		public static void Shutdown (bool closeDriver = true)

+ 26 - 17
Terminal.Gui/Core/MainLoop.cs

@@ -53,27 +53,26 @@ namespace Terminal.Gui {
 		internal SortedList<long, Timeout> timeouts = new SortedList<long, Timeout> ();
 		internal List<Func<bool>> idleHandlers = new List<Func<bool>> ();
 
-		IMainLoopDriver driver;
-
 		/// <summary>
 		/// The current IMainLoopDriver in use.
 		/// </summary>
 		/// <value>The driver.</value>
-		public IMainLoopDriver Driver => driver;
+		public IMainLoopDriver Driver { get; }
 
 		/// <summary>
-		///  Creates a new Mainloop, to run it you must provide a driver, and choose
-		///  one of the implementations UnixMainLoop, NetMainLoop or WindowsMainLoop.
+		///  Creates a new Mainloop. 
 		/// </summary>
+		/// <param name="driver">Should match the <see cref="ConsoleDriver"/> (one of the implementations UnixMainLoop, NetMainLoop or WindowsMainLoop).</param>
 		public MainLoop (IMainLoopDriver driver)
 		{
-			this.driver = driver;
+			Driver = driver;
 			driver.Setup (this);
 		}
 
 		/// <summary>
-		///   Runs @action on the thread that is processing events
+		///   Runs <c>action</c> on the thread that is processing events
 		/// </summary>
+		/// <param name="action">the action to be invoked on the main processing thread.</param>
 		public void Invoke (Action action)
 		{
 			AddIdle (() => {
@@ -83,8 +82,17 @@ namespace Terminal.Gui {
 		}
 
 		/// <summary>
-		///   Executes the specified @idleHandler on the idle loop.  The return value is a token to remove it.
+		///   Adds specified idle handler function to mainloop processing. The handler function will be called once per iteration of the main loop after other events have been handled.
 		/// </summary>
+		/// <remarks>
+		/// <para>
+		///   Remove an idle hander by calling <see cref="RemoveIdle(Func{bool})"/> with the token this method returns.
+		/// </para>
+		/// <para>
+		///   If the <c>idleHandler</c> returns <c>false</c> it will be removed and not called subsequently.
+		/// </para>
+		/// </remarks>
+		/// <param name="idleHandler">Token that can be used to remove the idle handler with <see cref="RemoveIdle(Func{bool})"/> .</param>
 		public Func<bool> AddIdle (Func<bool> idleHandler)
 		{
 			lock (idleHandlers)
@@ -94,12 +102,13 @@ namespace Terminal.Gui {
 		}
 
 		/// <summary>
-		///   Removes the specified idleHandler from processing.
+		///   Removes an idle handler added with <see cref="AddIdle(Func{bool})"/> from processing.
 		/// </summary>
-		public void RemoveIdle (Func<bool> idleHandler)
+		/// <param name="token">A token returned by <see cref="AddIdle(Func{bool})"/></param>
+		public void RemoveIdle (Func<bool> token)
 		{
-			lock (idleHandler)
-				idleHandlers.Remove (idleHandler);
+			lock (token)
+				idleHandlers.Remove (token);
 		}
 
 		void AddTimeout (TimeSpan time, Timeout timeout)
@@ -113,10 +122,10 @@ namespace Terminal.Gui {
 		/// <remarks>
 		///   When time time specified passes, the callback will be invoked.
 		///   If the callback returns true, the timeout will be reset, repeating
-		///   the invocation. If it returns false, the timeout will stop.
+		///   the invocation. If it returns false, the timeout will stop and be removed.
 		///
 		///   The returned value is a token that can be used to stop the timeout
-		///   by calling RemoveTimeout.
+		///   by calling <see cref="RemoveTimeout(object)"/>.
 		/// </remarks>
 		public object AddTimeout (TimeSpan time, Func<MainLoop, bool> callback)
 		{
@@ -182,7 +191,7 @@ namespace Terminal.Gui {
 		public void Stop ()
 		{
 			running = false;
-			driver.Wakeup ();
+			Driver.Wakeup ();
 		}
 
 		/// <summary>
@@ -195,7 +204,7 @@ namespace Terminal.Gui {
 		/// </remarks>
 		public bool EventsPending (bool wait = false)
 		{
-			return driver.EventsPending (wait);
+			return Driver.EventsPending (wait);
 		}
 
 		/// <summary>
@@ -212,7 +221,7 @@ namespace Terminal.Gui {
 			if (timeouts.Count > 0)
 				RunTimers ();
 
-			driver.MainIteration ();
+			Driver.MainIteration ();
 
 			lock (idleHandlers) {
 				if (idleHandlers.Count > 0)

+ 1 - 1
Terminal.Gui/Core/Toplevel.cs

@@ -20,7 +20,7 @@ namespace Terminal.Gui {
 	///     been called (which sets the <see cref="Toplevel.Running"/> property to false). 
 	///   </para>
 	///   <para>
-	///     A Toplevel is created when an application initialzies Terminal.Gui by callling <see cref="Application.Init(ConsoleDriver)"/>.
+	///     A Toplevel is created when an application initialzies Terminal.Gui by callling <see cref="Application.Init(ConsoleDriver, IMainLoopDriver)"/>.
 	///     The application Toplevel can be accessed via <see cref="Application.Top"/>. Additional Toplevels can be created 
 	///     and run (e.g. <see cref="Dialog"/>s. To run a Toplevel, create the <see cref="Toplevel"/> and 
 	///     call <see cref="Application.Run(Toplevel, bool)"/>.

+ 24 - 14
UnitTests/ApplicationTests.cs

@@ -21,7 +21,7 @@ namespace Terminal.Gui {
 			Assert.Null (Application.MainLoop);
 			Assert.Null (Application.Driver);
 
-			Application.Init (new FakeDriver ());
+			Application.Init (new FakeDriver (), new NetMainLoop (() => FakeConsole.ReadKey (true)));
 			Assert.NotNull (Application.Current);
 			Assert.NotNull (Application.CurrentView);
 			Assert.NotNull (Application.Top);
@@ -57,12 +57,23 @@ namespace Terminal.Gui {
 			Assert.Throws<InvalidOperationException> (() => rs.Dispose ());
 		}
 
+		void Init ()
+		{
+			Application.Init (new FakeDriver (), new NetMainLoop (() => FakeConsole.ReadKey(true)));
+			Assert.NotNull (Application.Driver);
+			Assert.NotNull (Application.MainLoop);
+		}
+
+		void Shutdown ()
+		{
+			Application.Shutdown (true);
+		}
+
 		[Fact]
 		public void Begin_End_Cleana_Up ()
 		{
 			// Setup Mock driver
-			Application.Init (new FakeDriver ());
-			Assert.NotNull (Application.Driver);
+			Init ();
 
 			// Test null Toplevel
 			Assert.Throws<ArgumentNullException> (() => Application.Begin (null));
@@ -79,15 +90,14 @@ namespace Terminal.Gui {
 			Assert.Null (Application.MainLoop);
 			Assert.Null (Application.Driver);
 
-			Application.Shutdown (true);
+			Shutdown ();
 		}
 
 		[Fact]
 		public void RequestStop_Stops ()
 		{
 			// Setup Mock driver
-			Application.Init (new FakeDriver ());
-			Assert.NotNull (Application.Driver);
+			Init ();
 
 			var top = new Toplevel ();
 			var rs = Application.Begin (top);
@@ -112,8 +122,7 @@ namespace Terminal.Gui {
 		public void RunningFalse_Stops ()
 		{
 			// Setup Mock driver
-			Application.Init (new FakeDriver ());
-			Assert.NotNull (Application.Driver);
+			Init ();
 
 			var top = new Toplevel ();
 			var rs = Application.Begin (top);
@@ -139,20 +148,17 @@ namespace Terminal.Gui {
 		public void KeyUp_Event ()
 		{
 			// Setup Mock driver
-			Application.Init (new FakeDriver ());
-			Assert.NotNull (Application.Driver);
+			Init ();
 
 			// Setup some fake kepresses (This)
 			var input = "Tests";
 
 			// Put a control-q in at the end
 			Console.MockKeyPresses.Push (new ConsoleKeyInfo ('q', ConsoleKey.Q, shift: false, alt: false, control: true));
-			foreach (var c in input.Reverse()) {
+			foreach (var c in input.Reverse ()) {
 				if (char.IsLetter (c)) {
 					Console.MockKeyPresses.Push (new ConsoleKeyInfo (char.ToLower (c), (ConsoleKey)char.ToUpper (c), shift: char.IsUpper (c), alt: false, control: false));
-				}
-				else
-				{
+				} else {
 					Console.MockKeyPresses.Push (new ConsoleKeyInfo (c, (ConsoleKey)c, shift: false, alt: false, control: false));
 				}
 			}
@@ -162,6 +168,10 @@ namespace Terminal.Gui {
 			int iterations = 0;
 			Application.Iteration = () => {
 				iterations++;
+				// Stop if we run out of control...
+				if (iterations > 10) {
+					Application.RequestStop ();
+				}
 			};
 
 			int keyUps = 0;

+ 391 - 1
UnitTests/MainLoopTests.cs

@@ -1,17 +1,407 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
 using System.Linq;
+using System.Runtime.InteropServices.ComTypes;
+using System.Threading;
 using Terminal.Gui;
 using Xunit;
+using Xunit.Sdk;
 
 // Alais Console to MockConsole so we don't accidentally use Console
 using Console = Terminal.Gui.FakeConsole;
 
 namespace Terminal.Gui {
 	public class MainLoopTests {
+
+		[Fact]
+		public void Constructor_Setups_Driver ()
+		{
+			var ml = new MainLoop (new NetMainLoop(() => FakeConsole.ReadKey (true)));
+			Assert.NotNull (ml.Driver);
+		}
+
+		// Idle Handler tests
+		[Fact]
+		public void AddIdle_Adds_And_Removes ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			Func<bool> fnTrue = () => { return true; };
+			Func<bool> fnFalse = () => { return false; };
+			ml.AddIdle (fnTrue);
+			ml.AddIdle (fnFalse);
+
+			ml.RemoveIdle (fnTrue);
+
+			// BUGBUG: This doens't throw or indicate an error. Ideally RemoveIdle would either 
+			// trhow an exception in this case, or return an error.
+			ml.RemoveIdle (fnTrue);
+
+			ml.RemoveIdle (fnFalse);
+
+			// BUGBUG: This doesn't throw an exception or indicate an error. Ideally RemoveIdle would either 
+			// trhow an exception in this case, or return an error.
+			ml.RemoveIdle (fnFalse);
+
+			// Add again, but with dupe
+			ml.AddIdle (fnTrue);
+			ml.AddIdle (fnTrue);
+
+			ml.RemoveIdle (fnTrue);
+			ml.RemoveIdle (fnTrue);
+
+			// BUGBUG: This doesn't throw an exception or indicate an error. Ideally RemoveIdle would either 
+			// trhow an exception in this case, or return an error.
+			ml.RemoveIdle (fnTrue);
+		}
+
+		[Fact]
+		public void AddIdle_Function_GetsCalled_OnIteration ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn = () => {
+				functionCalled++;
+				return true;
+			};
+
+			ml.AddIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (1, functionCalled);
+		}
+
+		[Fact]
+		public void RemoveIdle_Function_NotCalled ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn = () => {
+				functionCalled++;
+				return true;
+			};
+
+			functionCalled = 0;
+			ml.RemoveIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (0, functionCalled);
+		}
+
 		[Fact]
-		public void Init_Shutdown_Cleans_Up ()
+		public void AddThenRemoveIdle_Function_NotCalled ()
 		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn = () => {
+				functionCalled++;
+				return true;
+			};
+
+			functionCalled = 0;
+			ml.AddIdle (fn);
+			ml.RemoveIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (0, functionCalled);
 		}
+
+		[Fact]
+		public void AddTwice_Function_CalledTwice ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn = () => {
+				functionCalled++;
+				return true;
+			};
+
+			functionCalled = 0;
+			ml.AddIdle (fn);
+			ml.AddIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (2, functionCalled);
+
+			functionCalled = 0;
+			ml.RemoveIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (1, functionCalled);
+
+			functionCalled = 0;
+			ml.RemoveIdle (fn);
+			ml.MainIteration ();
+			Assert.Equal (0, functionCalled);
+		}
+
+		[Fact]
+		public void False_Idle_Stops_It_Being_Called_Again ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn1 = () => {
+				functionCalled++;
+				if (functionCalled == 10) {
+					return false;
+				}
+				return true;
+			};
+
+			// Force stop if 20 iterations
+			var stopCount = 0;
+			Func<bool> fnStop = () => {
+				stopCount++;
+				if (stopCount == 20) {
+					ml.Stop ();
+				}
+				return true;
+			};
+
+			ml.AddIdle (fnStop);
+			ml.AddIdle (fn1);
+			ml.Run ();
+			ml.RemoveIdle (fnStop);
+			ml.RemoveIdle (fn1);
+
+			Assert.Equal (10, functionCalled);
+		}
+
+		[Fact]
+		public void AddIdle_Twice_Returns_False_Called_Twice ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn1 = () => {
+				functionCalled++;
+				return false;
+			};
+
+			// Force stop if 10 iterations
+			var stopCount = 0;
+			Func<bool> fnStop = () => {
+				stopCount++;
+				if (stopCount == 10) {
+					ml.Stop ();
+				}
+				return true;
+			};
+
+			ml.AddIdle (fnStop);
+			ml.AddIdle (fn1);
+			ml.AddIdle (fn1);
+			ml.Run ();
+			ml.RemoveIdle (fnStop);
+			ml.RemoveIdle (fn1);
+			ml.RemoveIdle (fn1);
+
+			Assert.Equal (2, functionCalled);
+		}
+
+		[Fact]
+		public void Run_Runs_Idle_Stop_Stops_Idle ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var functionCalled = 0;
+			Func<bool> fn = () => {
+				functionCalled++;
+				if (functionCalled == 10) {
+					ml.Stop ();
+				}
+				return true;
+			};
+
+			ml.AddIdle (fn);
+			ml.Run ();
+			ml.RemoveIdle (fn);
+
+			Assert.Equal (10, functionCalled);
+		}
+
+		// Timeout Handler Tests
+		[Fact]
+		public void AddTimer_Adds_Removes_NoFaults ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = 100;
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				callbackCount++;
+				return true;
+			};
+
+			var token = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback);
+
+			ml.RemoveTimeout (token);
+
+			// BUGBUG: This should probably fault?
+			ml.RemoveTimeout (token);
+		}
+
+		[Fact]
+		public void AddTimer_Run_Called ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = 100;
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				callbackCount++;
+				ml.Stop ();
+				return true;
+			};
+
+			var token = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback);
+			ml.Run ();
+			ml.RemoveTimeout (token);
+
+			Assert.Equal (1, callbackCount);
+		}
+
+
+		class MillisecondTolerance : IEqualityComparer<TimeSpan> {
+			int _tolerance = 0;
+			public MillisecondTolerance (int tolerance) { _tolerance = tolerance; }
+			public bool Equals (TimeSpan x, TimeSpan y) => Math.Abs (x.Milliseconds - y.Milliseconds) <= _tolerance;
+			public int GetHashCode (TimeSpan obj) => obj.GetHashCode ();
+		}
+
+		[Fact]
+		public void AddTimer_Run_CalledAtApproximatelyRightTime ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = TimeSpan.FromMilliseconds (50);
+			var watch = new System.Diagnostics.Stopwatch ();
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				watch.Stop ();
+				callbackCount++;
+				ml.Stop ();
+				return true;
+			};
+
+			var token = ml.AddTimeout (ms, callback);
+			watch.Start ();
+			ml.Run ();
+			// +/- 10ms should be good enuf
+			// https://github.com/xunit/assert.xunit/pull/25
+			Assert.Equal<TimeSpan> (ms * callbackCount, watch.Elapsed, new MillisecondTolerance (10));
+
+			ml.RemoveTimeout (token);
+			Assert.Equal (1, callbackCount);
+		}
+
+		[Fact]
+		public void AddTimer_Run_CalledTwiceApproximatelyRightTime ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = TimeSpan.FromMilliseconds (50);
+			var watch = new System.Diagnostics.Stopwatch ();
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				callbackCount++;
+				if (callbackCount == 2) {
+					watch.Stop ();
+					ml.Stop ();
+				}
+				return true;
+			};
+
+			var token = ml.AddTimeout (ms, callback);
+			watch.Start ();
+			ml.Run ();
+			// +/- 10ms should be good enuf
+			// https://github.com/xunit/assert.xunit/pull/25
+			Assert.Equal<TimeSpan> (ms * callbackCount, watch.Elapsed, new MillisecondTolerance (10));
+
+			ml.RemoveTimeout (token);
+			Assert.Equal (2, callbackCount);
+		}
+
+		[Fact]
+		public void AddTimer_Remove_NotCalled ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = TimeSpan.FromMilliseconds (50);
+
+			// Force stop if 10 iterations
+			var stopCount = 0;
+			Func<bool> fnStop = () => {
+				stopCount++;
+				if (stopCount == 10) {
+					ml.Stop ();
+				}
+				return true;
+			};
+			ml.AddIdle (fnStop);
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				callbackCount++;
+				return true;
+			};
+
+			var token = ml.AddTimeout (ms, callback);
+			ml.RemoveTimeout (token);
+			ml.Run ();
+			Assert.Equal (0, callbackCount);
+		}
+
+		[Fact]
+		public void AddTimer_ReturnFalse_StopsBeingCalled ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+			var ms = TimeSpan.FromMilliseconds (50);
+
+			// Force stop if 10 iterations
+			var stopCount = 0;
+			Func<bool> fnStop = () => {
+				Thread.Sleep (10); // Sleep to enable timeer to fire
+				stopCount++;
+				if (stopCount == 10) {
+					ml.Stop ();
+				}
+				return true;
+			};
+			ml.AddIdle (fnStop);
+
+			var callbackCount = 0;
+			Func<MainLoop, bool> callback = (MainLoop loop) => {
+				callbackCount++;
+				return false;
+			};
+
+			var token = ml.AddTimeout (ms, callback);
+			ml.Run ();
+			Assert.Equal (1, callbackCount);
+			Assert.Equal (10, stopCount);
+			ml.RemoveTimeout (token);
+		}
+
+		// Invoke Tests
+		// TODO: Test with threading scenarios
+		[Fact]
+		public void Invoke_Adds_Idle ()
+		{
+			var ml = new MainLoop (new NetMainLoop (() => FakeConsole.ReadKey (true)));
+
+			var actionCalled = 0;
+			ml.Invoke (() => { actionCalled++; });
+			ml.MainIteration ();
+			Assert.Equal (1, actionCalled);
+		}
+
+		// TODO: EventsPending tests
+		// - wait = true
+		// - wait = false
+
+		// TODO: Add IMainLoop tests
 	}
 }

+ 1 - 0
UnitTests/UnitTests.csproj

@@ -7,6 +7,7 @@
 
   <ItemGroup>
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
+    <PackageReference Include="System.Collections" Version="4.3.0" />
     <PackageReference Include="xunit" Version="2.4.0" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
     <PackageReference Include="coverlet.collector" Version="1.2.0" />