From e4f0e8eb8740a1a84f67689946cbd157e2362172 Mon Sep 17 00:00:00 2001 From: Durim Kryeziu Date: Sun, 24 May 2026 04:17:45 +0200 Subject: [PATCH] Add L-shaped room support to paint calculator Models an L-shaped room as an outer rectangle with a rectangular cut-out removed from one corner. Area = outer.area() minus cutout.area(). The cut-out must be strictly smaller than the outer rectangle on both length and width; equal dimensions are also rejected. PaintCalculator gains a "3) L-shaped" menu option with four sequential prompts (outer length/width, cut-out length/width). The invalid-shape error is updated from "Please enter 1 or 2" to "Please enter 1, 2 or 3". --- .../dev/delivercraft/paint/LShapedRoom.java | 27 ++++ .../delivercraft/paint/PaintCalculator.java | 23 +++- .../delivercraft/paint/RoomDimensions.java | 2 +- .../delivercraft/paint/LShapedRoomTest.java | 120 ++++++++++++++++++ .../paint/PaintCalculatorTest.java | 77 ++++++++++- 5 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 paint-calculator/src/main/java/dev/delivercraft/paint/LShapedRoom.java create mode 100644 paint-calculator/src/test/java/dev/delivercraft/paint/LShapedRoomTest.java diff --git a/paint-calculator/src/main/java/dev/delivercraft/paint/LShapedRoom.java b/paint-calculator/src/main/java/dev/delivercraft/paint/LShapedRoom.java new file mode 100644 index 0000000..2df24a4 --- /dev/null +++ b/paint-calculator/src/main/java/dev/delivercraft/paint/LShapedRoom.java @@ -0,0 +1,27 @@ +package dev.delivercraft.paint; + +import java.math.BigDecimal; +import java.util.Objects; + +public record LShapedRoom(RectangularRoom outer, RectangularRoom cutout) implements RoomDimensions { + + public LShapedRoom(RectangularRoom outer, RectangularRoom cutout) { + this.outer = Objects.requireNonNull(outer, "outer must not be null"); + this.cutout = Objects.requireNonNull(cutout, "cutout must not be null"); + + BigDecimal outerLength = outer.length().value(); + BigDecimal outerWidth = outer.width().value(); + BigDecimal cutoutLength = cutout.length().value(); + BigDecimal cutoutWidth = cutout.width().value(); + + if (cutoutLength.compareTo(outerLength) >= 0 || cutoutWidth.compareTo(outerWidth) >= 0) { + throw new IllegalArgumentException( + "Cut-out must be smaller than the outer rectangle on both length and width."); + } + } + + @Override + public BigDecimal area() { + return this.outer.area().subtract(this.cutout.area()); + } +} diff --git a/paint-calculator/src/main/java/dev/delivercraft/paint/PaintCalculator.java b/paint-calculator/src/main/java/dev/delivercraft/paint/PaintCalculator.java index 7f3a85d..115e22a 100644 --- a/paint-calculator/src/main/java/dev/delivercraft/paint/PaintCalculator.java +++ b/paint-calculator/src/main/java/dev/delivercraft/paint/PaintCalculator.java @@ -7,7 +7,7 @@ final class PaintCalculator { - private static final String SHAPE_MENU = "Select room shape: 1) Rectangular 2) Round"; + private static final String SHAPE_MENU = "Select room shape: 1) Rectangular 2) Round 3) L-shaped"; private static final String LENGTH_PROMPT = "What is the length of the room in feet? "; @@ -15,6 +15,14 @@ final class PaintCalculator { private static final String RADIUS_PROMPT = "What is the radius of the room in feet? "; + private static final String OUTER_LENGTH_PROMPT = "What is the length of the outer rectangle in feet? "; + + private static final String OUTER_WIDTH_PROMPT = "What is the width of the outer rectangle in feet? "; + + private static final String CUTOUT_LENGTH_PROMPT = "What is the length of the cut-out in feet? "; + + private static final String CUTOUT_WIDTH_PROMPT = "What is the width of the cut-out in feet? "; + private final LineReader lineReader; private final LineWriter lineWriter; @@ -31,7 +39,8 @@ void calculatePaint() { RoomDimensions dimensions = switch (shapeChoice != null ? shapeChoice.trim() : null) { case "1" -> readRectangularDimensions(); case "2" -> readRoundDimensions(); - case null, default -> throw new IllegalArgumentException("Please enter 1 or 2"); + case "3" -> readLShapedDimensions(); + case null, default -> throw new IllegalArgumentException("Please enter 1, 2 or 3"); }; PaintEstimate estimate = PaintEstimator.estimate(dimensions); @@ -51,6 +60,16 @@ private RoundRoom readRoundDimensions() { return new RoundRoom(radius); } + private LShapedRoom readLShapedDimensions() { + RoomDimension outerLength = readDimension(OUTER_LENGTH_PROMPT); + RoomDimension outerWidth = readDimension(OUTER_WIDTH_PROMPT); + RoomDimension cutoutLength = readDimension(CUTOUT_LENGTH_PROMPT); + RoomDimension cutoutWidth = readDimension(CUTOUT_WIDTH_PROMPT); + return new LShapedRoom( + new RectangularRoom(outerLength, outerWidth), + new RectangularRoom(cutoutLength, cutoutWidth)); + } + private RoomDimension readDimension(String prompt) { this.lineWriter.write(prompt); return RoomDimension.of(this.lineReader.readLine()); diff --git a/paint-calculator/src/main/java/dev/delivercraft/paint/RoomDimensions.java b/paint-calculator/src/main/java/dev/delivercraft/paint/RoomDimensions.java index 126bb4a..75054f5 100644 --- a/paint-calculator/src/main/java/dev/delivercraft/paint/RoomDimensions.java +++ b/paint-calculator/src/main/java/dev/delivercraft/paint/RoomDimensions.java @@ -2,7 +2,7 @@ import java.math.BigDecimal; -sealed interface RoomDimensions permits RectangularRoom, RoundRoom { +sealed interface RoomDimensions permits RectangularRoom, RoundRoom, LShapedRoom { BigDecimal area(); } diff --git a/paint-calculator/src/test/java/dev/delivercraft/paint/LShapedRoomTest.java b/paint-calculator/src/test/java/dev/delivercraft/paint/LShapedRoomTest.java new file mode 100644 index 0000000..13958be --- /dev/null +++ b/paint-calculator/src/test/java/dev/delivercraft/paint/LShapedRoomTest.java @@ -0,0 +1,120 @@ +package dev.delivercraft.paint; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LShapedRoomTest { + + private static final RectangularRoom ANY_OUTER = + new RectangularRoom(RoomDimension.of("20"), RoomDimension.of("15")); + + private static final RectangularRoom ANY_CUTOUT = + new RectangularRoom(RoomDimension.of("5"), RoomDimension.of("4")); + + private static final String CUTOUT_TOO_BIG_MSG = + "Cut-out must be smaller than the outer rectangle on both length and width."; + + @Test + void construction_GivenNullOuter_ShouldThrowNullPointerException() { + assertThatThrownBy(() -> new LShapedRoom(null, ANY_CUTOUT)) + .isInstanceOf(NullPointerException.class) + .hasMessage("outer must not be null"); + } + + @Test + void construction_GivenNullCutout_ShouldThrowNullPointerException() { + assertThatThrownBy(() -> new LShapedRoom(ANY_OUTER, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("cutout must not be null"); + } + + @Test + void area_GivenOuter20x15AndCutout5x4_ShouldReturn280() { + LShapedRoom room = new LShapedRoom(ANY_OUTER, ANY_CUTOUT); + + assertThat(room.area()).isEqualByComparingTo(new BigDecimal("280")); + } + + @Test + void area_GivenFractionalDimensions_ShouldComputeExactly() { + LShapedRoom room = new LShapedRoom( + new RectangularRoom(RoomDimension.of("10.5"), RoomDimension.of("10.1")), + new RectangularRoom(RoomDimension.of("2.5"), RoomDimension.of("2"))); + + // 10.5 * 10.1 = 106.05 ; 2.5 * 2 = 5.0 ; diff = 101.05 + assertThat(room.area()).isEqualByComparingTo(new BigDecimal("101.05")); + } + + @Test + void construction_GivenCutoutEqualOnLength_ShouldThrow() { + RectangularRoom cutout = new RectangularRoom(RoomDimension.of("20"), RoomDimension.of("4")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new LShapedRoom(ANY_OUTER, cutout)) + .withMessage(CUTOUT_TOO_BIG_MSG); + } + + @Test + void construction_GivenCutoutEqualOnWidth_ShouldThrow() { + RectangularRoom cutout = new RectangularRoom(RoomDimension.of("5"), RoomDimension.of("15")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new LShapedRoom(ANY_OUTER, cutout)) + .withMessage(CUTOUT_TOO_BIG_MSG); + } + + @Test + void construction_GivenCutoutEqualOnBothAxes_ShouldThrow() { + RectangularRoom cutout = new RectangularRoom(RoomDimension.of("20"), RoomDimension.of("15")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new LShapedRoom(ANY_OUTER, cutout)) + .withMessage(CUTOUT_TOO_BIG_MSG); + } + + @Test + void construction_GivenCutoutLargerOnLength_ShouldThrow() { + RectangularRoom cutout = new RectangularRoom(RoomDimension.of("21"), RoomDimension.of("4")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new LShapedRoom(ANY_OUTER, cutout)) + .withMessage(CUTOUT_TOO_BIG_MSG); + } + + @Test + void construction_GivenCutoutLargerOnWidth_ShouldThrow() { + RectangularRoom cutout = new RectangularRoom(RoomDimension.of("5"), RoomDimension.of("16")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new LShapedRoom(ANY_OUTER, cutout)) + .withMessage(CUTOUT_TOO_BIG_MSG); + } + + @Test + void estimate_GivenLShapedRoom_ShouldRoundGallonsUp() { + // outer 20*15 = 300 ; cutout 5*4 = 20 ; area = 280 ; 280/350 -> ceil 1 + PaintEstimate estimate = PaintEstimator.estimate(new LShapedRoom(ANY_OUTER, ANY_CUTOUT)); + + assertThat(estimate.area()).isEqualTo("280"); + assertThat(estimate.gallons()).isEqualTo(BigInteger.ONE); + } + + @Test + void estimate_GivenLargeLShapedRoom_ShouldRequireMultipleGallons() { + // outer 50*30 = 1500 ; cutout 10*10 = 100 ; area = 1400 ; 1400/350 = 4 exact + RoomDimensions room = new LShapedRoom( + new RectangularRoom(RoomDimension.of("50"), RoomDimension.of("30")), + new RectangularRoom(RoomDimension.of("10"), RoomDimension.of("10"))); + + PaintEstimate estimate = PaintEstimator.estimate(room); + + assertThat(estimate.area()).isEqualTo("1400"); + assertThat(estimate.gallons()).isEqualTo(BigInteger.valueOf(4)); + } +} diff --git a/paint-calculator/src/test/java/dev/delivercraft/paint/PaintCalculatorTest.java b/paint-calculator/src/test/java/dev/delivercraft/paint/PaintCalculatorTest.java index 085d404..c6fe7b5 100644 --- a/paint-calculator/src/test/java/dev/delivercraft/paint/PaintCalculatorTest.java +++ b/paint-calculator/src/test/java/dev/delivercraft/paint/PaintCalculatorTest.java @@ -36,10 +36,20 @@ class PaintCalculatorTest { private static final String WIDTH_PROMPT = "What is the width of the room in feet? "; - private static final String SHAPE_MENU = "Select room shape: 1) Rectangular 2) Round"; + private static final String SHAPE_MENU = "Select room shape: 1) Rectangular 2) Round 3) L-shaped"; private static final String RADIUS_PROMPT = "What is the radius of the room in feet? "; + private static final String OUTER_LENGTH_PROMPT = "What is the length of the outer rectangle in feet? "; + + private static final String OUTER_WIDTH_PROMPT = "What is the width of the outer rectangle in feet? "; + + private static final String CUTOUT_LENGTH_PROMPT = "What is the length of the cut-out in feet? "; + + private static final String CUTOUT_WIDTH_PROMPT = "What is the width of the cut-out in feet? "; + + private static final String L_SHAPED_SHAPE = "3"; + private static final String RECTANGULAR_SHAPE = "1"; private static final String TEN_FEET = "10"; @@ -142,7 +152,7 @@ private static Stream invalidInputFlows() { void calculatePaint_GivenPlainDecimalInput_ShouldAcceptInput(String input) { LineWriter lineWriter = new CapturingLineWriter(); PaintCalculator calculator = new PaintCalculator( - new StubLineReader(RECTANGULAR_SHAPE, input, "20"), lineWriter); + new StubLineReader(RECTANGULAR_SHAPE, input, VALID_WIDTH), lineWriter); calculator.calculatePaint(); @@ -162,15 +172,72 @@ void calculatePaint_GivenRoundRoomSelection_ShouldCalculateCorrectArea() { + System.lineSeparator()); } + @Test + void calculatePaint_GivenLShapedRoomSelection_ShouldCalculateCorrectArea() { + // outer 20*15 = 300 ; cutout 5*4 = 20 ; area = 280 ; 280/350 -> ceil 1 + LineWriter lineWriter = new CapturingLineWriter(); + PaintCalculator calculator = new PaintCalculator( + new StubLineReader(L_SHAPED_SHAPE, VALID_WIDTH, "15", "5", "4"), lineWriter); + + calculator.calculatePaint(); + + assertThat(lineWriter).hasToString(SHAPE_MENU + System.lineSeparator() + + OUTER_LENGTH_PROMPT + OUTER_WIDTH_PROMPT + + CUTOUT_LENGTH_PROMPT + CUTOUT_WIDTH_PROMPT + + "You will need to purchase 1 gallon of paint to cover 280 square feet." + + System.lineSeparator()); + } + + @Test + void calculatePaint_GivenLShapedRoomWithInvalidOuterLength_ShouldFailFastBeforePromptingWidth() { + LineWriter lineWriter = new CapturingLineWriter(); + PaintCalculator calculator = new PaintCalculator( + new StubLineReader(L_SHAPED_SHAPE, NON_NUMERIC_INPUT), lineWriter); + + assertThatIllegalArgumentException() + .isThrownBy(calculator::calculatePaint); + + assertThat(lineWriter).hasToString(SHAPE_MENU + System.lineSeparator() + OUTER_LENGTH_PROMPT); + } + + @Test + void calculatePaint_GivenLShapedRoomWithInvalidCutoutWidth_ShouldFailFastAfterAllPriorPrompts() { + LineWriter lineWriter = new CapturingLineWriter(); + PaintCalculator calculator = new PaintCalculator( + new StubLineReader(L_SHAPED_SHAPE, VALID_WIDTH, "15", "5", NEGATIVE_INPUT), lineWriter); + + assertThatIllegalArgumentException() + .isThrownBy(calculator::calculatePaint); + + assertThat(lineWriter).hasToString(SHAPE_MENU + System.lineSeparator() + + OUTER_LENGTH_PROMPT + OUTER_WIDTH_PROMPT + + CUTOUT_LENGTH_PROMPT + CUTOUT_WIDTH_PROMPT); + } + + @Test + void calculatePaint_GivenCutoutNotSmallerThanOuter_ShouldThrowWithDescriptiveMessage() { + LineWriter lineWriter = new CapturingLineWriter(); + PaintCalculator calculator = new PaintCalculator( + new StubLineReader(L_SHAPED_SHAPE, VALID_WIDTH, "15", VALID_WIDTH, "4"), lineWriter); + + assertThatIllegalArgumentException() + .isThrownBy(calculator::calculatePaint) + .withMessage("Cut-out must be smaller than the outer rectangle on both length and width."); + + assertThat(lineWriter).hasToString(SHAPE_MENU + System.lineSeparator() + + OUTER_LENGTH_PROMPT + OUTER_WIDTH_PROMPT + + CUTOUT_LENGTH_PROMPT + CUTOUT_WIDTH_PROMPT); + } + @ParameterizedTest - @ValueSource(strings = {"3", "0", "abc", "", " "}) + @ValueSource(strings = {"4", "0", "abc", "", " "}) void calculatePaint_GivenInvalidShapeInput_ShouldThrowIllegalArgumentException(String invalidInput) { LineWriter lineWriter = new CapturingLineWriter(); PaintCalculator calculator = new PaintCalculator(new StubLineReader(invalidInput), lineWriter); assertThatIllegalArgumentException() .isThrownBy(calculator::calculatePaint) - .withMessage("Please enter 1 or 2"); + .withMessage("Please enter 1, 2 or 3"); assertThat(lineWriter.toString()).isEqualTo(SHAPE_MENU + System.lineSeparator()); } @@ -181,7 +248,7 @@ void calculatePaint_GivenNullShapeInput_ShouldThrowIllegalArgumentException() { assertThatIllegalArgumentException() .isThrownBy(calculator::calculatePaint) - .withMessage("Please enter 1 or 2"); + .withMessage("Please enter 1, 2 or 3"); assertThat(lineWriter.toString()).isEqualTo(SHAPE_MENU + System.lineSeparator()); } }