File: Commands\Test\MTP\Terminal\AnsiTerminalTestProgressFrame.cs
Web Access
Project: ..\..\..\src\Cli\dotnet\dotnet.csproj (dotnet)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
 
using System.Globalization;
 
namespace Microsoft.DotNet.Cli.Commands.Test.Terminal;
 
/// <summary>
/// Captures <see cref="TestProgressState"/> that was rendered to screen, so we can only partially update the screen on next update.
/// </summary>
internal sealed class AnsiTerminalTestProgressFrame(int width, int height)
{
    private const int MaxColumn = 250;
 
    public int Width { get; } = Math.Min(width, MaxColumn);
 
    public int Height { get; } = height;
 
    public List<RenderedProgressItem>? RenderedLines { get; set; }
 
    public void AppendTestWorkerProgress(TestProgressState progress, RenderedProgressItem currentLine, AnsiTerminal terminal)
    {
        string durationString = HumanReadableDurationFormatter.Render(progress.Stopwatch.Elapsed);
 
        currentLine.RenderedDurationLength = durationString.Length;
 
        int nonReservedWidth = Width - (durationString.Length + 2);
 
        int passed = progress.PassedTests;
        int failed = progress.FailedTests;
        int skipped = progress.SkippedTests;
        int retried = progress.RetriedFailedTests;
        int charsTaken = 0;
 
        terminal.Append('[');
        charsTaken++;
        terminal.SetColor(TerminalColor.DarkGreen);
        terminal.Append('✓');
        charsTaken++;
        string passedText = passed.ToString(CultureInfo.CurrentCulture);
        terminal.Append(passedText);
        charsTaken += passedText.Length;
        terminal.ResetColor();
 
        terminal.Append('/');
        charsTaken++;
 
        terminal.SetColor(TerminalColor.DarkRed);
        terminal.Append('x');
        charsTaken++;
        string failedText = failed.ToString(CultureInfo.CurrentCulture);
        terminal.Append(failedText);
        charsTaken += failedText.Length;
        terminal.ResetColor();
 
        terminal.Append('/');
        charsTaken++;
 
        terminal.SetColor(TerminalColor.DarkYellow);
        terminal.Append('↓');
        charsTaken++;
        string skippedText = skipped.ToString(CultureInfo.CurrentCulture);
        terminal.Append(skippedText);
        charsTaken += skippedText.Length;
        terminal.ResetColor();
 
        if (retried > 0)
        {
            terminal.Append('/');
            charsTaken++;
            terminal.SetColor(TerminalColor.Gray);
            terminal.Append('r');
            charsTaken++;
            string retriedText = retried.ToString(CultureInfo.CurrentCulture);
            terminal.Append(retriedText);
            charsTaken += retriedText.Length;
            terminal.ResetColor();
        }
 
        terminal.Append(']');
        charsTaken++;
 
        terminal.Append(' ');
        charsTaken++;
        AppendToWidth(terminal, progress.AssemblyName, nonReservedWidth, ref charsTaken);
 
        if (charsTaken < nonReservedWidth && (progress.TargetFramework != null || progress.Architecture != null))
        {
            int lengthNeeded = 0;
 
            lengthNeeded++; // for '('
            if (progress.TargetFramework != null)
            {
                lengthNeeded += progress.TargetFramework.Length;
                if (progress.Architecture != null)
                {
                    lengthNeeded++; // for '|'
                }
            }
 
            if (progress.Architecture != null)
            {
                lengthNeeded += progress.Architecture.Length;
            }
 
            lengthNeeded++; // for ')'
 
            if (charsTaken + lengthNeeded < nonReservedWidth)
            {
                terminal.Append(" (");
                if (progress.TargetFramework != null)
                {
                    terminal.Append(progress.TargetFramework);
                    if (progress.Architecture != null)
                    {
                        terminal.Append('|');
                    }
                }
 
                if (progress.Architecture != null)
                {
                    terminal.Append(progress.Architecture);
                }
 
                terminal.Append(')');
            }
        }
 
        terminal.SetCursorHorizontal(Width - durationString.Length);
        terminal.Append(durationString);
    }
 
    public void AppendTestWorkerDetail(TestDetailState detail, RenderedProgressItem currentLine, AnsiTerminal terminal)
    {
        string durationString = HumanReadableDurationFormatter.Render(detail.Stopwatch?.Elapsed);
 
        currentLine.RenderedDurationLength = durationString.Length;
 
        int nonReservedWidth = Width - (durationString.Length + 2);
        int charsTaken = 0;
 
        terminal.Append("  ");
        charsTaken += 2;
 
        AppendToWidth(terminal, detail.Text, nonReservedWidth, ref charsTaken);
 
        terminal.SetCursorHorizontal(Width - durationString.Length);
        terminal.Append(durationString);
    }
 
    private static void AppendToWidth(AnsiTerminal terminal, string text, int width, ref int charsTaken)
    {
        if (charsTaken + text.Length < width)
        {
            terminal.Append(text);
            charsTaken += text.Length;
        }
        else
        {
            terminal.Append("...");
            charsTaken += 3;
            if (charsTaken < width)
            {
                int charsToTake = width - charsTaken;
                string cutText = text[^charsToTake..];
                terminal.Append(cutText);
                charsTaken += charsToTake;
            }
        }
    }
 
    /// <summary>
    /// Render VT100 string to update from current to next frame.
    /// </summary>
    public void Render(AnsiTerminalTestProgressFrame previousFrame, TestProgressState?[] progress, AnsiTerminal terminal)
    {
        // Clear everything if Terminal width or height have changed.
        if (Width != previousFrame.Width || Height != previousFrame.Height)
        {
            terminal.EraseProgress();
        }
 
        // At the end of the terminal we're going to print the live progress.
        // We re-render this progress by moving the cursor to the beginning of the previous progress
        // and then overwriting the lines that have changed.
        // The assumption we do here is that:
        // - Each rendered line is a single line, i.e. a single detail cannot span multiple lines.
        // - Each rendered detail can be tracked via a unique ID and version, so that we can
        //   quickly determine if the detail has changed since the last render.
 
        // Don't go up if we did not render any lines in previous frame or we already cleared them.
        if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > 0)
        {
            // Move cursor back to 1st line of progress.
            // + 2 because we output and empty line right below.
            terminal.MoveCursorUp(previousFrame.RenderedLines.Count + 2);
        }
 
        // When there is nothing to render, don't write empty lines, e.g. when we start the test run, and then we kick off build
        // in dotnet test, there is a long pause where we have no assemblies and no test results (yet).
        //
        // We do pre-allocate the array so it is full of nulls once we allocate it but don't have any items to render yet.
        if (progress.Length > 0 && progress.Any(i => i != null))
        {
            terminal.AppendLine();
        }
 
        int i = 0;
        RenderedLines = new List<RenderedProgressItem>(progress.Length * 2);
        List<object> progresses = GenerateLinesToRender(progress);
 
        foreach (object item in progresses)
        {
            if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > i)
            {
                if (item is TestProgressState progressItem)
                {
                    var currentLine = new RenderedProgressItem(progressItem.Id, progressItem.Version);
                    RenderedLines.Add(currentLine);
 
                    // We have a line that was rendered previously, compare it and decide how to render.
                    RenderedProgressItem previouslyRenderedLine = previousFrame.RenderedLines[i];
                    if (previouslyRenderedLine.ProgressId == progressItem.Id && false)
                    {
                        // This is the same progress item and it was not updated since we rendered it, only update the timestamp if possible to avoid flicker.
                        string durationString = HumanReadableDurationFormatter.Render(progressItem.Stopwatch.Elapsed);
 
                        if (previouslyRenderedLine.RenderedDurationLength == durationString.Length)
                        {
                            // Duration is the same length rewrite just it.
                            terminal.SetCursorHorizontal(MaxColumn);
                            terminal.Append($"{AnsiCodes.SetCursorHorizontal(MaxColumn)}{AnsiCodes.MoveCursorBackward(durationString.Length)}{durationString}");
                            currentLine.RenderedDurationLength = durationString.Length;
                        }
                        else
                        {
                            // Duration is not the same length (it is longer because time moves only forward), we need to re-render the whole line
                            // to avoid writing the duration over the last portion of text: my.dll (1s) -> my.d (1m 1s)
                            terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}");
                            AppendTestWorkerProgress(progressItem, currentLine, terminal);
                        }
                    }
                    else
                    {
                        // These lines are different or the line was updated. Render the whole line.
                        terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}");
                        AppendTestWorkerProgress(progressItem, currentLine, terminal);
                    }
                }
 
                if (item is TestDetailState detailItem)
                {
                    var currentLine = new RenderedProgressItem(detailItem.Id, detailItem.Version);
                    RenderedLines.Add(currentLine);
 
                    // We have a line that was rendered previously, compare it and decide how to render.
                    RenderedProgressItem previouslyRenderedLine = previousFrame.RenderedLines[i];
                    if (previouslyRenderedLine.ProgressId == detailItem.Id && previouslyRenderedLine.ProgressVersion == detailItem.Version)
                    {
                        // This is the same progress item and it was not updated since we rendered it, only update the timestamp if possible to avoid flicker.
                        string durationString = HumanReadableDurationFormatter.Render(detailItem.Stopwatch?.Elapsed);
 
                        if (previouslyRenderedLine.RenderedDurationLength == durationString.Length)
                        {
                            // Duration is the same length rewrite just it.
                            terminal.SetCursorHorizontal(MaxColumn);
                            terminal.Append($"{AnsiCodes.SetCursorHorizontal(MaxColumn)}{AnsiCodes.MoveCursorBackward(durationString.Length)}{durationString}");
                            currentLine.RenderedDurationLength = durationString.Length;
                        }
                        else
                        {
                            // Duration is not the same length (it is longer because time moves only forward), we need to re-render the whole line
                            // to avoid writing the duration over the last portion of text: my.dll (1s) -> my.d (1m 1s)
                            terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}");
                            AppendTestWorkerDetail(detailItem, currentLine, terminal);
                        }
                    }
                    else
                    {
                        // These lines are different or the line was updated. Render the whole line.
                        terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}");
                        AppendTestWorkerDetail(detailItem, currentLine, terminal);
                    }
                }
            }
            else
            {
                // We are rendering more lines than we rendered in previous frame
                if (item is TestProgressState progressItem)
                {
                    var currentLine = new RenderedProgressItem(progressItem.Id, progressItem.Version);
                    RenderedLines.Add(currentLine);
                    AppendTestWorkerProgress(progressItem, currentLine, terminal);
                }
 
                if (item is TestDetailState detailItem)
                {
                    var currentLine = new RenderedProgressItem(detailItem.Id, detailItem.Version);
                    RenderedLines.Add(currentLine);
                    AppendTestWorkerDetail(detailItem, currentLine, terminal);
                }
            }
 
            // This makes the progress not stick to the last line on the command line, which is
            // not what I would prefer. But also if someone writes to console, the message will
            // start at the beginning of the new line. Not after the progress bar that is kept on screen.
            terminal.AppendLine();
        }
 
        // We rendered more lines in previous frame. Clear them.
        if (previousFrame.RenderedLines != null && i < previousFrame.RenderedLines.Count)
        {
            terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}");
        }
    }
 
    private List<object> GenerateLinesToRender(TestProgressState?[] progress)
    {
        var linesToRender = new List<object>(progress.Length);
 
        // Note: We want to render the list of active tests, but this can easily fill up the full screen.
        // As such, we should balance the number of active tests shown per project.
        // We do this by distributing the remaining lines for each projects.
        TestProgressState[] progressItems = [.. progress.OfType<TestProgressState>()];
        int linesToDistribute = (int)(Height * 0.7) - 1 - progressItems.Length;
        var detailItems = new IEnumerable<TestDetailState>[progressItems.Length];
        IEnumerable<int> sortedItemsIndices = Enumerable.Range(0, progressItems.Length).OrderBy(i => progressItems[i].TestNodeResultsState?.Count ?? 0);
 
        foreach (int sortedItemIndex in sortedItemsIndices)
        {
            detailItems[sortedItemIndex] = progressItems[sortedItemIndex].TestNodeResultsState?.GetRunningTasks(linesToDistribute / progressItems.Length) ?? [];
        }
 
        for (int progressI = 0; progressI < progressItems.Length; progressI++)
        {
            linesToRender.Add(progressItems[progressI]);
            linesToRender.AddRange(detailItems[progressI]);
        }
 
        return linesToRender;
    }
 
    public void Clear() => RenderedLines?.Clear();
 
    internal sealed class RenderedProgressItem(long id, long version)
    {
        public long ProgressId { get; } = id;
 
        public long ProgressVersion { get; } = version;
 
        public int RenderedHeight { get; set; }
 
        public int RenderedDurationLength { get; set; }
    }
}