Skip to content

Commit

Permalink
πŸ› Fix navigation models not being terminated when closed (#79)
Browse files Browse the repository at this point in the history
* πŸ› Ensure navigation nodes are consistently terminated

* πŸ› Return model when replacing existing model

* βœ… Add unit tests for navigation lifecycle
  • Loading branch information
tomrijnbeek committed May 1, 2023
1 parent 9f70e9a commit 05c7df7
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 22 deletions.
13 changes: 12 additions & 1 deletion Bearded.UI.Tests/Bearded.UI.Tests.csproj
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Bearded.UI.Tests</RootNamespace>
<TargetFrameworks>net5.0;net6.0;netcoreapp3.1</TargetFrameworks>
Expand All @@ -20,4 +20,15 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />
</ItemGroup>

<ItemGroup>
<Compile Remove="IsExternalInit.cs" />
</ItemGroup>
<!-- Hacks to make netstandard more usable -->
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.1'">
<!-- Needed to make indexes work -->
<PackageReference Include="IndexRange" Version="1.0.2" />
<!-- Needed to make C#9 features work -->
<Compile Include="IsExternalInit.cs" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions Bearded.UI.Tests/IsExternalInit.cs
@@ -0,0 +1,6 @@
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices
{
// HACK: This is needed in order to use C#9+ features in netstandard
public class IsExternalInit { }
}
128 changes: 128 additions & 0 deletions Bearded.UI.Tests/Navigation/NodeLifecycleTests.cs
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using Bearded.UI.Controls;
using Bearded.UI.Navigation;
using FluentAssertions;
using Xunit;

namespace Bearded.UI.Tests.Navigation;

public sealed class NodeLifecycleTests
{
private readonly NavigationController nav;

public NodeLifecycleTests()
{
var root = new CompositeControl();
nav = new NavigationController(
root,
new DependencyResolver(),
new Dictionary<Type, object> { { typeof(TestNode), TestNode.ModelFactory } },
new Dictionary<Type, object> { { typeof(TestNode), TestNode.ViewFactory } });
}

[Fact]
public void PushingNodesInitializesCorrectly()
{
var node = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());

node.Initialized.Should().BeTrue();
node.Terminated.Should().BeFalse();
}

[Fact]
public void ReplacingNodesInitializesAndTerminatesCorrectly()
{
var existingNode = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
var replacingNode = nav.Replace<TestNode, TestParameter>(TestParameter.UniqueInstance(), existingNode);

existingNode.Terminated.Should().BeTrue();
replacingNode.Initialized.Should().BeTrue();
replacingNode.Terminated.Should().BeFalse();
}

[Fact]
public void ReplacingAllNodesInitializesAndTerminatesCorrectly()
{
var existingNode1 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
var existingNode2 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
var replacingNode = nav.ReplaceAll<TestNode, TestParameter>(TestParameter.UniqueInstance());

existingNode1.Terminated.Should().BeTrue();
existingNode2.Terminated.Should().BeTrue();
replacingNode.Initialized.Should().BeTrue();
replacingNode.Terminated.Should().BeFalse();
}

[Fact]
public void ClosingNodesTerminatesCorrectly()
{
var node = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
nav.Close(node);

node.Initialized.Should().BeTrue();
node.Terminated.Should().BeTrue();
}

[Fact]
public void ClosingAllNodesTerminatesCorrectly()
{
var node1 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
var node2 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
nav.CloseAll();

node1.Initialized.Should().BeTrue();
node1.Terminated.Should().BeTrue();
node2.Initialized.Should().BeTrue();
node2.Terminated.Should().BeTrue();
}

[Fact]
public void ExitingNavigationTerminatesCorrectly()
{
var node1 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
var node2 = nav.Push<TestNode, TestParameter>(TestParameter.UniqueInstance());
nav.Exit();

node1.Initialized.Should().BeTrue();
node1.Terminated.Should().BeTrue();
node2.Initialized.Should().BeTrue();
node2.Terminated.Should().BeTrue();
}

[Fact]
public void ParameterIsPassedIntoModel()
{
var param = TestParameter.UniqueInstance();
var node = nav.Push<TestNode, TestParameter>(param);

node.PassedInParameter.Should().Be(param);
}

private sealed class TestNode : NavigationNode<TestParameter>
{
public static Func<TestNode> ModelFactory => () => new TestNode();
public static Func<TestNode, Control> ViewFactory => _ => new SimpleControl();

public bool Initialized { get; private set; }
public bool Terminated { get; private set; }
public TestParameter? PassedInParameter { get; private set; }

protected override void Initialize(DependencyResolver dependencies, TestParameter parameters)
{
Initialized = true;
PassedInParameter = parameters;
}

public override void Terminate()
{
Terminated = true;
}
}

private sealed class TestParameter
{
// Equality should by default use reference equality, so each new instance is unique.
public static TestParameter UniqueInstance() => new();
}
}
45 changes: 24 additions & 21 deletions Bearded.UI/Navigation/NavigationController.cs
Expand Up @@ -36,59 +36,64 @@ public void Exit()

public void CloseAll()
{
while (root.Children.Count > 0)
root.Remove(root.Children[0]);
foreach (var (node, view) in viewsByModel)
{
node.Terminate();
root.Remove(view);
}
viewsByModel.Clear();
}

public void Close(INavigationNode toClose)
{
var viewToReplace = viewsByModel[toClose];
root.Remove(viewToReplace);
var viewToRemove = viewsByModel[toClose];
toClose.Terminate();
root.Remove(viewToRemove);
viewsByModel.Remove(toClose);
}

public void ReplaceAll<TModel>()
public TModel ReplaceAll<TModel>()
where TModel : NavigationNode<Void>
{
ReplaceAll<TModel, Void>(default(Void));
return ReplaceAll<TModel, Void>(default);
}

public void ReplaceAll<TModel, TParameters>(TParameters parameters)
public TModel ReplaceAll<TModel, TParameters>(TParameters parameters)
where TModel : NavigationNode<TParameters>
{
CloseAll();
Push<TModel, TParameters>(parameters);
return Push<TModel, TParameters>(parameters);
}

public void Replace<TModel>(INavigationNode toReplace)
public TModel Replace<TModel>(INavigationNode toReplace)
where TModel : NavigationNode<Void>
{
Replace<TModel, Void>(default(Void), toReplace);
return Replace<TModel, Void>(default, toReplace);
}

public void Replace<TModel, TParameters>(TParameters parameters, INavigationNode toReplace)
public TModel Replace<TModel, TParameters>(TParameters parameters, INavigationNode toReplace)
where TModel : NavigationNode<TParameters>
{
toReplace.Terminate();
var viewToReplace = viewsByModel[toReplace];
var (_, view) = instantiateModelAndView<TModel, TParameters>(parameters);
toReplace.Terminate();
var (model, view) = instantiateModelAndView<TModel, TParameters>(parameters);
new AnchorTemplate(viewToReplace).ApplyTo(view);
root.AddOnTopOf(viewToReplace, view);
root.Remove(viewToReplace);
viewsByModel.Remove(toReplace);
return model;
}

public TModel Push<TModel>()
where TModel : NavigationNode<Void>
{
return Push<TModel, Void>(default(Void));
return Push<TModel, Void>(default);
}

public TModel Push<TModel>(Func<AnchorTemplate, AnchorTemplate> build)
where TModel : NavigationNode<Void>
{
return Push<TModel, Void>(default(Void), build);
return Push<TModel, Void>(default, build);
}

public TModel Push<TModel, TParameters>(TParameters parameters)
Expand Down Expand Up @@ -117,13 +122,11 @@ public TModel Push<TModel>(Func<AnchorTemplate, AnchorTemplate> build)
return (model, view);
}

private Func<T> findModelFactory<T>()
=> (Func<T>) modelFactories[typeof(T)];
private Func<T> findModelFactory<T>() => (Func<T>) modelFactories[typeof(T)];

private Func<T, Control> findViewFactory<T>()
=> (Func<T, Control>) viewFactories[typeof(T)];
private Func<T, Control> findViewFactory<T>() => (Func<T, Control>) viewFactories[typeof(T)];

private NavigationContext<T> createNavigationContext<T>(T parameters)
=> new NavigationContext<T>(this, dependencyResolver, parameters);
private NavigationContext<T> createNavigationContext<T>(T parameters) =>
new(this, dependencyResolver, parameters);
}
}

0 comments on commit 05c7df7

Please sign in to comment.