Signal WIP

This commit is contained in:
2024-11-06 12:30:39 +01:00
parent 8e6557eddc
commit 07bb6746d6
9 changed files with 266 additions and 11 deletions

View File

@@ -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<string>(null);
var signal2 = new Signal<string>(null);
var combineAll = new CombineAllSignal<string, string>([signal1, signal2], _ => "TEST");
// Act
var result = await combineAll.GetValueAsync();
// Assert
Assert.Equal("TEST", result);
}
}

View File

@@ -0,0 +1,33 @@
using Xunit;
namespace Signal.Tests;
public class DebounceTests
{
[Fact]
public async Task Debounce_WhenCalled_ReturnsDebouncedValue()
{
// Arrange
var signal = new Signal<int>(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);
}
}

View File

@@ -76,4 +76,19 @@ public class MapSignalTests
// Assert // Assert
Assert.Equal("TEST", result); Assert.Equal("TEST", result);
} }
[Fact]
public async Task GetValueAsync_WhenParentSometimesReturnsNull_ShouldInitialize()
{
int count = 0;
// Arrange
var signal = new Signal<string>(null);
var mapped = signal.Map(_ => "TEST");
// Act
var result = await mapped.GetValueAsync();
// Assert
Assert.Equal("TEST", result);
}
} }

View File

@@ -0,0 +1,69 @@
namespace Signal;
public class CombineAllSignal<T, TResult> : SignalBase<TResult>
{
private readonly IReadOnlyList<SignalBase<T>> _parentSignals;
private readonly Func<ICollection<T>, ValueTask<TResult>> _combiner;
private readonly T[] _lastParentResults;
private readonly T[] _currentParentResults;
private TResult? _lastResult;
private bool _isInitialized;
public CombineAllSignal(ICollection<SignalBase<T>> parentSignals, Func<ICollection<T>, TResult> combiner) :
base(parentSignals)
{
_parentSignals = parentSignals.ToList();
_combiner = c => new ValueTask<TResult>(combiner(c));
_lastParentResults = new T[_parentSignals.Count];
_currentParentResults = new T[_parentSignals.Count];
}
public CombineAllSignal(ICollection<SignalBase<T>> parentSignals, Func<ICollection<T>, ValueTask<TResult>> combiner) :
base(parentSignals)
{
_parentSignals = parentSignals.ToList();
_combiner = combiner;
_lastParentResults = new T[_parentSignals.Count];
_currentParentResults = new T[_parentSignals.Count];
}
protected override async ValueTask<TResult> 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;
}
}

View File

@@ -38,6 +38,7 @@ public sealed class CombineLatestSignal<T1, T2, TResult> : SignalBase<TResult>
return _result; return _result;
} }
// TODO caching
IsDirty = false; IsDirty = false;
_result = await _combine(); _result = await _combine();
return _result; return _result;

View File

@@ -0,0 +1,83 @@
namespace Signal;
public class DebounceSignal<T> : SignalBase<T>
{
private readonly object _debounceTaskLock = new();
private readonly SignalBase<T> _parentSignal;
private bool _isDebounceRunning;
private DateTime _debounceStartedAt;
private readonly Func<TimeSpan> _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<T> parentSignal, Func<TimeSpan> interval) : base(parentSignal)
{
_parentSignal = parentSignal;
_interval = interval;
}
protected override async ValueTask<T> 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;
}
}

View File

@@ -6,8 +6,19 @@ public static class Extensions
{ {
return new MapSignal<T, TResult>(signal, map); return new MapSignal<T, TResult>(signal, map);
} }
public static SignalBase<TResult> Map<T, TResult>(this SignalBase<T> signal, Func<T, Task<TResult>> map) public static SignalBase<TResult> Map<T, TResult>(this SignalBase<T> signal, Func<T, Task<TResult>> map)
{ {
return new MapSignal<T, TResult>(signal, map); return new MapSignal<T, TResult>(signal, map);
} }
public static SignalBase<T> Debounce<T>(this SignalBase<T> signal, TimeSpan interval)
{
return new DebounceSignal<T>(signal, () => interval);
}
public static SignalBase<T> Debounce<T>(this SignalBase<T> signal, Func<TimeSpan> interval)
{
return new DebounceSignal<T>(signal, interval);
}
} }

View File

@@ -4,6 +4,7 @@ public sealed class MapSignal<T, TResult> : SignalBase<TResult>
{ {
private readonly Func<T, ValueTask<TResult>> _map; private readonly Func<T, ValueTask<TResult>> _map;
private readonly SignalBase<T> _parentSignal; private readonly SignalBase<T> _parentSignal;
private bool _isInitialized;
private T? _lastParentValue; private T? _lastParentValue;
private TResult? _lastResult; private TResult? _lastResult;
@@ -43,12 +44,17 @@ public sealed class MapSignal<T, TResult> : SignalBase<TResult>
IsDirty = false; IsDirty = false;
var baseValue = await _parentSignal.GetValueAsync(); var baseValue = await _parentSignal.GetValueAsync();
if ( if (
(_lastParentValue == null && baseValue == null) || _isInitialized
(baseValue != null && baseValue.Equals(_lastParentValue))) &&
(
(baseValue == null && _lastParentValue == null)
|| (baseValue != null && baseValue.Equals(_lastParentValue))
))
{ {
return _lastResult!; return _lastResult!;
} }
_isInitialized = true;
_lastParentValue = baseValue; _lastParentValue = baseValue;
_lastResult = await _map(baseValue); _lastResult = await _map(baseValue);
return _lastResult; return _lastResult;

View File

@@ -6,7 +6,7 @@ public abstract class SignalBase : IReadOnlySignal
public event Action<bool>? IsDirtyChanged; public event Action<bool>? IsDirtyChanged;
public bool IsDirty public virtual bool IsDirty
{ {
get => _isDirty; get => _isDirty;
protected set protected set
@@ -20,11 +20,12 @@ public abstract class SignalBase : IReadOnlySignal
IsDirtyChanged?.Invoke(value); IsDirtyChanged?.Invoke(value);
} }
} }
public event Action<SignalBase> Disposed; public event Action<SignalBase> Disposed;
public bool IsDisposed { get; private set; } public bool IsDisposed { get; private set; }
internal TreeLocker TreeLock { get; } internal TreeLocker TreeLock { get; }
private protected SignalBase(TreeLocker treeTreeLock) private protected SignalBase(TreeLocker treeTreeLock)
{ {
TreeLock = treeTreeLock; TreeLock = treeTreeLock;
@@ -41,33 +42,47 @@ public abstract class SignalBase : IReadOnlySignal
public abstract class SignalBase<T> : SignalBase, IReadOnlySignal<T> public abstract class SignalBase<T> : SignalBase, IReadOnlySignal<T>
{ {
internal static AsyncLocal<TreeLocker> CurrentTreeLocker { get; } = new(); internal static AsyncLocal<TreeLocker> 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); SubscribeToParentSignalChanges(parentSignal);
} }
protected SignalBase(ICollection<SignalBase> parentSignals):base(CreateMultiParentTreeLock(parentSignals)) protected SignalBase(ICollection<SignalBase> parentSignals) : base(CreateMultiParentTreeLock(parentSignals))
{ {
ArgumentOutOfRangeException.ThrowIfZero(parentSignals.Count); ArgumentOutOfRangeException.ThrowIfZero(parentSignals.Count);
foreach (var parentSignal in parentSignals) foreach (var parentSignal in parentSignals)
{ {
SubscribeToParentSignalChanges(parentSignal); SubscribeToParentSignalChanges(parentSignal);
} }
} }
private static TreeLocker CreateMultiParentTreeLock(ICollection<SignalBase> parentSignals) protected SignalBase(IEnumerable<SignalBase> parentSignals) : base(CreateMultiParentTreeLock(parentSignals))
{
if (!parentSignals.Any())
{
throw new ArgumentOutOfRangeException(nameof(parentSignals));
}
foreach (var parentSignal in parentSignals)
{
SubscribeToParentSignalChanges(parentSignal);
}
}
private static TreeLocker CreateMultiParentTreeLock(IEnumerable<SignalBase> parentSignals)
{ {
var firstLock = parentSignals.First().TreeLock; var firstLock = parentSignals.First().TreeLock;
foreach (var parentSignal in parentSignals.Skip(1)) foreach (var parentSignal in parentSignals.Skip(1))
{ {
parentSignal.TreeLock.UseInstead(firstLock); parentSignal.TreeLock.UseInstead(firstLock);
} }
return firstLock; return firstLock;
} }