Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b1a02ea4 | ||
|
|
679e95eb83 | ||
|
|
1189041d8e |
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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 10; verified with `dotnet test` on SDK
|
||||||
|
10.0.4xx — pristine test is flaky by design, solution is deterministic).
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Verified
|
||||||
|
|
||||||
|
Built and run with `dotnet test` (SDK 10.0.4xx): solution green deterministically,
|
||||||
|
pristine test flaky (failed 2 of 3 runs, passed 1 — both surges rolled 0).
|
||||||
|
MSTest bumped to 3.6.4 (kata ships 2.1.1, whose transitive Newtonsoft.Json trips
|
||||||
|
a NU1903 vulnerability warning).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"rollForward": "latestFeature",
|
||||||
|
"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>net10.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>net10.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="MSTest.TestAdapter" Version="3.6.4" />
|
||||||
|
<PackageReference Include="MSTest.TestFramework" Version="3.6.4" />
|
||||||
|
</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": "10.0.0",
|
||||||
|
"rollForward": "latestFeature",
|
||||||
|
"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>net10.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>net10.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="MSTest.TestAdapter" Version="3.6.4" />
|
||||||
|
<PackageReference Include="MSTest.TestFramework" Version="3.6.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\src\Buyers.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Example 3 — Buyers: extract lambda in its purest form
|
||||||
|
|
||||||
|
Third rep of the Split Loop with Lambda mechanics, in C#. Example 1
|
||||||
|
(`longRangeForecast`) had a drift trap and a dead call; the demo
|
||||||
|
(`printSalesForecasts`) had three dependencies and grew into a role interface.
|
||||||
|
This one has **one loop and one lambda-able expression, and nothing else** —
|
||||||
|
the move in its purest form. Diagrams use the same
|
||||||
|
[vocabulary as the main storyboard](storyboard.md).
|
||||||
|
|
||||||
|
Kata theme: the sellers exist (`DailyForecasts` — Steve, Julie, Francis), the
|
||||||
|
buyers don't. `Buyers` computes a buyer's shopping-list total; `PriceList`
|
||||||
|
stands for the external pricing service. Exercise repo: `../exercise3/csharp`,
|
||||||
|
end state: `../exercise3/solution`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — one loop, one unwanted dependency
|
||||||
|
|
||||||
|
**Goal:** the test is red or flaky — why? (`PriceList` adds random surge
|
||||||
|
pricing; it stands for the external pricing service.)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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); // ← unwanted dependency
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checkpoint:** run the test. Flaky. What does the loop need from outside?
|
||||||
|
*Given a flavour, what does it cost?* That question is the lambda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — extract the pricing call into a lambda, and slide it
|
||||||
|
|
||||||
|
**Goal:** the move, with nothing else in the way. Extract, then slide — the
|
||||||
|
invocation stays in the loop.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int ShoppingListTotal(string buyerName)
|
||||||
|
{
|
||||||
|
Func<IceCream, int> priceFor = flavour => priceList.PriceFor(flavour); // ← SLID to the top...
|
||||||
|
var buyer = ByName(buyerName);
|
||||||
|
var total = 0;
|
||||||
|
foreach (var flavour in buyer.Favourites)
|
||||||
|
{
|
||||||
|
total += buyer.ScoopsOf(flavour) * priceFor(flavour); // ← ...invocation stays here
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checkpoint:** compiles; test still flaky (the lambda still calls
|
||||||
|
`PriceList` — that is fine). Unlike the scoring-loop example, `priceList` is a
|
||||||
|
*field*, so the lambda slides all the way to the top. Slide, don't move.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — extract the loop; the lambda becomes a parameter
|
||||||
|
|
||||||
|
**Goal:** select the loop, Extract Method. The loop touches no fields — the
|
||||||
|
lambda is the only thing crossing the boundary.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int ShoppingListTotal(string buyerName) // ← no-arg overload: callers unchanged
|
||||||
|
{
|
||||||
|
return ShoppingListTotal(ByName(buyerName), priceList.PriceFor); // ← the "real" pricing, as a method group
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ShoppingListTotal(Buyer buyer, Func<IceCream, int> priceFor) // ← the seam: pricing is a parameter
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var flavour in buyer.Favourites)
|
||||||
|
{
|
||||||
|
total += buyer.ScoopsOf(flavour) * priceFor(flavour);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The extracted loop method touches no fields; it sits in the same class as
|
||||||
|
`ByName` for convenience. Move it to a class without `PriceList` any time —
|
||||||
|
that is step 4 of the full pattern, unblocked.)
|
||||||
|
|
||||||
|
**The test — the whole point:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checkpoint:** green — deterministically. `PriceList` is never touched.
|
||||||
|
|
||||||
|
- `grep PriceFor src/` — the extracted method has no hits.
|
||||||
|
- C# note: the extracted method stays an instance method here because the
|
||||||
|
class also holds `ByName`; if you want a `static`, extract the loop only and
|
||||||
|
keep the lookup in the composed method.
|
||||||
|
|
||||||
|
**Debrief in one line:** the loop now reads "for each favourite flavour, add
|
||||||
|
scoops times price" — and the pricing is a question anyone can answer with one
|
||||||
|
line of test code.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 460" font-family="Helvetica, Arial, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#dc2626"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="760" height="460" fill="#ffffff"/>
|
||||||
|
<text x="24" y="34" font-size="20" font-weight="bold" fill="#0f172a">Step 0 — one loop, one unwanted dependency</text>
|
||||||
|
<text x="24" y="56" font-size="13" fill="#475569">PriceList stands for the external pricing service (random surge pricing) — that is why the test is red & flaky.</text>
|
||||||
|
|
||||||
|
<!-- method -->
|
||||||
|
<rect x="60" y="74" width="400" height="260" rx="10" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
<text x="76" y="98" font-size="14" font-weight="bold" fill="#0f172a">ShoppingListTotal(buyerName)</text>
|
||||||
|
|
||||||
|
<text x="80" y="126" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">buyer = ByName(buyerName)</text>
|
||||||
|
|
||||||
|
<!-- loop -->
|
||||||
|
<rect x="80" y="140" width="360" height="120" rx="8" fill="#eef2ff" stroke="#818cf8"/>
|
||||||
|
<text x="94" y="166" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">foreach (var flavour : buyer.Favourites)</text>
|
||||||
|
<text x="94" y="196" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">total += buyer.ScoopsOf(flavour) *</text>
|
||||||
|
<text x="94" y="216" font-size="11" font-family="Menlo, Consolas, monospace" font-weight="bold" fill="#dc2626"> priceList.PriceFor(flavour);</text>
|
||||||
|
<text x="94" y="244" font-size="10" fill="#64748b">the pricing loop — one expression, nothing else</text>
|
||||||
|
|
||||||
|
<text x="80" y="300" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">return total;</text>
|
||||||
|
|
||||||
|
<!-- PriceList -->
|
||||||
|
<rect x="580" y="150" width="150" height="90" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
|
||||||
|
<text x="655" y="180" font-size="14" font-weight="bold" fill="#dc2626" text-anchor="middle">PriceList</text>
|
||||||
|
<text x="655" y="200" font-size="11" fill="#7f1d1d" text-anchor="middle">stands for the pricing</text>
|
||||||
|
<text x="655" y="216" font-size="11" fill="#7f1d1d" text-anchor="middle">service — Random surge</text>
|
||||||
|
|
||||||
|
<line x1="444" y1="206" x2="572" y2="192" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#arrR)"/>
|
||||||
|
|
||||||
|
<!-- test -->
|
||||||
|
<rect x="580" y="290" width="150" height="60" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-dasharray="4 3"/>
|
||||||
|
<text x="655" y="314" font-size="12" fill="#334155" text-anchor="middle">test: Anke = 10?</text>
|
||||||
|
<text x="655" y="332" font-size="12" font-weight="bold" fill="#dc2626" text-anchor="middle">red & flaky ✗</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 460" font-family="Helvetica, Arial, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#dc2626"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrP" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#9333ea"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="760" height="460" fill="#ffffff"/>
|
||||||
|
<text x="24" y="34" font-size="20" font-weight="bold" fill="#0f172a">Step 1 — extract the pricing call into a lambda, and slide it</text>
|
||||||
|
<text x="24" y="56" font-size="13" fill="#9333ea" font-weight="bold">One expression, nothing else in the way. priceList is a field, so the lambda slides all the way to the top.</text>
|
||||||
|
|
||||||
|
<!-- method -->
|
||||||
|
<rect x="60" y="74" width="400" height="330" rx="10" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
<text x="76" y="98" font-size="14" font-weight="bold" fill="#0f172a">ShoppingListTotal(buyerName)</text>
|
||||||
|
|
||||||
|
<!-- lambda at top -->
|
||||||
|
<rect x="80" y="110" width="360" height="82" rx="8" fill="#faf5ff" stroke="#9333ea" stroke-width="1.5"/>
|
||||||
|
<text x="94" y="136" font-size="12" font-weight="bold" fill="#9333ea">λ priceFor</text>
|
||||||
|
<text x="94" y="162" font-size="11" font-family="Menlo, Consolas, monospace" fill="#dc2626">Func<IceCream,int> =</text>
|
||||||
|
<text x="94" y="180" font-size="11" font-family="Menlo, Consolas, monospace" fill="#dc2626"> flavour => priceList.PriceFor(flavour);</text>
|
||||||
|
|
||||||
|
<text x="80" y="200" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">buyer = ByName(buyerName)</text>
|
||||||
|
|
||||||
|
<!-- loop -->
|
||||||
|
<rect x="80" y="214" width="360" height="120" rx="8" fill="#eef2ff" stroke="#818cf8"/>
|
||||||
|
<text x="94" y="240" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">foreach (var flavour : buyer.Favourites)</text>
|
||||||
|
<text x="94" y="270" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">total += buyer.ScoopsOf(flavour) *</text>
|
||||||
|
<text x="94" y="290" font-size="11" font-family="Menlo, Consolas, monospace" font-weight="bold" fill="#6b21a8"> priceFor(flavour);</text>
|
||||||
|
<text x="94" y="318" font-size="10" fill="#64748b">invocation stays exactly where the code was</text>
|
||||||
|
|
||||||
|
<!-- ghost -->
|
||||||
|
<rect x="94" y="352" width="330" height="26" rx="6" fill="none" stroke="#9333ea" stroke-width="1" stroke-dasharray="4 3"/>
|
||||||
|
<text x="106" y="370" font-size="10" fill="#9333ea" font-style="italic">λ used to be defined right here</text>
|
||||||
|
|
||||||
|
<text x="80" y="398" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">return total;</text>
|
||||||
|
|
||||||
|
<!-- slide arrow -->
|
||||||
|
<line x1="260" y1="350" x2="260" y2="196" stroke="#9333ea" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#arrP)"/>
|
||||||
|
<text x="270" y="205" font-size="12" font-weight="bold" fill="#9333ea">slide</text>
|
||||||
|
|
||||||
|
<!-- PriceList -->
|
||||||
|
<rect x="580" y="130" width="150" height="90" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
|
||||||
|
<text x="655" y="160" font-size="14" font-weight="bold" fill="#dc2626" text-anchor="middle">PriceList</text>
|
||||||
|
<text x="655" y="180" font-size="11" fill="#7f1d1d" text-anchor="middle">still called —</text>
|
||||||
|
<text x="655" y="196" font-size="11" fill="#7f1d1d" text-anchor="middle">but only via the λ now</text>
|
||||||
|
|
||||||
|
<line x1="444" y1="142" x2="572" y2="162" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#arrR)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 460" font-family="Helvetica, Arial, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#dc2626"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrGr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="760" height="460" fill="#ffffff"/>
|
||||||
|
<text x="24" y="34" font-size="20" font-weight="bold" fill="#0f172a">Step 2 — extract the loop; the lambda becomes a parameter</text>
|
||||||
|
<text x="24" y="56" font-size="13" fill="#16a34a" font-weight="bold">The test passes its own pricing (_ => 2). PriceList is never touched: deterministic, glanceable.</text>
|
||||||
|
|
||||||
|
<!-- test -->
|
||||||
|
<rect x="50" y="150" width="230" height="140" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
|
||||||
|
<text x="66" y="176" font-size="13" font-weight="bold" fill="#14532d">BuyersTests</text>
|
||||||
|
<text x="66" y="204" font-size="11" font-family="Menlo, Consolas, monospace" fill="#334155">var anke = new Buyer(...)</text>
|
||||||
|
<text x="66" y="228" font-size="12" font-family="Menlo, Consolas, monospace" font-weight="bold" fill="#16a34a">priceFor: _ => 2</text>
|
||||||
|
<text x="66" y="252" font-size="11" font-family="Menlo, Consolas, monospace" fill="#14532d">ShoppingListTotal(anke, λ)</text>
|
||||||
|
<text x="66" y="276" font-size="11" font-family="Menlo, Consolas, monospace" fill="#14532d">Assert.AreEqual(8, ...)</text>
|
||||||
|
|
||||||
|
<!-- middle: overload -->
|
||||||
|
<rect x="330" y="150" width="200" height="180" rx="10" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
<text x="344" y="176" font-size="11" font-weight="bold" fill="#0f172a">ShoppingListTotal(buyer, <tspan fill="#9333ea">λ</tspan>)</text>
|
||||||
|
<text x="344" y="196" font-size="10" fill="#475569">no-arg overload passes</text>
|
||||||
|
<text x="344" y="210" font-size="10" fill="#475569">priceList.PriceFor as method group</text>
|
||||||
|
<text x="344" y="238" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">total = 0</text>
|
||||||
|
<text x="344" y="262" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">foreach flavour:</text>
|
||||||
|
<text x="344" y="286" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">total += scoops *</text>
|
||||||
|
<text x="344" y="306" font-size="11" font-family="Menlo, Consolas, monospace" fill="#1e1b4b"><tspan fill="#9333ea" font-weight="bold">λ</tspan>(flavour)</text>
|
||||||
|
|
||||||
|
<!-- right: the extracted loop, pure -->
|
||||||
|
<rect x="570" y="150" width="170" height="180" rx="10" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
<text x="584" y="176" font-size="10" font-weight="bold" fill="#0f172a">the pricing loop</text>
|
||||||
|
<text x="584" y="196" font-size="10" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">inputs: buyer, λ</text>
|
||||||
|
<text x="584" y="220" font-size="10" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">sum of scoops *</text>
|
||||||
|
<text x="584" y="238" font-size="10" font-family="Menlo, Consolas, monospace" fill="#1e1b4b"><tspan fill="#9333ea" font-weight="bold">λ</tspan>(flavour)</text>
|
||||||
|
<text x="584" y="270" font-size="10" font-family="Menlo, Consolas, monospace" fill="#1e1b4b">return total</text>
|
||||||
|
<text x="584" y="304" font-size="10" fill="#16a34a" font-weight="bold">pure ✓</text>
|
||||||
|
|
||||||
|
<!-- green arrows -->
|
||||||
|
<line x1="284" y1="225" x2="324" y2="225" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrGr)"/>
|
||||||
|
<text x="286" y="214" font-size="10" fill="#16a34a">λ</text>
|
||||||
|
<line x1="534" y1="225" x2="564" y2="225" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrGr)"/>
|
||||||
|
|
||||||
|
<!-- PriceList, never called -->
|
||||||
|
<rect x="570" y="370" width="170" height="56" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.8"/>
|
||||||
|
<text x="655" y="392" font-size="12" font-weight="bold" fill="#94a3b8" text-anchor="middle">PriceList</text>
|
||||||
|
<text x="655" y="412" font-size="10" fill="#64748b" text-anchor="middle">never called by the test ✗</text>
|
||||||
|
<line x1="655" y1="368" x2="655" y2="336" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#arrR)"/>
|
||||||
|
<text x="665" y="356" font-size="11" font-weight="bold" fill="#dc2626">✗</text>
|
||||||
|
|
||||||
|
<text x="50" y="430" font-size="12" fill="#475569">2 flavours x 2 scoops x 2 = 8 — every run, not just a lucky roll.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
Reference in New Issue
Block a user