Add split-loop-with-lambda learning hour: draft, pre-factored exercise, solution
- Samman learning hour: 5/5/10/25/5 outline; pairs stop at extracted method taking the lambda as a parameter - exercise/java: pre-factored variant of the kata (Q1/Q4 dead branch stripped, dates as constant), approval test red/flaky by design - exercise/solution: collectExpectedWeather(LongFunction<Boolean>) + no-arg overload; deterministic approval test (all-sunny lambda) - rehearse-with-agent.md: rehearsal setup with qwen + java-refactoring-mcp
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Exercise folder — Split Loop with Lambda (learning hour)
|
||||
|
||||
Contents:
|
||||
|
||||
- `java/` — pre-factored exercise repo (prefactored-longrange; runs with `mvn test`)
|
||||
- `solution/` — reference end state after step 3 (extracted method taking the lambda)
|
||||
- `rehearse-with-agent.md` — how Willem rehearses this with qwen-code + java-refactoring-mcp
|
||||
|
||||
## Reference solution
|
||||
|
||||
See `solution/`. End state of the production code (simplified):
|
||||
|
||||
```java
|
||||
public Map<IceCream, Integer> longRangeForecast() {
|
||||
return longRangeForecast(daysForward -> {
|
||||
scorer.updateSelection();
|
||||
return scorer.lookupWeather(daysForward);
|
||||
});
|
||||
}
|
||||
|
||||
public Map<IceCream, Integer> longRangeForecast(
|
||||
LongFunction<Boolean> dayOffsetToWeather) {
|
||||
var expectedWeather = collectExpectedWeather(dayOffsetToWeather);
|
||||
// ... reset loop + scoring loop, unchanged ...
|
||||
}
|
||||
|
||||
private static List<Boolean> collectExpectedWeather(
|
||||
LongFunction<Boolean> dayOffsetToWeather) {
|
||||
var expectedWeather = new ArrayList<Boolean>();
|
||||
for (Instant interestingDate : DATES) {
|
||||
long daysForward = ChronoUnit.DAYS.between(ANCHOR_DATE, interestingDate);
|
||||
expectedWeather.add(dayOffsetToWeather.apply(daysForward));
|
||||
}
|
||||
return expectedWeather;
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the end state:
|
||||
|
||||
- Key move: `scorer` is used **only inside the lambda**; the extracted method
|
||||
`collectExpectedWeather` has no dependency on `Scorer` at all — all its
|
||||
inputs are parameters.
|
||||
- The public no-arg overload keeps callers unchanged; it injects the
|
||||
"real" weather lambda. The test calls the overload with a pure
|
||||
`daysForward -> true` instead.
|
||||
- The test is pure and glanceable: `alwaysSunny` weather gives
|
||||
`{Strawberry:12, Chocolate:12, Vanilla:17, }` (approved file included).
|
||||
- `scorer.updateSelection()` turned out to be deletable (it only mutates
|
||||
static `Scorer.flavour` state that this method never reads). Leaving it
|
||||
in the lambda is fine for step 3; deleting it is a debrief aha.
|
||||
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
*.received.txt
|
||||
*.approved.txt
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -0,0 +1,37 @@
|
||||
# IceCreamForecasting — Split Loop with Lambda (exercise)
|
||||
|
||||
Pre-factored variant of [IceCreamForecasting-Refactoring-Kata](https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata):
|
||||
the Q1/Q4 dead branch is stripped and the hardcoded dates are a constant now.
|
||||
Everything else — including the noise in the scoring loop — is untouched.
|
||||
|
||||
## Task
|
||||
|
||||
`longRangeForecast()` is hard to test: it calls `scorer`, and `Scorer`
|
||||
stands for an external weather service you must not call from a unit test.
|
||||
The rest of the method's inputs are only `DATES` and `ANCHOR_DATE`.
|
||||
|
||||
Get it under test:
|
||||
|
||||
1. First get any test running that calls `longRangeForecast()` —
|
||||
it will fail or be flaky, because of the dependency on `Scorer`.
|
||||
2. Then use the **Split Loop with Lambda** mechanics (6 steps, on the handout)
|
||||
so that the weather collection becomes an extracted method that takes the
|
||||
weather-reading as a **lambda parameter**.
|
||||
3. Stop when you have an extracted method that takes the lambda as a parameter,
|
||||
and a test that passes **without** touching `Scorer`.
|
||||
|
||||
## Constraint
|
||||
|
||||
Don't change `Scorer` and don't change the *behaviour* of
|
||||
`longRangeForecast()`. Compile early, compile often.
|
||||
|
||||
## Debrief questions
|
||||
|
||||
- At which step did plain **Split Loop** stop working, and why?
|
||||
(Hint: what type did your introduced variable end up being?)
|
||||
- There are **two** calls to `scorer` in the weather loop.
|
||||
What did you do with each one?
|
||||
- Could you delete either call without changing the forecast?
|
||||
Try it. Why did that work?
|
||||
- Where would you take this next — more extraction, or moving the method
|
||||
to another class where `Scorer` is unknown?
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>codingdojo</groupId>
|
||||
<artifactId>icecream-split-loop-lambda-exercise</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.approvaltests</groupId>
|
||||
<artifactId>approvaltests</artifactId>
|
||||
<version>18.6.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,7 @@
|
||||
package codingdojo;
|
||||
|
||||
public enum IceCream {
|
||||
Strawberry,
|
||||
Chocolate,
|
||||
Vanilla;
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package codingdojo;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Pre-factored variant of LongRangeForecasts from
|
||||
* https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata
|
||||
*
|
||||
* Differences from the kata original (deliberate, for the learning hour):
|
||||
* - The Q1/Q4 branch was removed. It was dead code anyway: the unconditional
|
||||
* loop right after it overwrites every value it sets. (Splitting loops
|
||||
* sometimes reveals things like this.)
|
||||
* - The hardcoded list of interesting dates is now a constant, DATES, and the
|
||||
* anchor instant is ANCHOR_DATE. (Turning the dates into a parameter is a
|
||||
* *later* refactoring — not part of this exercise.)
|
||||
*
|
||||
* Your task: see README.md
|
||||
*/
|
||||
public class LongRangeForecasts {
|
||||
|
||||
private static final Instant ANCHOR_DATE = Instant.parse("2023-04-26T14:00:00.000-07:00");
|
||||
private static final List<Instant> DATES = List.of(
|
||||
Instant.parse("2023-05-01T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-05-18T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-06-05T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-06-23T14:00:00.000-07:00")
|
||||
);
|
||||
|
||||
private final Scorer scorer = new Scorer();
|
||||
|
||||
public Map<IceCream, Integer> longRangeForecast() {
|
||||
|
||||
var result = new HashMap<IceCream, Integer>();
|
||||
for (IceCream flavour : IceCream.values()) {
|
||||
result.put(flavour, 0);
|
||||
}
|
||||
List<Boolean> expectedWeather = new ArrayList<>();
|
||||
for (Instant interestingDate : DATES) {
|
||||
scorer.updateSelection();
|
||||
long daysForward = ChronoUnit.DAYS.between(ANCHOR_DATE, interestingDate);
|
||||
Boolean weather = scorer.lookupWeather(daysForward);
|
||||
expectedWeather.add(weather);
|
||||
}
|
||||
|
||||
for (IceCream flavour : result.keySet()) {
|
||||
result.put(flavour, result.get(flavour) + 10);
|
||||
var sunnyHolidays = expectedWeather.stream().filter(s -> s).count();
|
||||
if (sunnyHolidays > 2 && flavour == IceCream.Vanilla) {
|
||||
result.put(flavour, result.get(flavour) + 5);
|
||||
}
|
||||
if (sunnyHolidays > 1 && expectedWeather.get(1)) {
|
||||
result.put(flavour, result.get(flavour) + 2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package codingdojo;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Trimmed version of the kata's Scorer: only the parts LongRangeForecasts uses.
|
||||
* lookupWeather stands in for an API call to a weather service — in the real
|
||||
* system this is the "unwanted dependency".
|
||||
*/
|
||||
public class Scorer {
|
||||
|
||||
public static IceCream flavour = null;
|
||||
|
||||
public int getScore() {
|
||||
boolean sunnyToday = lookupWeather();
|
||||
if (flavour == null) {
|
||||
return -1;
|
||||
}
|
||||
switch (flavour) {
|
||||
case Strawberry:
|
||||
return sunnyToday ? 10 : 5;
|
||||
case Chocolate:
|
||||
return 6;
|
||||
case Vanilla:
|
||||
return sunnyToday ? 7 : 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean lookupWeather() {
|
||||
return new Random().nextBoolean();
|
||||
}
|
||||
|
||||
public boolean lookupWeather(long daysForward) {
|
||||
// placeholder implementation - real version would make API call to weather service
|
||||
return new Random().nextBoolean();
|
||||
}
|
||||
|
||||
public void updateSelection() {
|
||||
// placeholder implementation - real version would use machine learning to predict sales
|
||||
var score = getScore();
|
||||
if (score > 5) {
|
||||
var randomizer = new Random();
|
||||
IceCream[] allFlavours = IceCream.values();
|
||||
flavour = allFlavours[randomizer.nextInt(allFlavours.length)];
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package codingdojo;
|
||||
|
||||
import org.approvaltests.Approvals;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
public class LongRangeForecastsTest {
|
||||
|
||||
@Test
|
||||
void longRangeForecast() {
|
||||
var actual = new LongRangeForecasts().longRangeForecast();
|
||||
Approvals.verify(printIceCreamForecast(actual));
|
||||
}
|
||||
|
||||
private static String printIceCreamForecast(Map<IceCream, Integer> actual) {
|
||||
var printedResult = new StringBuilder();
|
||||
printedResult.append("{");
|
||||
var sortedKeys = new ArrayList<>(actual.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
for (IceCream key : sortedKeys) {
|
||||
printedResult.append(key);
|
||||
printedResult.append(":");
|
||||
printedResult.append(actual.get(key));
|
||||
printedResult.append(", ");
|
||||
}
|
||||
printedResult.append("}");
|
||||
return printedResult.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Rehearsing the exercise with a coding agent + java-refactoring-mcp
|
||||
|
||||
How Willem can rehearse the exercise (and test whether an agent can do it)
|
||||
using [java-refactoring-mcp](https://github.com/pfichtner/java-refactoring-mcp)'s
|
||||
CLI as the semantic refactoring backend. One-time setup, then a repeatable
|
||||
session script.
|
||||
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
# 1. Build the tool (needs Java 21 + Maven 3.8+)
|
||||
git clone https://github.com/pfichtner/java-refactoring-mcp
|
||||
cd java-refactoring-mcp && mvn package -DskipTests
|
||||
# → refactor-mcp/target/refactor-mcp-*-fat.jar
|
||||
|
||||
# 2. Install the CLI wrapper (see that repo's README, "CLI (optional)"):
|
||||
# gives you the `java-refactor` command.
|
||||
|
||||
# 3. Sanctioned dirs for the MCP server (it refuses paths outside this list)
|
||||
# — check the repo docs for the exact env var / flag.
|
||||
|
||||
# 4. Copy the exercise (NOT the solution folder) to a scratch workspace
|
||||
cp -r exercise/java ~/scratch/prefactored-longrange
|
||||
```
|
||||
|
||||
## Session script
|
||||
|
||||
Run the agent in `~/scratch/prefactored-longrange` with a prompt like:
|
||||
|
||||
> You may only change code by invoking `java-refactor` CLI commands
|
||||
> (`java-refactor --help` lists them). Never edit `.java` files with text
|
||||
> edits, except lines marked by the CLI as needing manual work.
|
||||
> After each refactoring, run `mvn -q test` (it may fail — that is expected;
|
||||
> the dependency on `Scorer` makes the method untestable — but it must
|
||||
> still *compile*).
|
||||
>
|
||||
> Goal: in `LongRangeForecasts.longRangeForecast()`, the loop over `DATES`
|
||||
> collects weather by calling `scorer` (an unwanted external dependency).
|
||||
>
|
||||
> 1. Introduce a lambda for the scorer-dependent code and move its
|
||||
> definition to the top of the method, leaving the invocation where it is.
|
||||
> (No tool supports this — do it as a minimal manual edit.)
|
||||
> 2. Extract the weather-collection loop into a new method
|
||||
> `collectExpectedWeather` that takes the lambda as a parameter
|
||||
> (suggested type `LongFunction<Boolean>`).
|
||||
> 3. Stop. Report the final diff.
|
||||
|
||||
Then compare the agent's end state against `exercise/solution/` (or diff
|
||||
against it directly) and run `mvn test` with the solution's test + approved
|
||||
file dropped in: it must pass deterministically.
|
||||
|
||||
## What this rehearsal tells you
|
||||
|
||||
- The mechanics-with-names prompt (introduce lambda / slide statement /
|
||||
extract method + introduce parameter) is unambiguous enough that an agent
|
||||
with weak tool-calling (qwen 35B) can follow it — if it can, a human
|
||||
pair can too.
|
||||
- Which steps the agent drifts on (usually step 1, where it tends to skip
|
||||
the lambda and just extract a method that closes over `scorer` — the
|
||||
exact mistake participants make). Collect these failure modes: they are
|
||||
gold for the concept slide and the debrief.
|
||||
- Time estimate for the exercise: run it twice, once tool-only, once with
|
||||
the manual lambda edit. The gap between the two is the "no tool support"
|
||||
friction participants will feel.
|
||||
|
||||
## Tool coverage cheat sheet
|
||||
|
||||
| Mechanic | Tool support |
|
||||
|---|---|
|
||||
| introduce lambda | none — manual edit |
|
||||
| slide statement | none — manual edit |
|
||||
| extract method | `java-refactor extract` |
|
||||
| add lambda parameter | `java-refactor introduce-param` / `change-method-signature` |
|
||||
| move method to another class (step 4, not in scope) | `java-refactor move-method` (call sites not rewritten) |
|
||||
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.received.txt
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -0,0 +1,27 @@
|
||||
# Reference solution — end state after step 3
|
||||
|
||||
Derived from [IceCreamForecasting-Refactoring-Kata](https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata)
|
||||
(same license as the kata applies to the copied `IceCream.java`).
|
||||
|
||||
Run with `mvn test` — the approval test passes deterministically without
|
||||
touching the weather service.
|
||||
|
||||
## What changed vs the exercise start
|
||||
|
||||
1. Weather loop extracted to `collectExpectedWeather(LongFunction<Boolean>)`
|
||||
— a static method whose only "unwanted dependency" is the lambda parameter.
|
||||
2. Public no-arg `longRangeForecast()` delegates to a new overload that takes
|
||||
the weather function, keeping existing callers working.
|
||||
3. Test passes a pure lambda (`daysForward -> true`) — the original test
|
||||
called the no-arg method and was flaky because of `Scorer`'s `Random`.
|
||||
|
||||
## Variations to try (for the debrief or a second round)
|
||||
|
||||
- `daysForward -> false` (no sunny holidays): scores drop to `+10` only.
|
||||
- `daysForward -> daysForward == 5` (exactly one sunny holiday, and it is not
|
||||
`expectedWeather.get(1)`, so no `+2`): all flavours `10`.
|
||||
- Delete the `scorer.updateSelection()` call inside the production lambda:
|
||||
the approved file does not change — the forecast never reads `Scorer.flavour`.
|
||||
- Variation end state: skip the overload; make `collectExpectedWeather`
|
||||
package-private and test it directly with pure lambdas. Trade-off:
|
||||
tests the pure slice but not the composition (scoring loop).
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>codingdojo</groupId>
|
||||
<artifactId>icecream-split-loop-lambda-exercise</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.approvaltests</groupId>
|
||||
<artifactId>approvaltests</artifactId>
|
||||
<version>18.6.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,7 @@
|
||||
package codingdojo;
|
||||
|
||||
public enum IceCream {
|
||||
Strawberry,
|
||||
Chocolate,
|
||||
Vanilla;
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package codingdojo;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.function.LongFunction;
|
||||
|
||||
/**
|
||||
* Reference end state of the learning hour exercise (step 3 of the
|
||||
* Split Loop with Lambda mechanics — stop here; step 4, moving the method
|
||||
* to another class, is future work).
|
||||
*
|
||||
* Derived from https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata
|
||||
*/
|
||||
public class LongRangeForecasts {
|
||||
|
||||
private static final Instant ANCHOR_DATE = Instant.parse("2023-04-26T14:00:00.000-07:00");
|
||||
private static final List<Instant> DATES = List.of(
|
||||
Instant.parse("2023-05-01T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-05-18T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-06-05T14:00:00.000-07:00"),
|
||||
Instant.parse("2023-06-23T14:00:00.000-07:00")
|
||||
);
|
||||
|
||||
private final Scorer scorer = new Scorer();
|
||||
|
||||
public Map<IceCream, Integer> longRangeForecast() {
|
||||
return longRangeForecast(daysForward -> {
|
||||
scorer.updateSelection();
|
||||
return scorer.lookupWeather(daysForward);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The forecast, with the weather source injected as a function.
|
||||
* Testable: pass any pure function instead of calling the weather service.
|
||||
*/
|
||||
public Map<IceCream, Integer> longRangeForecast(
|
||||
LongFunction<Boolean> dayOffsetToWeather) {
|
||||
var expectedWeather = collectExpectedWeather(dayOffsetToWeather);
|
||||
|
||||
var result = new HashMap<IceCream, Integer>();
|
||||
for (IceCream flavour : IceCream.values()) {
|
||||
result.put(flavour, 0);
|
||||
}
|
||||
for (IceCream flavour : result.keySet()) {
|
||||
result.put(flavour, result.get(flavour) + 10);
|
||||
var sunnyHolidays = expectedWeather.stream().filter(s -> s).count();
|
||||
if (sunnyHolidays > 2 && flavour == IceCream.Vanilla) {
|
||||
result.put(flavour, result.get(flavour) + 5);
|
||||
}
|
||||
if (sunnyHolidays > 1 && expectedWeather.get(1)) {
|
||||
result.put(flavour, result.get(flavour) + 2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted method. Note: it uses no fields — all its inputs are
|
||||
* parameters. The only leftover dependency is the lambda, which can
|
||||
* be replaced by any pure function in tests.
|
||||
*/
|
||||
private static List<Boolean> collectExpectedWeather(
|
||||
LongFunction<Boolean> dayOffsetToWeather) {
|
||||
var expectedWeather = new ArrayList<Boolean>();
|
||||
for (Instant interestingDate : DATES) {
|
||||
long daysForward = ChronoUnit.DAYS.between(ANCHOR_DATE, interestingDate);
|
||||
expectedWeather.add(dayOffsetToWeather.apply(daysForward));
|
||||
}
|
||||
return expectedWeather;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package codingdojo;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Trimmed version of the kata's Scorer: only the parts LongRangeForecasts uses.
|
||||
* lookupWeather stands in for an API call to a weather service — in the real
|
||||
* system this is the "unwanted dependency".
|
||||
*/
|
||||
public class Scorer {
|
||||
|
||||
public static IceCream flavour = null;
|
||||
|
||||
public int getScore() {
|
||||
boolean sunnyToday = lookupWeather();
|
||||
if (flavour == null) {
|
||||
return -1;
|
||||
}
|
||||
switch (flavour) {
|
||||
case Strawberry:
|
||||
return sunnyToday ? 10 : 5;
|
||||
case Chocolate:
|
||||
return 6;
|
||||
case Vanilla:
|
||||
return sunnyToday ? 7 : 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean lookupWeather() {
|
||||
return new Random().nextBoolean();
|
||||
}
|
||||
|
||||
public boolean lookupWeather(long daysForward) {
|
||||
// placeholder implementation - real version would make API call to weather service
|
||||
return new Random().nextBoolean();
|
||||
}
|
||||
|
||||
public void updateSelection() {
|
||||
// placeholder implementation - real version would use machine learning to predict sales
|
||||
var score = getScore();
|
||||
if (score > 5) {
|
||||
var randomizer = new Random();
|
||||
IceCream[] allFlavours = IceCream.values();
|
||||
flavour = allFlavours[randomizer.nextInt(allFlavours.length)];
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package codingdojo;
|
||||
|
||||
import org.approvaltests.Approvals;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.function.LongFunction;
|
||||
|
||||
public class LongRangeForecastsTest {
|
||||
|
||||
@Test
|
||||
void longRangeForecast() {
|
||||
// pure weather implementation: every holiday is sunny
|
||||
LongFunction<Boolean> alwaysSunny = daysForward -> true;
|
||||
var actual = new LongRangeForecasts().longRangeForecast(alwaysSunny);
|
||||
Approvals.verify(printIceCreamForecast(actual));
|
||||
}
|
||||
|
||||
private static String printIceCreamForecast(Map<IceCream, Integer> actual) {
|
||||
var printedResult = new StringBuilder();
|
||||
printedResult.append("{");
|
||||
var sortedKeys = new ArrayList<>(actual.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
for (IceCream key : sortedKeys) {
|
||||
printedResult.append(key);
|
||||
printedResult.append(":");
|
||||
printedResult.append(actual.get(key));
|
||||
printedResult.append(", ");
|
||||
}
|
||||
printedResult.append("}");
|
||||
return printedResult.toString();
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{Strawberry:12, Chocolate:12, Vanilla:17, }
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
theme: refactoring
|
||||
title: Split Loop with Lambda — when the variable you slide is a function
|
||||
name: split-loop-with-lambda
|
||||
kata: IceCreamForecasting-Refactoring-Kata (pre-factored "prefactored-longrange" variant)
|
||||
difficulty: 4
|
||||
author: Willem van den Ende
|
||||
affiliation: QWAN
|
||||
tags: refactoring java split-loop lambda testability composed-method
|
||||
---
|
||||
|
||||
# Split Loop with Lambda
|
||||
|
||||
You know Split Loop: separate the loops that compute from the loops that
|
||||
collect, using *introduce variable* and *slide statement*. But sometimes the
|
||||
thing you want to slide is not a value — it is a **function call** that
|
||||
reaches out to a dependency you do not want (a weather API, machine
|
||||
learning, a database). Plain Split Loop stalls: the variable you introduce
|
||||
would have to hold "the answer to a question that is not asked yet". The fix
|
||||
is to make the laziness explicit: introduce a **lambda**, slide *that*, and
|
||||
extract a method that takes the function as a parameter.
|
||||
|
||||
This is a follow-up session: the team has already done Split Loop (and has
|
||||
used these mechanics on production code, unnamed). Here we name the pattern,
|
||||
drill the mechanics on a pre-factored slice of
|
||||
[IceCreamForecasting-Refactoring-Kata](https://github.com/emilybache/IceCreamForecasting-Refactoring-Kata),
|
||||
and end at a testable, pure seam.
|
||||
|
||||
## Learning Goals
|
||||
|
||||
* Given a loop whose body needs an outside dependency, perform the six
|
||||
Split-Loop-with-Lambda mechanics (introduce lambda → slide → extract
|
||||
method with function parameter) on unfamiliar code, without the handout
|
||||
open.
|
||||
* Recognize when plain Split Loop is not enough — the variable you need to
|
||||
slide must be a *function*, not a value — and name the next move.
|
||||
|
||||
*(Testability is a consequence, not a goal this hour — it shows up as a
|
||||
debrief question: "what just became testable, and why?")*
|
||||
|
||||
## Session Outline
|
||||
|
||||
* 5 min connect: count the concerns (on the kata's original method)
|
||||
* 5 min concept: the mechanics, six steps with names
|
||||
* 10 min demo: the weather slice of `LongRangeForecast` (Java)
|
||||
* 25 min concrete: pairs do the whole pre-factored method
|
||||
* 5 min conclusion: debrief + "what became testable?" + next steps
|
||||
|
||||
Total 50 min + buffer.
|
||||
|
||||
## Connect - Count the concerns
|
||||
|
||||
Show the original `longRangeForecast` from the kata (the one with the Q1/Q4
|
||||
branch and the hardcoded dates) on a slide. Ask pairs, 90 seconds: "how many
|
||||
different things is this method doing? Point at the lines."
|
||||
|
||||
Take answers: quarter default, reset loop, date list, weather collection,
|
||||
scoring loop — five or six, depending how you count. Land the point: *your
|
||||
production code looks like this too, and today we learn one move that cuts
|
||||
one concern loose even when Split Loop alone cannot.*
|
||||
|
||||
## Concept - The six mechanics
|
||||
|
||||
Walk the pattern (handout: `zeroth-draft-of-refactoring-pattern.md`):
|
||||
|
||||
1. **Extract the dependency-using code into a lambda** — no tool does this;
|
||||
it is a small manual edit.
|
||||
2. **Slide the lambda definition** to the top of the method; leave the
|
||||
invocation where it is.
|
||||
3. **Extract the loop** into a method that takes the lambda as a parameter.
|
||||
(IntelliJ: Extract Method, then Introduce Parameter / Change Signature.
|
||||
Suggested type for this exercise: `LongFunction<Boolean>`.)
|
||||
4. Move the method to a class where the unwanted dependency is unknown.
|
||||
5. Test with a pure lambda instead of the real dependency.
|
||||
6. Check what new test combinations became easy.
|
||||
|
||||
Steps 1–2 are the new move — that is where plain Split Loop stalls, because
|
||||
the thing being slid is a *function*, not a value. Steps 3–6 are familiar
|
||||
Split Loop territory. Today we stop after step 3 (with a test, step 5-lite);
|
||||
step 4 is next time.
|
||||
|
||||
If there is time, show one slide: the slice graphic from the
|
||||
[Slice learning hour](https://www.sammancoaching.org/learning_hours/testable_design/slice.html).
|
||||
|
||||
## Demo - The weather slice (10 min)
|
||||
|
||||
Do the six mechanics on a whiteboard-projection slice of
|
||||
`LongRangeForecast` — only the `DATES` loop:
|
||||
|
||||
```java
|
||||
List<Boolean> expectedWeather = new ArrayList<>();
|
||||
for (Instant interestingDate : DATES) {
|
||||
scorer.updateSelection();
|
||||
long daysForward = ChronoUnit.DAYS.between(ANCHOR_DATE, interestingDate);
|
||||
Boolean weather = scorer.lookupWeather(daysForward);
|
||||
expectedWeather.add(weather);
|
||||
}
|
||||
```
|
||||
|
||||
Narrate: *everything this loop needs from outside is "given a day offset,
|
||||
is it sunny?"* Extract that question into a lambda, slide it up, extract the
|
||||
loop into `collectExpectedWeather(LongFunction<Boolean> dayOffsetToWeather)`.
|
||||
Note out loud where you paused and what you checked (compile, tests still
|
||||
compile). Point at the *invocation site* staying behind — that is what
|
||||
"slide, don't move" means.
|
||||
|
||||
## Concrete Practice - Pairs on prefactored-longrange (25 min)
|
||||
|
||||
Pairs, Rider or IntelliJ, one driver. Repo: `exercise/java` (a pre-factored
|
||||
copy of the kata: Q1/Q4 dead branch stripped, dates as a constant — you gave
|
||||
them the dates-as-parameter refactoring already, so the only "noise" left is
|
||||
the two loops and the noise inside the scoring loop).
|
||||
|
||||
1. (3 min) Run the approval test. It is red and flaky — why? (`Scorer`
|
||||
calls `Random`; it stands for the weather API.)
|
||||
2. (17 min) Do the mechanics on the whole method. Careful: the weather loop
|
||||
calls `scorer` **twice**. Everything the lambda needs has to be in scope
|
||||
at the invocation site.
|
||||
3. (5 min) When your extracted method takes the lambda as a parameter, make
|
||||
the test pass with a pure lambda (e.g. `daysForward -> true`) and check
|
||||
the approved file against `{Strawberry:12, Chocolate:12, Vanilla:17, }`.
|
||||
Reference end state: `exercise/solution/`.
|
||||
|
||||
Debrief questions on a slide (from `exercise/java/README.md`):
|
||||
|
||||
- At which step did plain Split Loop stop working, and why?
|
||||
- What did you do with each of the two `scorer` calls?
|
||||
- Could you delete either call without changing the forecast? Try it.
|
||||
Why did that work? *(updateSelection only mutates Scorer state this
|
||||
method never reads — the lambda made that visible)*
|
||||
- What just became testable that was not before?
|
||||
|
||||
## Conclusions - Name it, claim it, next move
|
||||
|
||||
- Pairs state the pattern in one sentence, using the words *lambda*,
|
||||
*slide*, *function parameter*. Add their sentences to the pattern draft.
|
||||
- Slide: "the new tests are actually glanceable and pure" — testability was
|
||||
the side effect; ask first ("what was a benefit?"), then show the slide.
|
||||
- Next moves this sets up: move the method to a class without `Scorer`
|
||||
(step 4), turn `DATES` into a parameter (the refactor Willem did before
|
||||
this exercise), and the ports-and-adapters view: a lambda is the smallest
|
||||
Port + Adapter that could work.
|
||||
- Feedback on the pattern draft: which steps felt named, which felt fuzzy?
|
||||
|
||||
## Facilitator notes
|
||||
|
||||
- Materials: `exercise/java` (participants), `exercise/solution`
|
||||
(facilitator + fast pairs), handout = the pattern draft, slides =
|
||||
original method + debrief questions.
|
||||
- Fast pairs finish early? Have them do step 4 for real (move
|
||||
`collectExpectedWeather` to a new class), or the
|
||||
`daysForward -> daysForward == 5` variation from `solution/README.md`.
|
||||
- If most pairs stall at step 3 (adding the parameter), interrupt after
|
||||
~8 min and demo *just* that: Extract Method, then Change Signature to
|
||||
`LongFunction<Boolean>`.
|
||||
- The overload extension (test can't reach the pure seam because
|
||||
`longRangeForecast()` bakes in the real lambda): Inline Variable on the
|
||||
lambda local (⌘⌥N), Introduce Parameter on the lambda (⌥⌘P), test passes
|
||||
`daysForward -> true`, then restore a 3-line no-arg overload delegating
|
||||
with the real lambda. End state: `exercise/solution/`.
|
||||
- Debrief fork: inject through the public overload (tests composition +
|
||||
slice) vs. test `collectExpectedWeather` directly, package-private (tests
|
||||
only the pure slice). No right answer — good conclusion discussion.
|
||||
- Willem's rehearsal: `exercise/rehearse-with-agent.md` — run the exercise
|
||||
with qwen + java-refactoring-mcp CLI first; agent failure modes become
|
||||
concept-slide material.
|
||||
Reference in New Issue
Block a user