diff --git a/src/Library/Signal.Tests/CombineAllTests.cs b/src/Library/Signal.Tests/CombineAllTests.cs new file mode 100644 index 0000000..3355c8c --- /dev/null +++ b/src/Library/Signal.Tests/CombineAllTests.cs @@ -0,0 +1,22 @@ +using Xunit; + +namespace Signal.Tests; + +public class CombineAllTests +{ + [Fact] + public async Task GetValueAsync_WhenParentSometimesReturnsNull_ShouldInitialize() + { + int count = 0; + // Arrange + var signal1 = new Signal(null); + var signal2 = new Signal(null); + var combineAll = new CombineAllSignal([signal1, signal2], _ => "TEST"); + + // Act + var result = await combineAll.GetValueAsync(); + + // Assert + Assert.Equal("TEST", result); + } +} \ No newline at end of file diff --git a/src/Library/Signal.Tests/DebounceTests.cs b/src/Library/Signal.Tests/DebounceTests.cs new file mode 100644 index 0000000..50a69f3 --- /dev/null +++ b/src/Library/Signal.Tests/DebounceTests.cs @@ -0,0 +1,33 @@ +using Xunit; + +namespace Signal.Tests; + +public class DebounceTests +{ + [Fact] + public async Task Debounce_WhenCalled_ReturnsDebouncedValue() + { + // Arrange + var signal = new Signal(1); + var debouncedSignal = signal.Debounce(TimeSpan.FromMilliseconds(100)); + + // Act + var result1 = await debouncedSignal.GetValueAsync(); + await signal.SetValueAsync(2); + var result2 = await debouncedSignal.GetValueAsync(); + await signal.SetValueAsync(3); + var result3_1 = await debouncedSignal.GetValueAsync(); + + await Task.Delay(300); + var result3_2 = await debouncedSignal.GetValueAsync(); + await signal.SetValueAsync(4); + var result4 = await debouncedSignal.GetValueAsync(); + + // Assert + Assert.Equal(1, result1); + Assert.Equal(1, result2); + Assert.Equal(1, result3_1); + Assert.Equal(3, result3_2); + Assert.Equal(3, result4); + } +} \ No newline at end of file diff --git a/src/Library/Signal.Tests/MapSignalTests.cs b/src/Library/Signal.Tests/MapSignalTests.cs index c35b1b4..bc09fd0 100644 --- a/src/Library/Signal.Tests/MapSignalTests.cs +++ b/src/Library/Signal.Tests/MapSignalTests.cs @@ -76,4 +76,19 @@ public class MapSignalTests // Assert Assert.Equal("TEST", result); } + + [Fact] + public async Task GetValueAsync_WhenParentSometimesReturnsNull_ShouldInitialize() + { + int count = 0; + // Arrange + var signal = new Signal(null); + var mapped = signal.Map(_ => "TEST"); + + // Act + var result = await mapped.GetValueAsync(); + + // Assert + Assert.Equal("TEST", result); + } } \ No newline at end of file diff --git a/src/Library/Signal/CombineAllSignal.cs b/src/Library/Signal/CombineAllSignal.cs new file mode 100644 index 0000000..c091d55 --- /dev/null +++ b/src/Library/Signal/CombineAllSignal.cs @@ -0,0 +1,69 @@ +namespace Signal; + +public class CombineAllSignal : SignalBase +{ + private readonly IReadOnlyList> _parentSignals; + private readonly Func, ValueTask> _combiner; + private readonly T[] _lastParentResults; + private readonly T[] _currentParentResults; + private TResult? _lastResult; + private bool _isInitialized; + + public CombineAllSignal(ICollection> parentSignals, Func, TResult> combiner) : + base(parentSignals) + { + _parentSignals = parentSignals.ToList(); + _combiner = c => new ValueTask(combiner(c)); + + _lastParentResults = new T[_parentSignals.Count]; + _currentParentResults = new T[_parentSignals.Count]; + } + + public CombineAllSignal(ICollection> parentSignals, Func, ValueTask> combiner) : + base(parentSignals) + { + _parentSignals = parentSignals.ToList(); + _combiner = combiner; + + _lastParentResults = new T[_parentSignals.Count]; + _currentParentResults = new T[_parentSignals.Count]; + } + + protected override async ValueTask GetValueInternalAsync() + { + if (!IsDirty) + { + return _lastResult!; + } + + IsDirty = false; + + if (_isInitialized) + { + bool anyChanged = false; + for (var i = 0; i < _parentSignals.Count; i++) + { + var parentSignal = _parentSignals[i]; + var newResult = await parentSignal.GetValueAsync(); + _currentParentResults[i] = newResult; + + if ((newResult == null && _lastParentResults[i] != null) + || (newResult != null && newResult.Equals(_lastParentResults[i]))) + { + anyChanged = true; + } + } + + if (!anyChanged) + { + return _lastResult!; + } + } + + _isInitialized = true; + Array.Copy(_currentParentResults, _lastParentResults, _currentParentResults.Length); + var result = await _combiner(_currentParentResults); + _lastResult = result; + return result; + } +} \ No newline at end of file diff --git a/src/Library/Signal/CombineLatestSignal.cs b/src/Library/Signal/CombineLatestSignal.cs index 80012a2..a914726 100644 --- a/src/Library/Signal/CombineLatestSignal.cs +++ b/src/Library/Signal/CombineLatestSignal.cs @@ -38,6 +38,7 @@ public sealed class CombineLatestSignal : SignalBase return _result; } + // TODO caching IsDirty = false; _result = await _combine(); return _result; diff --git a/src/Library/Signal/DebounceSignal.cs b/src/Library/Signal/DebounceSignal.cs new file mode 100644 index 0000000..06df85e --- /dev/null +++ b/src/Library/Signal/DebounceSignal.cs @@ -0,0 +1,83 @@ +namespace Signal; + +public class DebounceSignal : SignalBase +{ + private readonly object _debounceTaskLock = new(); + private readonly SignalBase _parentSignal; + private bool _isDebounceRunning; + private DateTime _debounceStartedAt; + private readonly Func _interval; + private T? _lastParentValue; + + public bool ResetTimer { get; init; } + public TimeSpan WaitInterval { get; init; } = TimeSpan.FromMilliseconds(10); + + public override bool IsDirty + { + get => base.IsDirty; + protected set + { + if(!value) + { + base.IsDirty = value; + return; + } + + lock (_debounceTaskLock) + { + if (_isDebounceRunning) + { + if (ResetTimer) + { + _debounceStartedAt = DateTime.Now; + } + + return; + } + _isDebounceRunning = true; + _debounceStartedAt = DateTime.Now; + Task.Run(StartDebouncing); + } + } + } + + async Task StartDebouncing() + { + while (DateTime.Now - _debounceStartedAt < _interval()) + { + await Task.Delay(WaitInterval); + } + + base.IsDirty = true; + lock (_debounceTaskLock) + { + _isDebounceRunning = false; + } + } + + public DebounceSignal(SignalBase parentSignal, Func interval) : base(parentSignal) + { + _parentSignal = parentSignal; + _interval = interval; + } + + protected override async ValueTask GetValueInternalAsync() + { + if (!IsDirty) + { + return _lastParentValue!; + } + + IsDirty = false; + var baseValue = await _parentSignal.GetValueAsync(); + if ( + (_lastParentValue == null && baseValue == null) || + (baseValue != null && baseValue.Equals(_lastParentValue))) + { + return _lastParentValue!; + } + + _lastParentValue = baseValue; + return _lastParentValue; + } +} \ No newline at end of file diff --git a/src/Library/Signal/Extensions.cs b/src/Library/Signal/Extensions.cs index eddb3f9..097a60c 100644 --- a/src/Library/Signal/Extensions.cs +++ b/src/Library/Signal/Extensions.cs @@ -6,8 +6,19 @@ public static class Extensions { return new MapSignal(signal, map); } + public static SignalBase Map(this SignalBase signal, Func> map) { return new MapSignal(signal, map); } + + public static SignalBase Debounce(this SignalBase signal, TimeSpan interval) + { + return new DebounceSignal(signal, () => interval); + } + + public static SignalBase Debounce(this SignalBase signal, Func interval) + { + return new DebounceSignal(signal, interval); + } } \ No newline at end of file diff --git a/src/Library/Signal/MapSignal.cs b/src/Library/Signal/MapSignal.cs index 37fe017..e02fd3c 100644 --- a/src/Library/Signal/MapSignal.cs +++ b/src/Library/Signal/MapSignal.cs @@ -4,6 +4,7 @@ public sealed class MapSignal : SignalBase { private readonly Func> _map; private readonly SignalBase _parentSignal; + private bool _isInitialized; private T? _lastParentValue; private TResult? _lastResult; @@ -43,12 +44,17 @@ public sealed class MapSignal : SignalBase IsDirty = false; var baseValue = await _parentSignal.GetValueAsync(); if ( - (_lastParentValue == null && baseValue == null) || - (baseValue != null && baseValue.Equals(_lastParentValue))) + _isInitialized + && + ( + (baseValue == null && _lastParentValue == null) + || (baseValue != null && baseValue.Equals(_lastParentValue)) + )) { return _lastResult!; } + _isInitialized = true; _lastParentValue = baseValue; _lastResult = await _map(baseValue); return _lastResult; diff --git a/src/Library/Signal/SignalBase.cs b/src/Library/Signal/SignalBase.cs index 2dce6ad..424d277 100644 --- a/src/Library/Signal/SignalBase.cs +++ b/src/Library/Signal/SignalBase.cs @@ -6,7 +6,7 @@ public abstract class SignalBase : IReadOnlySignal public event Action? IsDirtyChanged; - public bool IsDirty + public virtual bool IsDirty { get => _isDirty; protected set @@ -20,11 +20,12 @@ public abstract class SignalBase : IReadOnlySignal IsDirtyChanged?.Invoke(value); } } + public event Action Disposed; public bool IsDisposed { get; private set; } - + internal TreeLocker TreeLock { get; } - + private protected SignalBase(TreeLocker treeTreeLock) { TreeLock = treeTreeLock; @@ -41,33 +42,47 @@ public abstract class SignalBase : IReadOnlySignal public abstract class SignalBase : SignalBase, IReadOnlySignal { internal static AsyncLocal CurrentTreeLocker { get; } = new(); - private protected SignalBase():base(new TreeLocker()) + + private protected SignalBase() : base(new TreeLocker()) { } - protected SignalBase(SignalBase parentSignal):base(parentSignal.TreeLock) + protected SignalBase(SignalBase parentSignal) : base(parentSignal.TreeLock) { SubscribeToParentSignalChanges(parentSignal); } - protected SignalBase(ICollection parentSignals):base(CreateMultiParentTreeLock(parentSignals)) + protected SignalBase(ICollection parentSignals) : base(CreateMultiParentTreeLock(parentSignals)) { ArgumentOutOfRangeException.ThrowIfZero(parentSignals.Count); - + foreach (var parentSignal in parentSignals) { SubscribeToParentSignalChanges(parentSignal); } } - private static TreeLocker CreateMultiParentTreeLock(ICollection parentSignals) + protected SignalBase(IEnumerable parentSignals) : base(CreateMultiParentTreeLock(parentSignals)) + { + if (!parentSignals.Any()) + { + throw new ArgumentOutOfRangeException(nameof(parentSignals)); + } + + foreach (var parentSignal in parentSignals) + { + SubscribeToParentSignalChanges(parentSignal); + } + } + + private static TreeLocker CreateMultiParentTreeLock(IEnumerable parentSignals) { var firstLock = parentSignals.First().TreeLock; foreach (var parentSignal in parentSignals.Skip(1)) { parentSignal.TreeLock.UseInstead(firstLock); } - + return firstLock; }