adospace / reactorui-maui

MauiReactor is a MVU UI framework built on top of .NET MAUI
MIT License
552 stars 45 forks source link

The `Find` extension method that takes a `TimeSpan` is only available for `ITemplateHost`. Can one be added for `IElementController`? #220

Closed adospace closed 3 months ago

adospace commented 4 months ago
          The `Find` extension method that takes a `TimeSpan` is only available for `ITemplateHost`.  Can one be added for `IElementController`?

Originally posted by @powerdude in https://github.com/adospace/reactorui-maui/issues/216#issuecomment-1977255492

adospace commented 4 months ago

I've added the timeout parameter to the IElementController extensions methods: be aware that you still need to provide the ITemplateHost to the method.

powerdude commented 4 months ago

Thanks for adding, but not working like the other method. Here's my complete test. I'm ultimately trying to get all the way to ModelPage and verify the text in the label on that page.

public class TestShellPage : Component
{
    private MauiControls.Shell shell;

    /// <inheritdoc />
    public override VisualNode Render() =>
        new Shell(x => shell = x!)
            {
                new ShellContent("Models")
                    .AutomationId("Models")
                    .Icon(FontImages.Feature)
                    .RenderContent(() => new ListPage().Shell(shell))
            }
            .AutomationId("MainShell");
}

public record Model(string Id, string Name);

public partial class ListPage : Component<ListPage.PageState>
{
    [Prop("Shell")] protected MauiControls.Shell shellRef;

    public override VisualNode Render() =>
        new ContentPage
        {
            new CollectionView().AutomationId("list")
                .ItemsSource(State.Items, Render)
        };

    protected override void OnMounted()
    {
        Routing.RegisterRoute<ModelPage>("model");

        Task.Run(
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2));
                SetState(s => s.Items = new[] { new Model("m1", "model name 1") });
            });
        base.OnMounted();
    }

    private VisualNode Render(Model item) =>
        new VStack(5)
            {
                new Label(item.Name).AutomationId(item.Id + "-name"),
                new Label(item.Id).AutomationId(item.Id + "-id")
                    .FontSize(12)
                    .TextColor(Colors.Gray)
            }.AutomationId(item.Id + "-stack")
            .OnTapped(
                async () => await shellRef.GoToAsync<ModelPage.Props2>(
                    "model",
                    props => props.Id = item.Id))
            .Margin(5, 10);

    public class PageState
    {
        public Model[] Items { get; set; } =
        [
        ];
    }
}

public class ModelPage : Component<ModelPage.PageState, ModelPage.Props2>
{
    public override VisualNode Render() =>
        new ContentPage("Model")
        {
            new Label(State.Item?.Name)
                .AutomationId("name")
                .VCenter()
                .HCenter()
        };

    protected override void OnMountedOrPropsChanged()
    {
        Task.Run(
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2));
                SetState(s => s.Item = new Model("m1", "model name 1"));
            });

        base.OnMounted();
    }

    public class PageState
    {
        public Model? Item { get; set; }
    }

    public class Props2
    {
        public string Id { get; set; } = null!;
    }
}

public class BugIntegrationTests
{
    [Fact]
    public async Task VerifyLabelOnModelPage()
    {
        var services = new ServiceCollection();

        var provider = services.BuildServiceProvider();
        using var serviceContext = new ServiceContext(provider);

        var mainPage = TemplateHost.Create(new TestShellPage());
        var shell = mainPage.Find<MauiControls.Shell>("MainShell");
        var models = shell.Find<MauiControls.ShellContent>("Models");

        shell.CurrentItem = models;

        // first way
        var name = await mainPage.Find<MauiControls.Label>("m1-name", TimeSpan.FromSeconds(30));
        name.Text.Should()
            .Be("model name 1");

        // this way should also work
        var name2 = await models.Find<MauiControls.Label>(mainPage, "m1-name", TimeSpan.FromSeconds(30));
        name2.Text.Should()
            .Be("model name 1");

        // get and click on item in collection view
        var stack = await mainPage.Find<MauiControls.VerticalStackLayout>("m1-stack", TimeSpan.FromSeconds(30));

        // how to tap the stack??

        // get ModelPage and get the label

        // test the label of ModelPage
    }
}
adospace commented 4 months ago

ok, a few things: 1) I usually make unit tests on single functions/single components, what you're doing looks like more of an integration test that may be more suitable for tools like Appium. 2) models.Find<> above doesn't work because it relies on the MAUI .NET function IElementController.Descendant() that doesn't navigate to views created with a template: so, it won't find the rendered page inside the ShellContent or the the list items inside the CollectionView. To find those controls you need to use the ITemplateHost.Find<>() provided by MauiReactor that instead visits all the nodes forcing the creation of the children. 3) Unfortunately the Tapped event can't be raised easily because MAUI team has made the SendTapped event internal: https://github.com/dotnet/maui/blob/51cabfd4002e5b7d3617a5177251251f58ac73c1/src/Controls/src/Core/TapGestureRecognizer.cs#L56C33-L56C39 but I'm looking for a way to call it using reflection. I'll be back with a solution shortly

powerdude commented 4 months ago
  1. Yes, this is an integration-type test and I know I'm pushing the boundaries here. Fully aware of Appium, but trying to see how far we can go here without jumping to another tool.
  2. Understood. It would be great to somehow throw an error based on the conditions you've just mentioned as a way to inform the user of why nothing was found. Returning null is a bit misleading.
  3. Yeah, I remember having to use reflection for scenarios like this back when I was trying to hack my way though pseudo-UI testing through just the UI objects.
adospace commented 4 months ago

ok, this seems working fine:

public class TestBug220ShellPage : Component
{
    private MauiControls.Shell? shell;

    /// <inheritdoc />
    public override VisualNode Render() =>
        new Shell(x => shell = x!)
            {
                new ShellContent("Models")
                    .AutomationId("Models")
                    //.Icon(FontImages.Feature)
                    .RenderContent(() => new ListPage().Shell(shell))
            }
            .AutomationId("MainShell");
}

public record Model(string Id, string Name);

public partial class ListPage : Component<ListPage.PageState>
{
    [Prop("Shell")] protected MauiControls.Shell? shellRef;

    public override VisualNode Render() =>
        new ContentPage
        {
            new CollectionView()
                .AutomationId("list")
                .ItemsSource(State.Items, Render)
        }
        .AutomationId("Models_page");

    protected override void OnMounted()
    {
        Routing.RegisterRoute<TestBug220ModelPage>("model");

        Task.Run(
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2));
                SetState(s => s.Items = new[] { new Model("m1", "model name 1") });
            });
        base.OnMounted();
    }

    private VisualNode Render(Model item) =>
        new VStack(5)
            {
                new Label(item.Name).AutomationId(item.Id + "-name"),
                new Label(item.Id).AutomationId(item.Id + "-id")
                    .FontSize(12)
                    .TextColor(Colors.Gray)
            }.AutomationId(item.Id + "-stack")
            .OnTapped(
                async () => await shellRef!.GoToAsync<TestBug220ModelPage.Props2>(
                    "model",
                    props => props.Id = item.Id))
            .Margin(5, 10);

    public class PageState
    {
        public Model[] Items { get; set; } =
        [
        ];
    }
}

public class TestBug220ModelPage : Component<TestBug220ModelPage.PageState, TestBug220ModelPage.Props2>
{
    public override VisualNode Render() =>
        new ContentPage("Model")
        {
            new Label(State.Item?.Name)
                .AutomationId(State.Item?.Id ?? string.Empty)
                .VCenter()
                .HCenter()
        };

    protected override void OnMountedOrPropsChanged()
    {
        Task.Run(
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2));
                SetState(s => s.Item = new Model("m2", "model name 2"));
            });

        base.OnMounted();
    }

    public class PageState
    {
        public Model? Item { get; set; }
    }

    public class Props2
    {
        public string Id { get; set; } = null!;
    }
}
var services = new ServiceCollection();

var provider = services.BuildServiceProvider();
using var serviceContext = new ServiceContext(provider);

var mainPage = TemplateHost.Create(new TestBug220ShellPage());
var shell = mainPage.Find<MauiControls.Shell>("MainShell");
var models = shell.Find<MauiControls.ShellContent>("Models");
var modelsPage = mainPage.Find<MauiControls.ContentPage>("Models_page");

shell.CurrentItem = models;

// first way
var name = await mainPage.Find<MauiControls.Label>("m1-name", TimeSpan.FromSeconds(30));

name.ShouldNotBeNull();
name.Text.ShouldBe("model name 1");

// get and click on item in collection view
var stack = await mainPage.Find<MauiControls.VerticalStackLayout>("m1-stack", TimeSpan.FromSeconds(30));

stack.ShouldNotBeNull();

// how to tap the stack??
var tapGestureRecognizer = stack.GestureRecognizers.OfType<MauiControls.TapGestureRecognizer>().Single();

tapGestureRecognizer
    .GetType()
    .GetMethod("SendTapped", BindingFlags.Instance | BindingFlags.NonPublic)
    .ShouldNotBeNull().Invoke(tapGestureRecognizer, new[] { stack, null });

// get ModelPage and get the label
var label = await mainPage.Find<MauiControls.Label>("m2", TimeSpan.FromSeconds(5));

label.ShouldNotBeNull();

// test the label of ModelPage
label.Text.ShouldBe("model name 2");