Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci-align.yml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions jhotdraw-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@
<version>6.8.21</version>
<scope>test</scope>
</dependency>
<!-- The project already ships TestNG, but the lab asks for JUnit 4 and
notes that Swing and JUnit extensions work best with it. I add JUnit 4
next to TestNG rather than replacing it, so the existing tests keep
running and my new unit tests use JUnit 4. Surefire runs both in the
same build. "test" scope means it is only on the classpath for tests
and never ends up in the shipped jar. -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<!-- JGiven turns the user story into runnable Given-When-Then scenarios on
top of JUnit, and AssertJ gives the readable assertions the Then steps
use. Both are test scoped, so they never reach the shipped jar. -->
<dependency>
<groupId>com.tngtech.jgiven</groupId>
<artifactId>jgiven-junit</artifactId>
<version>1.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jhotdraw-actions</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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<Rectangle2D.Double> 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<Rectangle2D.Double> 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<Rectangle2D.Double> 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<Rectangle2D.Double> 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<Rectangle2D.Double> 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<Rectangle2D.Double> boxes = Arrays.asList(box(42, 0, 10, 10));
double[] xs = Distribution.HORIZONTAL.newPositions(boxes);
assertArrayEquals(new double[]{42}, xs, EPS);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>Java note to self. Extending {@code SimpleScenarioTest<AlignDistributeStage>}
* 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.</p>
*/
public class AlignDistributeScenarioTest extends SimpleScenarioTest<AlignDistributeStage> {

@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();
}
}
Loading
Loading