Skip to content
Merged
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
110 changes: 110 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Stunts design notes

## Behavior configuration API

`AddBehavior`/`InsertBehavior` (in `src/Stunts/StuntExtensions.cs`) are generic extension
methods constrained to `IStunt`:

```csharp
public static TStunt AddBehavior<TStunt>(this TStunt stunt, ...) where TStunt : IStunt
```

The constraint makes the API discoverable only on actual stunts (instead of any object), and
the generic type parameter preserves the concrete receiver type so calls can be chained.

## `Stunt.Of` vs `Stunt.For` vs `Stunt.Get`

`src/Stunts.Package/Stunt.cs` ships as content into consuming projects and is the
`[StuntGenerator]`-annotated factory API:

| API | Returns | Use |
|-----|---------|-----|
| `Stunt.Of<T>(...)` | `T` | Just the stunt, no behavior configuration needed |
| `Stunt.For<T>(...)` | `StuntReference<T>` | Create and configure behaviors |
| `Stunt.Get<T>(T stunt)` | `StuntReference<T>` | Configure an already created stunt |
| `Stunt.Builder()` | `StuntBuilder` | Configure behaviors once, build many stunts |

`StuntReference<T>` implements `IStunt`, so the behavior extension methods apply to it
directly, and exposes the stunt via `ToObject()`.

`Stunt.For<T>` passes a `Lazy<T>`. The stunt is constructed on the first `ToObject()` call
(the implicit conversion calls `ToObject()` too). That call installs the behaviors configured
so far with `BehaviorPipelineFactory.UseAmbient`, so virtual members invoked from a base
constructor are intercepted. If none were added, the factory already current at that call is
used. After construction, `Behaviors` forwards to the stunt's own pipeline. `Stunt.Get<T>`
still wraps an already constructed instance and forwards `Behaviors` immediately.

An implicit conversion to `T` is also declared, but **C# does not allow user-defined
conversions to interface types**, so it only kicks in for class and delegate stunts. Since most
stunts are interfaces, `ToObject()` is the usage pattern tests and docs should show. This
limitation is why `Stunt.Of<T>` keeps returning `T` instead of the reference type.

Delegate stunts are unwrapped when the instance is materialized: for a delegate `T`, the
generated stunt is the delegate's `Target`.

The VB content file (`src/Stunts.Package/Stunt.vb`) mirrors the C# one: `StuntReference(Of T)`,
`StuntBuilder`, `Get`, and the `Of`/`For`/`Build` overloads (including the delegate ones), with two
VB-specific notes:

- VB cannot constrain a type parameter to `System.Delegate` (BC32061), so the delegate `Of`/`For`
overloads are declared as `(Of T)(implementation As T)` without a constraint. Behavior is
identical (the argument is passed as the single constructor argument), but a call passing a
lone `Nothing` (e.g. `Stunt.Of(Of IFoo)(Nothing)`) is ambiguous: use
`Stunt.Of(Of IFoo)(New Object() {Nothing})`. The overload is kept because it's what gives
lambdas their parameter type inference in `Stunt.Of(Of MyDelegate)(Function(x, y) x + y)`.
- The `Widening` conversion to `T` has the same interface limitation as C# (BC30512 under
`Option Strict On`), so `ToObject()` is the pattern to show.

There is no VB project in the solution, so changes to this file are validated by compiling it
in a scratch VB project referencing `src/Stunts/bin/Debug/netstandard2.0/Stunts.dll`.

Every factory overload (including the `For` and `Build` ones) must carry `[StuntGenerator]`: the source
generator keys off that attribute on the invoked method (instance or static) and uses the call site's
generic type arguments to decide which stunt types to generate.

## `StuntBuilder`

`StuntBuilder` (also in `Stunt.cs`/`Stunt.vb`) implements `IStunt`, so the `AddBehavior`/
`InsertBehavior` extension methods configure the list of behaviors *being built*, returning the
builder itself for chaining. Its `Build<T>` overloads mirror `Stunt.Of<T>` one to one (including
the delegate one and the `T1`..`T8` extra interfaces), but wrap the stunt creation in
`BehaviorPipelineFactory.UseAmbient` with a factory that seeds every new pipeline from the
builder's behaviors.

That's the key difference with `Stunt.Of`/`Stunt.For`: the behaviors are already in the pipeline
when the stunt constructor runs, so they can intercept virtual members invoked from base class
constructors (`ClassProxyTests.VirtualCallDuringConstructionUsesThePipelineFactory` shows the
raw ambient-factory version of the same thing).

`BehaviorPipeline`'s `IEnumerable<IStuntBehavior>` constructor copies the list, so each built
stunt gets a snapshot of the behaviors at build time, while sharing the behavior *instances*
(a single `RecordingBehavior` records all stunts from the builder).

The scenario at `src/Stunts.UnitTests/Scenarios/StuntBuilder.cs` covers all of the above through
the real source generator (it uses the namespace `Stunts.Scenarios.Builders` because a
`StuntBuilder` namespace segment would shadow the type).

## Test usage pattern

Tests create the reference, configure behaviors on it, and assign the stunt to an explicitly
typed local so the invoked type is obvious. Behaviors added before `ToObject()` are in place
during construction; behaviors added after still modify the live pipeline:

```csharp
var stunt = Stunt.For<ICalculator>();
ICalculator calculator = stunt.ToObject();

stunt.AddBehavior(new DefaultValueBehavior());

Assert.Equal(0, calculator.Add(1, 2));
```

Note that calling members on the reference itself would target `StuntReference<T>` (for
`ToString`, `GetHashCode` and `Equals`), not the stunt.

## Building locally

The `Stunts` and `Stunts.CodeAnalysis` assemblies are consumed as analyzers by other projects
in the solution, so a full `dotnet build` may fail with file locks (`CS2012`) when an IDE has
the solution open. Close the IDE, and if needed build projects one at a time with
`/p:UseSharedCompilation=false`. Tests run with `dnx --yes retest`.
83 changes: 69 additions & 14 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ Stunts essentially implements the [proxy pattern](https://en.wikipedia.org/wiki/

## Usage

```csharp
var stunt = Stunt.For<ICalculator>();
ICalculator calc = stunt.ToObject();

stunt.AddBehavior((invocation, next) => ...);
```

`Stunt.Of<T>` returns the stunt directly, and `Stunt.Get(stunt)` gets a `StuntReference<T>` for an existing one, so behaviors can be added after the fact:

```csharp
ICalculator calc = Stunt.Of<ICalculator>();

calc.AddBehavior((invocation, next) => ...);
Stunt.Get(calc).AddBehavior((invocation, next) => ...);
```

`AddBehavior`/`InsertBehavior` overloads allow granular control of the stunt's behavior pipeline, which is basically a [chain of responsibility](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern) that invokes all configured behaviors that apply to the current invocation. Individual behaviors can determine whether to short-circuit the call or call the next behavior in the chain.
> NOTE: `StuntReference<T>` converts implicitly to `T` for classes and delegates. C# does not allow user-defined conversions to interfaces, so `ToObject()` is always available.

`AddBehavior`/`InsertBehavior` are extension methods on `IStunt` (which `StuntReference<T>` implements) and allow granular control of the stunt's behavior pipeline, which is basically a [chain of responsibility](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern) that invokes all configured behaviors that apply to the current invocation. Individual behaviors can determine whether to short-circuit the call or call the next behavior in the chain.

Behaviors can also dynamically determine whether they apply to a given invocation by providing the optional `appliesTo` argument. In addition to the delegate-based overloads (called *anonymous behaviors*), you can also create behaviors by implementing the `IStuntBehavior` interface:

Expand All @@ -64,6 +75,46 @@ Some commonly used behaviors that are generally useful are provided in the libra

* `RecordingBehavior`: simple behavior that keeps track of all invocations, for troubleshooting or reporting.

## Building Stunts

When you need the same behaviors on multiple stunts, `Stunt.Builder()` returns a `StuntBuilder`
that collects behaviors (with the very same `AddBehavior`/`InsertBehavior` extension methods) and
applies them to every stunt it builds, with the same `Build<T>` overloads as `Stunt.Of<T>`:

```csharp
var builder = Stunt.Builder()
.AddBehavior(new RecordingBehavior())
.AddBehavior(new DefaultValueBehavior());

ICalculator calculator = builder.Build<ICalculator>();
IStore store = builder.Build<IStore>();
```

Since the behaviors are in place *before* the stunt is instantiated (via an ambient
`BehaviorPipelineFactory`), they also intercept virtual members invoked from base class
constructors, which isn't possible when behaviors are added to an already created stunt:

```csharp
public class Greeter
{
public Greeter() => Seen = Name();
public string Seen { get; }
public virtual string Name() => "base";
}

Greeter greeter = Stunt.Builder()
.AddBehavior((invocation, next) => invocation.MethodBase.Name == nameof(Greeter.Name)
? invocation.CreateValueReturn("proxy")
: next(invocation, next))
.Build<Greeter>();

// greeter.Seen == "proxy"
```

Each `Build` call takes a snapshot of the behaviors configured at that point, so behaviors added
to the builder afterwards don't affect the stunts already built. Behavior instances themselves are
shared, so a single `RecordingBehavior` records the invocations of all stunts from that builder.

## Customizing Stunt Creation

If you want to centrally configure all your stunts, the easiest way is to simply provide your own factory method (i.e. `Stub.Of<T>`), which in turn calls the `Stunt.Of<T>` provided. For example:
Expand All @@ -72,10 +123,11 @@ If you want to centrally configure all your stunts, the easiest way is to simply
public static class Stub
{
[StuntGenerator]
public static T Of<T>() => Stunt.Of<T>()
public static T Of<T>() => Stunt.For<T>()
.AddBehavior(new RecordingBehavior())
.AddBehavior(new DefaultEqualityBehavior())
.AddBehavior(new DefaultValueBehavior());
.AddBehavior(new DefaultValueBehavior())
.ToObject();
}
```

Expand Down Expand Up @@ -117,9 +169,10 @@ The examples below use `ICalculator` from [the samples](samples/Samples/Core/ICa
An anonymous behavior can short-circuit a call. The `appliesTo` predicate limits it to the two-argument `Add` overload:

```csharp
var calc = Stunt.Of<ICalculator>().AddBehavior(
var calc = Stunt.For<ICalculator>().AddBehavior(
(call, _) => call.CreateValueReturn(42),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2);
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.ToObject();

calc.Add(2, 3); // 42
```
Expand All @@ -129,9 +182,10 @@ calc.Add(2, 3); // 42
Arguments are available by name (or index), so a behavior can use the values passed by the caller:

```csharp
var calc = Stunt.Of<ICalculator>().AddBehavior(
var calc = Stunt.For<ICalculator>().AddBehavior(
(call, _) => call.CreateValueReturn(call.Arguments.Get<int>("x") + call.Arguments.Get<int>("y")),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2);
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.ToObject();

calc.Add(2, 3); // 5
```
Expand All @@ -142,11 +196,12 @@ Behaviors run in order. Put recording first to capture calls and results, and a

```csharp
var recorder = new RecordingBehavior();
var calc = Stunt.Of<ICalculator>()
var calc = Stunt.For<ICalculator>()
.AddBehavior(recorder)
.AddBehavior((call, _) => call.CreateValueReturn(5),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.AddBehavior(new DefaultValueBehavior());
.AddBehavior(new DefaultValueBehavior())
.ToObject();

calc.Add(2, 3); // 5
var calls = recorder.Invocations.Count; // 1
Expand All @@ -159,21 +214,21 @@ Register a factory when the built-in defaults are not suitable. Here, each call
```csharp
var defaults = new DefaultValueProvider();
defaults.Register(() => "Hello!");
var greet = Stunt.Of<Func<string>>().AddBehavior(new DefaultValueBehavior(defaults));
var greet = Stunt.For<Func<string>>().AddBehavior(new DefaultValueBehavior(defaults)).ToObject();

greet(); // "Hello!"
```

### Intercept a real implementation

Pass a delegate implementation to `Stunt.Of` and call `next` to forward to it. Behaviors can change arguments before forwarding:
Pass a delegate implementation to `Stunt.For` and call `next` to forward to it. Behaviors can change arguments before forwarding:

```csharp
var add = Stunt.Of<Func<int, int, int>>((x, y) => x + y).AddBehavior((call, next) =>
var add = Stunt.For<Func<int, int, int>>((x, y) => x + y).AddBehavior((call, next) =>
{
call.Arguments.Set(0, 10);
return next(call, next);
});
}).ToObject();

add(1, 2); // 12
```
Expand Down
Loading
Loading