Add exercise 3 (Buyers, C#): the pure extract-lambda rep

New in-theme classes: Buyers computes a shopping-list total, PriceList
stands for the pricing service (random surge, flaky test). One loop, one
lambda-able expression — the third example teaches the move with nothing
else in the way. Pristine repo + reference solution (extract lambda,
slide, extract method with Func<IceCream,int> parameter, pure-lambda
test) + storyboard in the established style. Mirrors the kata's C#
setup (net7.0, MSTest); needs a build check on a machine with the SDK.
This commit is contained in:
Your Name
2026-09-28 21:48:30 +00:00
parent 558ad54516
commit 1189041d8e
22 changed files with 658 additions and 0 deletions
@@ -0,0 +1,40 @@
# Exercise 3 — Buyers (Split Loop with Lambda, the pure rep)
A third, minimal example in the same kata theme: the sellers exist
(`DailyForecasts` — Steve, Julie, Francis), the buyers don't. This adds them:
`Buyers` computes a buyer's shopping-list total, and `PriceList` stands for an
external pricing service (it adds random surge pricing, so the test is flaky).
Written in **C#** (MSTest, .NET 7 — same setup as the kata's `csharp/` folder).
Run with `dotnet test test/BuyersTests.csproj`.
Contents:
- `csharp/` — pristine exercise repo: one loop, **one** lambda-able expression
(`priceList.PriceFor(flavour)`), nothing else. No printing, no conditionals,
no void calls.
- `solution/` — end state: extracted static method taking
`Func<IceCream, int> priceFor`, a no-arg overload passing the method group,
and a deterministic test with a pure lambda (`_ => 2`).
`IceCream.cs` is copied from
[IceCreamForecasting-Refactoring-Kata](https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata)
(same license as the kata applies).
## Where this sits in the sequence
| example | lambda | teaches |
|---|---|---|
| 1. `longRangeForecast` weather loop (Java) | `LongFunction<Boolean>` | the pattern + the drift trap |
| 3. `Buyers` shopping list (C#, this one) | `Func<IceCream, int>` | the move in its purest form: one expression, nothing else |
| demo/next: `printSalesForecasts` → `SalesScorer`, `PriceList` → `Pricing` | role interface | when one function isn't enough (two side-effectful methods to stub) |
Next-time arc: marketing adds surge discounts, `PriceList` grows
`DiscountFor(...)` — two methods to stub → extract a role interface
(`IPricing`) instead of two lambdas. See the demo script's closing note.
## Not verified by a build
This exercise was authored without a local .NET SDK (structurally mirrored from
the kata's `csharp/` project files). Run `dotnet test test/BuyersTests.csproj`
once on a machine with the SDK before facilitating.
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "7.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
public class Buyer
{
public Buyer(string name, IReadOnlyList<IceCream> favourites)
{
Name = name;
Favourites = favourites;
}
public string Name { get; }
public IReadOnlyList<IceCream> Favourites { get; }
public int ScoopsOf(IceCream flavour)
{
return 2;
}
}
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
public class Buyers
{
private readonly PriceList priceList = new();
private readonly List<Buyer> buyers = new()
{
new("Anke", new List<IceCream> { IceCream.Strawberry, IceCream.Vanilla }),
new("Bram", new List<IceCream> { IceCream.Chocolate }),
new("Celine", new List<IceCream> { IceCream.Vanilla, IceCream.Chocolate }),
};
public int ShoppingListTotal(string buyerName)
{
var buyer = ByName(buyerName);
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceList.PriceFor(flavour);
}
return total;
}
private Buyer ByName(string name)
{
return buyers.Find(b => b.Name == name)
?? throw new InvalidOperationException($"Unknown buyer {name}.");
}
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,9 @@
namespace IceCreamBuyers
{
public enum IceCream
{
Strawberry,
Chocolate,
Vanilla
}
}
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
public class PriceList
{
// placeholder implementation - real version would call the pricing service
private static readonly Dictionary<IceCream, int> BasePrices =
new()
{
{ IceCream.Strawberry, 3 },
{ IceCream.Vanilla, 2 },
{ IceCream.Chocolate, 4 },
};
public int PriceFor(IceCream flavour)
{
return BasePrices[flavour] + SurgePricing();
}
private int SurgePricing()
{
return new Random().Next(3);
}
}
}
@@ -0,0 +1,19 @@
using IceCreamBuyers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace IceCreamBuyers.Tests
{
[TestClass]
public class BuyersTests
{
[TestMethod]
public void ShoppingListTotal()
{
// Anke: 2 flavours x 2 scoops x base prices (3 + 2) = 10.
// Red or flaky — why? (PriceList adds random surge pricing;
// it stands for the external pricing service.)
var buyers = new Buyers();
Assert.AreEqual(10, buyers.ShoppingListTotal("Anke"));
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\Buyers.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,50 @@
# Reference solution — end state of exercise 3
Derived from [IceCreamForecasting-Refactoring-Kata](https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata)
(same license as the kata applies to the copied `IceCream.cs`).
Run with `dotnet test test/BuyersTests.csproj` — the test passes deterministically
without touching the pricing service.
## What changed vs the exercise start
1. The pricing call extracted into a lambda and slid to the top:
`Func<IceCream, int> priceFor = flavour => priceList.PriceFor(flavour);`
— the invocation in the loop stays put (`priceFor(flavour)`).
2. The loop extracted to
`ShoppingListTotal(Buyer buyer, Func<IceCream, int> priceFor)` — static-free of
`PriceList`; all inputs are parameters. (C# note: this one is an instance method
because `ByName` uses the `buyers` field; the *extracted loop method itself*
touches no fields. If you want it `static`, extract the loop only and keep the
lookup in the composed method.)
3. No-arg `ShoppingListTotal(string)` overload keeps callers working; it passes the
method group `priceList.PriceFor` as the "real" pricing function.
4. The test passes a pure lambda (`_ => 2`) through the overload — deterministic,
glanceable: 2 flavours × 2 scoops × 2 = 8.
## Intermediate step (between pristine and this solution)
```csharp
public int ShoppingListTotal(string buyerName)
{
Func<IceCream, int> priceFor = flavour => priceList.PriceFor(flavour); // ← extracted & slid
var buyer = ByName(buyerName);
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceFor(flavour); // ← invocation stays
}
return total;
}
```
## Variations (debrief / second round)
- `_ => 5` — everything costs 5: Anke 20, Bram 10, Celine 20.
- `flavour => flavour == IceCream.Chocolate ? 10 : 1` — chocolate is expensive.
- Delete `PriceList` entirely: the extracted method no longer knows it exists —
a step-4 move is now unblocked (move `ShoppingListTotal(Buyer, Func)` to a
class without `PriceList`).
- Role-interface next step: add `DiscountFor(IceCream)` to `PriceList` (a second
side-effectful method) and feel why one lambda is no longer enough — extract
`IPricing` instead.
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "7.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
public class Buyer
{
public Buyer(string name, IReadOnlyList<IceCream> favourites)
{
Name = name;
Favourites = favourites;
}
public string Name { get; }
public IReadOnlyList<IceCream> Favourites { get; }
public int ScoopsOf(IceCream flavour)
{
return 2;
}
}
}
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
/// <summary>
/// Reference end state of exercise 3 (extract lambda, slide, extract method
/// with a function parameter — the pure rep of Split Loop with Lambda).
/// </summary>
public class Buyers
{
private readonly PriceList priceList = new();
private readonly List<Buyer> buyers = new()
{
new("Anke", new List<IceCream> { IceCream.Strawberry, IceCream.Vanilla }),
new("Bram", new List<IceCream> { IceCream.Chocolate }),
new("Celine", new List<IceCream> { IceCream.Vanilla, IceCream.Chocolate }),
};
/// <summary>
/// No-arg overload: callers unchanged, the "real" pricing function baked in.
/// </summary>
public int ShoppingListTotal(string buyerName)
{
return ShoppingListTotal(ByName(buyerName), priceList.PriceFor);
}
/// <summary>
/// The seam: the pricing source is injected as a function.
/// Testable: pass any pure function instead of calling the pricing service.
/// </summary>
public int ShoppingListTotal(Buyer buyer, Func<IceCream, int> priceFor)
{
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceFor(flavour);
}
return total;
}
private Buyer ByName(string name)
{
return buyers.Find(b => b.Name == name)
?? throw new InvalidOperationException($"Unknown buyer {name}.");
}
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,9 @@
namespace IceCreamBuyers
{
public enum IceCream
{
Strawberry,
Chocolate,
Vanilla
}
}
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace IceCreamBuyers
{
public class PriceList
{
// placeholder implementation - real version would call the pricing service
private static readonly Dictionary<IceCream, int> BasePrices =
new()
{
{ IceCream.Strawberry, 3 },
{ IceCream.Vanilla, 2 },
{ IceCream.Chocolate, 4 },
};
public int PriceFor(IceCream flavour)
{
return BasePrices[flavour] + SurgePricing();
}
private int SurgePricing()
{
return new Random().Next(3);
}
}
}
@@ -0,0 +1,20 @@
using IceCreamBuyers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
namespace IceCreamBuyers.Tests
{
[TestClass]
public class BuyersTests
{
[TestMethod]
public void ShoppingListTotalUsesThePricingWePassIn()
{
// pure pricing function: everything costs 2.
// Anke: 2 flavours x 2 scoops x 2 = 8 — deterministic, glanceable.
var buyers = new Buyers();
var anke = new Buyer("Anke", new List<IceCream> { IceCream.Strawberry, IceCream.Vanilla });
Assert.AreEqual(8, buyers.ShoppingListTotal(anke, _ => 2));
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\Buyers.csproj" />
</ItemGroup>
</Project>