File: InProcess\EditorInProcess_Classification.cs
Web Access
Project: src\src\Razor\src\Razor\test\Microsoft.VisualStudio.Razor.IntegrationTests\Microsoft.VisualStudio.Razor.IntegrationTests.csproj (Microsoft.VisualStudio.Razor.IntegrationTests)
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Classification;
using Microsoft.VisualStudio.Text.Editor;
using Xunit;
 
namespace Microsoft.VisualStudio.Extensibility.Testing;
 
internal partial class EditorInProcess
{
    /// <summary>
    /// Waits for the Razor component semantic classifications to be available on the active TextView
    /// </summary>
    /// <param name="cancellationToken">A cancellation token.</param>
    /// <param name="count">The number of the given classification to expect.</param>
    /// <param name="exact">Whether to wait for exactly <paramref name="count"/> classifications.</param>
    /// <returns>A <see cref="Task"/> which completes when classification is "ready".</returns>
    public Task WaitForComponentClassificationAsync(CancellationToken cancellationToken, int count = 1, bool exact = false)
        => WaitForSemanticClassificationAsync("RazorComponentElement", cancellationToken, count, exact);
 
    /// <summary>
    /// Waits for any semantic classifications to be available on the active TextView, and for at least one of the
    /// <paramref name="expectedClassification"/> if provided.
    /// </summary>
    /// <param name="cancellationToken">A cancellation token.</param>
    /// <param name="expectedClassification">The classification to wait for, if any.</param>
    /// <param name="count">The number of the given classification to expect.</param>
    /// <param name="exact">Whether to wait for exactly <paramref name="count"/> classifications.</param>
    /// <returns>A <see cref="Task"/> which completes when classification is "ready".</returns>
    public async Task WaitForSemanticClassificationAsync(string expectedClassification, CancellationToken cancellationToken, int count = 1, bool exact = false)
    {
        var textView = await TestServices.Editor.GetActiveTextViewAsync(cancellationToken);
        var classifier = await GetClassifierAsync(textView, cancellationToken);
 
        using var semaphore = new SemaphoreSlim(1);
        await semaphore.WaitAsync(cancellationToken);
 
        classifier.ClassificationChanged += Classifier_ClassificationChanged;
 
        // Check that we're not ALREADY changed
        if (HasClassification(classifier, textView, expectedClassification, count, exact))
        {
            semaphore.Release();
            classifier.ClassificationChanged -= Classifier_ClassificationChanged;
            return;
        }
 
        try
        {
            await semaphore.WaitAsync(cancellationToken);
        }
        finally
        {
            classifier.ClassificationChanged -= Classifier_ClassificationChanged;
        }
 
        void Classifier_ClassificationChanged(object sender, ClassificationChangedEventArgs e)
        {
            if (HasClassification(classifier, textView, expectedClassification, count, exact))
            {
                semaphore.Release();
            }
        }
 
        static bool HasClassification(IClassifier classifier, ITextView textView, string expectedClassification, int count, bool exact)
        {
            var classifications = GetClassifications(classifier, textView);
 
            var found = 0;
            foreach (var c in classifications)
            {
                if (ClassificationMatches(expectedClassification, c.ClassificationType) ||
                    c.ClassificationType.BaseTypes.Any(t => ClassificationMatches(expectedClassification, t)))
                {
                    found++;
                }
            }
 
            return found == count ||
                (!exact && found > count);
        }
 
        static bool ClassificationMatches(string expectedClassification, IClassificationType classificationType)
            => classificationType is ILayeredClassificationType layered &&
                layered.Layer == ClassificationLayer.Semantic &&
                layered.Classification == expectedClassification;
    }
 
    public async Task VerifyGetClassificationsAsync(IEnumerable<ClassificationSpan> expectedClassifications, CancellationToken cancellationToken)
    {
        var actualClassifications = await GetClassificationsAsync(cancellationToken);
        var actualArray = actualClassifications.ToArray();
        var expectedArray = expectedClassifications.ToArray();
 
        for (var i = 0; i < actualArray.Length; i++)
        {
            var actualClassification = actualArray[i];
            var expectedClassification = expectedArray[i];
 
            if (actualClassification.ClassificationType.BaseTypes.Count() > 1)
            {
                Assert.Equal(expectedClassification, actualClassification, ClassificationTypeComparer.Instance);
            }
            else if (!expectedClassification.Span.Span.Equals(actualClassification.Span.Span)
                || !string.Equals(expectedClassification.ClassificationType.Classification, actualClassification.ClassificationType.Classification))
            {
                Assert.Equal(expectedClassification.Span, actualClassification.Span);
                Assert.Equal(expectedClassification.ClassificationType.Classification, actualClassification.ClassificationType.Classification);
 
                Assert.Fail($"i: {i}" +
                    $"expected: {expectedClassification.Span} {expectedClassification.ClassificationType.Classification} " +
                    $"actual: {actualClassification.Span} {actualClassification.ClassificationType.Classification}");
            }
        }
 
        Assert.Equal(expectedArray.Length, actualArray.Length);
    }
 
    public async Task<IList<ClassificationSpan>> GetClassificationsAsync(CancellationToken cancellationToken)
    {
        var textView = await GetActiveTextViewAsync(cancellationToken);
        var classifier = await GetClassifierAsync(textView, cancellationToken);
        return GetClassifications(classifier, textView);
    }
 
    private static IList<ClassificationSpan> GetClassifications(IClassifier classifier, ITextView textView)
    {
        var selectionSpan = new SnapshotSpan(textView.TextSnapshot, new Span(0, textView.TextSnapshot.Length));
 
        return classifier.GetClassificationSpans(selectionSpan);
    }
 
    /// <summary>
    /// Validates that we aren't seeing disco colors in the editor
    /// </summary>
    /// <remarks>
    /// This actually just calls <see cref="GetClassificationsAsync(CancellationToken)"/> because we always check for disco colors
    /// when getting classifications, but this makes for a more discoverable API.
    /// </remarks>
    public async Task ValidateNoDiscoColorsAsync(CancellationToken cancellationToken)
    {
        var classifiedSpans = await GetClassificationsAsync(cancellationToken);
 
        ValidateNoDiscoColors(classifiedSpans);
    }
 
    private static void ValidateNoDiscoColors(IList<ClassificationSpan> classifiedSpans)
    {
        // We never expect a word to have a classification change in the middle of it, so we can check for disco colors
        // by making sure that each span either doesn't start with a letter or digit, or comes after something that isn't
        // a letter or digit.
        SnapshotSpan? previousSpan = null;
        foreach (var span in classifiedSpans)
        {
            if (span.Span.IsEmpty)
            {
                continue;
            }
 
            if (previousSpan is { } previous)
            {
                Assert.False(previous.End.Position == span.Span.Start.Position && char.IsLetterOrDigit(span.Span.Start.GetChar()) && char.IsLetterOrDigit((previous.End - 1).GetChar()), $"Disco colors detected: {previous.GetText()}{span.Span.GetText()} has classification {span.ClassificationType.Classification} starting at character {previous.Length}");
            }
 
            previousSpan = span.Span;
        }
    }
 
    private async Task<IClassifier> GetClassifierAsync(IWpfTextView textView, CancellationToken cancellationToken)
    {
        var classifierService = await GetComponentModelServiceAsync<IViewClassifierAggregatorService>(cancellationToken);
 
        return classifierService.GetClassifier(textView);
    }
 
    private class ClassificationTypeComparer : IEqualityComparer<ClassificationSpan>
    {
        public static ClassificationTypeComparer Instance { get; } = new();
 
        public bool Equals(ClassificationSpan x, ClassificationSpan y)
        {
            if (x.Span.Equals(y.Span))
            {
                var actualClassification = !x.ClassificationType.BaseTypes.Any() ? y : x;
                var expectedClassification = !x.ClassificationType.BaseTypes.Any() ? x : y;
                var semanticBaseTypes = actualClassification.ClassificationType.BaseTypes.Where(t => t is ILayeredClassificationType layered && layered.Layer == ClassificationLayer.Semantic);
                if (semanticBaseTypes.Count() == 1)
                {
                    return string.Equals(semanticBaseTypes.First().Classification, expectedClassification.ClassificationType.Classification);
                }
                else if (semanticBaseTypes.Count() > 1)
                {
                    return semanticBaseTypes.Select(s => s.Classification).Contains(expectedClassification.ClassificationType.Classification);
                }
                // Did not have semantic basetype
            }
 
            return false;
        }
 
        public int GetHashCode(ClassificationSpan obj)
        {
            throw new System.NotImplementedException();
        }
    }
}