File: TestCase.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.ObjectModel\Microsoft.TestPlatform.ObjectModel.csproj (Microsoft.VisualStudio.TestPlatform.ObjectModel)
// 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;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;

using Microsoft.VisualStudio.TestPlatform.CoreUtilities;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Utilities;

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel;

/// <summary>
/// Stores information about a test case.
/// </summary>
[DataContract]
public sealed class TestCase : TestObject
{
    private Guid _defaultId = Guid.Empty;
    private Guid _id;
    private string? _displayName;
    private string _fullyQualifiedName;
    private string _source;

    /// <summary>
    /// Initializes a new instance of the <see cref="TestCase"/> class.
    /// </summary>
    /// <remarks>This constructor doesn't perform any parameter validation, it is meant to be used for serialization."/></remarks>
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
    public TestCase()
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
    {
        // TODO: Make private
        // Default constructor for Serialization.
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="TestCase"/> class.
    /// </summary>
    /// <param name="fullyQualifiedName">
    /// Fully qualified name of the test case.
    /// </param>
    /// <param name="executorUri">
    /// The Uri of the executor to use for running this test.
    /// </param>
    /// <param name="source">
    /// Test container source from which the test is discovered.
    /// </param>
    public TestCase(string fullyQualifiedName, Uri executorUri, string source)
    {
        ValidateArg.NotNullOrEmpty(fullyQualifiedName, nameof(fullyQualifiedName));
        ValidateArg.NotNullOrEmpty(source, nameof(source));

        _fullyQualifiedName = fullyQualifiedName;
        ExecutorUri = executorUri ?? throw new ArgumentNullException(nameof(executorUri));
        _source = source;
        LineNumber = -1;
        _defaultId = Guid.Empty;
    }
    /// <summary>
    /// LocalExtensionData which can be used by Adapter developers for local transfer of extended properties.
    /// Note that this data is available only for in-Proc execution, and may not be available for OutProc executors
    /// </summary>
    public object? LocalExtensionData { get; set; }

    /// <summary>
    /// Gets or sets the id of the test case.
    /// </summary>
    [DataMember]
    public Guid Id
    {
        get
        {
            if (_id == Guid.Empty)
            {
                if (_defaultId == Guid.Empty)
                {
                    _defaultId = GetTestId();
                }

                return _defaultId;
            }

            return _id;
        }

        set => _id = value;
    }

    /// <summary>
    /// Gets or sets the fully qualified name of the test case.
    /// </summary>
    [DataMember]
    public string FullyQualifiedName
    {
        get => _fullyQualifiedName;

        // defaultId should be reset as it is based on FullyQualifiedName and Source.
        set => SetVariableAndResetId(ref _fullyQualifiedName, value);
    }

    /// <summary>
    /// Gets or sets the display name of the test case.
    /// </summary>
    [DataMember]
    public string DisplayName
    {
        get => _displayName.IsNullOrEmpty() ? GetFullyQualifiedName() : _displayName;
        set => _displayName = value;
    }

    /// <summary>
    /// Gets or sets the Uri of the Executor to use for running this test.
    /// </summary>
    [DataMember]
    public Uri ExecutorUri
    {
        get; set;
    }

    /// <summary>
    /// Gets the test container source from which the test is discovered.
    /// </summary>
    [DataMember]
    public string Source
    {
        get => _source;
        set
        {
            _source = value;

            // defaultId should be reset as it is based on FullyQualifiedName and Source.
            _defaultId = Guid.Empty;
        }
    }

    /// <summary>
    /// Gets or sets the source code file path of the test.
    /// </summary>
    [DataMember]
    public string? CodeFilePath
    {
        get; set;
    }

    /// <summary>
    /// Gets or sets the line number of the test.
    /// </summary>
    [DataMember]
    public int LineNumber
    {
        get; set;
    }

    /// <summary>
    /// Returns the TestProperties currently specified in this TestObject.
    /// </summary>
    public override IEnumerable<TestProperty> Properties
    {
        get
        {
            return TestCaseProperties.Properties.Concat(base.Properties);
        }
    }

    /// <summary>
    /// Creates a Id of TestCase
    /// </summary>
    /// <returns>Guid test id</returns>
    private Guid GetTestId()
    {
        // To generate id hash "ExecutorUri + source + Name";

        // If source is a file name then just use the filename for the identifier since the
        // file might have moved between discovery and execution (in appx mode for example)
        // This is not elegant because the Source contents should be a black box to the framework.
        // For example in the database adapter case this is not a file path.
        string source = Source;

        // As discussed with team, we found no scenario for netcore, & fullclr where the Source is not present where ID is generated,
        // which means we would always use FileName to generate ID. In cases where somehow Source Path contained garbage character the API Path.GetFileName()
        // we are simply returning original input.
        // For UWP where source during discovery, & during execution can be on different machine, in such case we should always use Path.GetFileName()
        try
        {
            // If source name is malformed, GetFileName API will throw exception, so use same input malformed string to generate ID
            source = Path.GetFileName(source);
        }
        catch
        {
            // do nothing
        }

        // We still need to handle parameters in the case of a Theory or TestGroup of test cases that are only
        // distinguished by parameters.
        var testcaseFullName = ExecutorUri + source;

        // If ManagedType and ManagedMethod properties are filled than TestId should be based on those.
        testcaseFullName += GetFullyQualifiedName();

        return EqtHash.GuidFromString(testcaseFullName);
    }

    private void SetVariableAndResetId<T>(ref T variable, T value)
    {
        variable = value;
        _defaultId = Guid.Empty;
    }

    private void SetPropertyAndResetId<T>(TestProperty property, T value)
    {
        SetPropertyValue(property, value);
        _defaultId = Guid.Empty;
    }

    /// <summary>
    /// Return TestProperty's value
    /// </summary>
    /// <returns></returns>
    protected override object? ProtectedGetPropertyValue(TestProperty property, object? defaultValue)
    {
        ValidateArg.NotNull(property, nameof(property));
        return property.Id switch
        {
            "TestCase.CodeFilePath" => CodeFilePath,
            "TestCase.DisplayName" => DisplayName,
            "TestCase.ExecutorUri" => ExecutorUri,
            "TestCase.FullyQualifiedName" => FullyQualifiedName,
            "TestCase.Id" => Id,
            "TestCase.LineNumber" => LineNumber,
            "TestCase.Source" => Source,
            // It is a custom test case property. Should be retrieved from the TestObject store.
            _ => base.ProtectedGetPropertyValue(property, defaultValue),
        };
    }

    /// <summary>
    /// Set TestProperty's value
    /// </summary>
    protected override void ProtectedSetPropertyValue(TestProperty property, object? value)
    {
        ValidateArg.NotNull(property, nameof(property));
        switch (property.Id)
        {
            case "TestCase.CodeFilePath":
                CodeFilePath = value as string;
                return;

            case "TestCase.DisplayName":
                DisplayName = (value as string)!;
                return;

            case "TestCase.ExecutorUri":
                ExecutorUri = value as Uri ?? new Uri((value as string)!);
                return;

            case "TestCase.FullyQualifiedName":
                FullyQualifiedName = (value as string)!;
                return;

            case "TestCase.Id":
                if (value is Guid guid)
                {
                    Id = guid;
                }
                else if (value is string guidString)
                {
                    Id = GuidPolyfill.Parse(guidString, CultureInfo.InvariantCulture);
                }
                else
                {
                    Id = Guid.Empty;
                }

                return;

            case "TestCase.LineNumber":
                LineNumber = (int)value!;
                return;

            case "TestCase.Source":
                Source = (value as string)!;
                return;
        }

        // It is a custom test case property. Should be set in the TestObject store.
        base.ProtectedSetPropertyValue(property, value);
    }

    private static readonly TestProperty ManagedTypeProperty = TestProperty.Register("TestCase.ManagedType", "ManagedType", string.Empty, string.Empty, typeof(string), o => !StringUtils.IsNullOrWhiteSpace(o as string), TestPropertyAttributes.Hidden, typeof(TestCase));
    private static readonly TestProperty ManagedMethodProperty = TestProperty.Register("TestCase.ManagedMethod", "ManagedMethod", string.Empty, string.Empty, typeof(string), o => !StringUtils.IsNullOrWhiteSpace(o as string), TestPropertyAttributes.Hidden, typeof(TestCase));

    private bool ContainsManagedMethodAndType => !StringUtils.IsNullOrWhiteSpace(ManagedMethod) && !StringUtils.IsNullOrWhiteSpace(ManagedType);

    private string? ManagedType
    {
        get => GetPropertyValue<string>(ManagedTypeProperty, null);
        set => SetPropertyAndResetId(ManagedTypeProperty, value);
    }

    private string? ManagedMethod
    {
        get => GetPropertyValue<string>(ManagedMethodProperty, null);
        set => SetPropertyAndResetId(ManagedMethodProperty, value);
    }

    private string GetFullyQualifiedName() => ContainsManagedMethodAndType ? $"{ManagedType}.{ManagedMethod}" : FullyQualifiedName;

    /// <inheritdoc/>
    public override string ToString() => GetFullyQualifiedName();
}

/// <summary>
/// Well-known TestCase properties
/// </summary>
public static class TestCaseProperties
{
    /// <summary>
    /// These are the core Test properties and may be available in commandline/TeamBuild to filter tests.
    /// These Property names should not be localized.
    /// </summary>
    private const string IdLabel = "Id";
    private const string FullyQualifiedNameLabel = "FullyQualifiedName";
    private const string NameLabel = "Name";
    private const string ExecutorUriLabel = "Executor Uri";
    private const string SourceLabel = "Source";
    private const string FilePathLabel = "File Path";
    private const string LineNumberLabel = "Line Number";

    public static readonly TestProperty Id = TestProperty.Register("TestCase.Id", IdLabel, string.Empty, string.Empty, typeof(Guid), ValidateGuid, TestPropertyAttributes.Hidden, typeof(TestCase));
    public static readonly TestProperty FullyQualifiedName = TestProperty.Register("TestCase.FullyQualifiedName", FullyQualifiedNameLabel, string.Empty, string.Empty, typeof(string), ValidateName, TestPropertyAttributes.Hidden, typeof(TestCase));
    public static readonly TestProperty DisplayName = TestProperty.Register("TestCase.DisplayName", NameLabel, string.Empty, string.Empty, typeof(string), ValidateDisplay, TestPropertyAttributes.None, typeof(TestCase));
    public static readonly TestProperty ExecutorUri = TestProperty.Register("TestCase.ExecutorUri", ExecutorUriLabel, string.Empty, string.Empty, typeof(Uri), ValidateExecutorUri, TestPropertyAttributes.Hidden, typeof(TestCase));
    public static readonly TestProperty Source = TestProperty.Register("TestCase.Source", SourceLabel, typeof(string), typeof(TestCase));
    public static readonly TestProperty CodeFilePath = TestProperty.Register("TestCase.CodeFilePath", FilePathLabel, typeof(string), typeof(TestCase));
    public static readonly TestProperty LineNumber = TestProperty.Register("TestCase.LineNumber", LineNumberLabel, typeof(int), TestPropertyAttributes.Hidden, typeof(TestCase));

    internal static TestProperty[] Properties { get; } =
    [
        CodeFilePath,
        DisplayName,
        ExecutorUri,
        FullyQualifiedName,
        Id,
        LineNumber,
        Source
    ];

    private static bool ValidateName(object? value)
    {
        return !StringUtils.IsNullOrWhiteSpace((string?)value);
    }

    private static bool ValidateDisplay(object? value)
    {
        // only check for null and pass the rest up to UI for validation
        return value != null;
    }

    private static bool ValidateExecutorUri(object? value)
    {
        return value != null;
    }

    private static bool ValidateGuid(object? value)
    {
        if (value?.ToString() is not string sValue)
        {
            return false;
        }

        // TODO: Replace with TryParse?
        try
        {
            _ = new Guid(sValue);
            return true;
        }
        catch (FormatException)
        {
            return false;
        }
        catch (OverflowException)
        {
            return false;
        }
    }
}