Add dotnet-counters console exporter tests (#4376)
authorNoah Falk <noahfalk@users.noreply.github.com>
Sat, 6 Jan 2024 01:05:29 +0000 (17:05 -0800)
committerGitHub <noreply@github.com>
Sat, 6 Jan 2024 01:05:29 +0000 (17:05 -0800)
Today all the testing of the console formatting is mostly manual. This
adds some automated testing of the formatting logic in preparation to
both refactor and make some design changes. The tests should make it
very obvious in the future when the output formatting intentionally
changes.

src/Tools/dotnet-counters/CounterMonitor.cs
src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs
src/Tools/dotnet-counters/Exporters/DefaultConsole.cs [new file with mode: 0644]
src/Tools/dotnet-counters/Exporters/IConsole.cs [new file with mode: 0644]
src/tests/dotnet-counters/ConsoleExporterTests.cs [new file with mode: 0644]
src/tests/dotnet-counters/MockConsole.cs [new file with mode: 0644]

index 04159229abf6637e718f9f59ff05f254a01f7078..94e6a7cac3e895bbefedcb9b39e6a58b8fa2d5dd 100644 (file)
@@ -16,6 +16,7 @@ using Microsoft.Diagnostics.Monitoring.EventPipe;
 using Microsoft.Diagnostics.NETCore.Client;
 using Microsoft.Diagnostics.Tools.Counters.Exporters;
 using Microsoft.Internal.Common.Utils;
+using IConsole = System.CommandLine.IConsole;
 
 namespace Microsoft.Diagnostics.Tools.Counters
 {
@@ -196,7 +197,7 @@ namespace Microsoft.Diagnostics.Tools.Counters
                         // the launch command may misinterpret app arguments as the old space separated
                         // provider list so we need to ignore it in that case
                         _counterList = ConfigureCounters(counters, _processId != 0 ? counter_list : null);
-                        _renderer = new ConsoleWriter(useAnsi);
+                        _renderer = new ConsoleWriter(new DefaultConsole(useAnsi));
                         _diagnosticsClient = holder.Client;
                         _settings = new MetricsPipelineSettings();
                         _settings.Duration = duration == TimeSpan.Zero ? Timeout.InfiniteTimeSpan : duration;
index 8795ba4e73067f889e13deb4666c1590011f8845..daae159e745cdf9aa47d426df893a8edb5cd83e5 100644 (file)
@@ -72,14 +72,14 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
         private string _errorText;
 
         private int _maxRow = -1;
-        private readonly bool _useAnsi;
 
         private int _consoleHeight = -1;
         private int _consoleWidth = -1;
+        private IConsole _console;
 
-        public ConsoleWriter(bool useAnsi)
+        public ConsoleWriter(IConsole console)
         {
-            _useAnsi = useAnsi;
+            _console = console;
         }
 
         public void Initialize()
@@ -98,33 +98,10 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             AssignRowsAndInitializeDisplay();
         }
 
-        private void SetCursorPosition(int col, int row)
-        {
-            if (_useAnsi)
-            {
-                Console.Write($"\u001b[{row + 1 - _topRow};{col + 1}H");
-            }
-            else
-            {
-                Console.SetCursorPosition(col, row);
-            }
-        }
-
-        private void Clear()
-        {
-            if (_useAnsi)
-            {
-                Console.Write($"\u001b[H\u001b[J");
-            }
-            else
-            {
-                Console.Clear();
-            }
-        }
         private void UpdateStatus()
         {
-            SetCursorPosition(0, _statusRow);
-            Console.Write($"    Status: {GetStatus()}{new string(' ', 40)}"); // Write enough blanks to clear previous status.
+            _console.SetCursorPosition(0, _statusRow);
+            _console.Write($"    Status: {GetStatus()}{new string(' ', 40)}"); // Write enough blanks to clear previous status.
         }
 
         private string GetStatus() => !_initialized ? "Waiting for initial payload..." : (_paused ? "Paused" : "Running");
@@ -132,7 +109,7 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
         /// <summary>Clears display and writes out category and counter name layout.</summary>
         public void AssignRowsAndInitializeDisplay()
         {
-            Clear();
+            _console.Clear();
 
             // clear row data on all counters
             foreach (ObservedProvider provider in _providers.Values)
@@ -147,20 +124,20 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                 }
             }
 
-            _consoleWidth = Console.WindowWidth;
-            _consoleHeight = Console.WindowHeight;
+            _consoleWidth = _console.WindowWidth;
+            _consoleHeight = _console.WindowHeight;
             _maxNameLength = Math.Max(Math.Min(80, _consoleWidth) - (CounterValueLength + Indent + 1), 0); // Truncate the name to prevent line wrapping as long as the console width is >= CounterValueLength + Indent + 1 characters
 
 
-            int row = Console.CursorTop;
+            int row = _console.CursorTop;
             _topRow = row;
 
             string instructions = "Press p to pause, r to resume, q to quit.";
-            Console.WriteLine((instructions.Length < _consoleWidth) ? instructions : instructions.Substring(0, _consoleWidth)); row++;
-            Console.WriteLine($"    Status: {GetStatus()}"); _statusRow = row++;
+            _console.WriteLine((instructions.Length < _consoleWidth) ? instructions : instructions.Substring(0, _consoleWidth)); row++;
+            _console.WriteLine($"    Status: {GetStatus()}"); _statusRow = row++;
             if (_errorText != null)
             {
-                Console.WriteLine(_errorText);
+                _console.WriteLine(_errorText);
                 row += GetLineWrappedLines(_errorText);
             }
 
@@ -173,12 +150,12 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
 
                 if (lineOutput != null)
                 {
-                    Console.Write(lineOutput);
+                    _console.Write(lineOutput);
                 }
 
                 if (row < _consoleHeight + _topRow - 1) // prevents screen from scrolling due to newline on last line of console
                 {
-                    Console.WriteLine();
+                    _console.WriteLine();
                 }
 
                 if (counterRow != null)
@@ -290,7 +267,7 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                     redraw = true;
                 }
 
-                if (Console.WindowWidth != _consoleWidth || Console.WindowHeight != _consoleHeight)
+                if (_console.WindowWidth != _consoleWidth || _console.WindowHeight != _consoleHeight)
                 {
                     redraw = true;
                 }
@@ -305,8 +282,8 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                 {
                     return;
                 }
-                SetCursorPosition(Indent + _maxNameLength + 1, row);
-                Console.Write(FormatValue(payload.Value));
+                _console.SetCursorPosition(Indent + _maxNameLength + 1, row);
+                _console.Write(FormatValue(payload.Value));
             }
         }
 
@@ -352,11 +329,11 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             }
         }
 
-        private static int GetLineWrappedLines(string text)
+        private int GetLineWrappedLines(string text)
         {
             string[] lines = text.Split(Environment.NewLine);
             int lineCount = lines.Length;
-            int width = Console.BufferWidth;
+            int width = _console.BufferWidth;
             foreach (string line in lines)
             {
                 lineCount += (int)Math.Floor(((float)line.Length) / width);
@@ -430,8 +407,8 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
 
                     if (row > -1)
                     {
-                        SetCursorPosition(0, row);
-                        Console.WriteLine();
+                        _console.SetCursorPosition(0, row);
+                        _console.WriteLine();
                     }
                 }
             }
diff --git a/src/Tools/dotnet-counters/Exporters/DefaultConsole.cs b/src/Tools/dotnet-counters/Exporters/DefaultConsole.cs
new file mode 100644 (file)
index 0000000..64d19b7
--- /dev/null
@@ -0,0 +1,61 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Diagnostics.Tools.Counters.Exporters
+{
+    /// <summary>
+    /// The default implementation of IConsole maps everything to System.Console. In the future
+    /// maybe we could map it to System.CommandLine's IConsole, but right now that interface doesn't
+    /// have enough functionality for everything we need.
+    /// </summary>
+    internal class DefaultConsole : IConsole
+    {
+        private readonly bool _useAnsi;
+        public DefaultConsole(bool useAnsi)
+        {
+            _useAnsi = useAnsi;
+        }
+
+        public int WindowHeight => Console.WindowHeight;
+
+        public int WindowWidth => Console.WindowWidth;
+
+        // Not all platforms implement this and that is OK. Callers need to be prepared for NotSupportedException
+#pragma warning disable CA1416
+        public bool CursorVisible { get => Console.CursorVisible; set { Console.CursorVisible = value; } }
+#pragma warning restore CA1416
+
+        public int CursorTop => Console.CursorTop;
+
+        public int BufferWidth => Console.BufferWidth;
+
+        public void Clear()
+        {
+            if (_useAnsi)
+            {
+                Write($"\u001b[H\u001b[J");
+            }
+            else
+            {
+                Console.Clear();
+            }
+        }
+
+        public void SetCursorPosition(int col, int row)
+        {
+            if (_useAnsi)
+            {
+                Write($"\u001b[{row + 1};{col + 1}H");
+            }
+            else
+            {
+                Console.SetCursorPosition(col, row);
+            }
+        }
+        public void Write(string text) => Console.Write(text);
+        public void WriteLine(string text) => Console.WriteLine(text);
+        public void WriteLine() => Console.WriteLine();
+    }
+}
diff --git a/src/Tools/dotnet-counters/Exporters/IConsole.cs b/src/Tools/dotnet-counters/Exporters/IConsole.cs
new file mode 100644 (file)
index 0000000..6691bcb
--- /dev/null
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Microsoft.Diagnostics.Tools.Counters.Exporters
+{
+    /// <summary>
+    /// This interface abstracts the console writing code from the physical console
+    /// and allows us to do unit testing. It is similar to the IConsole interface from System.CommandLine
+    /// but unfortunately that one doesn't support all the APIs we use such as the size and positioning of
+    /// the cursor.
+    /// </summary>
+    internal interface IConsole
+    {
+        int WindowHeight { get; }
+        int WindowWidth { get; }
+        bool CursorVisible { get; set; }
+        int CursorTop { get; }
+        int BufferWidth { get; }
+
+        void Clear();
+        void SetCursorPosition(int col, int row);
+        void Write(string text);
+        void WriteLine(string text);
+        void WriteLine();
+    }
+}
diff --git a/src/tests/dotnet-counters/ConsoleExporterTests.cs b/src/tests/dotnet-counters/ConsoleExporterTests.cs
new file mode 100644 (file)
index 0000000..d2f1bf3
--- /dev/null
@@ -0,0 +1,320 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Diagnostics.Monitoring.EventPipe;
+using Microsoft.Diagnostics.Tools.Counters.Exporters;
+using Xunit;
+
+namespace DotnetCounters.UnitTests
+{
+    public class ConsoleExporterTests
+    {
+        [Fact]
+        public void DisplayWaitingMessage()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Waiting for initial payload...");
+        }
+
+        [Fact]
+        public void DisplayEventCounter()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 12), false);
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12");
+        }
+
+        [Fact]
+        public void DisplayIncrementingEventCounter()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+        }
+
+        [Fact]
+        public void DisplayMultipleProviders()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            exporter.CounterPayloadReceived(CreateEventCounter("Provider2", "CounterXyz", "Doodads", 0.076), false);
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    Allocation Rate (B / 1 sec)                    1,731",
+                                     "[Provider2]",
+                                     "    CounterXyz (Doodads)                               0.076");
+        }
+
+        [Fact]
+        public void UpdateCounters()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            // update 1
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 12), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+
+            // update 2
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 7), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 123456), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                     7",
+                                     "    Allocation Rate (B / 1 sec)                  123,456");
+        }
+
+        [Fact]
+        public void PauseAndUnpause()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            // update 1
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 12), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+
+            // pause
+            exporter.ToggleStatus(true);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Paused",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+
+            // update 2, still paused
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 7), true);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 123456), true);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Paused",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+
+            // unpause doesn't automatically update values (maybe it should??)
+            exporter.ToggleStatus(false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                    12",
+                                     "    Allocation Rate (B / 1 sec)                    1,731");
+
+
+            // update 3 will change the values
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 1), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 2), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                     1",
+                                     "    Allocation Rate (B / 1 sec)                        2");
+        }
+
+        [Fact]
+        public void AlignValues()
+        {
+            MockConsole console = new MockConsole(60, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 0.1), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "BigCounter", "nanoseconds", 602341234567890123.0), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)                     0.1",
+                                     "    Allocation Rate (B / 1 sec)                    1,731",
+                                     "    BigCounter (nanoseconds)                      6.0234e+17");
+        }
+
+        [Fact]
+        public void NameColumnWidthAdjusts()
+        {
+            MockConsole console = new MockConsole(50, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "% Time in GC since last GC", "%", 0.1), false);
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    % Time in GC since last GC (%)           0.1",
+                                     "    Allocation Rate (B / 1 sec)          1,731");
+        }
+
+        [Fact]
+        public void LongNamesAreTruncated()
+        {
+            MockConsole console = new MockConsole(50, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateEventCounter("System.Runtime", "ThisCounterHasAVeryLongNameThatDoesNotFit", "%", 0.1), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[System.Runtime]",
+                                     "    ThisCounterHasAVeryLongNameTha           0.1");
+        }
+
+        [Fact]
+        public void MultiDimensionalCountersAreListed()
+        {
+            MockConsole console = new MockConsole(50, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                          87",
+                                     "        color=red                            0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                              14",
+                                     "        temp=hot                           160");
+        }
+
+        [Fact]
+        public void LongMultidimensionalTagsAreTruncated()
+        {
+            MockConsole console = new MockConsole(50, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue,LongNameTag=ThisDoesNotFit,AnotherOne=Hi", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue,LongNameTag=Thi          87",
+                                     "        color=red                            0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                              14",
+                                     "        temp=hot                           160");
+        }
+
+        [Fact]
+        public void CountersAreTruncatedBeyondScreenHeight()
+        {
+            MockConsole console = new MockConsole(50, 6);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                          87");
+        }
+
+        [Fact]
+        public void ErrorStatusIsDisplayed()
+        {
+            MockConsole console = new MockConsole(50, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            exporter.SetErrorText("Uh-oh, a bad thing happened");
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "Uh-oh, a bad thing happened",
+                                     "",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                          87",
+                                     "        color=red                            0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                              14",
+                                     "        temp=hot                           160");
+        }
+
+
+
+        private static CounterPayload CreateEventCounter(string provider, string displayName, string unit, double value)
+        {
+            return new EventCounterPayload(DateTime.MinValue, provider, displayName, displayName, unit, value, CounterType.Metric, 0, 0, "");
+        }
+
+        private static CounterPayload CreateIncrementingEventCounter(string provider, string displayName, string unit, double value)
+        {
+            return new EventCounterPayload(DateTime.MinValue, provider, displayName, displayName, unit, value, CounterType.Rate, 0, 1, "");
+        }
+
+        private static CounterPayload CreateMeterCounter(string meterName, string instrumentName, string unit, string tags, double value)
+        {
+            return new RatePayload(meterName, instrumentName, instrumentName, unit, tags, value, 1, DateTime.MinValue);
+        }
+    }
+}
diff --git a/src/tests/dotnet-counters/MockConsole.cs b/src/tests/dotnet-counters/MockConsole.cs
new file mode 100644 (file)
index 0000000..acc5aa1
--- /dev/null
@@ -0,0 +1,131 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics;
+using Microsoft.Diagnostics.Tools.Counters.Exporters;
+using Xunit;
+
+namespace DotnetCounters.UnitTests
+{
+    internal class MockConsole : IConsole
+    {
+        char[][] _chars;
+
+        int _cursorLeft;
+
+        public MockConsole(int width, int height)
+        {
+            WindowWidth = BufferWidth = width;
+            WindowHeight = height;
+            Clear();
+        }
+        public int WindowHeight { get; init; }
+
+        public int WindowWidth { get; init; }
+
+        public bool CursorVisible { get => throw new NotSupportedException(); set => throw new NotImplementedException(); }
+
+        public int CursorTop { get; private set; }
+
+        public int BufferWidth { get; private set; }
+
+        public void Clear()
+        {
+            _chars = new char[WindowHeight][];
+            for(int i = 0; i < WindowHeight; i++)
+            {
+                _chars[i] = new char[WindowWidth];
+                for(int j = 0; j < WindowWidth; j++)
+                {
+                    _chars[i][j] = ' ';
+                }
+            }
+            CursorTop = 0;
+            _cursorLeft = 0;
+        }
+        public void SetCursorPosition(int col, int row)
+        {
+            CursorTop = row;
+            _cursorLeft = col;
+        }
+        public void Write(string text)
+        {
+            for(int textPos = 0; textPos < text.Length; )
+            {
+                // This attempts to mirror the behavior of System.Console
+                // if the console width is X then it is possible to write X characters and still have the console
+                // report you are on the same line. If the X+1'th character isn't a newline then the console automatically
+                // wraps and writes that character at the beginning of the next line leaving the cursor at index 1. If
+                // the X+1'th character is a newline then the cursor moves to the next line at index 0.
+                Debug.Assert(_cursorLeft <= WindowWidth);
+                if(text.AsSpan(textPos).StartsWith(Environment.NewLine))
+                {
+                    textPos += Environment.NewLine.Length;
+                    _cursorLeft = 0;
+                    CursorTop++;
+                }
+                else
+                {
+                    if (_cursorLeft == WindowWidth)
+                    {
+                        _cursorLeft = 0;
+                        CursorTop++;
+                    }
+                    // make sure we are writing inside the legal buffer area, if not we'll hit the exception below
+                    if (CursorTop < WindowHeight)
+                    {
+                        _chars[CursorTop][_cursorLeft] = text[textPos];
+                        textPos++;
+                        _cursorLeft++;
+                    }
+                }
+                if (CursorTop >= WindowHeight)
+                {
+                    // For now we assume that no test case intentionally scrolls the buffer. If we want to have tests that
+                    // scroll the buffer by design then update this implementation.
+                    throw new Exception("Writing beyond the end of the console buffer would have caused text to scroll.");
+                }
+            }
+        }
+        public void WriteLine(string text)
+        {
+            Write(text);
+            Write(Environment.NewLine);
+        }
+        public void WriteLine() => Write(Environment.NewLine);
+
+        public string GetLineText(int row) => new string(_chars[row]).TrimEnd();
+
+        public string[] Lines
+        {
+            get
+            {
+                string[] lines = new string[WindowHeight];
+                for(int i = 0; i < WindowHeight; i++)
+                {
+                    lines[i] = GetLineText(i);
+                }
+                return lines;
+            }
+        }
+
+        public void AssertLinesEqual(params string[] expectedLines) => AssertLinesEqual(0, expectedLines);
+
+        public void AssertLinesEqual(int startLine, params string[] expectedLines)
+        {
+            for(int i = 0; i < expectedLines.Length; i++)
+            {
+                string actualLine = GetLineText(startLine+i);
+                string expectedLine = expectedLines[i];
+                if(actualLine != expectedLine)
+                {
+                    Assert.Fail("MockConsole output did not match expected output." + Environment.NewLine +
+                        $"Expected line {startLine + i,2}: {expectedLine}" + Environment.NewLine +
+                        $"Actual line     : {actualLine}");
+                }
+
+            }
+        }
+    }
+}