Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ obj/
.DS_Store
*.DotSettings.user
*.binlog
*.g.cs
Chickensoft.Sync.Benchmarks/BenchmarkDotNet.Artifacts/
207 changes: 207 additions & 0 deletions Chickensoft.Sync.Tests/src/primitives/AutoEventTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
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<TestEventArgs>? TestEventOne;
public event Action<object, TestEventArgs>? 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<TestEventArgs> 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<object, TestEventArgs>(
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<string>();
var helloOnly = new List<string>();

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<string>();
var messages2 = new List<string>();

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<string>();

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<string>();

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<string>();

autoEvent.Bind().On(args => messages.Add(args.Message));

source.FireOne("before");
messages.ShouldBe(["before"]);

autoEvent.Dispose();

source.FireOne("after");
messages.ShouldBe(["before"]);
}
}
10 changes: 10 additions & 0 deletions Chickensoft.Sync/Chickensoft.Sync.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@

<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/chickensoft-games/Sync.git</RepositoryUrl>
<TransformOnBuild>true</TransformOnBuild>
<AfterTransform>AddGeneratedT4FilesToCompile;$(AfterTransform)</AfterTransform>
</PropertyGroup>

<Target Name="AddGeneratedT4FilesToCompile">
<ItemGroup>
<Compile Include="@(GeneratedTemplates)" />
</ItemGroup>
</Target>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="\" />
<None Include="../LICENSE" Pack="true" PackagePath="\" />
<None Include="./icon.png" Pack="true" PackagePath="" />
<T4Transform Include="**\*.tt" />
</ItemGroup>

<ItemGroup>
Expand All @@ -47,5 +56,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="T4.BuildTools" Version="3.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>
130 changes: 130 additions & 0 deletions Chickensoft.Sync/src/primitives/AutoEvent.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ output extension=".g.cs" #>
<#
const int maxArity = 16;
#>
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<bool>";
var subType = $"Action<{actionType}>";
var iCref = arity > 0 ? $"IAutoEvent{{{tpList}}}" : "IAutoEvent";
var classCref = arity > 0 ? $"AutoEvent{{{tpList}}}" : "AutoEvent";
#>

/// <summary>
/// <para>
/// An observable adapter for a C# event that forwards event firings to
/// subscribers through the sync pipeline.
/// </para>
/// </summary>
<# for (int i = 1; i <= arity; i++) { #>
/// <typeparam name="T<#= i #>">Type of argument <#= i #>.</typeparam>
<# } #>
public interface IAutoEvent<#= tpBrk #> :
IAutoObject<AutoEvent<#= tpBrk #>.Binding>;

/// <inheritdoc cref="<#= iCref #>"/>
public sealed class AutoEvent<#= tpBrk #> :
IAutoEvent<#= tpBrk #>,
IPerform<AutoEvent<#= tpBrk #>.RaiseOp>
{
// Atomic operations
private readonly record struct RaiseOp(<#= argFields #>);

// Broadcasts
private readonly record struct RaiseBroadcast(<#= argFields #>);

/// <summary>
/// A binding to an <see cref="<#= classCref #>"/>.
/// </summary>
public class Binding : SyncBinding
{
internal Binding(ISyncSubject subject) : base(subject) { }

/// <summary>
/// Registers a callback that is invoked whenever the event is raised.
/// </summary>
/// <param name="callback">Callback to invoke.</param>
/// <param name="condition">Optional condition that must be true for the
/// callback to be invoked.</param>
/// <returns>This binding (for chaining).</returns>
[
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;

/// <summary>
/// <para>
/// Creates a new auto event that subscribes to a C# event and forwards
/// firings to its bindings through the sync pipeline.
/// </para>
/// </summary>
/// <param name="subscribe">Action that subscribes the internal handler to
/// the source event (e.g., <c>h => myObj.MyEvent += h</c>).</param>
/// <param name="unsubscribe">Action that unsubscribes the internal handler
/// from the source event (e.g., <c>h => myObj.MyEvent -= h</c>).</param>
public AutoEvent(
<#= subType #> subscribe,
<#= subType #> unsubscribe
)
{
_subject = new SyncSubject(this);
_unsubscribe = unsubscribe;
subscribe(OnEventRaised);
}

private void OnEventRaised(<#= argParams #>) =>
_subject.Perform(new RaiseOp(<#= argValues #>));

/// <inheritdoc />
public Binding Bind() => new(_subject);

/// <inheritdoc />
public void ClearBindings() => _subject.ClearBindings();

/// <inheritdoc />
public void Dispose()
{
_unsubscribe(OnEventRaised);
_subject.Dispose();
}

void IPerform<RaiseOp>.Perform(in RaiseOp op) =>
_subject.Broadcast(new RaiseBroadcast(<#= opArgs #>));
}
<# } #>
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"**/*.*"
],
"ignorePaths": [
"**/*.tt",
"**/*.tscn",
"**/*.import",
"**/badges/**/*.*",
Expand Down
Loading