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:
Your Name
2026-09-27 13:30:00 +00:00
parent 1a063860c9
commit a27e32688a
18 changed files with 737 additions and 0 deletions
@@ -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;
}
@@ -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)];
}
}
}
@@ -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;
}
@@ -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)];
}
}
}
@@ -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();
}
}
@@ -0,0 +1 @@
{Strawberry:12, Chocolate:12, Vanilla:17, }