From eb2e9880dfad893a5ca8d81c7dc7cea4d08a16a1 Mon Sep 17 00:00:00 2001 From: phongsakon Date: Sun, 31 May 2026 11:52:47 +0200 Subject: [PATCH 1/3] Add JUnit 4 unit tests for alignment and distribution geometry Added JUnit 4 next to the existing TestNG so the lab's framework is in place without disturbing the current tests. The tests cover the pure geometry the refactoring exposed: 9 cases for Alignment, one per direction plus the already-aligned and oversized-figure boundaries, and 6 for Distribution, the equal-gap best case plus the two and one figure boundaries where it must do nothing. All 15 green. --- jhotdraw-core/pom.xml | 12 ++ .../jhotdraw/draw/action/AlignmentTest.java | 120 ++++++++++++++++++ .../draw/action/DistributionTest.java | 109 ++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java create mode 100644 jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java diff --git a/jhotdraw-core/pom.xml b/jhotdraw-core/pom.xml index 7c276da85..9f6ad3a04 100644 --- a/jhotdraw-core/pom.xml +++ b/jhotdraw-core/pom.xml @@ -35,6 +35,18 @@ 6.8.21 test + + + junit + junit + 4.13.2 + test + ${project.groupId} jhotdraw-actions diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java new file mode 100644 index 000000000..60a7cb104 --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java @@ -0,0 +1,120 @@ +/* + * AlignmentTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for the {@link Alignment} geometry. + * + *

These test the pure part of the align feature, the movement each direction + * works out. This is exactly what the refactoring made possible. Before, the + * same arithmetic was buried inside Swing action subclasses and could only be + * checked by clicking the real buttons. Now it is a plain method from two + * rectangles to a vector, so a test just sets up the input, calls the method, + * and checks the number, with no editor and no mocks.

+ * + *

Java notes to self. The {@code @Test} annotation marks a method JUnit runs. + * {@code assertEquals(expected, actual, EPS)} compares two doubles and the third + * argument is the tolerance, because comparing decimals for exact equality is + * unreliable. The fixture below is two boxes, a fixed selection and a smaller + * figure that moves, and each test comment states the expected delta.

+ */ +public class AlignmentTest { + + private static final double EPS = 1e-9; + + /** Selection bounds: x=0, y=0, width=100, height=100. */ + private Rectangle2D.Double selection() { + return new Rectangle2D.Double(0, 0, 100, 100); + } + + /** A 20 by 20 figure sitting near the middle, at (40, 40). */ + private Rectangle2D.Double figure() { + return new Rectangle2D.Double(40, 40, 20, 20); + } + + // ---- Best case, each direction moves the figure where it should ---- + + @Test + public void northMovesTopEdgeToSelectionTop() { + // top of selection is y=0, figure top is y=40, so dy = -40 and dx = 0 + Point2D.Double d = Alignment.NORTH.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(-40, d.y, EPS); + } + + @Test + public void southMovesBottomEdgeToSelectionBottom() { + // selection bottom is y=100, figure bottom is 40+20=60, so dy = +40 + Point2D.Double d = Alignment.SOUTH.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(40, d.y, EPS); + } + + @Test + public void westMovesLeftEdgeToSelectionLeft() { + // selection left is x=0, figure left is x=40, so dx = -40 + Point2D.Double d = Alignment.WEST.delta(figure(), selection()); + assertEquals(-40, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void eastMovesRightEdgeToSelectionRight() { + // selection right is x=100, figure right is 40+20=60, so dx = +40 + Point2D.Double d = Alignment.EAST.delta(figure(), selection()); + assertEquals(40, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void verticalCentresOnHorizontalMidline() { + // selection mid y = 50, figure centre y = 40 + 10 = 50, already centred + Point2D.Double d = Alignment.VERTICAL.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void horizontalCentresOnVerticalMidline() { + // selection mid x = 50, figure centre x = 50, already centred + Point2D.Double d = Alignment.HORIZONTAL.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + // ---- Boundary cases ---- + + @Test + public void alreadyAlignedFigureDoesNotMove() { + // A figure already flush with the top should get a zero delta for NORTH. + Rectangle2D.Double flushTop = new Rectangle2D.Double(10, 0, 20, 20); + Point2D.Double d = Alignment.NORTH.delta(flushTop, selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void figureLargerThanSelectionStillProducesConsistentDelta() { + // A figure wider than the selection still lines its left edge up for WEST. + Rectangle2D.Double wide = new Rectangle2D.Double(5, 40, 200, 20); + Point2D.Double d = Alignment.WEST.delta(wide, selection()); + assertEquals(-5, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void deltaIsNeverNull() { + for (Alignment a : Alignment.values()) { + assertEquals(false, a.delta(figure(), selection()) == null); + } + } +} diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java new file mode 100644 index 000000000..b17c612fc --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java @@ -0,0 +1,109 @@ +/* + * DistributionTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Rectangle2D; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; + +/** + * Unit tests for the {@link Distribution} geometry. + * + *

Distribution is the new feature, so it gets its own best case and boundary + * tests. The method under test maps a list of boxes to a list of new + * coordinates, so the tests check the returned array directly.

+ * + *

Java note to self. {@code assertArrayEquals(expected, actual, EPS)} checks + * two double arrays element by element within a tolerance. The best case fixes + * three boxes with an uneven gap and checks the middle one moves to even the + * spacing. The boundary cases are the two inputs where distribution must do + * nothing, a selection of two boxes and of one box, because there is no inner + * figure to move.

+ */ +public class DistributionTest { + + private static final double EPS = 1e-9; + + private Rectangle2D.Double box(double x, double y, double w, double h) { + return new Rectangle2D.Double(x, y, w, h); + } + + // ---- Best case ---- + + @Test + public void horizontalEqualisesGapsBetweenThreeBoxes() { + // Three 10 wide boxes. Left at x=0, right at x=100, middle bunched at 20. + // Total width = 30, span = 110, leftover = 80 over 2 gaps = 40 each. + // Positions become 0, then 0+10+40=50, then the end stays at 100. + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(20, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 50, 100}, xs, EPS); + } + + @Test + public void verticalEqualisesGapsBetweenThreeBoxes() { + // Same idea on the y axis. + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(0, 20, 10, 10), + box(0, 100, 10, 10)); + double[] ys = Distribution.VERTICAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 50, 100}, ys, EPS); + } + + @Test + public void distributionKeepsTheOutermostBoxesFixed() { + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(33, 0, 10, 10), + box(70, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + // first and last never move + assertEquals(0, xs[0], EPS); + assertEquals(100, xs[3], EPS); + } + + @Test + public void inputOrderDoesNotChangeTheResultMapping() { + // The same three boxes as the best case but supplied out of order. The + // result lines up with the input by index, so the box that started at + // x=20 (index 0 here) still ends up at the middle coordinate 50. + List boxes = Arrays.asList( + box(20, 0, 10, 10), + box(100, 0, 10, 10), + box(0, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertEquals(50, xs[0], EPS); + assertEquals(100, xs[1], EPS); + assertEquals(0, xs[2], EPS); + } + + // ---- Boundary cases ---- + + @Test + public void twoBoxesAreLeftUnchanged() { + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 100}, xs, EPS); + } + + @Test + public void oneBoxIsLeftUnchanged() { + List boxes = Arrays.asList(box(42, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{42}, xs, EPS); + } +} From 91e1c2102efdf312ac5c88a5a49397beb416a143 Mon Sep 17 00:00:00 2001 From: phongsakon Date: Sun, 31 May 2026 14:23:11 +0200 Subject: [PATCH 2/3] Add JGiven BDD scenarios for align and distribute Mapped the user story to Given-When-Then scenarios with JGiven and AssertJ. One stage holds the steps, and the three scenarios cover the story's promises: aligning north lines up the top edges, distributing makes the gaps equal, and distributing two figures changes nothing. --- jhotdraw-core/pom.xml | 15 +++ .../bdd/AlignDistributeScenarioTest.java | 58 +++++++++ .../draw/action/bdd/AlignDistributeStage.java | 115 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java create mode 100644 jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java diff --git a/jhotdraw-core/pom.xml b/jhotdraw-core/pom.xml index 9f6ad3a04..3f8182570 100644 --- a/jhotdraw-core/pom.xml +++ b/jhotdraw-core/pom.xml @@ -47,6 +47,21 @@ 4.13.2 test
+ + + com.tngtech.jgiven + jgiven-junit + 1.3.1 + test + + + org.assertj + assertj-core + 3.24.2 + test + ${project.groupId} jhotdraw-actions diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java new file mode 100644 index 000000000..4f22fad05 --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java @@ -0,0 +1,58 @@ +/* + * AlignDistributeScenarioTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action.bdd; + +import com.tngtech.jgiven.junit.SimpleScenarioTest; +import org.jhotdraw.draw.action.Alignment; +import org.jhotdraw.draw.action.Distribution; +import org.junit.Test; + +/** + * BDD scenarios for the align and distribute user story. + * + *

This maps the user story onto runnable scenarios. The story reads, as + * someone editing a drawing I want to align or distribute the figures I have + * selected so the diagram looks tidy. Each test below is one acceptance + * criterion written as Given a selection, When I run the command, Then the + * figures end up where the story promises.

+ * + *

Java note to self. Extending {@code SimpleScenarioTest} + * is what gives this class the {@code given()}, {@code when()}, and {@code then()} + * methods, each returning the one stage so the steps can be chained. I used the + * Simple variant because a single stage holds all three step groups.

+ */ +public class AlignDistributeScenarioTest extends SimpleScenarioTest { + + @Test + public void aligning_to_the_north_lines_up_the_top_edges() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 10, 20, 20) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(40, 60, 20, 20) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(80, 30, 20, 20) + .and().the_figures_are_selected(); + when().I_align_them_to_the(Alignment.NORTH); + then().every_figure_shares_the_same_top_edge(); + } + + @Test + public void distributing_horizontally_makes_the_gaps_equal() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(20, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(100, 0, 10, 10) + .and().the_figures_are_selected(); + when().I_distribute_them(Distribution.HORIZONTAL); + then().the_gaps_between_neighbours_are_equal(); + } + + @Test + public void distributing_two_figures_changes_nothing() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(100, 0, 10, 10) + .and().the_figures_are_selected(); + when().I_distribute_them(Distribution.HORIZONTAL); + then().the_positions_are_unchanged(); + } +} diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java new file mode 100644 index 000000000..8d21c1cbd --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java @@ -0,0 +1,115 @@ +/* + * AlignDistributeStage.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action.bdd; + +import com.tngtech.jgiven.Stage; +import com.tngtech.jgiven.annotation.ProvidedScenarioState; +import com.tngtech.jgiven.annotation.Quoted; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.jhotdraw.draw.action.Alignment; +import org.jhotdraw.draw.action.Distribution; + +/** + * A single JGiven stage holding the Given, When, and Then steps for the align + * and distribute scenarios. + * + *

Where a unit test speaks in coordinates, BDD describes the feature in the + * words of the user story. Each public method below is one line of a + * Given-When-Then sentence, and JGiven turns the method names into a readable + * report. The stage holds the scenario state, the figures and the result, so the + * scenario class itself stays short and declarative.

+ * + *

Java notes to self. A JGiven stage is a normal class whose methods return + * {@code this} so they can be chained. {@code @ProvidedScenarioState} marks the + * fields JGiven shares between the steps. {@code @Quoted} just makes the argument + * show up in quotes in the generated report. One stage is enough here because + * the feature is small, a bigger feature would split Given, When, and Then into + * three stages.

+ */ +public class AlignDistributeStage extends Stage { + + @ProvidedScenarioState + private final List figures = new ArrayList<>(); + + @ProvidedScenarioState + private Rectangle2D.Double selectionBounds; + + @ProvidedScenarioState + private final List alignDeltas = new ArrayList<>(); + + @ProvidedScenarioState + private double[] distributedPositions; + + // ---- Given ---- + + public AlignDistributeStage a_figure_at_x_$_y_$_with_width_$_height_$( + @Quoted double x, @Quoted double y, @Quoted double w, @Quoted double h) { + figures.add(new Rectangle2D.Double(x, y, w, h)); + return this; + } + + public AlignDistributeStage the_figures_are_selected() { + selectionBounds = null; + for (Rectangle2D.Double f : figures) { + if (selectionBounds == null) { + selectionBounds = (Rectangle2D.Double) f.clone(); + } else { + selectionBounds.add(f); + } + } + return this; + } + + // ---- When ---- + + public AlignDistributeStage I_align_them_to_the(@Quoted Alignment alignment) { + alignDeltas.clear(); + for (Rectangle2D.Double f : figures) { + alignDeltas.add(alignment.delta(f, selectionBounds)); + } + return this; + } + + public AlignDistributeStage I_distribute_them(@Quoted Distribution distribution) { + distributedPositions = distribution.newPositions(figures); + return this; + } + + // ---- Then ---- + + public AlignDistributeStage every_figure_shares_the_same_top_edge() { + // Applying the NORTH delta should land every figure on the same y. + List tops = new ArrayList<>(); + for (int i = 0; i < figures.size(); i++) { + tops.add(figures.get(i).y + alignDeltas.get(i).y); + } + Assertions.assertThat(tops).allMatch(t -> Math.abs(t - tops.get(0)) < 1e-9); + return this; + } + + public AlignDistributeStage the_gaps_between_neighbours_are_equal() { + // Sort the new leading coordinates and check the gaps all match. + double[] sorted = distributedPositions.clone(); + java.util.Arrays.sort(sorted); + double firstGap = sorted[1] - sorted[0]; + for (int i = 2; i < sorted.length; i++) { + Assertions.assertThat(sorted[i] - sorted[i - 1]).isCloseTo(firstGap, Assertions.within(1e-9)); + } + return this; + } + + public AlignDistributeStage the_positions_are_unchanged() { + for (int i = 0; i < figures.size(); i++) { + Assertions.assertThat(distributedPositions[i]).isCloseTo(figures.get(i).x, Assertions.within(1e-9)); + } + return this; + } +} From 4c1b7045532472250a3c0d259c4009a10ecea4fc Mon Sep 17 00:00:00 2001 From: phongsakon Date: Sun, 31 May 2026 17:06:38 +0200 Subject: [PATCH 3/3] Add CI workflow for build and test on pull requests GitHub Actions pipeline that builds all modules and runs the tests on JDK 11 for every pull request. Named ci-align.yml so it does not clash with the ci.yml a teammate added on another branch. --- .github/workflows/ci-align.yml | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci-align.yml diff --git a/.github/workflows/ci-align.yml b/.github/workflows/ci-align.yml new file mode 100644 index 000000000..6cf48f291 --- /dev/null +++ b/.github/workflows/ci-align.yml @@ -0,0 +1,38 @@ +# Continuous integration for the align and distribute feature. +# +# Continuous integration means every change is built and tested automatically +# when it is proposed, so problems show up at the moment of the pull request +# instead of at merge time. This workflow builds the whole multi-module project +# and runs the tests on every pull request and on pushes to my feature branches. +# +# The file is named ci-align.yml on purpose. A teammate already added a ci.yml on +# another branch, so a separate name lets both pipelines run side by side and +# avoids a conflict on the workflow file when the branches merge. +name: CI Align and Distribute + +on: + pull_request: + push: + branches: + - "phongsakon/**" + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v4 + + # The project compiles at Java 1.8 source level but is built with JDK 11, + # so the runner uses Temurin 11 to match the local toolchain. + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "11" + cache: maven + + # A clean build of every module. Tests run by default, which is the point + # of the pipeline, so there is no -DskipTests here. + - name: Build and test with Maven + run: mvn --batch-mode --no-transfer-progress clean install