diff --git a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Cycle.cs b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Cycle.cs index cf08992..4eff034 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Cycle.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/CompositeLightTransitionReactiveNodeConfigurator.Cycle.cs @@ -52,10 +52,13 @@ public ILightTransitionReactiveNodeConfigurator AddCycle(IObservable trigg configure(compositeCycleConfigurator); configurators.ForEach(kvp => kvp.Value.AddNodeSource(triggerObservable.ToCycleObservable(cycleConfigurators[kvp.Key].CycleNodeFactories.Select(tuple => { - var serviceScope = serviceProvider.CreateScope(); - var context = new LightPipelineContext(serviceScope.ServiceProvider, kvp.Value.Light); - var factory = new Func>(() => new ScopedNode(serviceScope, tuple.nodeFactory(context))); - var valueIsActiveFunc = () => tuple.matchesNodeState(context); + var factory = new Func>(() => + { + var serviceScope = serviceProvider.CreateScope(); + var context = new LightPipelineContext(serviceScope.ServiceProvider, kvp.Value.Light); + return new ScopedNode(serviceScope, tuple.nodeFactory(context)); + }); + var valueIsActiveFunc = () => tuple.matchesNodeState(new LightPipelineContext(serviceProvider, kvp.Value.Light)); return (factory, valueIsActiveFunc); })))); return this; diff --git a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Cycle.cs b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Cycle.cs index 019de03..fd6ce52 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Cycle.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.Cycle.cs @@ -51,10 +51,13 @@ public ILightTransitionReactiveNodeConfigurator AddCycle(IObservable trigg configure(cycleConfigurator); AddNodeSource(triggerObservable.ToCycleObservable(cycleConfigurator.CycleNodeFactories.Select(tuple => { - var serviceScope = serviceProvider.CreateScope(); - var context = new LightPipelineContext(serviceScope.ServiceProvider, Light); - var factory = new Func>(() => new ScopedNode(serviceScope, tuple.nodeFactory(context))); - var valueIsActiveFunc = () => tuple.matchesNodeState(context); + var factory = new Func>(() => + { + var serviceScope = serviceProvider.CreateScope(); + var context = new LightPipelineContext(serviceScope.ServiceProvider, Light); + return new ScopedNode(serviceScope, tuple.nodeFactory(context)); + }); + var valueIsActiveFunc = () => tuple.matchesNodeState(new LightPipelineContext(serviceProvider, Light)); return (factory, valueIsActiveFunc); }))); return this; diff --git a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.cs b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.cs index ad02ac1..8578c82 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/ReactiveNode/LightTransitionReactiveNodeConfigurator.cs @@ -1,12 +1,15 @@ -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using CodeCasa.Abstractions; +using CodeCasa.Abstractions; using CodeCasa.AutomationPipelines.Lights.Context; using CodeCasa.AutomationPipelines.Lights.Extensions; using CodeCasa.AutomationPipelines.Lights.Nodes; using CodeCasa.AutomationPipelines.Lights.Pipeline; using CodeCasa.Lights; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Xml.Linq; +using CodeCasa.AutomationPipelines.Lights.Utils; +using Microsoft.Extensions.DependencyInjection; namespace CodeCasa.AutomationPipelines.Lights.ReactiveNode; @@ -99,7 +102,18 @@ public ILightTransitionReactiveNodeConfigurator AddNodeSource(IObservable public ILightTransitionReactiveNodeConfigurator AddNodeSource(IObservable?>> nodeFactorySource) { - return AddNodeSource(nodeFactorySource.Select(f => f(new LightPipelineContext(serviceProvider, Light)))); + return AddNodeSource(nodeFactorySource.Select(nodeFactory => + { + var scope = serviceProvider.CreateScope(); + var context = new LightPipelineContext(scope.ServiceProvider, Light); + var node = nodeFactory(context); + if (node != null) + { + return new ScopedNode(scope, node); + } + scope.Dispose(); + return null; + })); } /// diff --git a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj index d8c591c..66182e8 100644 --- a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj +++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/CodeCasa.AutomationPipelines.Lights.Tests/ReactiveNodeTests.cs b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ReactiveNodeTests.cs new file mode 100644 index 0000000..538b161 --- /dev/null +++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ReactiveNodeTests.cs @@ -0,0 +1,365 @@ +using CodeCasa.AutomationPipelines.Lights.Nodes; +using CodeCasa.AutomationPipelines.Lights.ReactiveNode; +using Microsoft.Extensions.DependencyInjection; +using System.Reactive.Concurrency; +using CodeCasa.Lights; +using CodeCasa.AutomationPipelines.Lights.Pipeline; +using Microsoft.Extensions.Logging; +using Moq; +using System.Reactive.Subjects; +using CodeCasa.AutomationPipelines.Lights.Context; +using ReactiveNodeClass = CodeCasa.AutomationPipelines.Lights.ReactiveNode.ReactiveNode; + +namespace CodeCasa.AutomationPipelines.Lights.Tests +{ + [TestClass] + public sealed class ReactiveNodeTests + { + private Mock _serviceProviderMock = null!; + private IScheduler _scheduler = null!; + private ReactiveNodeFactory _reactiveNodeFactory = null!; + private Mock _lightMock = null!; + private LightPipelineContextProvider _contextProvider = null!; + private Mock _scopeFactoryMock = null!; + private Mock _scopeMock = null!; + private Mock _scopedServiceProviderMock = null!; + + [TestInitialize] + public void Initialize() + { + _serviceProviderMock = new Mock(); + _scheduler = Scheduler.Immediate; + _reactiveNodeFactory = new ReactiveNodeFactory(_serviceProviderMock.Object, _scheduler); + + var pipelineLoggerMock = new Mock>>(); + var lightPipelineFactory = new LightPipelineFactory(pipelineLoggerMock.Object, _serviceProviderMock.Object, _reactiveNodeFactory); + + _serviceProviderMock.Setup(x => x.GetService(typeof(LightPipelineFactory))) + .Returns(lightPipelineFactory); + + var reactiveNodeLoggerMock = new Mock>(); + _serviceProviderMock.Setup(x => x.GetService(typeof(ILogger))) + .Returns(reactiveNodeLoggerMock.Object); + + _serviceProviderMock.Setup(x => x.GetService(typeof(IScheduler))) + .Returns(_scheduler); + + _lightMock = new Mock(); + _lightMock.Setup(l => l.Id).Returns("test_light"); + _lightMock.Setup(l => l.GetParameters()).Returns(new LightParameters()); + _lightMock.Setup(l => l.GetChildren()).Returns(Array.Empty()); + + _contextProvider = new LightPipelineContextProvider(); + _serviceProviderMock.Setup(x => x.GetService(typeof(LightPipelineContextProvider))) + .Returns(_contextProvider); + + _scopeFactoryMock = new Mock(); + _scopeMock = new Mock(); + _scopeMock.As(); + _scopedServiceProviderMock = new Mock(); + + _serviceProviderMock.Setup(x => x.GetService(typeof(IServiceScopeFactory))) + .Returns(_scopeFactoryMock.Object); + + _scopeFactoryMock.Setup(x => x.CreateScope()) + .Returns(_scopeMock.Object); + + _scopeMock.Setup(x => x.ServiceProvider) + .Returns(_scopedServiceProviderMock.Object); + + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(LightPipelineContextProvider))) + .Returns(_contextProvider); + + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(IScheduler))) + .Returns(_scheduler); + } + + [TestMethod] + public void CreateNode() + { + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.SetName("TestNode"); + }); + + // Assert + Assert.IsNotNull(node); + } + + [TestMethod] + public void On_Triggered_AppliesTransition() + { + // Arrange + var triggerSubject = new Subject(); + var expectedParameters = new LightParameters { Brightness = 50 }; + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject, expectedParameters); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + triggerSubject.OnNext(1); + + // Assert + Assert.IsNotNull(lastOutput); + Assert.AreEqual(expectedParameters.Brightness, lastOutput.LightParameters.Brightness); + } + + [TestMethod] + public void On_Triggered_ActivatesNode_Generic() + { + // Arrange + var triggerSubject = new Subject(); + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + triggerSubject.OnNext(1); + + // Assert + Assert.IsNotNull(lastOutput); + Assert.AreEqual(100, lastOutput.LightParameters.Brightness); + } + + [TestMethod] + public void On_Triggered_ReplacesAndDisposesOldNode() + { + // Arrange + var triggerSubject = new Subject(); + + var trackerMock = new Mock(); + _serviceProviderMock.Setup(x => x.GetService(typeof(ILifecycleTracker))) + .Returns(trackerMock.Object); + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(ILifecycleTracker))) + .Returns(trackerMock.Object); + + var createdIds = new List(); + var disposedIds = new List(); + + trackerMock.Setup(x => x.Created(It.IsAny())) + .Callback(id => createdIds.Add(id)); + trackerMock.Setup(x => x.Disposed(It.IsAny())) + .Callback(id => disposedIds.Add(id)); + + // Act + _ = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject); + }); + + // Trigger 1 + triggerSubject.OnNext(1); + + // Assert 1 + Assert.HasCount(1, createdIds, "Should have created one node"); + Assert.HasCount(0, disposedIds, "Should not have disposed any node yet"); + + // Trigger 2 + triggerSubject.OnNext(2); + + // Assert 2 + Assert.HasCount(2, createdIds, "Should have created second node"); + Assert.HasCount(1, disposedIds, "Should have disposed one node"); + Assert.AreEqual(createdIds[0], disposedIds[0], "Should have disposed the first node"); + } + + [TestMethod] + public void On_Triggered_ActivatesNode_WithContext() + { + // Arrange + var triggerSubject = new Subject(); + + _serviceProviderMock.Setup(x => x.GetService(typeof(ILightPipelineContext))) + .Returns(() => _contextProvider.GetLightPipelineContext()); + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(ILightPipelineContext))) + .Returns(() => _contextProvider.GetLightPipelineContext()); + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + triggerSubject.OnNext(1); + + // Assert + Assert.IsNotNull(lastOutput); + Assert.AreEqual(100, lastOutput.LightParameters.Brightness); + } + + public class TestPipelineNode : PipelineNode + { + public TestPipelineNode() + { + Output = new LightParameters { Brightness = 100 }.AsTransition(); + } + } + + public interface ILifecycleTracker + { + void Created(Guid id); + void Disposed(Guid id); + } + + public class LifecycleTrackingPipelineNode : PipelineNode, IDisposable + { + private readonly ILifecycleTracker _tracker; + public Guid Id { get; } = Guid.NewGuid(); + + public LifecycleTrackingPipelineNode(ILifecycleTracker tracker) + { + _tracker = tracker; + _tracker.Created(Id); + Output = new LightParameters { Brightness = 100 }.AsTransition(); + } + + public void Dispose() + { + _tracker.Disposed(Id); + } + } + + public class ContextAwarePipelineNode : PipelineNode + { + public ContextAwarePipelineNode(ILightPipelineContext context) + { + if (context.Light.Id == "test_light") + { + Output = new LightParameters { Brightness = 100 }.AsTransition(); + } + } + } + + [TestMethod] + public void On_Triggered_CreatesScopedServiceProvider_AndDisposesIt() + { + // Arrange + var triggerSubject = new Subject(); + + // Allow resolving IServiceProvider from itself + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(IServiceProvider))) + .Returns(_scopedServiceProviderMock.Object); + + // Act + _ = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject); + }); + + // Trigger 1 + triggerSubject.OnNext(1); + + // Assert 1 + _scopeFactoryMock.Verify(x => x.CreateScope(), Times.Once, "Scope should be created on trigger"); + _scopeMock.As().Verify(x => x.DisposeAsync(), Times.Never, "Scope should not be disposed yet"); + + // Trigger 2 + triggerSubject.OnNext(2); + + // Assert 2 + _scopeFactoryMock.Verify(x => x.CreateScope(), Times.Exactly(2), "New scope should be created on second trigger"); + _scopeMock.As().Verify(x => x.DisposeAsync(), Times.Once, "Old scope should be disposed"); + } + + [TestMethod] + public void TurnOffWhen_Triggered_EmitsOffAndPassesThrough() + { + // Arrange + var triggerSubject = new Subject(); + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.TurnOffWhen(triggerSubject); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + // Trigger + triggerSubject.OnNext(1); + + // Assert + Assert.IsNotNull(lastOutput); + Assert.AreEqual(LightTransition.Off(), lastOutput); + + // Verify pass-through behavior + var inputTransition = new LightParameters { Brightness = 100 }.AsTransition(); + node.Input = inputTransition; + Assert.AreEqual(inputTransition, lastOutput); + } + + [TestMethod] + public void PassThroughOn_Triggered_PassesThrough() + { + // Arrange + var triggerSubject = new Subject(); + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.PassThroughOn(triggerSubject); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + // Trigger + triggerSubject.OnNext(1); + + // Verify pass-through behavior + var inputTransition = new LightParameters { Brightness = 100 }.AsTransition(); + node.Input = inputTransition; + Assert.AreEqual(inputTransition, lastOutput); + } + + [TestMethod] + public void Input_PropagatedToActiveNode() + { + // Arrange + var triggerSubject = new Subject(); + + // Act + var node = _reactiveNodeFactory.CreateReactiveNode(_lightMock.Object, config => + { + config.On(triggerSubject, _ => new FactoryNode(input => input == null + ? null + : input with { LightParameters = new LightParameters { Brightness = 100 } })); + }); + + LightTransition? lastOutput = null; + node.OnNewOutput.Subscribe(output => lastOutput = output); + + triggerSubject.OnNext(1); // Activate the node + + // Act + node.Input = new LightTransition { LightParameters = new LightParameters { Brightness = 10 } }; + + // Assert + Assert.IsNotNull(lastOutput); + Assert.AreEqual(100, lastOutput.LightParameters.Brightness); + } + + public class ScopedPipelineNode : PipelineNode + { + public ScopedPipelineNode() + { + Output = new LightParameters { Brightness = 100 }.AsTransition(); + } + } + } +}