${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);
+ }
+}
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;
+ }
+}