From 17e5d25b39452f5eadaa0d34382e1e7464d59e1e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 29 Sep 2026 16:28:20 +0000 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.md | 80 ++++++++++++++++++ SliceWithLambda.sln | 57 +++++++++++++ global.json | 6 ++ solution/Buyers/Buyer.cs | 22 +++++ solution/Buyers/Buyers.cs | 48 +++++++++++ solution/Buyers/Buyers.csproj | 9 ++ solution/Buyers/IceCream.cs | 9 ++ solution/Buyers/PriceList.cs | 27 ++++++ .../SliceWithLambda/IceCreamShopSolution.cs | 72 ++++++++++++++++ .../SliceWithLambda/MetaphorFreeSolution.cs | 52 ++++++++++++ .../SliceWithLambda/SliceWithLambda.csproj | 9 ++ .../SliceWithLambda/WeatherStationSolution.cs | 72 ++++++++++++++++ src/Buyers/Buyer.cs | 22 +++++ src/Buyers/Buyers.cs | 33 ++++++++ src/Buyers/Buyers.csproj | 9 ++ src/Buyers/IceCream.cs | 9 ++ src/Buyers/PriceList.cs | 27 ++++++ src/SliceWithLambda/IceCreamShop.cs | 79 ++++++++++++++++++ src/SliceWithLambda/MetaphorFree.cs | 54 ++++++++++++ src/SliceWithLambda/SliceWithLambda.csproj | 9 ++ src/SliceWithLambda/WeatherStation.cs | 71 ++++++++++++++++ tests/BuyersSolutionTests/BuyersTests.cs | 20 +++++ tests/BuyersSolutionTests/BuyersTests.csproj | 20 +++++ tests/BuyersTests/BuyersTests.cs | 19 +++++ tests/BuyersTests/BuyersTests.csproj | 20 +++++ .../SliceWithLambdaTests.cs | 82 +++++++++++++++++++ .../SliceWithLambdaTests.csproj | 20 +++++ 28 files changed, 961 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SliceWithLambda.sln create mode 100644 global.json create mode 100644 solution/Buyers/Buyer.cs create mode 100644 solution/Buyers/Buyers.cs create mode 100644 solution/Buyers/Buyers.csproj create mode 100644 solution/Buyers/IceCream.cs create mode 100644 solution/Buyers/PriceList.cs create mode 100644 solution/SliceWithLambda/IceCreamShopSolution.cs create mode 100644 solution/SliceWithLambda/MetaphorFreeSolution.cs create mode 100644 solution/SliceWithLambda/SliceWithLambda.csproj create mode 100644 solution/SliceWithLambda/WeatherStationSolution.cs create mode 100644 src/Buyers/Buyer.cs create mode 100644 src/Buyers/Buyers.cs create mode 100644 src/Buyers/Buyers.csproj create mode 100644 src/Buyers/IceCream.cs create mode 100644 src/Buyers/PriceList.cs create mode 100644 src/SliceWithLambda/IceCreamShop.cs create mode 100644 src/SliceWithLambda/MetaphorFree.cs create mode 100644 src/SliceWithLambda/SliceWithLambda.csproj create mode 100644 src/SliceWithLambda/WeatherStation.cs create mode 100644 tests/BuyersSolutionTests/BuyersTests.cs create mode 100644 tests/BuyersSolutionTests/BuyersTests.csproj create mode 100644 tests/BuyersTests/BuyersTests.cs create mode 100644 tests/BuyersTests/BuyersTests.csproj create mode 100644 tests/SliceWithLambdaTests/SliceWithLambdaTests.cs create mode 100644 tests/SliceWithLambdaTests/SliceWithLambdaTests.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e829bc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c775ac7 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Slice with Lambda — C# exercises + +Exercises for the **Slice with Lambda** learning hour (C# variant). + +- Condensed mechanics: + (where that page says mock, we use a lambda instead). +- Full Java walkthrough: `../../ideas/slice-with-lambda/storyboard/storyboard.md`. + +Open `SliceWithLambda.sln` in Rider (or run `dotnet test` — see below). +Requires the .NET 10 SDK (pinned by `global.json`). + +## What's in the box + +| folder | what | start state | +|---|---|---| +| `src/SliceWithLambda/` | three demo examples (pristine) | the refactoring **not** done yet | +| `src/Buyers/` | the pair exercise (pristine) | one loop, one lambda-able expression | +| `solution/` | reference end states (facilitator + fast pairs) | mechanics done, tests stub through the seam | +| `tests/` | test projects | see the red/green table | + +## The three demo examples (`src/SliceWithLambda/`) + +| file | guard (client state) | unwanted dependency | lambda | tail | +|---|---|---|---|---| +| `MetaphorFree.cs` | `if (a < 0) return 0;` | `_someDependency.mightThrowWhenPreconditionNotSatisfied(a)` | `Func readB = a => ...` | `int c = a + b; return c * 2;` | +| `WeatherStation.cs` | `if (_wateredToday)` | `_lightSensor.Read()` (byte) | `Func readLight = () => ...` | sunny -> `WateringDuration.Medium`, shady -> `Short` (reading > 128 => `SunExposure.Sunny`) | +| `IceCreamShop.cs` | `if (_thawedToday)` | `_thermometer.Read()` (byte) | `Func readTemperature = () => ...` | warm -> `ThawAmount.Medium`, cold -> `Low` (temperature > 25 => `Warmth.Warm`) | + +In all three, the guard reads client state (a parameter or a flag the client +owns), so it stays in the extracted method. Only the dependency call moves +into the lambda. + +`MetaphorFree.cs` is the smallest possible shape: no loop, one dependency +call, a tail of one arithmetic step. It carries the capture-to-parameter +lesson: the lambda takes `a` as its parameter because a lambda moved up into +the function head cannot capture method parameters. `WeatherStation.cs` and +`IceCreamShop.cs` are same-shape domain repetitions, so participants can +practice extracting the lambda twice — the C# delegate syntax (`Func`) +is worth the repetition. + +## The pair exercise (`src/Buyers/`) + +`Buyers.ShoppingListTotal(buyerName)` computes a buyer's shopping-list +total: one loop, and exactly **one** lambda-able expression — +`priceList.PriceFor(flavour)`. `PriceList` adds random surge pricing, so the +test in `tests/BuyersTests/` is red or flaky by design: it stands for the +external pricing service. + +Do the mechanics on the whole method: + +1. Extract the dependency-using code into a lambda and slide it to the top + (`Func priceFor = flavour => priceList.PriceFor(flavour);` + — the invocation in the loop stays put). +2. Extract the loop into a method taking the lambda as a parameter. +3. Keep a no-arg overload for callers (pass the method group + `priceList.PriceFor` as the "real" pricing function) and make the test + pass with a pure lambda (`_ => 2`): 2 flavours x 2 scoops x 2 = 8. + +Reference end state: `solution/Buyers/` (green test in +`tests/BuyersSolutionTests/`). + +## Tests: red/green map + +| test project | against | expected at start | after the refactoring | +|---|---|---|---| +| `tests/BuyersTests/` | `src/Buyers/` | red or **flaky** (surge pricing) — that is the point | green once you stub with a pure lambda | +| `tests/BuyersSolutionTests/` | `solution/Buyers/` | green | — | +| `tests/SliceWithLambdaTests/` | `solution/SliceWithLambda/` | green | — (the demo end states, with pure-lambda stubs, incl. a throwing stub) | + +## Run + +```sh +dotnet test # everything +dotnet test tests/BuyersTests # the pair exercise only +``` + +## Next move (debrief) + +Add `DiscountFor(IceCream)` to `PriceList` and feel why one lambda is no +longer enough — extract a role interface (`IPricing`) instead of two lambdas. diff --git a/SliceWithLambda.sln b/SliceWithLambda.sln new file mode 100644 index 0000000..5feb1a6 --- /dev/null +++ b/SliceWithLambda.sln @@ -0,0 +1,57 @@ +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}") = "SliceWithLambda", "src\SliceWithLambda\SliceWithLambda.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Buyers", "src\Buyers\Buyers.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SliceWithLambdaSolution", "solution\SliceWithLambda\SliceWithLambda.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuyersSolution", "solution\Buyers\Buyers.csproj", "{D4E5F6A7-B8C9-0123-DEFA-234567890123}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SliceWithLambdaTests", "tests\SliceWithLambdaTests\SliceWithLambdaTests.csproj", "{E5F6A7B8-C9D0-1234-DEFA-234567890123}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuyersTests", "tests\BuyersTests\BuyersTests.csproj", "{F6A7B8C9-D0E1-2345-EFAB-345678901234}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuyersSolutionTests", "tests\BuyersSolutionTests\BuyersTests.csproj", "{A7B8C9D0-E1F2-3456-FABC-456789012345}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEFA-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-DEFA-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-DEFA-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-DEFA-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-DEFA-234567890123}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2345-EFAB-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2345-EFAB-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2345-EFAB-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2345-EFAB-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B8C9D0-E1F2-3456-FABC-456789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B8C9D0-E1F2-3456-FABC-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B8C9D0-E1F2-3456-FABC-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B8C9D0-E1F2-3456-FABC-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 0000000..01cb658 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature" + } +} diff --git a/solution/Buyers/Buyer.cs b/solution/Buyers/Buyer.cs new file mode 100644 index 0000000..752b770 --- /dev/null +++ b/solution/Buyers/Buyer.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace IceCreamBuyers +{ + public class Buyer + { + public Buyer(string name, IReadOnlyList favourites) + { + Name = name; + Favourites = favourites; + } + + public string Name { get; } + public IReadOnlyList Favourites { get; } + + public int ScoopsOf(IceCream flavour) + { + return 2; + } + } +} diff --git a/solution/Buyers/Buyers.cs b/solution/Buyers/Buyers.cs new file mode 100644 index 0000000..98da9b6 --- /dev/null +++ b/solution/Buyers/Buyers.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace IceCreamBuyers +{ + /// + /// Reference end state of exercise 3 (extract lambda, slide, extract method + /// with a function parameter — the pure rep of Slice with Lambda). + /// + public class Buyers + { + private readonly PriceList priceList = new(); + private readonly List buyers = new() + { + new("Anke", new List { IceCream.Strawberry, IceCream.Vanilla }), + new("Bram", new List { IceCream.Chocolate }), + new("Celine", new List { IceCream.Vanilla, IceCream.Chocolate }), + }; + + /// + /// No-arg overload: callers unchanged, the "real" pricing function baked in. + /// + public int ShoppingListTotal(string buyerName) + { + return ShoppingListTotal(ByName(buyerName), priceList.PriceFor); + } + + /// + /// The seam: the pricing source is injected as a function. + /// Testable: pass any pure function instead of calling the pricing service. + /// + public int ShoppingListTotal(Buyer buyer, Func 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}."); + } + } +} diff --git a/solution/Buyers/Buyers.csproj b/solution/Buyers/Buyers.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/solution/Buyers/Buyers.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/solution/Buyers/IceCream.cs b/solution/Buyers/IceCream.cs new file mode 100644 index 0000000..16c45ae --- /dev/null +++ b/solution/Buyers/IceCream.cs @@ -0,0 +1,9 @@ +namespace IceCreamBuyers +{ + public enum IceCream + { + Strawberry, + Chocolate, + Vanilla + } +} diff --git a/solution/Buyers/PriceList.cs b/solution/Buyers/PriceList.cs new file mode 100644 index 0000000..6240fa6 --- /dev/null +++ b/solution/Buyers/PriceList.cs @@ -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 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); + } + } +} diff --git a/solution/SliceWithLambda/IceCreamShopSolution.cs b/solution/SliceWithLambda/IceCreamShopSolution.cs new file mode 100644 index 0000000..ecea977 --- /dev/null +++ b/solution/SliceWithLambda/IceCreamShopSolution.cs @@ -0,0 +1,72 @@ +// Example 3 solution — Ice cream shop demo of Slice with Lambda (C#), after +// +// Lambda extracted (step 1), slid to the top (step 2), method extracted +// below the guard (step 3). The constructor now takes the lambda directly, +// so tests construct with new IceCreamShop(() => 30). +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). + +using System; + +public enum Warmth +{ + Cold, + Warm +} + +public enum ThawAmount +{ + None, + Medium, + Low +} + +public class Thermometer +{ + private readonly Random _rng = new Random(); // stand-in for the hardware + + public byte Read() + { + return (byte)_rng.Next(50); // raw sensor value + } +} + +public class IceCreamShop +{ + private readonly Func _readTemperature; // the thermometer, as a lambda + private bool _thawedToday; // event-style client state + + public IceCreamShop(Func readTemperature) + { + _readTemperature = readTemperature; + } + + public ThawAmount ThawStock() + { + Func readTemperature = () => _readTemperature(); // lambda at the top + return ThawStockTail(readTemperature); // pass the lambda on + } + + private ThawAmount ThawStockTail(Func readTemperature) + { + if (_thawedToday) // guard: client state, stays + { + return ThawAmount.None; + } + + _thawedToday = true; // set only after thawing + byte temperature = readTemperature(); // middle: the stubbed lambda + Warmth warmth = ToWarmth(temperature); + if (warmth == Warmth.Warm) // tail: complex behaviour below + { // the extraction point + return ThawAmount.Medium; + } + return ThawAmount.Low; + } + + private static Warmth ToWarmth(byte temperature) + { + return temperature > 25 ? Warmth.Warm : Warmth.Cold; + } +} \ No newline at end of file diff --git a/solution/SliceWithLambda/MetaphorFreeSolution.cs b/solution/SliceWithLambda/MetaphorFreeSolution.cs new file mode 100644 index 0000000..4eb6578 --- /dev/null +++ b/solution/SliceWithLambda/MetaphorFreeSolution.cs @@ -0,0 +1,52 @@ +// Example 1 solution — Metaphor-free demo of Slice with Lambda (C#), after +// +// Lambda extracted (step 1), slid to the top (step 2), method extracted +// below the guard (step 3), overload seam added (step 3b), b inlined +// (final cleanup). +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). + +using System; + +public class Calculator +{ + private readonly SomeDependency _someDependency; + + public Calculator(SomeDependency someDependency) + { + _someDependency = someDependency; + } + + public int Calculate(int a) // overload: callers unchanged + { + return Calculate(a, a => + _someDependency.mightThrowWhenPreconditionNotSatisfied(a)); + } // the real λ, baked into the head + + public int Calculate(int a, Func readB) // the seam: λ is a parameter + { + if (a < 0) return 0; // guard clause: stays + return CalculateTail(a, readB); + } + + private int CalculateTail(int a, Func readB) + { + int c = a + readB(a); // b inlined + return c * 2; + } +} + +public class SomeDependency +{ + private readonly Random _rng = new Random(); // stand-in for unpredictable computation + + public int mightThrowWhenPreconditionNotSatisfied(int a) + { + if (a > 99) + { + throw new InvalidOperationException("precondition not satisfied"); + } + return _rng.Next(100); + } +} \ No newline at end of file diff --git a/solution/SliceWithLambda/SliceWithLambda.csproj b/solution/SliceWithLambda/SliceWithLambda.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/solution/SliceWithLambda/SliceWithLambda.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/solution/SliceWithLambda/WeatherStationSolution.cs b/solution/SliceWithLambda/WeatherStationSolution.cs new file mode 100644 index 0000000..6e50bc0 --- /dev/null +++ b/solution/SliceWithLambda/WeatherStationSolution.cs @@ -0,0 +1,72 @@ +// Example 2 solution — Weather Station domain demo of Slice with Lambda (C#), after +// +// Lambda extracted (step 1), slid to the top (step 2), method extracted +// below the guard (step 3). The constructor now takes the lambda directly, +// so tests construct with new SprinklerController(() => 200). +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). + +using System; + +public enum SunExposure +{ + Shady, + Sunny +} + +public enum WateringDuration +{ + None, + Medium, + Short +} + +public class LightSensor +{ + private readonly Random _rng = new Random(); // stand-in for hardware noise + + public byte Read() + { + return (byte)_rng.Next(256); + } +} + +public class SprinklerController +{ + private readonly Func _readLight; // the sensor, as a lambda + private bool _wateredToday; // event-style client state + + public SprinklerController(Func readLight) + { + _readLight = readLight; + } + + public WateringDuration ScheduleWatering() + { + Func readLight = () => _readLight(); // lambda definition at the top + return ScheduleWateringTail(readLight); // pass the lambda on + } + + private WateringDuration ScheduleWateringTail(Func readLight) + { + if (_wateredToday) // guard: client state, stays + { + return WateringDuration.None; + } + + _wateredToday = true; // set only after watering + byte reading = readLight(); // middle: the stubbed lambda + SunExposure exposure = ToSunExposure(reading); + if (exposure == SunExposure.Sunny) // tail: complex behaviour below + { // the extraction point + return WateringDuration.Medium; + } + return WateringDuration.Short; + } + + private static SunExposure ToSunExposure(byte reading) + { + return reading > 128 ? SunExposure.Sunny : SunExposure.Shady; + } +} \ No newline at end of file diff --git a/src/Buyers/Buyer.cs b/src/Buyers/Buyer.cs new file mode 100644 index 0000000..752b770 --- /dev/null +++ b/src/Buyers/Buyer.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace IceCreamBuyers +{ + public class Buyer + { + public Buyer(string name, IReadOnlyList favourites) + { + Name = name; + Favourites = favourites; + } + + public string Name { get; } + public IReadOnlyList Favourites { get; } + + public int ScoopsOf(IceCream flavour) + { + return 2; + } + } +} diff --git a/src/Buyers/Buyers.cs b/src/Buyers/Buyers.cs new file mode 100644 index 0000000..c8acf4e --- /dev/null +++ b/src/Buyers/Buyers.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace IceCreamBuyers +{ + public class Buyers + { + private readonly PriceList priceList = new(); + private readonly List buyers = new() + { + new("Anke", new List { IceCream.Strawberry, IceCream.Vanilla }), + new("Bram", new List { IceCream.Chocolate }), + new("Celine", new List { 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}."); + } + } +} diff --git a/src/Buyers/Buyers.csproj b/src/Buyers/Buyers.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/src/Buyers/Buyers.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Buyers/IceCream.cs b/src/Buyers/IceCream.cs new file mode 100644 index 0000000..16c45ae --- /dev/null +++ b/src/Buyers/IceCream.cs @@ -0,0 +1,9 @@ +namespace IceCreamBuyers +{ + public enum IceCream + { + Strawberry, + Chocolate, + Vanilla + } +} diff --git a/src/Buyers/PriceList.cs b/src/Buyers/PriceList.cs new file mode 100644 index 0000000..6240fa6 --- /dev/null +++ b/src/Buyers/PriceList.cs @@ -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 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); + } + } +} diff --git a/src/SliceWithLambda/IceCreamShop.cs b/src/SliceWithLambda/IceCreamShop.cs new file mode 100644 index 0000000..d5da947 --- /dev/null +++ b/src/SliceWithLambda/IceCreamShop.cs @@ -0,0 +1,79 @@ +// Example 3 — Ice cream shop demo of Slice with Lambda (C#) +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). +// +// Third repetition, same complexity as ScheduleWatering in WeatherStation.cs +// (guard -> one dependency call -> map to enum -> constant tail), so +// participants can practice extracting a lambda a second time. The C# +// delegate syntax is worth the repetition: +// +// Func readTemperature = () => _thermometer.Read(); +// +// Cast: +// Thermometer — hardware-ish dependency. Read() returns a raw +// byte value (not calibrated, the client decides +// what it means). +// Warmth — enum. Reading > 25 => Warm, otherwise Cold. +// (Sunny/Shady's cousin from WeatherStation.cs.) +// ThawAmount — enum. None / Medium / Low. +// IceCreamShop — the client. Owns the _thawedToday flag, so the +// guard is about client state, not sensor state. + +using System; + +public enum Warmth +{ + Cold, + Warm +} + +public enum ThawAmount +{ + None, + Medium, + Low +} + +public class Thermometer +{ + private readonly Random _rng = new Random(); // stand-in for the hardware + + public byte Read() + { + return (byte)_rng.Next(50); // raw sensor value + } +} + +public class IceCreamShop +{ + private readonly Thermometer _thermometer; + private bool _thawedToday; // event-style client state + + public IceCreamShop(Thermometer thermometer) + { + _thermometer = thermometer; + } + + public ThawAmount ThawStock() + { + if (_thawedToday) // guard: client state, stays + { + return ThawAmount.None; + } + + _thawedToday = true; // set only after thawing + byte temperature = _thermometer.Read(); // middle: unwanted dependency + Warmth warmth = ToWarmth(temperature); + if (warmth == Warmth.Warm) // tail: complex behaviour below + { // the extraction point + return ThawAmount.Medium; + } + return ThawAmount.Low; + } + + private static Warmth ToWarmth(byte temperature) + { + return temperature > 25 ? Warmth.Warm : Warmth.Cold; + } +} diff --git a/src/SliceWithLambda/MetaphorFree.cs b/src/SliceWithLambda/MetaphorFree.cs new file mode 100644 index 0000000..73820d4 --- /dev/null +++ b/src/SliceWithLambda/MetaphorFree.cs @@ -0,0 +1,54 @@ +// Example 1 — Metaphor-free demo of Slice with Lambda (C#) +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). +// +// Shape of the method (per zeroth-draft-of-refactoring-pattern.md): +// +// guard clauses — must run first +// middle: dependency — one call on an object we don't want in the +// extracted method +// tail: depends on b — uses the value the dependency produced +// +// Deliberately NOT a loop: this is the smallest method that shows the +// mechanics. The dependency both validates its precondition (using the +// parameter a) and produces b — one expression, one thing to move into +// the lambda. +// +// Unwanted dependency: SomeDependency — might throw when its precondition +// on a is not satisfied, and returns an unpredictable value. An approval +// test on Calculate is flaky until the lambda is stubbed. + +using System; + +public class Calculator +{ + private readonly SomeDependency _someDependency; + + public Calculator(SomeDependency someDependency) + { + _someDependency = someDependency; + } + + public int Calculate(int a) + { + if (a < 0) return 0; // guard clause: stays + int b = _someDependency.mightThrowWhenPreconditionNotSatisfied(a); // middle: unwanted dependency + int c = a + b; // tail: depends on b + return c * 2; + } +} + +public class SomeDependency +{ + private readonly Random _rng = new Random(); // stand-in for unpredictable computation + + public int mightThrowWhenPreconditionNotSatisfied(int a) + { + if (a > 99) + { + throw new InvalidOperationException("precondition not satisfied"); + } + return _rng.Next(100); + } +} diff --git a/src/SliceWithLambda/SliceWithLambda.csproj b/src/SliceWithLambda/SliceWithLambda.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/src/SliceWithLambda/SliceWithLambda.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/SliceWithLambda/WeatherStation.cs b/src/SliceWithLambda/WeatherStation.cs new file mode 100644 index 0000000..d374214 --- /dev/null +++ b/src/SliceWithLambda/WeatherStation.cs @@ -0,0 +1,71 @@ +// Example 2 — Weather Station domain demo of Slice with Lambda (C#) +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). +// +// Cast (Head First Design Patterns Weather Station, reduced): +// LightSensor — hardware-ish dependency. Read() returns a byte. +// SunExposure — enum. Reading > 128 => Sunny, otherwise Shady. +// WateringDuration — enum. None / Medium / Short. +// (Long is YAGNI until the for-loop version of this +// exercise exists.) +// SprinklerController — the client. Owns the _wateredToday flag, so the +// guard is about client state, not sensor state. + +using System; + +public enum SunExposure +{ + Shady, + Sunny +} + +public enum WateringDuration +{ + None, + Medium, + Short +} + +public class LightSensor +{ + private readonly Random _rng = new Random(); // stand-in for hardware noise + + public byte Read() + { + return (byte)_rng.Next(256); + } +} + +public class SprinklerController +{ + private readonly LightSensor _lightSensor; + private bool _wateredToday; // event-style client state + + public SprinklerController(LightSensor lightSensor) + { + _lightSensor = lightSensor; + } + + public WateringDuration ScheduleWatering() + { + if (_wateredToday) // guard: client state, stays + { + return WateringDuration.None; + } + + _wateredToday = true; // set only after watering + byte reading = _lightSensor.Read(); // middle: unwanted dependency + SunExposure exposure = ToSunExposure(reading); + if (exposure == SunExposure.Sunny) // tail: complex behaviour below + { // the extraction point + return WateringDuration.Medium; + } + return WateringDuration.Short; + } + + private static SunExposure ToSunExposure(byte reading) + { + return reading > 128 ? SunExposure.Sunny : SunExposure.Shady; + } +} diff --git a/tests/BuyersSolutionTests/BuyersTests.cs b/tests/BuyersSolutionTests/BuyersTests.cs new file mode 100644 index 0000000..284b366 --- /dev/null +++ b/tests/BuyersSolutionTests/BuyersTests.cs @@ -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.Strawberry, IceCream.Vanilla }); + Assert.AreEqual(8, buyers.ShoppingListTotal(anke, _ => 2)); + } + } +} diff --git a/tests/BuyersSolutionTests/BuyersTests.csproj b/tests/BuyersSolutionTests/BuyersTests.csproj new file mode 100644 index 0000000..7d21aa2 --- /dev/null +++ b/tests/BuyersSolutionTests/BuyersTests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/BuyersTests/BuyersTests.cs b/tests/BuyersTests/BuyersTests.cs new file mode 100644 index 0000000..5f1b386 --- /dev/null +++ b/tests/BuyersTests/BuyersTests.cs @@ -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")); + } + } +} diff --git a/tests/BuyersTests/BuyersTests.csproj b/tests/BuyersTests/BuyersTests.csproj new file mode 100644 index 0000000..bf01d8e --- /dev/null +++ b/tests/BuyersTests/BuyersTests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/tests/SliceWithLambdaTests/SliceWithLambdaTests.cs b/tests/SliceWithLambdaTests/SliceWithLambdaTests.cs new file mode 100644 index 0000000..271e0ac --- /dev/null +++ b/tests/SliceWithLambdaTests/SliceWithLambdaTests.cs @@ -0,0 +1,82 @@ +// Tests for the three Slice-with-Lambda solutions — step 4: the lambda as a stub. +// +// Condensed mechanics: https://sammancoaching.org/refactorings/slice.html +// (where that page says mock, we use a lambda instead). + +using System; +using Xunit; + +public class SliceWithLambdaTests +{ + // --- Example 1: metaphor-free (Calculator) --- + + [Fact] + public void Guards_against_negative_input() + { + var calc = new Calculator(new SomeDependency()); // the real, flaky one... + Assert.Equal(0, calc.Calculate(-1, a => 42)); // ...never asked: stub not invoked + } + + [Fact] + public void Throws_when_precondition_not_satisfied() + { + var calc = new Calculator(new SomeDependency()); + Assert.Throws( + () => calc.Calculate(7, a => throw new InvalidOperationException())); + } + + [Fact] + public void Combines_input_and_the_stubbed_value() + { + var calc = new Calculator(new SomeDependency()); + Assert.Equal(84, calc.Calculate(0, a => 42)); // stub through the seam + } + + // --- Example 2: Weather Station (SprinklerController) --- + + [Fact] + public void Skips_watering_when_already_watered() + { + var controller = new SprinklerController(() => 200); // pure lambda: no sensor + controller.ScheduleWatering(); // water once... + Assert.Equal(WateringDuration.None, controller.ScheduleWatering()); + } + + [Fact] + public void Waters_longer_in_full_sun() + { + var controller = new SprinklerController(() => 200); // sunny: > 128 + Assert.Equal(WateringDuration.Medium, controller.ScheduleWatering()); + } + + [Fact] + public void Waters_less_in_the_shade() + { + var controller = new SprinklerController(() => 10); // shady: ≤ 128 + Assert.Equal(WateringDuration.Short, controller.ScheduleWatering()); + } + + // --- Example 3: Ice Cream Shop (IceCreamShop) --- + + [Fact] + public void Skips_thawing_when_already_thawed() + { + var shop = new IceCreamShop(() => 30); // pure lambda: no freezer + shop.ThawStock(); // thaw once... + Assert.Equal(ThawAmount.None, shop.ThawStock()); + } + + [Fact] + public void Thaws_more_when_warm() + { + var shop = new IceCreamShop(() => 30); // warm: > 25 + Assert.Equal(ThawAmount.Medium, shop.ThawStock()); + } + + [Fact] + public void Thaws_less_when_cold() + { + var shop = new IceCreamShop(() => 5); // cold: ≤ 25 + Assert.Equal(ThawAmount.Low, shop.ThawStock()); + } +} \ No newline at end of file diff --git a/tests/SliceWithLambdaTests/SliceWithLambdaTests.csproj b/tests/SliceWithLambdaTests/SliceWithLambdaTests.csproj new file mode 100644 index 0000000..4b5e492 --- /dev/null +++ b/tests/SliceWithLambdaTests/SliceWithLambdaTests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + +