File: parent\dotnet\Commands\Test\MTP\Terminal\TerminalTestReporter.Coverage.cs
Web Access
Project: dotnet-aot.csproj (dotnet-aot)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using System.Text;
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;

namespace Microsoft.DotNet.Cli.Commands.Test.Terminal;

internal sealed partial class TerminalTestReporter
{
    private readonly Lock _coverageLock = new();
    private readonly Dictionary<CoverageSummaryKey, ApplicationCoverageSummary> _coverageSummaries = [];
    private readonly Dictionary<string, MergedCoverageSummary> _mergedCoverageSummaries = new(FileUtilities.PathComparer);
    private readonly Dictionary<CoverageInputOrigin, (int Started, int Ended)> _coverageTestSessions = [];
    private readonly Dictionary<string, HashSet<CoverageInputOrigin>> _coverageInputOrigins = new(FileUtilities.PathComparer);
    private bool _coverageWasTruncated;

    internal void TestSessionStarted(string executionId, string sessionUid, string? instanceId = null)
    {
        lock (_coverageLock)
        {
            var key = new CoverageInputOrigin(executionId, instanceId, sessionUid);
            (int started, int ended) = _coverageTestSessions.GetValueOrDefault(key);
            _coverageTestSessions[key] = (started + 1, ended);
        }
    }

    internal void TestSessionEnded(string executionId, string sessionUid, string? instanceId = null)
    {
        lock (_coverageLock)
        {
            var key = new CoverageInputOrigin(executionId, instanceId, sessionUid);
            (int started, int ended) = _coverageTestSessions.GetValueOrDefault(key);
            _coverageTestSessions[key] = (started, ended + 1);
        }
    }

    internal void CoverageArtifactReplaced(string path)
    {
        lock (_coverageLock)
        {
            _mergedCoverageSummaries.Remove(path);
        }
    }

    private void RecordCoverageInputOrigin(string path, string? executionId, string? instanceId, string? sessionUid)
    {
        lock (_coverageLock)
        {
            if (!_coverageInputOrigins.TryGetValue(path, out HashSet<CoverageInputOrigin>? origins))
            {
                origins = [];
                _coverageInputOrigins.Add(path, origins);
            }

            origins.Add(new CoverageInputOrigin(executionId, instanceId, sessionUid));
        }
    }

    private void RecordCoverageApplicationExit(int exitCode)
    {
        // Aggregate and ignore policies can erase an individual application's truncation code.
        if (exitCode is ExitCode.TestSessionAborted or ExitCode.TestExecutionStoppedForMaxFailedTests or ExitCode.TestExecutionStoppedAtDeadline)
        {
            MarkCoverageIncomplete();
        }
    }

    internal void MarkCoverageIncomplete()
    {
        lock (_coverageLock)
        {
            _coverageWasTruncated = true;
        }
    }

    internal void CoverageSummaryAdded(string assembly, string executionId, string instanceId, CoverageSummaryMessage[] summaries)
    {
        foreach (CoverageSummaryMessage summary in summaries)
        {
            if (!IsValidCoverageSummary(summary, merged: false))
            {
                WriteWarningMessage(CliCommandStrings.InvalidCoverageSummary);
                continue;
            }

            var key = new CoverageSummaryKey(
                executionId, instanceId, summary.SessionUid!, summary.ProducerId!,
                summary.ScopeLevel!.Value, summary.ScopeName, summary.Metric!.Value, summary.CustomMetricName);
            lock (_coverageLock)
            {
                _coverageSummaries[key] = new ApplicationCoverageSummary(assembly, summary);
            }
        }
    }

    internal void MergedCoverageSummaryAdded(
        string path,
        IReadOnlySet<string> inputPaths,
        bool complete,
        CoverageSummaryMessage[] summaries)
    {
        CoverageArtifactReplaced(path);
        Dictionary<(string ProducerId, int Metric, string? CustomMetricName), CoverageSummaryMessage> metrics = [];
        foreach (CoverageSummaryMessage summary in summaries)
        {
            if (!IsValidCoverageSummary(summary, merged: true))
            {
                WriteWarningMessage(CliCommandStrings.InvalidCoverageSummary);
                return;
            }

            metrics[(summary.ProducerId!, summary.Metric!.Value, summary.CustomMetricName)] = summary;
        }

        lock (_coverageLock)
        {
            _mergedCoverageSummaries[path] = new MergedCoverageSummary(
                path, new HashSet<string>(inputPaths, FileUtilities.PathComparer), complete, [.. metrics.Values]);
        }
    }

    private static bool IsValidCoverageSummary(CoverageSummaryMessage summary, bool merged)
        => !string.IsNullOrWhiteSpace(summary.ProducerId)
            && summary.Metric is >= 0
            && summary.CoveredCount is >= 0
            && summary.CoverableCount is >= 0
            && summary.CoveredCount <= summary.CoverableCount
            && (summary.Metric == (int)CoverageMetric.Custom
                ? !string.IsNullOrWhiteSpace(summary.CustomMetricName)
                : summary.CustomMetricName is null)
            && (merged ? summary.SessionUid is null : !string.IsNullOrWhiteSpace(summary.SessionUid))
            && (summary.ScopeLevel == 0
                ? summary.ScopeName is null
                : !merged && summary.ScopeLevel == 1 && !string.IsNullOrWhiteSpace(summary.ScopeName));

    private void AppendCoverageSummary(ITerminal terminal, int? exitCode)
    {
        KeyValuePair<CoverageSummaryKey, ApplicationCoverageSummary>[] applications;
        MergedCoverageSummary[] merged;
        MergedCoverageSummary[] completeMerges;
        bool singleMeasurementSource;
        bool wasTruncated;
        bool completeLifecycle;
        Dictionary<string, IReadOnlySet<string>> testInstances = _assemblies.ToDictionary(
            entry => entry.Key, entry => entry.Value.GetInstanceIds(), StringComparer.Ordinal);
        lock (_coverageLock)
        {
            applications = [.. _coverageSummaries
                .OrderBy(entry => entry.Value.Assembly, FileUtilities.PathComparer)
                .ThenBy(entry => entry.Key.ExecutionId, StringComparer.Ordinal)
                .ThenBy(entry => entry.Key.InstanceId, StringComparer.Ordinal)
                .ThenBy(entry => entry.Key.SessionUid, StringComparer.Ordinal)
                .ThenBy(entry => entry.Key.ScopeLevel)
                .ThenBy(entry => entry.Key.ScopeName, StringComparer.Ordinal)
                .ThenBy(entry => entry.Key.Metric)
                .ThenBy(entry => entry.Key.CustomMetricName, StringComparer.Ordinal)
                .ThenBy(entry => entry.Key.ProducerId, StringComparer.Ordinal)];
            merged = [.. _mergedCoverageSummaries.Values.OrderBy(summary => summary.Path, FileUtilities.PathComparer)];
            wasTruncated = _coverageWasTruncated;
            Dictionary<string, HashSet<string>> testSessions = _coverageTestSessions.Keys
                .GroupBy(session => session.ExecutionId!, StringComparer.Ordinal)
                .ToDictionary(group => group.Key, group => group.Select(session => session.SessionUid!).ToHashSet(StringComparer.Ordinal), StringComparer.Ordinal);
            HashSet<CoverageInputOrigin> expectedSources = [];
            completeLifecycle = _coverageTestSessions.Values.All(session => session.Started > 0 && session.Started == session.Ended);
            foreach (CoverageInputOrigin source in _coverageTestSessions.Keys)
            {
                if (TryResolveCoverageOrigin(source, testInstances, testSessions) is { } resolved)
                {
                    expectedSources.Add(resolved);
                }
                else
                {
                    completeLifecycle = false;
                }
            }

            Dictionary<string, HashSet<string>> observedInstances = expectedSources
                .GroupBy(source => source.ExecutionId!, StringComparer.Ordinal)
                .ToDictionary(group => group.Key, group => group.Select(source => source.InstanceId!).ToHashSet(StringComparer.Ordinal), StringComparer.Ordinal);
            completeLifecycle &= testInstances.All(entry =>
                observedInstances.TryGetValue(entry.Key, out HashSet<string>? observed) && entry.Value.SetEquals(observed));
            foreach (CoverageSummaryKey key in applications.Select(entry => entry.Key))
            {
                var measured = new CoverageInputOrigin(key.ExecutionId, key.InstanceId, key.SessionUid);
                if (TryResolveCoverageOrigin(measured, testInstances, testSessions) is not { } resolved
                    || !expectedSources.Contains(resolved))
                {
                    completeLifecycle = false;
                }
            }

            CoverageInputOrigin[] measurementSources =
            [
                .. applications.Select(entry => new CoverageInputOrigin(entry.Key.ExecutionId, entry.Key.InstanceId, entry.Key.SessionUid)).Distinct(),
            ];
            singleMeasurementSource = expectedSources.Count == 1
                && testInstances.Count == 1
                && testInstances.Values.Single().Count == 1
                && measurementSources.Length == 1
                && TryResolveCoverageOrigin(measurementSources[0], testInstances, testSessions) is { } singleSource
                && expectedSources.Contains(singleSource);
            completeMerges =
            [
                .. merged.Where(summary => summary.Complete && CoversAllCoverageSources(summary.InputPaths, expectedSources, testInstances, testSessions)),
            ];
        }

        bool canReportTotal = !_wasCancelled
            && !wasTruncated
            && exitCode is not null
                and not ExitCode.TestSessionAborted
                and not ExitCode.TestExecutionStoppedForMaxFailedTests
                and not ExitCode.TestExecutionStoppedAtDeadline
            && !HasHandshakeFailure
            && completeLifecycle
            && !_assemblies.IsEmpty;
        bool useMergedTotal = canReportTotal && completeMerges.Length == 1;
        bool useApplicationTotal = canReportTotal
            && merged.Length == 0
            && singleMeasurementSource;
        List<(string Scope, CoverageSummaryMessage Summary)> rows = [];

        if (useMergedTotal)
        {
            rows.AddRange(completeMerges[0].Summaries.Select(summary => (CliCommandStrings.CoverageScopeOverall, summary)));
        }
        else
        {
            foreach ((CoverageSummaryKey key, ApplicationCoverageSummary application) in applications)
            {
                string scope = key.ScopeLevel == 0
                    ? useApplicationTotal
                        ? CliCommandStrings.CoverageScopeOverall
                        : $"{application.Assembly} ({key.InstanceId}|{key.SessionUid})"
                    : $"{application.Assembly}: {key.ScopeName} ({key.InstanceId}|{key.SessionUid})";
                rows.Add((scope, application.Summary));
            }

            foreach (MergedCoverageSummary summary in merged)
            {
                string scope = string.Format(CultureInfo.CurrentCulture, CliCommandStrings.CoverageMergedArtifact, summary.Path);
                rows.AddRange(summary.Summaries.Select(metric => (scope, metric)));
            }
        }

        if (rows.Count == 0)
        {
            return;
        }

        terminal.AppendLine();
        terminal.AppendLine($"{SingleIndentation}{CliCommandStrings.CodeCoverageSummary}");
        var metricCounts = rows.GroupBy(row => (row.Scope, row.Summary.Metric, row.Summary.CustomMetricName))
            .ToDictionary(group => group.Key, group => group.Count());
        foreach ((string scope, CoverageSummaryMessage summary) in rows)
        {
            string metric = GetCoverageMetricLabel(summary);
            if (metricCounts[(scope, summary.Metric, summary.CustomMetricName)] > 1)
            {
                metric = string.Format(
                    CultureInfo.CurrentCulture, CliCommandStrings.CoverageMetricWithProducer,
                    metric, NormalizeCoverageLabel(summary.ProducerId!));
            }

            string value = summary.CoverableCount > 0
                ? $"{(100m * summary.CoveredCount!.Value / summary.CoverableCount!.Value).ToString("F1", CultureInfo.InvariantCulture)}%"
                : CliCommandStrings.CoverageNoData;
            terminal.AppendLine($"{DoubleIndentation}{NormalizeCoverageLabel(scope)} - {metric}: {value}");
        }
    }

    private bool CoversAllCoverageSources(
        IReadOnlySet<string> inputPaths,
        IReadOnlySet<CoverageInputOrigin> expectedSources,
        IReadOnlyDictionary<string, IReadOnlySet<string>> testInstances,
        IReadOnlyDictionary<string, HashSet<string>> testSessions)
    {
        HashSet<CoverageInputOrigin> coveredSources = [];

        foreach (string inputPath in inputPaths)
        {
            if (!_coverageInputOrigins.TryGetValue(inputPath, out HashSet<CoverageInputOrigin>? origins) || origins.Count == 0)
            {
                return false;
            }

            CoverageInputOrigin? resolvedOrigin = null;
            foreach (CoverageInputOrigin origin in origins)
            {
                CoverageInputOrigin? normalized = TryResolveCoverageOrigin(origin, testInstances, testSessions);
                if (normalized is null || !expectedSources.Contains(normalized))
                {
                    return false;
                }

                if (resolvedOrigin is not null && resolvedOrigin != normalized)
                {
                    // A reused path contains only its last contents, not both sessions' data.
                    return false;
                }

                resolvedOrigin = normalized;
            }

            if (resolvedOrigin is null)
            {
                return false;
            }

            coveredSources.Add(resolvedOrigin);
        }

        return inputPaths.Count > 0
            && expectedSources.Count > 0
            && expectedSources.SetEquals(coveredSources);
    }

    private static CoverageInputOrigin? TryResolveCoverageOrigin(
        CoverageInputOrigin origin,
        IReadOnlyDictionary<string, IReadOnlySet<string>> testInstances,
        IReadOnlyDictionary<string, HashSet<string>> testSessions)
    {
        if (origin.ExecutionId is not { } executionId
            || !testInstances.TryGetValue(executionId, out IReadOnlySet<string>? instances)
            || !testSessions.TryGetValue(executionId, out HashSet<string>? sessions))
        {
            return null;
        }

        // Controller/legacy messages can omit child identity. Only a genuinely single-source
        // execution permits inference; matching separate instance/session projections is not proof.
        string? instanceId = origin.InstanceId is { } reportedInstance && instances.Contains(reportedInstance)
            ? reportedInstance
            : instances.Count == 1 ? instances.Single() : null;
        string? sessionUid = !string.IsNullOrEmpty(origin.SessionUid)
            ? sessions.Contains(origin.SessionUid) ? origin.SessionUid : null
            : sessions.Count == 1 ? sessions.Single() : null;
        return instanceId is not null && sessionUid is not null
            ? new CoverageInputOrigin(executionId, instanceId, sessionUid)
            : null;
    }

    private static string GetCoverageMetricLabel(CoverageSummaryMessage summary)
        => (CoverageMetric)summary.Metric!.Value switch
        {
            CoverageMetric.Line => CliCommandStrings.CoverageMetricLine,
            CoverageMetric.Statement => CliCommandStrings.CoverageMetricStatement,
            CoverageMetric.Branch => CliCommandStrings.CoverageMetricBranch,
            CoverageMetric.Method => CliCommandStrings.CoverageMetricMethod,
            CoverageMetric.Function => CliCommandStrings.CoverageMetricFunction,
            CoverageMetric.Block => CliCommandStrings.CoverageMetricBlock,
            CoverageMetric.Instruction => CliCommandStrings.CoverageMetricInstruction,
            CoverageMetric.Region => CliCommandStrings.CoverageMetricRegion,
            CoverageMetric.Class => CliCommandStrings.CoverageMetricClass,
            CoverageMetric.Condition => CliCommandStrings.CoverageMetricCondition,
            CoverageMetric.Complexity => CliCommandStrings.CoverageMetricComplexity,
            CoverageMetric.Custom => NormalizeCoverageLabel(summary.CustomMetricName!),
            _ => summary.Metric.Value.ToString(CultureInfo.InvariantCulture),
        };

    private static string NormalizeCoverageLabel(string text)
    {
        var builder = new StringBuilder(text.Length);
        foreach (char character in text)
        {
            if (char.IsControl(character)
                || char.GetUnicodeCategory(character) is UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
            {
                builder.Append("\\u").Append(((int)character).ToString("x4", CultureInfo.InvariantCulture));
            }
            else
            {
                builder.Append(character);
            }
        }

        return builder.ToString();
    }

    private void ClearCoverageSummaries()
    {
        lock (_coverageLock)
        {
            _coverageSummaries.Clear();
            _mergedCoverageSummaries.Clear();
            _coverageTestSessions.Clear();
            _coverageInputOrigins.Clear();
            _coverageWasTruncated = false;
        }
    }

    private readonly record struct CoverageSummaryKey(
        string ExecutionId,
        string InstanceId,
        string SessionUid,
        string ProducerId,
        int ScopeLevel,
        string? ScopeName,
        int Metric,
        string? CustomMetricName);

    private sealed record ApplicationCoverageSummary(string Assembly, CoverageSummaryMessage Summary);

    private sealed record CoverageInputOrigin(string? ExecutionId, string? InstanceId, string? SessionUid);

    private sealed record MergedCoverageSummary(
        string Path,
        IReadOnlySet<string> InputPaths,
        bool Complete,
        CoverageSummaryMessage[] Summaries);
}