From b131514d511fbdf4d6ad565b85dbec6ff98868a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 19:03:28 +0200 Subject: [PATCH] feat(ui): #14 IPanelRenderer widget extension - TextColored, Checkbox, Combo, InputTextSubmit, BeginTable, etc. Adds 14 widget signatures to IPanelRenderer + ImGuiPanelRenderer impl: TextColored, CollapsingHeader, TreeNode/TreePop, Checkbox, Button, Combo, SliderFloat, PlotLines, BeginTable/TableNextColumn/EndTable, InputTextSubmit (Enter-key submit), Spacing, Dummy, TextWrapped. InputTextSubmit uses ImGuiInputTextFlags.EnterReturnsTrue and clears the buffer + emits via `out submitted` on the frame Enter is pressed. PlotLines passes `ref values[0]` with empty-array guard. CollapsingHeader defaultOpen=true uses ImGuiTreeNodeFlags.DefaultOpen (= 0x20). FakePanelRenderer test double records (Method, Args) tuples and exposes knobs to drive ref/out values. 17 new tests dispatch through IPanelRenderer (not the concrete fake) so tests fail to compile when the interface itself lacks a method - real RED -> GREEN signal. Tests: 26 -> 43 in UI.Abstractions.Tests. Total solution 881 green. Foundation for Phase I.2 (DebugPanel) and I.4 (ChatPanel input field). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.UI.Abstractions/IPanelRenderer.cs | 122 +++++++ src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 111 +++++++ .../FakePanelRenderer.cs | 142 ++++++++ .../IPanelRendererWidgetTests.cs | 308 ++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/IPanelRendererWidgetTests.cs diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs index 93e2584..91cc42f 100644 --- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -1,3 +1,5 @@ +using System.Numerics; + namespace AcDream.UI.Abstractions; /// @@ -40,4 +42,124 @@ public interface IPanelRenderer /// is optional text (e.g. "54%") rendered on top. /// void ProgressBar(float fraction, float width, string? overlay = null); + + // -- Phase I.1 widget extensions ---------------------------------- + + /// + /// Draw a single line of text in the supplied RGBA color. + /// Used for combat-event color-coding (damage red, heal green) and + /// any other surface where a glance distinguishes severity. + /// + void TextColored(Vector4 rgba, string text); + + /// + /// Open / close section grouping inside a window. Returns + /// true when the section is currently expanded — only render + /// the section's contents in that branch. + /// + /// If the user has never toggled the + /// header before, start in this state. + bool CollapsingHeader(string label, bool defaultOpen = true); + + /// + /// Push an expandable nested node. Pair every successful + /// true return with a matching ; + /// when this returns false do NOT call . + /// + bool TreeNode(string label); + + /// Pop a previously-pushed . + void TreePop(); + + /// + /// A boolean toggle. Returns true on the frame the user + /// flipped the box (and updates in place); + /// returns false the rest of the time. + /// + bool Checkbox(string label, ref bool value); + + /// + /// A clickable button. Returns true on the single frame the + /// user clicked it; false every other frame. + /// + bool Button(string label); + + /// + /// A drop-down selector. Returns true on the frame the user + /// changed the selection (and updates + /// to the new value); false otherwise. + /// + bool Combo(string label, ref int selectedIndex, string[] items); + + /// + /// A horizontal slider clamped to / + /// . Returns true on frames where the + /// user dragged the value (and updates ); + /// false when idle. + /// + bool SliderFloat(string label, ref float value, float min, float max); + + /// + /// Time-series line graph (e.g. fps history). The active window is + /// [.. + /// +] interpreted + /// as a ring buffer. + /// + /// Header text shown above the plot. + /// Sample buffer (kept by the caller). + /// Number of valid samples in the ring. + /// Index of the oldest sample in + /// . + /// Optional centered overlay text (e.g. + /// "60 fps"). + /// Optional fixed lower bound; null = autoscale. + /// Optional fixed upper bound; null = autoscale. + /// Optional pixel size; null = backend default. + void PlotLines( + string label, + float[] values, + int count, + int offset = 0, + string? overlay = null, + float? min = null, + float? max = null, + Vector2? size = null); + + /// + /// Begin a multi-column table. Pair every call with . + /// Inside the table, advance to each cell with + /// before emitting widgets for it. + /// + void BeginTable(string id, int columns); + + /// Move to the next cell of the active table. + void TableNextColumn(); + + /// Close the most recent . + void EndTable(); + + /// + /// A single-line text input that submits on Enter. Returns + /// true on the frame the user pressed Enter; in that case + /// is the value entered and the + /// implementation clears for the next + /// frame. On every other frame returns false and + /// is null; the implementation may + /// still mutate as the user types. + /// + /// Maximum characters the buffer may grow to. + bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted); + + /// Insert a small vertical gap. + void Spacing(); + + /// Reserve invisible space of the given size, useful for + /// layout-only padding. + void Dummy(Vector2 size); + + /// + /// Draw text that wraps at the current content region width. + /// Use for descriptions, error messages, anything multi-line. + /// + void TextWrapped(string text); } diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index d1fde2f..e39cc43 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -39,4 +39,115 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty); } + + // -- Phase I.1 widget extensions --------------------------------- + + /// + public void TextColored(Vector4 rgba, string text) + => ImGuiNET.ImGui.TextColored(rgba, text); + + /// + public bool CollapsingHeader(string label, bool defaultOpen = true) + => ImGuiNET.ImGui.CollapsingHeader( + label, + defaultOpen ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None); + + /// + public bool TreeNode(string label) => ImGuiNET.ImGui.TreeNode(label); + + /// + public void TreePop() => ImGuiNET.ImGui.TreePop(); + + /// + public bool Checkbox(string label, ref bool value) + => ImGuiNET.ImGui.Checkbox(label, ref value); + + /// + public bool Button(string label) => ImGuiNET.ImGui.Button(label); + + /// + public bool Combo(string label, ref int selectedIndex, string[] items) + => ImGuiNET.ImGui.Combo(label, ref selectedIndex, items, items.Length); + + /// + public bool SliderFloat(string label, ref float value, float min, float max) + => ImGuiNET.ImGui.SliderFloat(label, ref value, min, max); + + /// + public void PlotLines( + string label, + float[] values, + int count, + int offset = 0, + string? overlay = null, + float? min = null, + float? max = null, + Vector2? size = null) + { + // ImGui.NET 1.91.6.1's PlotLines binding takes `ref float values` + // (pointer-to-first-element semantics) plus a separate values_count + // and values_offset. The "no fixed bound" / "default size" sentinels + // are float.MaxValue and Vector2.Zero respectively — we pass those + // when the caller leaves the optional args null. + if (count <= 0 || values.Length == 0) + { + // Nothing to plot — emit the label so layout doesn't shift but + // skip the native call (ref to values[0] would NRE on empty). + ImGuiNET.ImGui.TextUnformatted(label); + return; + } + + float scaleMin = min ?? float.MaxValue; + float scaleMax = max ?? float.MaxValue; + Vector2 graphSize = size ?? Vector2.Zero; + ImGuiNET.ImGui.PlotLines( + label, + ref values[0], + count, + offset, + overlay ?? string.Empty, + scaleMin, + scaleMax, + graphSize); + } + + /// + public void BeginTable(string id, int columns) + => ImGuiNET.ImGui.BeginTable(id, columns); + + /// + public void TableNextColumn() => ImGuiNET.ImGui.TableNextColumn(); + + /// + public void EndTable() => ImGuiNET.ImGui.EndTable(); + + /// + public bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted) + { + // EnterReturnsTrue: the call returns true on the frame the user + // pressed Enter. On every other frame ImGui still mutates `buffer` + // as the user types; we just don't surface a submit. + bool entered = ImGuiNET.ImGui.InputText( + label, + ref buffer, + (uint)maxLen, + ImGuiInputTextFlags.EnterReturnsTrue); + if (entered) + { + submitted = buffer; + buffer = string.Empty; // contract: clear for next frame + return true; + } + submitted = null; + return false; + } + + /// + public void Spacing() => ImGuiNET.ImGui.Spacing(); + + /// + public void Dummy(Vector2 size) => ImGuiNET.ImGui.Dummy(size); + + /// + public void TextWrapped(string text) => ImGuiNET.ImGui.TextWrapped(text); } diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs new file mode 100644 index 0000000..70fcfc3 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs @@ -0,0 +1,142 @@ +using System.Numerics; + +namespace AcDream.UI.Abstractions.Tests; + +/// +/// In-memory test double for . Records every +/// widget call so tests can assert against the recorded trace without +/// running an ImGui context. New widgets added to the interface must +/// implement here too — that compilation error IS the TDD red signal. +/// +internal sealed class FakePanelRenderer : IPanelRenderer +{ + /// Ordered list of (method, args) pairs recorded across this renderer's lifetime. + public List<(string Method, object?[] Args)> Calls { get; } = new(); + + /// If is called, return this value. Defaults to true so panels render. + public bool BeginReturns { get; set; } = true; + + // -- Inputs the panel under test can mutate via ref args ----------- + + /// Pre-set return value for the next — caller flips bool to simulate user click. + public bool CheckboxNextReturn { get; set; } + public bool? CheckboxNextValue { get; set; } + + /// Pre-set return value for the next — caller sets to simulate user click. + public bool ButtonNextReturn { get; set; } + + /// Pre-set return value for the next . + public bool ComboNextReturn { get; set; } + public int? ComboNextSelectedIndex { get; set; } + + /// Pre-set return for . + public bool SliderFloatNextReturn { get; set; } + public float? SliderFloatNextValue { get; set; } + + /// Pre-set return for . + public bool CollapsingHeaderNextReturn { get; set; } = true; + + /// Pre-set return for . + public bool TreeNodeNextReturn { get; set; } = true; + + /// Pre-set return for . + public bool BeginTableNextReturn { get; set; } = true; + + /// Pre-set "submitted" string for the next ; null = no submit this frame. + public string? InputTextSubmitNextSubmitted { get; set; } + public string? InputTextSubmitNextBufferAfter { get; set; } + + public bool Begin(string title) + { + Calls.Add(("Begin", new object?[] { title })); + return BeginReturns; + } + + public void End() => Calls.Add(("End", Array.Empty())); + + public void Text(string text) => Calls.Add(("Text", new object?[] { text })); + + public void SameLine() => Calls.Add(("SameLine", Array.Empty())); + + public void Separator() => Calls.Add(("Separator", Array.Empty())); + + public void ProgressBar(float fraction, float width, string? overlay = null) + => Calls.Add(("ProgressBar", new object?[] { fraction, width, overlay })); + + public void TextColored(Vector4 rgba, string text) + => Calls.Add(("TextColored", new object?[] { rgba, text })); + + public bool CollapsingHeader(string label, bool defaultOpen = true) + { + Calls.Add(("CollapsingHeader", new object?[] { label, defaultOpen })); + return CollapsingHeaderNextReturn; + } + + public bool TreeNode(string label) + { + Calls.Add(("TreeNode", new object?[] { label })); + return TreeNodeNextReturn; + } + + public void TreePop() => Calls.Add(("TreePop", Array.Empty())); + + public bool Checkbox(string label, ref bool value) + { + Calls.Add(("Checkbox", new object?[] { label, value })); + if (CheckboxNextValue is bool nv) value = nv; + return CheckboxNextReturn; + } + + public bool Button(string label) + { + Calls.Add(("Button", new object?[] { label })); + return ButtonNextReturn; + } + + public bool Combo(string label, ref int selectedIndex, string[] items) + { + Calls.Add(("Combo", new object?[] { label, selectedIndex, items })); + if (ComboNextSelectedIndex is int idx) selectedIndex = idx; + return ComboNextReturn; + } + + public bool SliderFloat(string label, ref float value, float min, float max) + { + Calls.Add(("SliderFloat", new object?[] { label, value, min, max })); + if (SliderFloatNextValue is float v) value = v; + return SliderFloatNextReturn; + } + + public void PlotLines( + string label, + float[] values, + int count, + int offset = 0, + string? overlay = null, + float? min = null, + float? max = null, + Vector2? size = null) + => Calls.Add(("PlotLines", new object?[] { label, values, count, offset, overlay, min, max, size })); + + public void BeginTable(string id, int columns) + => Calls.Add(("BeginTable", new object?[] { id, columns })); + + public void TableNextColumn() => Calls.Add(("TableNextColumn", Array.Empty())); + + public void EndTable() => Calls.Add(("EndTable", Array.Empty())); + + public bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted) + { + Calls.Add(("InputTextSubmit", new object?[] { label, buffer, maxLen })); + submitted = InputTextSubmitNextSubmitted; + if (submitted is not null) + buffer = InputTextSubmitNextBufferAfter ?? string.Empty; + return submitted is not null; + } + + public void Spacing() => Calls.Add(("Spacing", Array.Empty())); + + public void Dummy(Vector2 size) => Calls.Add(("Dummy", new object?[] { size })); + + public void TextWrapped(string text) => Calls.Add(("TextWrapped", new object?[] { text })); +} diff --git a/tests/AcDream.UI.Abstractions.Tests/IPanelRendererWidgetTests.cs b/tests/AcDream.UI.Abstractions.Tests/IPanelRendererWidgetTests.cs new file mode 100644 index 0000000..78315bd --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/IPanelRendererWidgetTests.cs @@ -0,0 +1,308 @@ +using System.Numerics; + +namespace AcDream.UI.Abstractions.Tests; + +/// +/// Tests the shape of the new Phase I.1 widget extensions on +/// . Every test dispatches through the +/// interface (not the concrete fake) so the test only compiles when +/// the interface actually has the method — that's the RED signal for +/// TDD when adding new widgets in the future. +/// +public sealed class IPanelRendererWidgetTests +{ + private static (IPanelRenderer Iface, FakePanelRenderer Fake) New() + { + var f = new FakePanelRenderer(); + return (f, f); + } + + [Fact] + public void TextColored_RecordsCallWithColorAndText() + { + var (r, fake) = New(); + var color = new Vector4(0.9f, 0.4f, 0.2f, 1f); + + r.TextColored(color, "ouch"); + + var call = Assert.Single(fake.Calls); + Assert.Equal("TextColored", call.Method); + Assert.Equal(color, call.Args[0]); + Assert.Equal("ouch", call.Args[1]); + } + + [Fact] + public void CollapsingHeader_RecordsLabelAndDefaultOpen_ReturnsBackingValue() + { + var (r, fake) = New(); + fake.CollapsingHeaderNextReturn = false; + + bool open = r.CollapsingHeader("Player Info"); + Assert.False(open); + + var call = Assert.Single(fake.Calls); + Assert.Equal("CollapsingHeader", call.Method); + Assert.Equal("Player Info", call.Args[0]); + Assert.Equal(true, call.Args[1]); // default + } + + [Fact] + public void CollapsingHeader_PassesDefaultOpenFalse() + { + var (r, fake) = New(); + + r.CollapsingHeader("Diagnostics", defaultOpen: false); + + var call = Assert.Single(fake.Calls); + Assert.Equal(false, call.Args[1]); + } + + [Fact] + public void TreeNode_AndTreePop_FormPair() + { + var (r, fake) = New(); + fake.TreeNodeNextReturn = true; + + if (r.TreeNode("Inventory")) + { + r.Text("sword"); + r.TreePop(); + } + + Assert.Equal(3, fake.Calls.Count); + Assert.Equal("TreeNode", fake.Calls[0].Method); + Assert.Equal("Inventory", fake.Calls[0].Args[0]); + Assert.Equal("Text", fake.Calls[1].Method); + Assert.Equal("TreePop", fake.Calls[2].Method); + } + + [Fact] + public void Checkbox_AppliesNextValueToRefAndRecordsBefore() + { + var (r, fake) = New(); + fake.CheckboxNextReturn = true; + fake.CheckboxNextValue = true; + + bool flag = false; + bool changed = r.Checkbox("DumpMotion", ref flag); + + Assert.True(changed); + Assert.True(flag); // fake mutated the ref + var call = Assert.Single(fake.Calls); + Assert.Equal("Checkbox", call.Method); + Assert.Equal("DumpMotion", call.Args[0]); + Assert.Equal(false, call.Args[1]); // value-before-the-call captured in trace + } + + [Fact] + public void Button_ReturnsBackingValueAndRecordsLabel() + { + var (r, fake) = New(); + fake.ButtonNextReturn = true; + + bool clicked = r.Button("Cycle Weather"); + + Assert.True(clicked); + var call = Assert.Single(fake.Calls); + Assert.Equal("Button", call.Method); + Assert.Equal("Cycle Weather", call.Args[0]); + } + + [Fact] + public void Combo_AppliesNextSelectedIndex_AndReturnsChanged() + { + var (r, fake) = New(); + fake.ComboNextReturn = true; + fake.ComboNextSelectedIndex = 2; + + int idx = 0; + var items = new[] { "Dawn", "Noon", "Dusk", "Night" }; + bool changed = r.Combo("Time", ref idx, items); + + Assert.True(changed); + Assert.Equal(2, idx); + var call = Assert.Single(fake.Calls); + Assert.Equal("Combo", call.Method); + Assert.Equal("Time", call.Args[0]); + Assert.Equal(0, call.Args[1]); + Assert.Same(items, call.Args[2]); + } + + [Fact] + public void SliderFloat_AppliesNextValueAndReturnsChanged() + { + var (r, fake) = New(); + fake.SliderFloatNextReturn = true; + fake.SliderFloatNextValue = 0.7f; + + float v = 0.1f; + bool changed = r.SliderFloat("Sensitivity", ref v, 0f, 1f); + + Assert.True(changed); + Assert.Equal(0.7f, v); + var call = Assert.Single(fake.Calls); + Assert.Equal("SliderFloat", call.Method); + Assert.Equal("Sensitivity", call.Args[0]); + Assert.Equal(0.1f, call.Args[1]); + Assert.Equal(0f, call.Args[2]); + Assert.Equal(1f, call.Args[3]); + } + + [Fact] + public void PlotLines_RecordsAllOptionalArgs() + { + var (r, fake) = New(); + var values = new[] { 16f, 17f, 16.5f, 16.2f }; + + r.PlotLines( + "FrameMs", + values, + count: values.Length, + offset: 1, + overlay: "60 fps", + min: 0f, + max: 33f, + size: new Vector2(120, 40)); + + var call = Assert.Single(fake.Calls); + Assert.Equal("PlotLines", call.Method); + Assert.Equal("FrameMs", call.Args[0]); + Assert.Same(values, call.Args[1]); + Assert.Equal(values.Length, call.Args[2]); + Assert.Equal(1, call.Args[3]); + Assert.Equal("60 fps", call.Args[4]); + Assert.Equal(0f, call.Args[5]); + Assert.Equal(33f, call.Args[6]); + Assert.Equal(new Vector2(120, 40), call.Args[7]); + } + + [Fact] + public void PlotLines_DefaultsApplied_WhenOptionalsOmitted() + { + var (r, fake) = New(); + var values = new[] { 1f, 2f, 3f }; + + r.PlotLines("FPS", values, count: values.Length); + + var call = Assert.Single(fake.Calls); + Assert.Equal(0, call.Args[3]); // offset default + Assert.Null(call.Args[4]); // overlay default + Assert.Null(call.Args[5]); // min default + Assert.Null(call.Args[6]); // max default + Assert.Null(call.Args[7]); // size default + } + + [Fact] + public void Table_BeginNextColumnEnd_FormSequence() + { + var (r, fake) = New(); + + r.BeginTable("Keybinds", 2); + r.TableNextColumn(); + r.Text("F1"); + r.TableNextColumn(); + r.Text("Toggle Info"); + r.EndTable(); + + Assert.Equal(6, fake.Calls.Count); + Assert.Equal("BeginTable", fake.Calls[0].Method); + Assert.Equal("Keybinds", fake.Calls[0].Args[0]); + Assert.Equal(2, fake.Calls[0].Args[1]); + Assert.Equal("TableNextColumn", fake.Calls[1].Method); + Assert.Equal("Text", fake.Calls[2].Method); + Assert.Equal("TableNextColumn", fake.Calls[3].Method); + Assert.Equal("Text", fake.Calls[4].Method); + Assert.Equal("EndTable", fake.Calls[5].Method); + } + + [Fact] + public void InputTextSubmit_NoSubmit_ReturnsFalseAndSubmittedNull() + { + var (r, fake) = New(); + // InputTextSubmitNextSubmitted left null → the buffer should be untouched + // and the out arg should be null. + + string buf = "hel"; + bool submitted = r.InputTextSubmit("##chat", ref buf, 256, out var output); + + Assert.False(submitted); + Assert.Null(output); + Assert.Equal("hel", buf); + + var call = Assert.Single(fake.Calls); + Assert.Equal("InputTextSubmit", call.Method); + Assert.Equal("##chat", call.Args[0]); + Assert.Equal("hel", call.Args[1]); + Assert.Equal(256, call.Args[2]); + } + + [Fact] + public void InputTextSubmit_OnSubmit_OutputsTextAndClearsBuffer() + { + var (r, fake) = New(); + fake.InputTextSubmitNextSubmitted = "hello world"; + fake.InputTextSubmitNextBufferAfter = string.Empty; + + string buf = "hello world"; + bool submitted = r.InputTextSubmit("##chat", ref buf, 256, out var output); + + Assert.True(submitted); + Assert.Equal("hello world", output); + // Contract: on submit the impl clears the buffer for the next frame. + Assert.Equal(string.Empty, buf); + } + + [Fact] + public void Spacing_RecordsCall() + { + var (r, fake) = New(); + r.Spacing(); + var call = Assert.Single(fake.Calls); + Assert.Equal("Spacing", call.Method); + } + + [Fact] + public void Dummy_RecordsSize() + { + var (r, fake) = New(); + r.Dummy(new Vector2(8, 12)); + var call = Assert.Single(fake.Calls); + Assert.Equal("Dummy", call.Method); + Assert.Equal(new Vector2(8, 12), call.Args[0]); + } + + [Fact] + public void TextWrapped_RecordsText() + { + var (r, fake) = New(); + r.TextWrapped("a long description that wraps onto multiple lines"); + + var call = Assert.Single(fake.Calls); + Assert.Equal("TextWrapped", call.Method); + Assert.Equal("a long description that wraps onto multiple lines", call.Args[0]); + } + + // -- Sanity: existing widgets still recorded correctly via the fake ------- + + [Fact] + public void BeginEndTextSeparatorProgressBar_StillRecorded() + { + var (r, fake) = New(); + fake.BeginReturns = true; + + Assert.True(r.Begin("Window")); + r.Text("hi"); + r.SameLine(); + r.Separator(); + r.ProgressBar(0.5f, 100f, "50%"); + r.End(); + + Assert.Equal(6, fake.Calls.Count); + Assert.Equal("Begin", fake.Calls[0].Method); + Assert.Equal("Text", fake.Calls[1].Method); + Assert.Equal("SameLine", fake.Calls[2].Method); + Assert.Equal("Separator", fake.Calls[3].Method); + Assert.Equal("ProgressBar", fake.Calls[4].Method); + Assert.Equal("End", fake.Calls[5].Method); + } +}