diff --git a/.editorconfig b/.editorconfig index 9fdf044..26cf8c7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -154,8 +154,8 @@ dotnet_style_null_propagation = true:warning dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning # Unused parameters and methods -dotnet_diagnostic.IDE0060.severity = warn -dotnet_diagnostic.IDE0051.severity = warn +dotnet_diagnostic.IDE0060.severity = warning +dotnet_diagnostic.IDE0051.severity = warning # File header preferences # Keep operators at end of line when wrapping. diff --git a/.gitignore b/.gitignore index 131857d..01bf856 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ obj/ .DS_Store *.DotSettings.user *.binlog +*.g.cs Chickensoft.Sync.Benchmarks/BenchmarkDotNet.Artifacts/ diff --git a/Chickensoft.Sync.Tests/src/primitives/AutoCacheTest.cs b/Chickensoft.Sync.Tests/src/primitives/AutoCacheTest.cs index 71f6e73..8f29c82 100644 --- a/Chickensoft.Sync.Tests/src/primitives/AutoCacheTest.cs +++ b/Chickensoft.Sync.Tests/src/primitives/AutoCacheTest.cs @@ -273,7 +273,7 @@ public void Disposes() Should.Throw(() => cache.Update(2)); } - private class AllSameComparer : IEqualityComparer + private sealed class AllSameComparer : IEqualityComparer { public bool Equals(int x, int y) => true; public int GetHashCode(int obj) => 0; diff --git a/Chickensoft.Sync.Tests/src/primitives/AutoEventTest.cs b/Chickensoft.Sync.Tests/src/primitives/AutoEventTest.cs new file mode 100644 index 0000000..020c674 --- /dev/null +++ b/Chickensoft.Sync.Tests/src/primitives/AutoEventTest.cs @@ -0,0 +1,214 @@ +namespace Chickensoft.Sync.Tests.Primitives; + +using System; +using Shouldly; +using Sync.Primitives; + +public sealed class AutoEventTest +{ + private sealed class TestEventArgs(string message) + { + public string Message { get; } = message; + } + + private sealed class EventSource + { + public event Action? TestEventZero; + public event Action? TestEventOne; + public event Action? TestEventTwo; + public void FireZero() => TestEventZero?.Invoke(); + public void FireOne(string message) => TestEventOne?.Invoke(new TestEventArgs(message)); + public void FireTwo(string message) => TestEventTwo?.Invoke(this, new TestEventArgs(message)); + } + + private static AutoEvent Wrap(EventSource source) => + new(h => source.TestEventOne += h, h => source.TestEventOne -= h); + + [Fact] + public void Initializes() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var raised = false; + + autoEvent.Bind().On(_ => raised = true); + + raised.ShouldBeFalse(); + } + + [Fact] + public void ForwardsEventToBindingsWithParamsZero() + { + var source = new EventSource(); + var autoEvent = new AutoEvent( + action => source.TestEventZero += action, + action => source.TestEventZero -= action + ); + bool? received = null; + + autoEvent.Bind().On(() => received = true); + + source.FireZero(); + + received.ShouldBe(true); + } + + [Fact] + public void ForwardsEventToBindingsWithParamsOne() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + string? received = null; + + autoEvent.Bind().On(args => received = args.Message); + + source.FireOne("hello"); + + received.ShouldBe("hello"); + } + + [Fact] + public void ForwardsEventToBindingsWithParamsTwo() + { + var source = new EventSource(); + var autoEvent = new AutoEvent( + action => source.TestEventTwo += action, + action => source.TestEventTwo -= action + ); + string? received = null; + object? obj = null; + + autoEvent.Bind().On((sender, args) => + { + received = args.Message; + obj = sender; + }); + + source.FireTwo("hello"); + + obj.ShouldBe(source); + received.ShouldBe("hello"); + } + + [Fact] + public void NoReentrancy() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var inCallback = false; + var reentered = false; + + autoEvent.Bind().On(args => + { + if (inCallback) + { + reentered = true; + } + inCallback = true; + + if (args.Message == "first") + { + source.FireOne("second"); + } + inCallback = false; + }); + + source.FireOne("first"); + + reentered.ShouldBeFalse(); + } + + [Fact] + public void ConditionalCallbacks() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var all = new List(); + var helloOnly = new List(); + + autoEvent.Bind() + .On(args => all.Add(args.Message)) + .On(args => helloOnly.Add(args.Message), condition: args => args.Message == "hello"); + + source.FireOne("hello"); + source.FireOne("world"); + source.FireOne("hello"); + + all.ShouldBe(["hello", "world", "hello"]); + helloOnly.ShouldBe(["hello", "hello"]); + } + + [Fact] + public void MultipleBindings() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var messages1 = new List(); + var messages2 = new List(); + + autoEvent.Bind().On(args => messages1.Add(args.Message)); + autoEvent.Bind().On(args => messages2.Add(args.Message)); + + source.FireOne("ping"); + + messages1.ShouldBe(["ping"]); + messages2.ShouldBe(["ping"]); + } + + [Fact] + public void DisposedBindingStopsReceiving() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var messages = new List(); + + var binding = autoEvent.Bind(); + binding.On(args => messages.Add(args.Message)); + + source.FireOne("first"); + messages.ShouldBe(["first"]); + + binding.Dispose(); + + source.FireOne("second"); + messages.ShouldBe(["first"]); + } + + [Fact] + public void ClearsBindings() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var messages = new List(); + + using var binding = autoEvent.Bind(); + binding.On(args => messages.Add(args.Message)); + + source.FireOne("a"); + source.FireOne("b"); + messages.ShouldBe(["a", "b"]); + messages.Clear(); + + autoEvent.ClearBindings(); + source.FireOne("c"); + messages.ShouldBeEmpty(); + } + + [Fact] + public void DisposeUnsubscribesFromSource() + { + var source = new EventSource(); + var autoEvent = Wrap(source); + var messages = new List(); + + autoEvent.Bind().On(args => messages.Add(args.Message)); + + source.FireOne("before"); + messages.ShouldBe(["before"]); + + autoEvent.Dispose(); + + source.FireOne("after"); + messages.ShouldBe(["before"]); + } +} diff --git a/Chickensoft.Sync/Chickensoft.Sync.csproj b/Chickensoft.Sync/Chickensoft.Sync.csproj index a3fa8d4..c91f32a 100644 --- a/Chickensoft.Sync/Chickensoft.Sync.csproj +++ b/Chickensoft.Sync/Chickensoft.Sync.csproj @@ -29,12 +29,21 @@ git https://github.com/chickensoft-games/Sync.git + true + AddGeneratedT4FilesToCompile;$(AfterTransform) + + + + + + + @@ -47,5 +56,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/Chickensoft.Sync/src/primitives/AutoEvent.tt b/Chickensoft.Sync/src/primitives/AutoEvent.tt new file mode 100644 index 0000000..a42ca94 --- /dev/null +++ b/Chickensoft.Sync/src/primitives/AutoEvent.tt @@ -0,0 +1,131 @@ +<#@ template debug="false" hostspecific="false" language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ output extension=".g.cs" #> +<# + const int maxArity = 16; +#> +#nullable enable +namespace Chickensoft.Sync.Primitives; + +using System; +using System.Diagnostics.CodeAnalysis; +using Sync; +<# for (int arity = 0; arity <= maxArity; arity++) { + var tp = Enumerable.Range(1, arity).Select(i => "T" + i).ToArray(); + var tpList = string.Join(", ", tp); + var tpBrk = arity > 0 ? "<" + tpList + ">" : ""; + var argFields = string.Join(", ", tp.Select((t, i) => t + " Arg" + (i + 1))); + var argParams = string.Join(", ", tp.Select((t, i) => t + " arg" + (i + 1))); + var argValues = string.Join(", ", Enumerable.Range(1, arity).Select(i => "arg" + i)); + var bcastArgs = string.Join(", ", Enumerable.Range(1, arity).Select(i => "b.Arg" + i)); + var opArgs = string.Join(", ", Enumerable.Range(1, arity).Select(i => "op.Arg" + i)); + var actionType= arity > 0 ? "Action<" + tpList + ">" : "Action"; + var funcType = arity > 0 ? "Func<" + tpList + ", bool>" : "Func"; + var subType = "Action<" + actionType + ">"; + var iCref = arity > 0 ? "IAutoEvent{" + tpList + "}" : "IAutoEvent"; + var classCref = arity > 0 ? "AutoEvent{" + tpList + "}" : "AutoEvent"; +#> + +/// +/// +/// An observable adapter for a C# event that forwards event firings to +/// subscribers through the sync pipeline. +/// +/// +<# for (int i = 1; i <= arity; i++) { #> +/// Type of argument <#= i #>. +<# } #> +public interface IAutoEvent<#= tpBrk #> : + IAutoObject.Binding>; + +/// +public sealed class AutoEvent<#= tpBrk #> : + IAutoEvent<#= tpBrk #>, + IPerform.RaiseOp> +{ + // Atomic operations + private readonly record struct RaiseOp(<#= argFields #>); + + // Broadcasts + private readonly record struct RaiseBroadcast(<#= argFields #>); + + /// + /// A binding to an . + /// + public class Binding : SyncBinding + { + internal Binding(ISyncSubject subject) : base(subject) { } + + /// + /// Registers a callback that is invoked whenever the event is raised. + /// + /// Callback to invoke. + /// Optional condition that must be true for the + /// callback to be invoked. + /// This binding (for chaining). + [ + SuppressMessage( + "Style", + "IDE0350", + Justification = "Implicit lambda with ref type won't compile" + ) + ] + public Binding On( + <#= actionType #> callback, <#= funcType #>? condition = null + ) + { + bool predicate(<#= argParams #>) => condition?.Invoke(<#= argValues #>) ?? true; + + AddCallback( + (in RaiseBroadcast b) => callback(<#= bcastArgs #>), + (in RaiseBroadcast b) => predicate(<#= bcastArgs #>) + ); + + return this; + } + } + + private readonly SyncSubject _subject; + private readonly <#= subType #> _unsubscribe; + + /// + /// + /// Creates a new auto event that subscribes to a C# event and forwards + /// firings to its bindings through the sync pipeline. + /// + /// + /// Action that subscribes the internal handler to + /// the source event (e.g., h => myObj.MyEvent += h). + /// Action that unsubscribes the internal handler + /// from the source event (e.g., h => myObj.MyEvent -= h). + public AutoEvent( + <#= subType #> subscribe, + <#= subType #> unsubscribe + ) + { + _subject = new SyncSubject(this); + _unsubscribe = unsubscribe; + subscribe(OnEventRaised); + } + + private void OnEventRaised(<#= argParams #>) => + _subject.Perform(new RaiseOp(<#= argValues #>)); + + /// + public Binding Bind() => new(_subject); + + /// + public void ClearBindings() => _subject.ClearBindings(); + + /// + public void Dispose() + { + _unsubscribe(OnEventRaised); + _subject.Dispose(); + } + + void IPerform.Perform(in RaiseOp op) => + _subject.Broadcast(new RaiseBroadcast(<#= opArgs #>)); +} +<# } #> diff --git a/cspell.json b/cspell.json index 1163540..0a34cd5 100644 --- a/cspell.json +++ b/cspell.json @@ -3,6 +3,7 @@ "**/*.*" ], "ignorePaths": [ + "**/*.tt", "**/*.tscn", "**/*.import", "**/badges/**/*.*",