File: Dialogs\FilterDialogTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Bunit;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Dialogs;
 
public class FilterDialogTests : DashboardTestContext
{
    [Fact]
    public void Render_DurationFilter_UsesNumericInputAndNumericConditions()
    {
        SetupFilterDialogServices();
 
        var cut = RenderComponent<FilterDialog>(builder =>
        {
            builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter
            {
                Field = KnownTraceFields.DurationField,
                Condition = FilterCondition.GreaterThanOrEqual,
                Value = "50"
            }));
        });
 
        Assert.Single(cut.FindComponents<FluentNumberField<double?>>());
        Assert.DoesNotContain("fluent-combobox", cut.Markup);
 
        var conditionSelect = Assert.Single(cut.FindComponents<FluentSelect<SelectViewModel<FilterCondition>>>());
        Assert.Collection(conditionSelect.Instance.Items!,
            item => Assert.Equal(FilterCondition.Equals, item.Id),
            item => Assert.Equal(FilterCondition.NotEqual, item.Id),
            item => Assert.Equal(FilterCondition.GreaterThanOrEqual, item.Id),
            item => Assert.Equal(FilterCondition.GreaterThan, item.Id),
            item => Assert.Equal(FilterCondition.LessThanOrEqual, item.Id),
            item => Assert.Equal(FilterCondition.LessThan, item.Id));
    }
 
    [Fact]
    public void Render_StringFilter_UsesComboboxAndStringConditions()
    {
        SetupFilterDialogServices();
 
        var cut = RenderComponent<FilterDialog>(builder =>
        {
            builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter
            {
                Field = KnownTraceFields.NameField,
                Condition = FilterCondition.Contains,
                Value = "request"
            }));
        });
 
        Assert.Empty(cut.FindComponents<FluentNumberField<double?>>());
        Assert.Contains("fluent-combobox", cut.Markup);
        Assert.Equal(3, cut.FindAll(".filter-input-container > label").Count);
        Assert.Empty(cut.FindAll(".input-line-container label"));
 
        var conditionSelect = Assert.Single(cut.FindComponents<FluentSelect<SelectViewModel<FilterCondition>>>());
        Assert.Collection(conditionSelect.Instance.Items!,
            item => Assert.Equal(FilterCondition.Equals, item.Id),
            item => Assert.Equal(FilterCondition.Contains, item.Id),
            item => Assert.Equal(FilterCondition.NotEqual, item.Id),
            item => Assert.Equal(FilterCondition.NotContains, item.Id));
    }
 
    [Fact]
    public async Task Render_PropertyKeysLoading_DisablesParameterSelectAndDisplaysProgressRing()
    {
        SetupFilterDialogServices();
        var loadingStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var propertyKeys = new TaskCompletionSource<List<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = CreateContent(new FieldTelemetryFilter
        {
            Field = KnownTraceFields.NameField,
            Condition = FilterCondition.Contains,
            Value = "request"
        });
        content = new FilterDialogViewModel
        {
            Filter = content.Filter,
            KnownKeys = content.KnownKeys,
            GetPropertyKeysAsync = _ =>
            {
                loadingStarted.SetResult();
                return propertyKeys.Task;
            },
            GetFieldValuesAsync = content.GetFieldValuesAsync
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        await loadingStarted.Task.WaitAsync(DefaultWaitTimeout);
 
        Assert.True(cut.FindComponent<FluentSelect<SelectViewModel<string>>>().Instance.Disabled);
        Assert.Single(cut.FindComponents<FluentProgressRing>());
        Assert.NotNull(cut.Find(".input-line-container .input-progress"));
 
        propertyKeys.SetResult(["custom.attribute"]);
 
        cut.WaitForAssertion(() =>
        {
            var parameterSelect = cut.FindComponent<FluentSelect<SelectViewModel<string>>>();
            Assert.False(parameterSelect.Instance.Disabled);
            Assert.Empty(cut.FindComponents<FluentProgressRing>());
            Assert.Contains(parameterSelect.Instance.Items!, item => item.Id == "custom.attribute");
        });
    }
 
    [Fact]
    public async Task Render_FieldValuesLoading_DisablesValueComboboxAndDisplaysProgressRing()
    {
        SetupFilterDialogServices();
        var loadingStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var fieldValues = new TaskCompletionSource<Dictionary<string, int>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = CreateContent(new FieldTelemetryFilter
        {
            Field = KnownTraceFields.NameField,
            Condition = FilterCondition.Contains,
            Value = "request"
        });
        content = new FilterDialogViewModel
        {
            Filter = content.Filter,
            KnownKeys = content.KnownKeys,
            GetPropertyKeysAsync = content.GetPropertyKeysAsync,
            GetFieldValuesAsync = (_, _) =>
            {
                loadingStarted.SetResult();
                return fieldValues.Task;
            }
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        await loadingStarted.Task.WaitAsync(DefaultWaitTimeout);
 
        Assert.True(cut.Find("fluent-combobox").HasAttribute("disabled"));
        Assert.Single(cut.FindAll("fluent-combobox + fluent-progress-ring"));
 
        fieldValues.SetResult(new Dictionary<string, int> { ["request"] = 1 });
 
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.Find("fluent-combobox").HasAttribute("disabled"));
            Assert.Empty(cut.FindAll("fluent-combobox + fluent-progress-ring"));
        });
    }
 
    [Fact]
    public async Task Render_ChangingStringFieldAfterValuesLoad_LoadsNewValues()
    {
        SetupFilterDialogServices();
        var loadedFields = new List<string>();
        var content = new FilterDialogViewModel
        {
            Filter = new FieldTelemetryFilter
            {
                Field = KnownTraceFields.NameField,
                Condition = FilterCondition.Contains,
                Value = "request"
            },
            KnownKeys = [KnownTraceFields.NameField, KnownTraceFields.TraceIdField],
            GetPropertyKeysAsync = static _ => Task.FromResult<List<string>>([]),
            GetFieldValuesAsync = (field, _) =>
            {
                loadedFields.Add(field);
                return Task.FromResult<Dictionary<string, int>>([]);
            }
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        var parameterSelect = cut.FindComponent<FluentSelect<SelectViewModel<string>>>();
        var traceIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.TraceIdField);
 
        await parameterSelect.InvokeAsync(() => parameterSelect.Instance.SelectedOptionChanged.InvokeAsync(traceIdOption));
 
        Assert.Collection(loadedFields,
            field => Assert.Equal(KnownTraceFields.NameField, field),
            field => Assert.Equal(KnownTraceFields.TraceIdField, field));
        Assert.False(cut.Find("fluent-combobox").HasAttribute("disabled"));
    }
 
    [Fact]
    public async Task Render_FieldValueReadsCompleteOutOfOrder_LatestValuesDisplayed()
    {
        SetupFilterDialogServices();
        var firstLoadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var secondLoadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var firstFieldValues = new TaskCompletionSource<Dictionary<string, int>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var secondFieldValues = new TaskCompletionSource<Dictionary<string, int>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = new FilterDialogViewModel
        {
            Filter = new FieldTelemetryFilter
            {
                Field = KnownTraceFields.NameField,
                Condition = FilterCondition.Contains,
                Value = "request"
            },
            KnownKeys = [KnownTraceFields.NameField, KnownTraceFields.TraceIdField, KnownTraceFields.SpanIdField],
            GetPropertyKeysAsync = static _ => Task.FromResult<List<string>>([]),
            GetFieldValuesAsync = (field, _) => field switch
            {
                KnownTraceFields.NameField => Task.FromResult<Dictionary<string, int>>([]),
                KnownTraceFields.TraceIdField => StartLoad(firstLoadStarted, firstFieldValues),
                KnownTraceFields.SpanIdField => StartLoad(secondLoadStarted, secondFieldValues),
                _ => throw new InvalidOperationException($"Unexpected field '{field}'.")
            }
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        var parameterSelect = cut.FindComponent<FluentSelect<SelectViewModel<string>>>();
        var traceIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.TraceIdField);
        var spanIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.SpanIdField);
 
        var firstChangeTask = parameterSelect.InvokeAsync(() => parameterSelect.Instance.SelectedOptionChanged.InvokeAsync(traceIdOption));
        await firstLoadStarted.Task.WaitAsync(DefaultWaitTimeout);
        var secondChangeTask = parameterSelect.InvokeAsync(() => parameterSelect.Instance.SelectedOptionChanged.InvokeAsync(spanIdOption));
        await secondLoadStarted.Task.WaitAsync(DefaultWaitTimeout);
 
        secondFieldValues.SetResult(new Dictionary<string, int> { ["latest-value"] = 1 });
        await secondChangeTask;
        firstFieldValues.SetResult(new Dictionary<string, int> { ["stale-value"] = 1 });
        await firstChangeTask;
 
        cut.WaitForAssertion(() =>
        {
            var options = cut.Find("fluent-combobox").QuerySelectorAll("fluent-option");
            var option = Assert.Single(options);
            Assert.Contains("latest-value", option.TextContent, StringComparison.Ordinal);
        });
 
        static Task<Dictionary<string, int>> StartLoad(
            TaskCompletionSource loadStarted,
            TaskCompletionSource<Dictionary<string, int>> fieldValues)
        {
            loadStarted.SetResult();
            return fieldValues.Task;
        }
    }
 
    [Fact]
    public async Task Render_PropertyKeysReadFails_ClearsLoadingState()
    {
        SetupFilterDialogServices();
        var loadingStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var propertyKeys = new TaskCompletionSource<List<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = CreateContent(new FieldTelemetryFilter
        {
            Field = KnownTraceFields.NameField,
            Condition = FilterCondition.Contains,
            Value = "request"
        });
        content = new FilterDialogViewModel
        {
            Filter = content.Filter,
            KnownKeys = content.KnownKeys,
            GetPropertyKeysAsync = _ =>
            {
                loadingStarted.SetResult();
                return propertyKeys.Task;
            },
            GetFieldValuesAsync = content.GetFieldValuesAsync
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        await loadingStarted.Task.WaitAsync(DefaultWaitTimeout);
        Assert.Single(cut.FindComponents<FluentProgressRing>());
 
        propertyKeys.SetException(new InvalidOperationException("Database read failed."));
 
        // A failed read must still clear the loading state. Otherwise the parameter select stays disabled behind a
        // spinner for the life of the dialog and the user cannot pick a different field.
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.FindComponent<FluentSelect<SelectViewModel<string>>>().Instance.Disabled);
            Assert.Empty(cut.FindComponents<FluentProgressRing>());
        });
    }
 
    [Fact]
    public async Task Render_FieldValuesReadFails_ClearsLoadingState()
    {
        SetupFilterDialogServices();
        var loadingStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var fieldValues = new TaskCompletionSource<Dictionary<string, int>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = CreateContent(new FieldTelemetryFilter
        {
            Field = KnownTraceFields.NameField,
            Condition = FilterCondition.Contains,
            Value = "request"
        });
        content = new FilterDialogViewModel
        {
            Filter = content.Filter,
            KnownKeys = content.KnownKeys,
            GetPropertyKeysAsync = content.GetPropertyKeysAsync,
            GetFieldValuesAsync = (_, _) =>
            {
                loadingStarted.SetResult();
                return fieldValues.Task;
            }
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        await loadingStarted.Task.WaitAsync(DefaultWaitTimeout);
        Assert.True(cut.Find("fluent-combobox").HasAttribute("disabled"));
 
        fieldValues.SetException(new InvalidOperationException("Database read failed."));
 
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.Find("fluent-combobox").HasAttribute("disabled"));
            Assert.Empty(cut.FindAll("fluent-combobox + fluent-progress-ring"));
        });
    }
 
    [Fact]
    public async Task DisposeAsync_InFlightRead_CancelsToken()
    {
        SetupFilterDialogServices();
        var loadingStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var propertyKeys = new TaskCompletionSource<List<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var readCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var content = CreateContent(new FieldTelemetryFilter
        {
            Field = KnownTraceFields.NameField,
            Condition = FilterCondition.Contains,
            Value = "request"
        });
        content = new FilterDialogViewModel
        {
            Filter = content.Filter,
            KnownKeys = content.KnownKeys,
            GetPropertyKeysAsync = cancellationToken =>
            {
                cancellationToken.Register(() => readCancelled.TrySetResult());
                loadingStarted.SetResult();
                return propertyKeys.Task;
            },
            GetFieldValuesAsync = content.GetFieldValuesAsync
        };
 
        var cut = RenderComponent<FilterDialog>(builder => builder.Add(p => p.Content, content));
        await loadingStarted.Task.WaitAsync(DefaultWaitTimeout);
 
        // Telemetry reads run against SQLite on the thread pool. Closing the dialog must cancel them so a scan
        // started for a dialog nobody is looking at does not keep running.
        await cut.Instance.DisposeAsync();
 
        await readCancelled.Task.WaitAsync(DefaultWaitTimeout);
 
        // Complete the read so the component's initialization task does not stay pending past the test.
        propertyKeys.SetResult([]);
    }
 
    private void SetupFilterDialogServices()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentInputLabel(this);
        FluentUISetupHelpers.SetupFluentTextField(this);
        FluentUISetupHelpers.SetupFluentButton(this);
        FluentUISetupHelpers.SetupFluentList(this);
        FluentUISetupHelpers.SetupFluentCombobox(this);
    }
 
    private static FilterDialogViewModel CreateContent(FieldTelemetryFilter filter)
    {
        return new FilterDialogViewModel
        {
            Filter = filter,
            KnownKeys = [KnownTraceFields.NameField, KnownTraceFields.DurationField],
            GetPropertyKeysAsync = static _ => Task.FromResult<List<string>>([]),
            GetFieldValuesAsync = static (field, _) => Task.FromResult(field == KnownTraceFields.NameField
                ? new Dictionary<string, int> { ["request"] = 1 }
                : [])
        };
    }
}