commit bbe2bd3691c7eef83eeb0e4edd7133bd267a1dba Author: Willem van den Ende Date: Thu Sep 3 08:33:54 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53d8e7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +## NuGet +*.nupkg +**/packages/ + +## Build +bin/ +obj/ + +## IDE +*.user +*.suo +*.userosscache +*.sln.docstates +.idea/ +*.slnx +*.slnx launches + +## NuGet local config (workaround for locked ~/.nuget) +nuget.config diff --git a/ShowDontTell.sln b/ShowDontTell.sln new file mode 100644 index 0000000..b177877 --- /dev/null +++ b/ShowDontTell.sln @@ -0,0 +1,17 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderDomain", "src/OrderDomain/OrderDomain.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTests", "tests/OrderTests/OrderTests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/learning-hour.md b/learning-hour.md new file mode 100644 index 0000000..1b9448b --- /dev/null +++ b/learning-hour.md @@ -0,0 +1,151 @@ +--- +theme: agentic_engineering +title: Show, Don't Tell — Using Examples with Coding Agents +name: show_dont_tell +difficulty: 2 +author: willem +affiliation: +tags: agentic copilot refactoring tests examples +--- + +# Show, Don't Tell — Using Examples with Coding Agents + +You've described what you want to a coding agent, and it didn't get it right. Or it took so many rounds of corrections that you'd have just done it yourself. This learning hour explores a simpler approach: instead of telling the agent what style you want, show it. + +## Learning Goals + +* Use a concrete code example (show, don't tell) to guide a coding agent in propagating a refactoring pattern across a test suite. + +## Session Outline + +* 5 min connect: Frustrating agent conversations +* 5 min concept: LLMs are pattern matchers +* 35 min concrete practice: Refactor tests using a custom matcher example +* 5 min conclusion: Share back and recognize the pattern in your own code + +## Connect — Frustrating Agent Conversations + +Pair up and share a time when you had to iterate multiple times with a coding agent just to get it to match the style you wanted. What did you try? How many rounds did it take? + +This is a [Pair Share]({% link _activities/connect/pair_share.md %}) connect. + +## Concept — LLMs Are Pattern Matchers + +LLMs are pattern matchers and translation machines. They are better at recognizing a pattern in concrete code than interpreting abstract instructions. + +### The "Tell" Approach (doesn't work well) + +> Refactor these tests to encapsulate the total price check. Instead of asserting on `order.Total`, create a method `IsFullyPaid()` and test for that. + +The agent might: +- Create the method but not use it consistently +- Misinterpret "encapsulate" and change production code instead of test code +- Apply the pattern incorrectly or only partially + +### The "Show" Approach (works better) + +Refactor one test yourself — encapsulate the attribute check behind a behavior method: + +```csharp +// Before — checking an attribute +[Test] +public void CompletedOrder_TotalIsCorrect() +{ + var order = new Order(); + order.AddLine(100m, 1); + order.Complete(); + + Assert.That(order.Total, Is.EqualTo(100m)); +} + +// After — encapsulating behavior +[Test] +public void CompletedOrder_TotalIsCorrect() +{ + var order = new Order(); + order.AddLine(100m, 1); + order.Complete(); + + Assert.That(order.IsFullyPaid(100m), Is.True); +} +``` + +Then ask the agent: + +> The first test was refactored to use `order.IsFullyPaid(expected)` instead of asserting on `order.Total`. Apply the same pattern to the remaining tests. + +The agent sees the concrete pattern and replicates it. No description needed. + +### Key Insight + +**Examples beat descriptions.** One concrete example is worth a paragraph of instructions. This works for any refactoring pattern — splitting tests, introducing custom matchers, extracting methods, renaming for clarity. Show the agent what "done" looks like. + +## Concrete Practice — Refactor Tests with a Custom Matcher + +### Setup + +Open the project in `exercises/show-dont-tell/` in Rider with GitHub Copilot Agent mode. + +The project contains an `Order` domain and a test file `OrderTotalTests.cs` with 6 tests. + +### Step 1 — Identify the Smell (5 min) + +Open `OrderTotalTests.cs`. Look at the tests. What's the pattern you see? + +- The first test (`DiscountOnFirstLine_TotalAndLineValues`) was already refactored — notice how it uses `Has.OrderState()` with a single assertion. +- The remaining 5 tests each have multiple `Assert.That()` calls checking `order.Total`, `order.Lines[0].Value`, `order.Lines[1].Value`, etc. + +These multiple asserts are checking attributes (state) rather than expressing intention. They're also hard to read and fragile — if the order structure changes, every test needs updating. + +### Step 2 — Examine the Example (5 min) + +Look at the first test and the custom matcher file `OrderStateConstraint.cs`. You don't need to understand how the matcher is implemented — just recognize the pattern: + +```csharp +// One assertion that bundles all checks +Assert.That(order, Has.OrderState( + expectedTotal: 210m, + expectedLineValues: new[] { 160m, 50m })); +``` + +vs. + +```csharp +// Three separate assertions +Assert.That(order.Total, Is.EqualTo(240m)); +Assert.That(order.Lines[0].Value, Is.EqualTo(200m)); +Assert.That(order.Lines[1].Value, Is.EqualTo(40m)); +``` + +### Step 3 — Use the Agent (20-25 min) + +Use GitHub Copilot Agent mode to refactor the remaining 5 tests. Prompt: + +> The first test uses a custom matcher `Has.OrderState()`. Refactor the remaining tests to use the same pattern — one assertion with `Has.OrderState()` instead of multiple individual asserts. + +Run the tests after the agent makes changes to verify nothing broke. + +**If the agent doesn't get it right on the first try:** +- Check that the example test is visible in the same file +- Be more specific: "Look at `DiscountOnFirstLine_TotalAndLineValues` as the example" +- Point out what went wrong and ask it to retry + +### Tools Needed + +- Rider with GitHub Copilot Agent mode +- The `exercises/show-dont-tell/` project (included in this repo) +- .NET 10 SDK + +## Conclusions — Share Back + +Go around and ask: + +1. **Did the agent get it right on the first try? What happened if it didn't?** +2. **What made the example work — what would have made it fail?** +3. **Do you recognize this pattern in your own tests?** Where could you use "show, don't tell" in your real work? + +This is an [Explain the Main Idea]({% link _activities/conclusions/explain_main_idea.md %}) conclusion. + +### One-Sentence Takeaway + +When working with coding agents, show them what you want with a concrete example instead of describing it in words — LLMs are pattern matchers, and examples are patterns. \ No newline at end of file diff --git a/src/OrderDomain/Order.cs b/src/OrderDomain/Order.cs new file mode 100644 index 0000000..58114ff --- /dev/null +++ b/src/OrderDomain/Order.cs @@ -0,0 +1,14 @@ +namespace OrderDomain; + +public class Order +{ + public List Lines { get; } = new(); + + public decimal Total => Lines.Sum(l => l.Value); + + public void AddLine(decimal unitPrice, int quantity) + => Lines.Add(new OrderLine(unitPrice, quantity)); + + public void ApplyDiscount(int lineIndex) + => Lines[lineIndex].ApplyDiscount(); +} diff --git a/src/OrderDomain/OrderDomain.csproj b/src/OrderDomain/OrderDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/src/OrderDomain/OrderDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/src/OrderDomain/OrderLine.cs b/src/OrderDomain/OrderLine.cs new file mode 100644 index 0000000..dbd1431 --- /dev/null +++ b/src/OrderDomain/OrderLine.cs @@ -0,0 +1,19 @@ +namespace OrderDomain; + +public class OrderLine +{ + public decimal UnitPrice { get; } + public int Quantity { get; } + public bool HasDiscount { get; private set; } + + public decimal Value => + HasDiscount ? UnitPrice * Quantity * 0.8m : UnitPrice * Quantity; + + public OrderLine(decimal unitPrice, int quantity) + { + UnitPrice = unitPrice; + Quantity = quantity; + } + + public void ApplyDiscount() => HasDiscount = true; +} diff --git a/tests/OrderTests/OrderStateConstraint.cs b/tests/OrderTests/OrderStateConstraint.cs new file mode 100644 index 0000000..5024fc9 --- /dev/null +++ b/tests/OrderTests/OrderStateConstraint.cs @@ -0,0 +1,49 @@ +using System.Linq; +using NUnit.Framework.Constraints; +using OrderDomain; + +namespace OrderTests; + +/// +/// Custom NUnit matcher that bundles multiple order assertions into one. +/// Participants: you do not need to understand how this works — +/// just recognize the pattern and use it as an example for the agent. +/// +public static class Has +{ + public static IResolveConstraint OrderState(decimal expectedTotal, decimal[] expectedLineValues) + { + return new OrderStateConstraint(expectedTotal, expectedLineValues); + } +} + +public class OrderStateConstraint : Constraint +{ + private readonly decimal _expectedTotal; + private readonly decimal[] _expectedLineValues; + + public OrderStateConstraint(decimal expectedTotal, decimal[] expectedLineValues) + : base($"order with total {expectedTotal} and line values [{string.Join(", ", expectedLineValues)}]") + { + _expectedTotal = expectedTotal; + _expectedLineValues = expectedLineValues; + } + + public override ConstraintResult ApplyTo(TActual actual) + { + var order = (Order)(object)actual!; + var actualTotal = order.Total; + var actualLineValues = order.Lines.Select(l => l.Value).ToArray(); + + var totalMatch = actualTotal == _expectedTotal; + var linesMatch = actualLineValues.SequenceEqual(_expectedLineValues); + + var messages = new List(); + if (!totalMatch) + messages.Add($"Total: expected {_expectedTotal}, got {actualTotal}"); + if (!linesMatch) + messages.Add($"Line values: expected [{string.Join(", ", _expectedLineValues)}], got [{string.Join(", ", actualLineValues)}]"); + + return new ConstraintResult(this, actual, totalMatch && linesMatch); + } +} \ No newline at end of file diff --git a/tests/OrderTests/OrderTests.csproj b/tests/OrderTests/OrderTests.csproj new file mode 100644 index 0000000..ee1bc80 --- /dev/null +++ b/tests/OrderTests/OrderTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/tests/OrderTests/OrderTotalTests.cs b/tests/OrderTests/OrderTotalTests.cs new file mode 100644 index 0000000..dad0859 --- /dev/null +++ b/tests/OrderTests/OrderTotalTests.cs @@ -0,0 +1,92 @@ +using NUnit.Framework; +using OrderDomain; + +namespace OrderTests; + +public class OrderTotalTests +{ + // This test was refactored — notice the custom matcher. + // The other tests still need the same treatment. + [Test] + public void DiscountOnFirstLine_TotalAndLineValues() + { + var order = new Order(); + order.AddLine(100m, 2); // line 0: 200 → 160 with discount + order.AddLine(50m, 1); // line 1: 50 + + order.ApplyDiscount(0); + + Assert.That(order, Has.OrderState( + expectedTotal: 210m, + expectedLineValues: new[] { 160m, 50m })); + } + + [Test] + public void DiscountOnSecondLine_TotalAndLineValues() + { + var order = new Order(); + order.AddLine(100m, 2); // line 0: 200 + order.AddLine(50m, 1); // line 1: 50 → 40 with discount + + order.ApplyDiscount(1); + + Assert.That(order.Total, Is.EqualTo(240m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(200m)); + Assert.That(order.Lines[1].Value, Is.EqualTo(40m)); + } + + [Test] + public void DiscountOnBothLines_TotalAndLineValues() + { + var order = new Order(); + order.AddLine(100m, 2); // line 0: 200 → 160 + order.AddLine(50m, 1); // line 1: 50 → 40 + + order.ApplyDiscount(0); + order.ApplyDiscount(1); + + Assert.That(order.Total, Is.EqualTo(200m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(160m)); + Assert.That(order.Lines[1].Value, Is.EqualTo(40m)); + } + + [Test] + public void NoDiscount_TotalAndLineValues() + { + var order = new Order(); + order.AddLine(100m, 2); // line 0: 200 + order.AddLine(50m, 1); // line 1: 50 + + Assert.That(order.Total, Is.EqualTo(250m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(200m)); + Assert.That(order.Lines[1].Value, Is.EqualTo(50m)); + } + + [Test] + public void SingleLineWithDiscount_TotalAndValue() + { + var order = new Order(); + order.AddLine(75m, 4); // line 0: 300 → 240 + + order.ApplyDiscount(0); + + Assert.That(order.Total, Is.EqualTo(240m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(240m)); + } + + [Test] + public void ThreeLinesOneDiscount_TotalAndLineValues() + { + var order = new Order(); + order.AddLine(20m, 1); // line 0: 20 + order.AddLine(30m, 2); // line 1: 60 → 48 + order.AddLine(10m, 3); // line 2: 30 + + order.ApplyDiscount(1); + + Assert.That(order.Total, Is.EqualTo(98m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(20m)); + Assert.That(order.Lines[1].Value, Is.EqualTo(48m)); + Assert.That(order.Lines[2].Value, Is.EqualTo(30m)); + } +} \ No newline at end of file