From 419bd08a26c3b41ad017a98f5a6ba0a68e7077d9 Mon Sep 17 00:00:00 2001 From: Jasper Date: Sun, 8 Mar 2026 16:04:58 +0100 Subject: [PATCH] Allow ResettableTimeoutNode to keep its state. --- .../LightTransitionNodeExtensions.cs | 34 +- .../Extensions/ServiceProviderExtensions.cs | 29 +- .../Nodes/ResettableTimeoutNode.cs | 61 +++- ...sa.AutomationPipelines.Lights.Tests.csproj | 1 + .../ResettableTimeoutNodeTests.cs | 306 ++++++++++++++++++ 5 files changed, 382 insertions(+), 49 deletions(-) create mode 100644 tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs index 040e9fd..86a67c8 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/LightTransitionNodeExtensions.cs @@ -13,33 +13,31 @@ namespace CodeCasa.AutomationPipelines.Lights.Extensions public static class LightTransitionNodeExtensions { /// - /// Creates a timeout node that automatically turns off the light after the specified time span. - /// The timeout is not reset by any external events. + /// Wraps the pipeline node to automatically transition to an 'Off' state after a specified duration of inactivity. /// - /// The pipeline node to wrap with timeout behavior. - /// The duration after which the light will turn off. - /// The scheduler to use for timing operations. - /// A new pipeline node that wraps the original node with timeout behavior. + /// The source pipeline node. + /// The duration to wait before turning off. + /// The scheduler used for timing operations. + /// A new pipeline node that times out to 'Off'. public static IPipelineNode TurnOffAfter(this IPipelineNode node, TimeSpan timeSpan, IScheduler scheduler) { - return new ResettableTimeoutNode(node, timeSpan, Observable.Empty(), scheduler); + return new ResettableTimeoutNode(node, timeSpan, Observable.Empty(), scheduler); } /// - /// Creates a timeout node that automatically turns off the light after the specified time span. - /// The timeout can be reset when the observable emits a value. + /// Wraps the pipeline node to automatically transition to an 'Off' state after a specified duration of inactivity, + /// with an optional observable to persist the current state and bypass the timeout. /// - /// The type of values emitted by the reset timer observable. - /// The pipeline node to wrap with timeout behavior. - /// The duration after which the light will turn off. - /// An observable that resets the timeout timer when it emits a value. - /// The scheduler to use for timing operations. - /// A new pipeline node that wraps the original node with resettable timeout behavior. - public static IPipelineNode TurnOffAfter(this IPipelineNode node, - TimeSpan timeSpan, IObservable resetTimerObservable, IScheduler scheduler) + /// The source pipeline node. + /// The duration to wait before turning off. + /// An observable that, when true, prevents the timeout from triggering. + /// The scheduler used for timing operations. + /// A new pipeline node that times out to 'Off' unless persistence is active. + public static IPipelineNode TurnOffAfter(this IPipelineNode node, + TimeSpan timeSpan, IObservable persistObservable, IScheduler scheduler) { - return new ResettableTimeoutNode(node, timeSpan, resetTimerObservable, scheduler); + return new ResettableTimeoutNode(node, timeSpan, persistObservable, scheduler); } } } diff --git a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs index 3eba3fb..5b3aafd 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Extensions/ServiceProviderExtensions.cs @@ -26,13 +26,13 @@ internal static IServiceScope CreateLightContextScope(this IServiceProvi } /// - /// Creates a pipeline node that applies the specified light parameters and automatically turns off the light after a specified duration. - /// The timeout is not reset by any external events. + /// Creates a pipeline node that applies the specified light parameters and automatically transitions + /// to an 'Off' state after a specified duration of inactivity. /// - /// The service provider. + /// The service provider used to resolve the . /// The light parameters to apply as a transition. - /// The duration after which the light should turn off. - /// A pipeline node that applies the light parameters and handles the turn-off behavior. + /// The duration to wait before turning off. + /// A pipeline node that applies the light parameters and manages the turn-off timeout. public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider, LightParameters lightParameters, TimeSpan timeSpan) @@ -43,21 +43,20 @@ public static IPipelineNode CreateAutoOffLightNode(this IServic } /// - /// Creates a pipeline node that applies the specified light parameters and automatically turns off the light after a specified duration. - /// The timeout can be reset when the observable emits a value. + /// Creates a pipeline node that applies the specified light parameters and automatically transitions + /// to an 'Off' state after a specified duration of inactivity. /// - /// The type of elements emitted by the reset timer observable. - /// The service provider. + /// The service provider used to resolve the . /// The light parameters to apply as a transition. - /// The duration after which the light should turn off. - /// An observable that resets the turn-off timer when it emits. - /// A pipeline node that applies the light parameters and handles the turn-off behavior. - public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider, + /// The duration to wait before turning off. + /// An observable that, when true, prevents the timeout from triggering. + /// A pipeline node that applies the light parameters and manages the turn-off timeout. + public static IPipelineNode CreateAutoOffLightNode(this IServiceProvider serviceProvider, LightParameters lightParameters, - TimeSpan timeSpan, IObservable resetTimerObservable) + TimeSpan timeSpan, IObservable persistObservable) { var scheduler = serviceProvider.GetRequiredService(); var innerNode = new StaticLightTransitionNode(lightParameters.AsTransition(), scheduler); - return innerNode.TurnOffAfter(timeSpan, resetTimerObservable, scheduler); + return innerNode.TurnOffAfter(timeSpan, persistObservable, scheduler); } } \ No newline at end of file diff --git a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs index a9da7a4..41e6d0f 100644 --- a/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs +++ b/src/CodeCasa.AutomationPipelines.Lights/Nodes/ResettableTimeoutNode.cs @@ -1,29 +1,58 @@ -using System.Reactive; +using CodeCasa.Lights; using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; -using CodeCasa.Lights; namespace CodeCasa.AutomationPipelines.Lights.Nodes { - internal class ResettableTimeoutNode : LightTransitionNode{ + internal class ResettableTimeoutNode : LightTransitionNode, IDisposable + { + private readonly CompositeDisposable _disposables = new(); + private IDisposable? _timerSubscription; + private bool _isPersisting; + public ResettableTimeoutNode(IPipelineNode childNode, TimeSpan turnOffTime, - IObservable refreshObservable, IScheduler scheduler) : base(scheduler) + IObservable persistObservable, IScheduler scheduler) : base(scheduler) { - var serializedChild = childNode.OnNewOutput.Prepend(childNode.Output).ObserveOn(scheduler); + childNode.OnNewOutput + .Prepend(childNode.Output) + .ObserveOn(scheduler) + .Subscribe(output => + { + Output = output; + RestartTimer(); + }).DisposeWith(_disposables); - var serializedTurnOff = - refreshObservable.Select(_ => Unit.Default) - .Prepend(Unit.Default) - .Throttle(turnOffTime, scheduler) - .Take(1) - .ObserveOn(scheduler); + persistObservable + .ObserveOn(scheduler) + .DistinctUntilChanged() + .Subscribe(persist => + { + _isPersisting = persist; + if (persist) + { + _timerSubscription?.Dispose(); + } + else + { + RestartTimer(); + } + }).DisposeWith(_disposables); - serializedChild - .TakeUntil(serializedTurnOff) - .Subscribe(output => { Output = output; }); + void RestartTimer() + { + if (_isPersisting) + { + return; + } - serializedTurnOff - .Subscribe(_ => { ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition.Off()); }); + _timerSubscription?.Dispose(); + _timerSubscription = Observable.Timer(turnOffTime, scheduler) + .Subscribe(_ => ChangeOutputAndTurnOnPassThroughOnNextInput(LightTransition.Off())); + } } + + public void Dispose() => _disposables.Dispose(); } } 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 6f4ddfb..4c8480b 100644 --- a/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj +++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/CodeCasa.AutomationPipelines.Lights.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs new file mode 100644 index 0000000..b801283 --- /dev/null +++ b/tests/CodeCasa.AutomationPipelines.Lights.Tests/ResettableTimeoutNodeTests.cs @@ -0,0 +1,306 @@ +using CodeCasa.AutomationPipelines.Lights.Nodes; +using Microsoft.Reactive.Testing; +using System.Reactive.Subjects; +using CodeCasa.Lights; +using Moq; + +namespace CodeCasa.AutomationPipelines.Lights.Tests +{ + [TestClass] + public sealed class ResettableTimeoutNodeTests + { + private TestScheduler _scheduler = null!; + private Mock> _childNodeMock = null!; + private Subject _childOutputSubject = null!; + private Subject _persistSubject = null!; + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(5); + + [TestInitialize] + public void Initialize() + { + _scheduler = new TestScheduler(); + _childNodeMock = new Mock>(); + _childOutputSubject = new Subject(); + _persistSubject = new Subject(); + + _childNodeMock.Setup(x => x.OnNewOutput).Returns(_childOutputSubject); + _childNodeMock.Setup(x => x.Output).Returns((LightTransition?)null); + } + + [TestMethod] + public void Constructor_InitializesWithChildNodeOutput() + { + // Arrange + var expectedTransition = LightTransition.On(); + _childNodeMock.Setup(x => x.Output).Returns(expectedTransition); + + // Act + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _scheduler.AdvanceBy(1); + + // Assert + Assert.AreEqual(expectedTransition, node.Output); + } + + [TestMethod] + public void TimeoutElapsed_TurnsOffLight() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Act + _scheduler.AdvanceBy(DefaultTimeout.Ticks + 1); + + // Assert + Assert.IsNotNull(node.Output); + Assert.AreEqual(0, node.Output.LightParameters.Brightness); + } + + [TestMethod] + public void ChildOutputChange_RestartsTimer() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Advance halfway through the timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2); + + // Act - trigger another output change to restart the timer + var newTransition = new LightTransition { LightParameters = new LightParameters { Brightness = 50 } }; + _childOutputSubject.OnNext(newTransition); + _scheduler.AdvanceBy(1); + + // Advance past the original timeout but not past the new one + _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2); + + // Assert - light should still be on + Assert.AreEqual(newTransition, node.Output); + } + + [TestMethod] + public void ChildOutputChange_RestartsTimer_EventuallyTurnsOff() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Advance halfway through the timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2); + + // Restart timer + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Act - advance past the full timeout from the restart + _scheduler.AdvanceBy(DefaultTimeout.Ticks); + + // Assert + Assert.IsNotNull(node.Output); + Assert.AreEqual(0, node.Output.LightParameters.Brightness); + } + + [TestMethod] + public void PersistTrue_StopsTimer() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Act - enable persist mode + _persistSubject.OnNext(true); + _scheduler.AdvanceBy(1); + + // Advance past the timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2); + + // Assert - light should still be on because persist is enabled + Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness); + } + + [TestMethod] + public void PersistFalse_RestartsTimer() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Enable persist + _persistSubject.OnNext(true); + _scheduler.AdvanceBy(1); + + // Advance past original timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2); + + // Act - disable persist + _persistSubject.OnNext(false); + _scheduler.AdvanceBy(1); + + // Advance past the new timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks); + + // Assert - light should be off now + Assert.AreEqual(0, node.Output?.LightParameters.Brightness); + } + + [TestMethod] + public void PersistTrue_ChildOutputChange_DoesNotRestartTimer() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + // Enable persist first + _persistSubject.OnNext(true); + _scheduler.AdvanceBy(1); + + // Act - trigger child output change + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Advance past timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks * 2); + + // Assert - light should still be on (no timer running) + Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness); + } + + [TestMethod] + public void PersistDuplicateValues_IgnoredDueToDistinctUntilChanged() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Advance halfway + _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2); + + // Act - send duplicate persist values (should be ignored) + _persistSubject.OnNext(false); + _scheduler.AdvanceBy(1); + _persistSubject.OnNext(false); + _scheduler.AdvanceBy(1); + + // Advance remaining time from original start + _scheduler.AdvanceBy(DefaultTimeout.Ticks / 2); + + // Assert - light should still be on because duplicate false doesn't restart timer + Assert.AreNotEqual(0, node.Output?.LightParameters.Brightness); + } + + [TestMethod] + public void Dispose_CleansUpSubscriptions() + { + // Arrange + var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + + // Act + node.Dispose(); + + // Assert - no exceptions should occur when subjects complete after disposal + _childOutputSubject.OnCompleted(); + _persistSubject.OnCompleted(); + } + + [TestMethod] + public void MultipleChildOutputChanges_OnlyLastTimerIsActive() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + // Act - rapid fire multiple output changes + _childOutputSubject.OnNext(LightTransition.On()); + _scheduler.AdvanceBy(1); + _childOutputSubject.OnNext(new LightTransition { LightParameters = new LightParameters { Brightness = 50 } }); + _scheduler.AdvanceBy(1); + _childOutputSubject.OnNext(new LightTransition { LightParameters = new LightParameters { Brightness = 100 } }); + _scheduler.AdvanceBy(1); + + // Advance to just before timeout + _scheduler.AdvanceBy(DefaultTimeout.Ticks - 10); + + // Assert - light should still be on + Assert.AreEqual(100, node.Output?.LightParameters.Brightness); + + // Advance past timeout + _scheduler.AdvanceBy(20); + + // Assert - light should be off + Assert.AreEqual(0, node.Output?.LightParameters.Brightness); + } + + [TestMethod] + public void NullChildOutput_HandledCorrectly() + { + // Arrange + using var node = new ResettableTimeoutNode( + _childNodeMock.Object, + DefaultTimeout, + _persistSubject, + _scheduler); + + // Act + _childOutputSubject.OnNext(null); + _scheduler.AdvanceBy(1); + + // Assert + Assert.IsNull(node.Output); + } + } +}