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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ Read the component's source (all `partial` parts) and find every place it:

The *concrete idioms* for all three depend on which interop stack the component currently sits on — the [cheat-sheet below](#current-stack-cheat-sheet-legacy-renderermessage-pipeline) lists them for the legacy `RendererMessage` pipeline. For a component on a different stack, find the equivalent call sites in its implementation (whatever sits between the public member and the JS runtime — a direct `IJSRuntime.InvokeAsync`, a channel service, etc.); the principles and the DSL below are unchanged.

### Values that only exist after the render — one mechanism, everywhere

Interop instance ids, data-item uuids and element handles are assigned when the component renders, which is *after* the contract itself is constructed. Wherever a spec states such a value, the parameter is a `FromRender<T>`: pass the value directly when it is fixed (the common case — nothing to think about), or `FromRender.Of((interop, cut) => …)` when it can only be settled once rendered. The DSL has exactly one such mechanism, so a new kind of late value needs no new API:

```csharp
returns: FromRender.Of((interop, cut) => InteropReturn.Ref(...)) // a getter's stubbed return
args: [FromRender.Of((interop, cut) => $"containerId:::{...}")] // one late argument among fixed ones
argsJson: FromRender.Of((interop, cut) => ...) // an event payload carrying a child ref
wire: FromRender.Of<object?>((interop, cut) => new RawJson(...)) // a prop whose value references data items
```

The render scope handed to the lambda is the cut for an arranged spec and the whole host render for a hosted one — the same scope the spec's own `assert:` receives. Pair it with `arrange:` (or `host:`/`target:`) so there is something rendered to reference.

### 1. Methods and getters

- Every public API member that produces an outbound invocation gets a spec. Distinguish two kinds:
Expand All @@ -53,7 +66,7 @@ The *concrete idioms* for all three depend on which interop stack the component
(An explicit `InteropReturn` + separate `expect:` overload exists for wire returns that decode to a different value than sent — rare.)
- **Value-returning methods must state their return** — enforced at compile time: a `Task<TResult>`-returning lambda on the void `.Method` overload is `[Obsolete(error: true)]` as a guard, so the contract states the `returns:` stub. The void overload needs no stub.
- **Hosted arrange — child components that only exist inside a parent** - Currently available in the hosted `Getter` overload (e.g. a tree item's `GetPathAsync` needs real ancestors). Build the full render with `ContractHost.Of<THost>(ps => ps.AddChildContent(...))` (parent as the root, structure nested inside), pick the component under test with `target: h => h.FindComponents<TComponent>()[n]`, and note that `returns:`/`assert:` receive the whole host render — so `interop.ContainerIdOf(h, "<selector>")` reaches ancestors and siblings outside the cut's subtree. The read still travels on the target's own container. See `TreeItemTests` in `TreeTests.cs`.
- **Render-sourced arguments — a value that only exists once rendered** (a sibling component instance, an `ElementReference` captured from markup): the spec's selectors run *after* its render, so read the value from a holder the `arrange:` render fills — `.Method(c => c.ShowAsync(anchor.Component), ...)` — rather than reaching for a new overload. The expected *wire* value comes the same way: `args: [new FromRender((interop, cut) => ...)]` is resolved right before comparison, when child instance ids exist. `elements:` states the element handles the invocation must carry alongside its arguments; omitting it asserts the invocation carries none — so a component-typed target is positively pinned as crossing without an element handle. Both `arrange:` and `elements:` are available on the void and the value-returning twin overloads. See the `show`/`toggle` target overloads in `DropdownTests.cs`.
- **Render-sourced arguments — a value that only exists once rendered** (a sibling component instance, an `ElementReference` captured from markup): the spec's selectors run *after* its render, so read the value from a holder the `arrange:` render fills — `.Method(c => c.ShowAsync(anchor.Component), ...)` — rather than reaching for a new overload. The expected *wire* value comes the same way: `args: [FromRender.Of((interop, cut) => ...)]` is settled right before comparison, when child instance ids exist. `elements:` states the element handles the invocation must carry alongside its arguments; omitting it asserts the invocation carries none — so a component-typed target is positively pinned as crossing without an element handle. Both `arrange:` and `elements:` are available on the void and the value-returning twin overloads. See the `show`/`toggle` target overloads in `DropdownTests.cs`.
- **Sync twins are declared inside the member's spec**: every `X()`/`XAsync()` pair uses the twin overloads — both selectors up front, async first: `.Method(c => c.ShowAsync(), c => c.Show(), "show", returns: true)` (same for `.Getter`, including the arranged/hosted forms). The runner re-invokes the sync twin against the same expectations (identifier, args, types, decoded return). Declaring the twin is the contract author's responsibility — when enumerating methods, treat an `X()`/`XAsync()` pair as one member and use the twin overload. Sync dispatch rides the in-process JS runtime (`IJSInProcessRuntime` — WASM/WebView-only in production; the Server-hosted integration TestBed can't run sync variants, so contracts are their only coverage).
- **Skip and list**: a member whose decode hits an implementation gap (unregistered child type, missing marshal-by-value entry) is covered, not skipped — per the authoring modes above.

Expand All @@ -77,7 +90,7 @@ The contract entry is the member selector plus a sample value: the wire name is
- A property belongs in the contract **only when its value travels over interop** rather than as a rendered attribute. Determine which from the component's serialization path (stack-specific — see the cheat-sheet) or empirically: for an attribute prop, the runner fails with *"no property update transmission was observed"*. Attribute props are covered by the suite's attribute facts instead.
- **Serialized config objects and arrays**: set an instance with 2–3 distinctive values and expect a `JsonSubset` — subset match, extra bookkeeping fields on the transmitted value are ignored; arrays compare element-wise (equal length, per-element subset). See `ChatTests.cs`, `CalendarTests.cs`.
- **Data sources** (`Data`/`DataSource` props): covered by `.Prop(c => c.Data, ...)` too — the harness follows whatever indirection the stack uses to the actual transfer. Item property names cross with their .NET names (not camelized). See `ComboTests.cs`.
- **Props referencing data items** (e.g. Combo's `Value`): use the arranged overload — arrange the `Data` the value points into and state the wire value as a factory, since tracked items cross as refs whose ids are only assigned on transfer: `.Prop(c => c.Value, [_item1], arrange: ps => ps.Add(c => c.Data, items), wire: (interop, cut) => new RawJson(...))`.
- **Props referencing data items** (e.g. Combo's `Value`): add `arrange:` for the `Data` the value points into and state the wire value late, since tracked items cross as refs whose ids are only assigned on transfer: `.Prop(c => c.Value, [_item1], wire: FromRender.Of<object?>((interop, cut) => new RawJson(...)), arrange: ps => ps.Add(c => c.Data, items))`.

## Current-stack cheat-sheet (legacy `RendererMessage` pipeline)

Expand Down
8 changes: 4 additions & 4 deletions tests/IgniteUI.Blazor.Tests/AccordionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ public class AccordionTests : ComponentWithContractTestBase<IgbAccordion>
.Method(c => c.ShowAllAsync(), c => c.ShowAll(), "showAll")
.Event(c => c.Opening,
arrange,
argsJson: (interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}""",
argsJson: FromRender.Of((interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}"""),
assert: (cut, args) => Assert.Same(cut.FindComponents<IgbExpansionPanel>()[1].Instance, args.Detail))
.Event(c => c.Opened,
arrange,
argsJson: (interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}""",
argsJson: FromRender.Of((interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}"""),
assert: (cut, args) => Assert.Same(cut.FindComponents<IgbExpansionPanel>()[1].Instance, args.Detail))
.Event(c => c.Closing,
arrange,
argsJson: (interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}""",
argsJson: FromRender.Of((interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}"""),
assert: (cut, args) => Assert.Same(cut.FindComponents<IgbExpansionPanel>()[1].Instance, args.Detail))
.Event(c => c.Closed,
arrange,
argsJson: (interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}""",
argsJson: FromRender.Of((interop, cut) => $$$"""{"detail": {"refType": "name", "id": "{{{interop.ContainerIdOf(cut, "igc-expansion-panel:nth-of-type(2)")}}}"}}"""),
assert: (cut, args) => Assert.Same(cut.FindComponents<IgbExpansionPanel>()[1].Instance, args.Detail));

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion tests/IgniteUI.Blazor.Tests/CalendarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class CalendarTests : ComponentWithContractTestBase<IgbCalendar>
returns: new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc))
.Getter(c => c.GetCurrentValuesAsync(), c => c.GetCurrentValues(), "Values",
arrange: _ => { },
returns: (interop, cut) => InteropReturn.Array("""["2026-01-02T03:04:05.000Z", "2026-03-16T12:30:00.000Z"]"""),
returns: FromRender.Of((interop, cut) => InteropReturn.Array("""["2026-01-02T03:04:05.000Z", "2026-03-16T12:30:00.000Z"]""")),
assert: (cut, result) =>
{
Assert.Equal(2, result.Length);
Expand Down
2 changes: 1 addition & 1 deletion tests/IgniteUI.Blazor.Tests/ChatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class ChatTests : ComponentWithContractTestBase<IgbChat>
args: ["message-42"], types: ["String"])
.Getter(c => c.GetCurrentDraftMessageAsync(), c => c.GetCurrentDraftMessage(), "DraftMessage",
arrange: _ => { },
returns: (interop, cut) => InteropReturn.Object("", """{"text": "wip draft"}"""),
returns: FromRender.Of((interop, cut) => InteropReturn.Object("", """{"text": "wip draft"}""")),
assert: (cut, result) => Assert.Equal("wip draft", result.Text))
.Event(c => c.TypingChange,
argsJson: """{"detail": true}""",
Expand Down
20 changes: 10 additions & 10 deletions tests/IgniteUI.Blazor.Tests/ComboTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,17 @@ internal static string ChangeDetail(string newValues, string items, string type
args: ["invalid entry"], types: ["String"])
.Getter(c => c.GetCurrentValueAsync(), c => c.GetCurrentValue(), "Value",
arrange: ps => ps.Add(c => c.Data, new[] { _valueItem1, _valueItem2 }),
returns: (interop, cut) => InteropReturn.Array(
$$"""[{"refType": "uuid", "id": "{{DataItemId(interop, cut, 0)}}"}]"""),
returns: FromRender.Of((interop, cut) => InteropReturn.Array(
$$"""[{"refType": "uuid", "id": "{{DataItemId(interop, cut, 0)}}"}]""")),
assert: (cut, result) => Assert.Same(_valueItem1, Assert.Single(result)))
.Getter(c => c.GetSelectionAsync(), c => c.GetSelection(), "Selection",
arrange: ps => ps.Add(c => c.Data, new[] { _valueItem1, _valueItem2 }),
returns: (interop, cut) => InteropReturn.Array(
$$"""[{"refType": "uuid", "id": "{{DataItemId(interop, cut, 1)}}"}]"""),
returns: FromRender.Of((interop, cut) => InteropReturn.Array(
$$"""[{"refType": "uuid", "id": "{{DataItemId(interop, cut, 1)}}"}]""")),
assert: (cut, result) => Assert.Same(_valueItem2, Assert.Single(result)))
.Event(c => c.Change,
arrange: ps => ps.Add(c => c.Data, new[] { _valueItem1, _valueItem2 }),
argsJson: (interop, cut) => ChangeDetail(UuidRef(interop, cut, 0), UuidRef(interop, cut, 0)),
argsJson: FromRender.Of((interop, cut) => ChangeDetail(UuidRef(interop, cut, 0), UuidRef(interop, cut, 0))),
assert: (cut, args) =>
{
Assert.Same(_valueItem1, Assert.Single(args.Detail.NewValue));
Expand All @@ -68,7 +68,7 @@ internal static string ChangeDetail(string newValues, string items, string type
arrange: ps => ps
.Add(c => c.Data, new[] { _valueItem1, _valueItem2 })
.Add(c => c.Value, [_valueItem1]),
argsJson: (interop, cut) => ChangeDetail("", UuidRef(interop, cut, 0), "deselection"),
argsJson: FromRender.Of((interop, cut) => ChangeDetail("", UuidRef(interop, cut, 0), "deselection")),
assert: (cut, args) =>
{
Assert.Empty(args.Detail.NewValue);
Expand All @@ -79,9 +79,9 @@ internal static string ChangeDetail(string newValues, string items, string type
})
.Event(c => c.Change,
arrange: ps => ps.Add(c => c.Data, new[] { _valueItem1, _valueItem2 }),
argsJson: (interop, cut) => ChangeDetail(
argsJson: FromRender.Of((interop, cut) => ChangeDetail(
UuidRef(interop, cut, 0) + ", " + UuidRef(interop, cut, 1),
UuidRef(interop, cut, 0) + ", " + UuidRef(interop, cut, 1)),
UuidRef(interop, cut, 0) + ", " + UuidRef(interop, cut, 1))),
assert: (cut, args) =>
{
// Multi-selection: every element resolves back to its original data instance.
Expand Down Expand Up @@ -128,7 +128,7 @@ internal static string ChangeDetail(string newValues, string items, string type
.Prop(c => c.Value,
value: [_valueItem1],
arrange: ps => ps.Add(c => c.Data, new[] { _valueItem1, _valueItem2 }),
wire: (interop, cut) => new RawJson($"[{UuidRef(interop, cut, 0)}]"))
wire: FromRender.Of<object?>((interop, cut) => new RawJson($"[{UuidRef(interop, cut, 0)}]")))
.Prop(c => c.Data,
new[]
{
Expand Down Expand Up @@ -295,7 +295,7 @@ public class ComboValueKeyTests : ComponentWithContractTestBase<IgbCombo<double>
protected override ComponentContract<IgbCombo<double>> InteropContract { get; } = new ComponentContract<IgbCombo<double>>()
.Event(c => c.Change,
arrange,
argsJson: (interop, cut) => ComboTests.ChangeDetail("2", ComboTests.UuidRef(interop, cut, 1)),
argsJson: FromRender.Of((interop, cut) => ComboTests.ChangeDetail("2", ComboTests.UuidRef(interop, cut, 1))),
assert: (cut, args) =>
{
Assert.Equal(2.0, Assert.Single(args.Detail.NewValue)); // numbers decode as double
Expand Down
Loading
Loading