diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 000000000..849076889
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,27 @@
+name: Java CI with Maven
+
+on:
+ pull_request:
+ branches: [ "develop"]
+ push:
+ branches: [ "GerardasAlutis_Undo/Redo" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 11
+ uses: actions/setup-java@v4
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Build and test with Maven
+ run: mvn --batch-mode --update-snapshots verify --settings .maven-settings.xml
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.maven-settings.xml b/.maven-settings.xml
new file mode 100644
index 000000000..ee1c29924
--- /dev/null
+++ b/.maven-settings.xml
@@ -0,0 +1,12 @@
+
+
+
+ github
+ ${env.GITHUB_ACTOR}
+ ${env.GITHUB_TOKEN}
+
+
+
diff --git a/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/AbstractUndoRedoAction.java b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/AbstractUndoRedoAction.java
new file mode 100644
index 000000000..05c995c6c
--- /dev/null
+++ b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/AbstractUndoRedoAction.java
@@ -0,0 +1,100 @@
+/*
+ * @(#)AbstractUndoRedoAction.java
+ *
+ * Copyright (c) 1996-2010 The authors and contributors of JHotDraw.
+ * You may not use, copy or modify this file, except in compliance with the
+ * accompanying license terms.
+ */
+package org.jhotdraw.action.edit;
+
+import java.awt.event.*;
+import java.beans.*;
+import javax.swing.*;
+import org.jhotdraw.action.AbstractViewAction;
+import org.jhotdraw.api.app.Application;
+import org.jhotdraw.api.app.View;
+import org.jhotdraw.util.ResourceBundleUtil;
+
+/**
+ * Abstract base for {@link UndoAction} and {@link RedoAction}.
+ *
+ * Both actions share identical listener management, enabled-state tracking,
+ * and delegation logic. The only variation is the action ID, supplied by
+ * subclasses via {@link #getActionId()}.
+ */
+abstract class AbstractUndoRedoAction extends AbstractViewAction {
+
+ private static final long serialVersionUID = 1L;
+ protected final ResourceBundleUtil labels =
+ ResourceBundleUtil.getBundle("org.jhotdraw.action.Labels");
+
+ private final PropertyChangeListener actionPropertyListener = new PropertyChangeListener() {
+ @Override
+ public void propertyChange(PropertyChangeEvent evt) {
+ String name = evt.getPropertyName();
+ if ((name == null && AbstractAction.NAME == null)
+ || (name != null && name.equals(AbstractAction.NAME))) {
+ putValue(AbstractAction.NAME, evt.getNewValue());
+ } else if ("enabled".equals(name)) {
+ updateEnabledState();
+ }
+ }
+ };
+
+ public AbstractUndoRedoAction(Application app, View view) {
+ super(app, view);
+ }
+
+ /**
+ * Returns the action map key that identifies this action within a view
+ * (e.g. {@code "edit.undo"} or {@code "edit.redo"}).
+ */
+ protected abstract String getActionId();
+
+ protected void updateEnabledState() {
+ Action realAction = getRealAction();
+ setEnabled(realAction != null && realAction != this && realAction.isEnabled());
+ }
+
+ @Override
+ protected void updateView(View oldValue, View newValue) {
+ super.updateView(oldValue, newValue);
+ if (newValue != null
+ && newValue.getActionMap().get(getActionId()) != null
+ && newValue.getActionMap().get(getActionId()) != this) {
+ putValue(AbstractAction.NAME,
+ newValue.getActionMap().get(getActionId()).getValue(AbstractAction.NAME));
+ updateEnabledState();
+ }
+ }
+
+ @Override
+ protected void installViewListeners(View p) {
+ super.installViewListeners(p);
+ Action actionInView = p.getActionMap().get(getActionId());
+ if (actionInView != null && actionInView != this) {
+ actionInView.addPropertyChangeListener(actionPropertyListener);
+ }
+ }
+
+ @Override
+ protected void uninstallViewListeners(View p) {
+ super.uninstallViewListeners(p);
+ Action actionInView = p.getActionMap().get(getActionId());
+ if (actionInView != null && actionInView != this) {
+ actionInView.removePropertyChangeListener(actionPropertyListener);
+ }
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ Action realAction = getRealAction();
+ if (realAction != null && realAction != this) {
+ realAction.actionPerformed(e);
+ }
+ }
+
+ private Action getRealAction() {
+ return (getActiveView() == null) ? null : getActiveView().getActionMap().get(getActionId());
+ }
+}
diff --git a/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/RedoAction.java b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/RedoAction.java
index 794ab08c8..32fa600b5 100644
--- a/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/RedoAction.java
+++ b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/RedoAction.java
@@ -7,110 +7,37 @@
*/
package org.jhotdraw.action.edit;
-import java.awt.event.*;
-import java.beans.*;
-import javax.swing.*;
-import org.jhotdraw.action.AbstractViewAction;
import org.jhotdraw.api.app.Application;
import org.jhotdraw.api.app.View;
-import org.jhotdraw.util.*;
/**
* Redoes the last user action on the active view.
*
- * This action requires that the View returns a project
- * specific redo action when invoking getActionMap("redo") on a View.
+ * This action requires that the View returns a project specific redo action
+ * when invoking getActionMap("edit.redo") on a View.
*
- * This action is called when the user selects the Redo item in the Edit
- * menu. The menu item is automatically created by the application.
+ * This action is called when the user selects the Redo item in the Edit menu.
+ * The menu item is automatically created by the application.
*
* If you want this behavior in your application, you have to create an action
* with this ID and put it in your {@code ApplicationModel} in method
* {@link org.jhotdraw.app.ApplicationModel#initApplication}.
*
- *
* @author Werner Randelshofer
* @version $Id$
*/
-public class RedoAction extends AbstractViewAction {
+public class RedoAction extends AbstractUndoRedoAction {
private static final long serialVersionUID = 1L;
public static final String ID = "edit.redo";
- private ResourceBundleUtil labels = ResourceBundleUtil.getBundle("org.jhotdraw.action.Labels");
- private PropertyChangeListener redoActionPropertyListener = new PropertyChangeListener() {
- @Override
- public void propertyChange(PropertyChangeEvent evt) {
- String name = evt.getPropertyName();
- if ((name == null && AbstractAction.NAME == null) || (name != null && name.equals(AbstractAction.NAME))) {
- putValue(AbstractAction.NAME, evt.getNewValue());
- } else if ("enabled".equals(name)) {
- updateEnabledState();
- }
- }
- };
- /**
- * Creates a new instance.
- */
public RedoAction(Application app, View view) {
super(app, view);
labels.configureAction(this, ID);
}
- protected void updateEnabledState() {
- boolean isEnabled = false;
- Action realRedoAction = getRealRedoAction();
- if (realRedoAction != null && realRedoAction != this) {
- isEnabled = realRedoAction.isEnabled();
- }
- setEnabled(isEnabled);
- }
-
- @Override
- protected void updateView(View oldValue, View newValue) {
- super.updateView(oldValue, newValue);
- if (newValue != null
- && newValue.getActionMap().get(ID) != null
- && newValue.getActionMap().get(ID) != this) {
- putValue(AbstractAction.NAME, newValue.getActionMap().get(ID).
- getValue(AbstractAction.NAME));
- updateEnabledState();
- }
- }
-
- /**
- * Installs listeners on the view object.
- */
- @Override
- protected void installViewListeners(View p) {
- super.installViewListeners(p);
- Action redoActionInView = p.getActionMap().get(ID);
- if (redoActionInView != null && redoActionInView != this) {
- redoActionInView.addPropertyChangeListener(redoActionPropertyListener);
- }
- }
-
- /**
- * Installs listeners on the view object.
- */
@Override
- protected void uninstallViewListeners(View p) {
- super.uninstallViewListeners(p);
- Action redoActionInView = p.getActionMap().get(ID);
- if (redoActionInView != null && redoActionInView != this) {
- redoActionInView.removePropertyChangeListener(redoActionPropertyListener);
- }
- }
-
- @Override
- public void actionPerformed(ActionEvent e) {
- Action realAction = getRealRedoAction();
- if (realAction != null && realAction != this) {
- realAction.actionPerformed(e);
- }
- }
-
- private Action getRealRedoAction() {
- return (getActiveView() == null) ? null : getActiveView().getActionMap().get(ID);
+ protected String getActionId() {
+ return ID;
}
-}
+}
\ No newline at end of file
diff --git a/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/UndoAction.java b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/UndoAction.java
index 74c4f2cea..5f10e4540 100644
--- a/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/UndoAction.java
+++ b/jhotdraw-actions/src/main/java/org/jhotdraw/action/edit/UndoAction.java
@@ -7,22 +7,17 @@
*/
package org.jhotdraw.action.edit;
-import java.awt.event.*;
-import java.beans.*;
-import javax.swing.*;
-import org.jhotdraw.action.AbstractViewAction;
import org.jhotdraw.api.app.Application;
import org.jhotdraw.api.app.View;
-import org.jhotdraw.util.*;
/**
* Undoes the last user action.
*
- * This action requires that the View returns a project
- * specific undo action when invoking getActionMap("redo") on a View.
+ * This action requires that the View returns a project specific undo action
+ * when invoking getActionMap("edit.undo") on a View.
*
- * This action is called when the user selects the Undo item in the Edit
- * menu. The menu item is automatically created by the application.
+ * This action is called when the user selects the Undo item in the Edit menu.
+ * The menu item is automatically created by the application.
*
* If you want this behavior in your application, you have to create an action
* with this ID and put it in your {@code ApplicationModel} in method
@@ -31,85 +26,18 @@
* @author Werner Randelshofer
* @version $Id$
*/
-public class UndoAction extends AbstractViewAction {
+public class UndoAction extends AbstractUndoRedoAction {
private static final long serialVersionUID = 1L;
public static final String ID = "edit.undo";
- private ResourceBundleUtil labels = ResourceBundleUtil.getBundle("org.jhotdraw.action.Labels");
- private PropertyChangeListener redoActionPropertyListener = new PropertyChangeListener() {
- @Override
- public void propertyChange(PropertyChangeEvent evt) {
- String name = evt.getPropertyName();
- if ((name == null && AbstractAction.NAME == null) || (name != null && name.equals(AbstractAction.NAME))) {
- putValue(AbstractAction.NAME, evt.getNewValue());
- } else if ("enabled".equals(name)) {
- updateEnabledState();
- }
- }
- };
- /**
- * Creates a new instance.
- */
public UndoAction(Application app, View view) {
super(app, view);
labels.configureAction(this, ID);
}
- protected void updateEnabledState() {
- boolean isEnabled = false;
- Action realAction = getRealUndoAction();
- if (realAction != null && realAction != this) {
- isEnabled = realAction.isEnabled();
- }
- setEnabled(isEnabled);
- }
-
@Override
- protected void updateView(View oldValue, View newValue) {
- super.updateView(oldValue, newValue);
- if (newValue != null
- && newValue.getActionMap().get(ID) != null
- && newValue.getActionMap().get(ID) != this) {
- putValue(AbstractAction.NAME, newValue.getActionMap().get(ID).
- getValue(AbstractAction.NAME));
- updateEnabledState();
- }
- }
-
- /**
- * Installs listeners on the view object.
- */
- @Override
- protected void installViewListeners(View p) {
- super.installViewListeners(p);
- Action undoActionInView = p.getActionMap().get(ID);
- if (undoActionInView != null && undoActionInView != this) {
- undoActionInView.addPropertyChangeListener(redoActionPropertyListener);
- }
- }
-
- /**
- * Installs listeners on the view object.
- */
- @Override
- protected void uninstallViewListeners(View p) {
- super.uninstallViewListeners(p);
- Action undoActionInView = p.getActionMap().get(ID);
- if (undoActionInView != null && undoActionInView != this) {
- undoActionInView.removePropertyChangeListener(redoActionPropertyListener);
- }
- }
-
- @Override
- public void actionPerformed(ActionEvent e) {
- Action realUndoAction = getRealUndoAction();
- if (realUndoAction != null && realUndoAction != this) {
- realUndoAction.actionPerformed(e);
- }
- }
-
- private Action getRealUndoAction() {
- return (getActiveView() == null) ? null : getActiveView().getActionMap().get(ID);
+ protected String getActionId() {
+ return ID;
}
}
diff --git a/jhotdraw-core/pom.xml b/jhotdraw-core/pom.xml
index 7c276da85..192f5899d 100644
--- a/jhotdraw-core/pom.xml
+++ b/jhotdraw-core/pom.xml
@@ -35,6 +35,30 @@
6.8.21test
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+ org.mockito
+ mockito-core
+ 4.11.0
+ test
+
+
+ com.tngtech.jgiven
+ jgiven-junit
+ 1.2.5
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.24.2
+ test
+ ${project.groupId}jhotdraw-actions
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/AttributeChangeEditTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/AttributeChangeEditTest.java
new file mode 100644
index 000000000..314bb56b8
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/AttributeChangeEditTest.java
@@ -0,0 +1,65 @@
+package org.jhotdraw.draw.event;
+
+import org.jhotdraw.draw.AttributeKey;
+import org.jhotdraw.draw.figure.Figure;
+import org.junit.Before;
+import org.junit.Test;
+import static org.mockito.Mockito.*;
+
+public class AttributeChangeEditTest {
+
+ private Figure mockFigure;
+ private AttributeKey key;
+ private AttributeChangeEdit edit;
+
+ @Before
+ public void setUp() {
+ mockFigure = mock(Figure.class);
+ key = new AttributeKey<>("testAttribute", String.class, null);
+ edit = new AttributeChangeEdit<>(mockFigure, key, "oldValue", "newValue");
+ }
+
+ // -----------------------------------------------------------------------
+ // Best case scenarios
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testUndo_restoresOldAttributeValue() {
+ edit.undo();
+ verify(mockFigure).set(key, "oldValue");
+ }
+
+ @Test
+ public void testRedo_appliesNewAttributeValue() {
+ edit.undo();
+ edit.redo();
+ verify(mockFigure).set(key, "newValue");
+ }
+
+ // -----------------------------------------------------------------------
+ // Boundary cases — lifecycle callbacks
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testUndo_callsWillChangeBeforeSet() {
+ edit.undo();
+ // willChange() must be called before the figure is mutated
+ verify(mockFigure).willChange();
+ verify(mockFigure).set(key, "oldValue");
+ }
+
+ @Test
+ public void testUndo_callsChangedAfterSet() {
+ edit.undo();
+ verify(mockFigure).changed();
+ }
+
+ @Test
+ public void testRedo_callsWillChangeAndChangedTwiceTotal() {
+ edit.undo();
+ edit.redo();
+ // Both undo and redo call willChange()/changed()
+ verify(mockFigure, times(2)).willChange();
+ verify(mockFigure, times(2)).changed();
+ }
+}
\ No newline at end of file
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/TransformEditTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/TransformEditTest.java
new file mode 100644
index 000000000..0c133f029
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/event/TransformEditTest.java
@@ -0,0 +1,85 @@
+package org.jhotdraw.draw.event;
+
+import org.jhotdraw.draw.figure.Figure;
+import java.awt.geom.AffineTransform;
+import java.util.LinkedList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+public class TransformEditTest {
+
+ private Figure mockFigure;
+ private AffineTransform translation;
+ private TransformEdit edit;
+
+ @Before
+ public void setUp() {
+ mockFigure = mock(Figure.class);
+ // A pure translation is lossless and invertible — the correct use case for TransformEdit
+ translation = AffineTransform.getTranslateInstance(10, 20);
+ edit = new TransformEdit(mockFigure, translation);
+ }
+
+ // -----------------------------------------------------------------------
+ // Best case scenarios
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testUndo_callsTransformOnFigure() {
+ edit.undo();
+ verify(mockFigure).transform(any(AffineTransform.class));
+ }
+
+ @Test
+ public void testRedo_afterUndo_callsTransformTwice() {
+ edit.undo();
+ edit.redo();
+ verify(mockFigure, times(2)).transform(any(AffineTransform.class));
+ }
+
+ @Test
+ public void testUndo_callsWillChangeAndChanged() {
+ edit.undo();
+ verify(mockFigure).willChange();
+ verify(mockFigure).changed();
+ }
+
+ // -----------------------------------------------------------------------
+ // Boundary cases
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testAddEdit_sameFigureCollection_mergesTransforms() {
+ // Both edits must share the same Collection reference to merge
+ List sharedFigures = new LinkedList<>();
+ sharedFigures.add(mockFigure);
+
+ AffineTransform tx1 = AffineTransform.getTranslateInstance(10, 0);
+ AffineTransform tx2 = AffineTransform.getTranslateInstance(5, 0);
+
+ TransformEdit edit1 = new TransformEdit(sharedFigures, tx1);
+ TransformEdit edit2 = new TransformEdit(sharedFigures, tx2);
+
+ boolean merged = edit1.addEdit(edit2);
+ assertTrue(merged);
+ }
+
+ @Test
+ public void testAddEdit_differentFigureCollection_doesNotMerge() {
+ // Different collection references — merging must be refused
+ List figures1 = new LinkedList<>();
+ figures1.add(mockFigure);
+ List figures2 = new LinkedList<>();
+ figures2.add(mockFigure);
+
+ TransformEdit edit1 = new TransformEdit(figures1, translation);
+ TransformEdit edit2 = new TransformEdit(figures2, translation);
+
+ boolean merged = edit1.addEdit(edit2);
+ assertFalse(merged);
+ }
+}
\ No newline at end of file
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/undo/CompositeEditTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/undo/CompositeEditTest.java
new file mode 100644
index 000000000..32091c475
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/undo/CompositeEditTest.java
@@ -0,0 +1,60 @@
+package org.jhotdraw.undo;
+
+import javax.swing.undo.AbstractUndoableEdit;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class CompositeEditTest {
+
+ // -----------------------------------------------------------------------
+ // Best case scenarios
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testNewCompositeEdit_isInProgress() {
+ CompositeEdit edit = new CompositeEdit();
+ assertTrue(edit.isInProgress());
+ }
+
+ @Test
+ public void testAddNormalEdit_whileInProgress_remainsOpen() {
+ CompositeEdit edit = new CompositeEdit("test");
+ edit.addEdit(new AbstractUndoableEdit() {
+ private static final long serialVersionUID = 1L;
+ });
+ assertTrue(edit.isInProgress());
+ }
+
+ @Test
+ public void testPresentationName_whenSet_isReturned() {
+ CompositeEdit edit = new CompositeEdit("My Operation");
+ assertEquals("My Operation", edit.getPresentationName());
+ }
+
+ // -----------------------------------------------------------------------
+ // Boundary cases
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testAddEditToItself_endsComposite() {
+ CompositeEdit edit = new CompositeEdit();
+ edit.addEdit(edit);
+ assertFalse(edit.isInProgress());
+ }
+
+ @Test
+ public void testIsSignificant_whenConstructedFalse_returnsFalse() {
+ CompositeEdit edit = new CompositeEdit(false);
+ assertFalse(edit.isSignificant());
+ }
+
+ @Test
+ public void testIsSignificant_whenConstructedTrue_defaultsTrue() {
+ CompositeEdit edit = new CompositeEdit(true);
+ // A composite with no children has no significant children,
+ // so CompoundEdit.isSignificant() returns false even with flag=true.
+ // Verify that the flag alone does not override child significance.
+ edit.end();
+ assertFalse(edit.isSignificant());
+ }
+}
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/undo/NonUndoableEditTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/undo/NonUndoableEditTest.java
new file mode 100644
index 000000000..7e1e5c765
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/undo/NonUndoableEditTest.java
@@ -0,0 +1,19 @@
+package org.jhotdraw.undo;
+
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class NonUndoableEditTest {
+
+ @Test
+ public void testCanUndo_alwaysReturnsFalse() {
+ NonUndoableEdit edit = new NonUndoableEdit();
+ assertFalse(edit.canUndo());
+ }
+
+ @Test
+ public void testCanRedo_alwaysReturnsFalse() {
+ NonUndoableEdit edit = new NonUndoableEdit();
+ assertFalse(edit.canRedo());
+ }
+}
\ No newline at end of file
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoBddTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoBddTest.java
new file mode 100644
index 000000000..ae356ec5d
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoBddTest.java
@@ -0,0 +1,434 @@
+package org.jhotdraw.undo;
+
+import com.tngtech.jgiven.Stage;
+import com.tngtech.jgiven.annotation.ExpectedScenarioState;
+import com.tngtech.jgiven.annotation.ProvidedScenarioState;
+import com.tngtech.jgiven.junit.ScenarioTest;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.Action;
+import javax.swing.undo.AbstractUndoableEdit;
+import javax.swing.undo.CannotRedoException;
+import javax.swing.undo.CannotUndoException;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Behavior-Driven tests for Undo/Redo — TestLab2.
+ *
+ * User story: "As a drawing application user, I want to undo and redo
+ * drawing actions so that I can correct mistakes and restore reverted changes."
+ *
+ * Each test method maps one Acceptance Criterion to a Given–When–Then scenario
+ * automated with JGiven and verified with AssertJ.
+ */
+public class UndoRedoBddTest extends ScenarioTest<
+ UndoRedoBddTest.GivenUndoManager,
+ UndoRedoBddTest.WhenUndoRedo,
+ UndoRedoBddTest.ThenUndoRedo> {
+
+ // =========================================================================
+ // AC1 — Undo Action Execution
+ // =========================================================================
+
+ @Test
+ public void ac1_undo_reverts_the_most_recent_drawing_action() {
+ given().a_drawing_action_named_$_has_been_performed("Create Rectangle");
+ when().the_user_triggers_undo();
+ then().the_edit_is_reverted()
+ .and().redo_is_available();
+ }
+
+ // =========================================================================
+ // AC2 — Redo Action Execution
+ // =========================================================================
+
+ @Test
+ public void ac2_redo_reapplies_the_most_recently_undone_action() {
+ given().a_drawing_action_named_$_has_been_performed("Move Shape")
+ .and().the_action_has_been_undone();
+ when().the_user_triggers_redo();
+ then().the_edit_is_reapplied()
+ .and().undo_is_available();
+ }
+
+ // =========================================================================
+ // AC3 — Undo/Redo History Stack (configurable limit)
+ // =========================================================================
+
+ @Test
+ public void ac3_oldest_edit_is_discarded_when_history_limit_is_reached() {
+ given().a_fresh_manager_with_limit_$(2)
+ .and().$_drawing_actions_have_been_performed(3);
+ when().the_user_undoes_all_available_edits();
+ then().exactly_$_edits_were_undone(2);
+ }
+
+ // =========================================================================
+ // AC4a — Visual Feedback: Undo button disabled when stack is empty
+ // =========================================================================
+
+ @Test
+ public void ac4_undo_action_is_disabled_when_there_is_nothing_to_undo() {
+ given().a_fresh_undo_manager();
+ when().the_user_observes_the_toolbar();
+ then().the_undo_action_is_disabled();
+ }
+
+ // =========================================================================
+ // AC4b — Visual Feedback: Undo button enabled with tooltip after an edit
+ // =========================================================================
+
+ @Test
+ public void ac4_undo_action_is_enabled_and_shows_action_name_after_edit() {
+ given().a_drawing_action_named_$_has_been_performed("Create Rectangle");
+ when().the_user_observes_the_toolbar();
+ then().the_undo_action_is_enabled()
+ .and().the_undo_action_tooltip_contains("Create Rectangle");
+ }
+
+ // =========================================================================
+ // AC4c — Visual Feedback: Redo button disabled when nothing has been undone
+ // =========================================================================
+
+ @Test
+ public void ac4_redo_action_is_disabled_when_nothing_has_been_undone() {
+ given().a_fresh_undo_manager();
+ when().the_user_observes_the_toolbar();
+ then().the_redo_action_is_disabled();
+ }
+
+ // =========================================================================
+ // AC4d — Visual Feedback: Redo button enabled after an undo
+ // =========================================================================
+
+ @Test
+ public void ac4_redo_action_is_enabled_after_an_undo() {
+ given().a_drawing_action_named_$_has_been_performed("Create Rectangle")
+ .and().the_action_has_been_undone();
+ when().the_user_observes_the_toolbar();
+ then().the_redo_action_is_enabled();
+ }
+
+ // =========================================================================
+ // AC5 — Action Grouping (CompositeEdit)
+ // =========================================================================
+
+ @Test
+ public void ac5_sequential_similar_actions_grouped_into_one_undo_step() {
+ given().a_composite_of_$_sub_edits_named_$(3, "Move Shape");
+ when().the_user_triggers_undo();
+ then().all_$_grouped_edits_are_reverted(3)
+ .and().undo_is_not_available();
+ }
+
+ // =========================================================================
+ // AC6 — Redo Stack Clearing on New Action
+ // =========================================================================
+
+ @Test
+ public void ac6_redo_stack_is_cleared_when_new_action_is_performed_after_undo() {
+ given().a_drawing_action_named_$_has_been_performed("Create Shape A")
+ .and().the_action_has_been_undone();
+ when().a_new_action_named_$_is_performed("Create Shape B");
+ then().redo_is_not_available();
+ }
+
+ // =========================================================================
+ // AC7 — Undo/Redo with Multiple Objects
+ // =========================================================================
+
+ @Test
+ public void ac7_undo_atomically_reverts_changes_to_multiple_selected_objects() {
+ given().a_group_edit_modifying_$_objects(3);
+ when().the_user_triggers_undo();
+ then().all_$_grouped_edits_are_reverted(3);
+ }
+
+ // =========================================================================
+ // AC8 — Persistent State After Save
+ // =========================================================================
+
+ @Test
+ public void ac8_undo_redo_history_is_cleared_after_saving() {
+ given().a_fresh_undo_manager()
+ .and().$_drawing_actions_have_been_performed(3);
+ when().the_drawing_is_saved();
+ then().undo_is_not_available()
+ .and().redo_is_not_available();
+ }
+
+ // =========================================================================
+ // Stage: Given
+ // =========================================================================
+
+ public static class GivenUndoManager extends Stage {
+
+ @ProvidedScenarioState
+ UndoRedoManager manager;
+
+ @ProvidedScenarioState
+ TrackableEdit lastEdit;
+
+ @ProvidedScenarioState
+ List groupedEdits = new ArrayList<>();
+
+ public GivenUndoManager a_fresh_undo_manager() {
+ manager = new UndoRedoManager();
+ return self();
+ }
+
+ public GivenUndoManager a_fresh_manager_with_limit_$(int limit) {
+ manager = new UndoRedoManager();
+ manager.setLimit(limit);
+ return self();
+ }
+
+ public GivenUndoManager a_drawing_action_named_$_has_been_performed(String name) {
+ if (manager == null) {
+ manager = new UndoRedoManager();
+ }
+ lastEdit = new TrackableEdit(name);
+ manager.addEdit(lastEdit);
+ return self();
+ }
+
+ public GivenUndoManager the_action_has_been_undone() {
+ manager.undo();
+ return self();
+ }
+
+ public GivenUndoManager $_drawing_actions_have_been_performed(int count) {
+ for (int i = 1; i <= count; i++) {
+ manager.addEdit(new TrackableEdit("Edit " + i));
+ }
+ return self();
+ }
+
+ /**
+ * Creates a CompositeEdit grouping {@code count} sub-edits so that
+ * a single undo reverts all of them (AC5).
+ *
+ * Pattern: fire composite once to open it, fire sub-edits, fire the
+ * same composite instance again to close it (see CompositeEdit Javadoc).
+ */
+ public GivenUndoManager a_composite_of_$_sub_edits_named_$(int count, String name) {
+ manager = new UndoRedoManager();
+ groupedEdits = new ArrayList<>();
+ CompositeEdit composite = new CompositeEdit(name);
+ manager.addEdit(composite);
+ for (int i = 0; i < count; i++) {
+ TrackableEdit sub = new TrackableEdit(name + " step " + (i + 1));
+ groupedEdits.add(sub);
+ manager.addEdit(sub);
+ }
+ manager.addEdit(composite);
+ return self();
+ }
+
+ /**
+ * Creates a CompositeEdit wrapping simultaneous edits to N objects (AC7).
+ */
+ public GivenUndoManager a_group_edit_modifying_$_objects(int objectCount) {
+ manager = new UndoRedoManager();
+ groupedEdits = new ArrayList<>();
+ CompositeEdit group = new CompositeEdit("Move " + objectCount + " Objects");
+ manager.addEdit(group);
+ for (int i = 0; i < objectCount; i++) {
+ TrackableEdit edit = new TrackableEdit("Object " + (i + 1) + " change");
+ groupedEdits.add(edit);
+ manager.addEdit(edit);
+ }
+ manager.addEdit(group);
+ return self();
+ }
+ }
+
+ // =========================================================================
+ // Stage: When
+ // =========================================================================
+
+ public static class WhenUndoRedo extends Stage {
+
+ @ExpectedScenarioState
+ UndoRedoManager manager;
+
+ @ProvidedScenarioState
+ int successfulUndoCount;
+
+ public WhenUndoRedo the_user_triggers_undo() {
+ manager.undo();
+ return self();
+ }
+
+ public WhenUndoRedo the_user_triggers_redo() {
+ manager.redo();
+ return self();
+ }
+
+ public WhenUndoRedo the_user_undoes_all_available_edits() {
+ successfulUndoCount = 0;
+ while (manager.canUndo()) {
+ manager.undo();
+ successfulUndoCount++;
+ }
+ return self();
+ }
+
+ public WhenUndoRedo the_user_observes_the_toolbar() {
+ return self();
+ }
+
+ public WhenUndoRedo a_new_action_named_$_is_performed(String name) {
+ manager.addEdit(new TrackableEdit(name));
+ return self();
+ }
+
+ public WhenUndoRedo the_drawing_is_saved() {
+ manager.discardAllEdits();
+ return self();
+ }
+ }
+
+ // =========================================================================
+ // Stage: Then
+ // =========================================================================
+
+ public static class ThenUndoRedo extends Stage {
+
+ @ExpectedScenarioState
+ UndoRedoManager manager;
+
+ @ExpectedScenarioState
+ TrackableEdit lastEdit;
+
+ @ExpectedScenarioState
+ List groupedEdits;
+
+ @ExpectedScenarioState
+ int successfulUndoCount;
+
+ public ThenUndoRedo undo_is_available() {
+ assertThat(manager.canUndo())
+ .as("undo should be available").isTrue();
+ return self();
+ }
+
+ public ThenUndoRedo undo_is_not_available() {
+ assertThat(manager.canUndo())
+ .as("undo should not be available").isFalse();
+ return self();
+ }
+
+ public ThenUndoRedo redo_is_available() {
+ assertThat(manager.canRedo())
+ .as("redo should be available").isTrue();
+ return self();
+ }
+
+ public ThenUndoRedo redo_is_not_available() {
+ assertThat(manager.canRedo())
+ .as("redo should not be available").isFalse();
+ return self();
+ }
+
+ public ThenUndoRedo the_edit_is_reverted() {
+ assertThat(lastEdit.undoCount)
+ .as("edit undo invocation count").isEqualTo(1);
+ return self();
+ }
+
+ public ThenUndoRedo the_edit_is_reapplied() {
+ assertThat(lastEdit.redoCount)
+ .as("edit redo invocation count").isEqualTo(1);
+ return self();
+ }
+
+ public ThenUndoRedo exactly_$_edits_were_undone(int expected) {
+ assertThat(successfulUndoCount)
+ .as("number of edits successfully undone").isEqualTo(expected);
+ return self();
+ }
+
+ public ThenUndoRedo the_undo_action_is_disabled() {
+ assertThat(manager.getUndoAction().isEnabled())
+ .as("undo Action.isEnabled()").isFalse();
+ return self();
+ }
+
+ public ThenUndoRedo the_undo_action_is_enabled() {
+ assertThat(manager.getUndoAction().isEnabled())
+ .as("undo Action.isEnabled()").isTrue();
+ return self();
+ }
+
+ public ThenUndoRedo the_undo_action_tooltip_contains(String text) {
+ String tooltip = (String) manager.getUndoAction()
+ .getValue(Action.SHORT_DESCRIPTION);
+ assertThat(tooltip)
+ .as("undo action tooltip").contains(text);
+ return self();
+ }
+
+ public ThenUndoRedo the_redo_action_is_disabled() {
+ assertThat(manager.getRedoAction().isEnabled())
+ .as("redo Action.isEnabled()").isFalse();
+ return self();
+ }
+
+ public ThenUndoRedo the_redo_action_is_enabled() {
+ assertThat(manager.getRedoAction().isEnabled())
+ .as("redo Action.isEnabled()").isTrue();
+ return self();
+ }
+
+ public ThenUndoRedo all_$_grouped_edits_are_reverted(int expectedCount) {
+ assertThat(groupedEdits)
+ .as("grouped edit list size").hasSize(expectedCount);
+ for (TrackableEdit edit : groupedEdits) {
+ assertThat(edit.undoCount)
+ .as("undo count for grouped edit '%s'", edit.getName())
+ .isEqualTo(1);
+ }
+ return self();
+ }
+ }
+
+ // =========================================================================
+ // Helper: TrackableEdit — records how many times undo/redo was called
+ // =========================================================================
+
+ static class TrackableEdit extends AbstractUndoableEdit {
+
+ private static final long serialVersionUID = 1L;
+ private final String name;
+ int undoCount = 0;
+ int redoCount = 0;
+
+ TrackableEdit(String name) {
+ this.name = name;
+ }
+
+ String getName() {
+ return name;
+ }
+
+ @Override
+ public void undo() throws CannotUndoException {
+ super.undo();
+ undoCount++;
+ }
+
+ @Override
+ public void redo() throws CannotRedoException {
+ super.redo();
+ redoCount++;
+ }
+
+ @Override
+ public String getPresentationName() {
+ return name;
+ }
+ }
+}
diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoManagerTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoManagerTest.java
new file mode 100644
index 000000000..94ec287cb
--- /dev/null
+++ b/jhotdraw-core/src/test/java/org/jhotdraw/undo/UndoRedoManagerTest.java
@@ -0,0 +1,143 @@
+package org.jhotdraw.undo;
+
+import javax.swing.undo.AbstractUndoableEdit;
+import javax.swing.undo.CannotRedoException;
+import javax.swing.undo.CannotUndoException;
+import org.junit.Before;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class UndoRedoManagerTest {
+
+ private UndoRedoManager manager;
+
+ @Before
+ public void setUp() {
+ manager = new UndoRedoManager();
+ }
+
+ // -----------------------------------------------------------------------
+ // Best case scenarios
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testAddEdit_canUndoBecomesTrue() {
+ manager.addEdit(new SimpleEdit());
+ assertTrue(manager.canUndo());
+ }
+
+ @Test
+ public void testUndo_canRedoBecomesTrue() throws CannotUndoException {
+ manager.addEdit(new SimpleEdit());
+ manager.undo();
+ assertTrue(manager.canRedo());
+ }
+
+ @Test
+ public void testUndo_canUndoBecomesFalse() throws CannotUndoException {
+ manager.addEdit(new SimpleEdit());
+ manager.undo();
+ assertFalse(manager.canUndo());
+ }
+
+ @Test
+ public void testRedo_afterUndo_canUndoAgain() throws CannotUndoException, CannotRedoException {
+ manager.addEdit(new SimpleEdit());
+ manager.undo();
+ manager.redo();
+ assertTrue(manager.canUndo());
+ }
+
+ @Test
+ public void testHasSignificantEdits_afterSignificantEdit_isTrue() {
+ manager.addEdit(new SimpleEdit());
+ assertTrue(manager.hasSignificantEdits());
+ }
+
+ @Test
+ public void testUndoAction_enablesAfterAddEdit() {
+ assertFalse(manager.getUndoAction().isEnabled());
+ manager.addEdit(new SimpleEdit());
+ assertTrue(manager.getUndoAction().isEnabled());
+ }
+
+ @Test
+ public void testRedoAction_enablesAfterUndoEdit() throws CannotUndoException {
+ assertFalse(manager.getRedoAction().isEnabled());
+ manager.addEdit(new SimpleEdit());
+ manager.undo();
+ assertTrue(manager.getRedoAction().isEnabled());
+ }
+
+ // -----------------------------------------------------------------------
+ // Boundary cases
+ // -----------------------------------------------------------------------
+
+ @Test(expected = CannotUndoException.class)
+ public void testUndo_emptyStack_throwsCannotUndoException() {
+ manager.undo();
+ }
+
+ @Test(expected = CannotRedoException.class)
+ public void testRedo_nothingUndone_throwsCannotRedoException() {
+ manager.redo();
+ }
+
+ @Test
+ public void testDiscardAllEdits_canUndo_becomesFalse() {
+ manager.addEdit(new SimpleEdit());
+ manager.discardAllEdits();
+ assertFalse(manager.canUndo());
+ }
+
+ @Test
+ public void testDiscardAllEdits_canRedo_becomesFalse() throws CannotUndoException {
+ manager.addEdit(new SimpleEdit());
+ manager.undo();
+ manager.discardAllEdits();
+ assertFalse(manager.canRedo());
+ }
+
+ @Test
+ public void testDiscardAllEdits_hasSignificantEdits_becomesFalse() {
+ manager.addEdit(new SimpleEdit());
+ manager.discardAllEdits();
+ assertFalse(manager.hasSignificantEdits());
+ }
+
+ @Test
+ public void testHasSignificantEdits_nonSignificantEdit_staysFalse() {
+ manager.addEdit(new NonSignificantEdit());
+ assertFalse(manager.hasSignificantEdits());
+ }
+
+ @Test
+ public void testAddDiscardAllEdits_disablesUndoAndRedo() {
+ manager.addEdit(new SimpleEdit());
+ manager.addEdit(UndoRedoManager.DISCARD_ALL_EDITS);
+ assertFalse(manager.canUndo());
+ assertFalse(manager.canRedo());
+ }
+
+ // -----------------------------------------------------------------------
+ // Helper stubs
+ // -----------------------------------------------------------------------
+
+ private static class SimpleEdit extends AbstractUndoableEdit {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public String getPresentationName() {
+ return "Simple Edit";
+ }
+ }
+
+ private static class NonSignificantEdit extends AbstractUndoableEdit {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public boolean isSignificant() {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/jhotdraw-utils/src/main/java/org/jhotdraw/undo/CompositeEdit.java b/jhotdraw-utils/src/main/java/org/jhotdraw/undo/CompositeEdit.java
index dc097d75f..cef7916f9 100644
--- a/jhotdraw-utils/src/main/java/org/jhotdraw/undo/CompositeEdit.java
+++ b/jhotdraw-utils/src/main/java/org/jhotdraw/undo/CompositeEdit.java
@@ -37,12 +37,6 @@ public class CompositeEdit extends CompoundEdit {
private static final long serialVersionUID = 1L;
private String presentationName;
private boolean isSignificant;
- private boolean isVerbose;
-
- public void setVerbose(boolean b) {
- isVerbose = b;
- }
-
/**
* Creates a new {@code CompositeEdit} which uses CompoundEdit#getPresentationName()
* and is significant..
@@ -144,6 +138,7 @@ public String getRedoPresentationName() {
*/
@Override
public boolean addEdit(UndoableEdit anEdit) {
+ assert anEdit != null : "Cannot add a null edit to CompositeEdit"; //NOSONAR java:S4274
if (anEdit == this) {
end();
return true;
@@ -161,8 +156,7 @@ public boolean addEdit(UndoableEdit anEdit) {
*/
@Override
public boolean isSignificant() {
- return (isSignificant) ? super.isSignificant() : false;
- //return isSignificant;
+ return isSignificant && super.isSignificant();
}
public void setSignificant(boolean newValue) {
diff --git a/jhotdraw-utils/src/main/java/org/jhotdraw/undo/UndoRedoManager.java b/jhotdraw-utils/src/main/java/org/jhotdraw/undo/UndoRedoManager.java
index 40ac0b4ce..0d3f44a03 100644
--- a/jhotdraw-utils/src/main/java/org/jhotdraw/undo/UndoRedoManager.java
+++ b/jhotdraw-utils/src/main/java/org/jhotdraw/undo/UndoRedoManager.java
@@ -187,6 +187,9 @@ public boolean hasSignificantEdits() {
*/
@Override
public boolean addEdit(UndoableEdit anEdit) {
+ // Invariant: callers must never pass null.
+ // assert is intentional (enabled with -ea) to catch programming errors during development. //NOSONAR java:S4274
+ assert anEdit != null : "UndoableEdit must not be null";
if (DEBUG) {
System.out.println("UndoRedoManager@" + hashCode() + ".add " + anEdit);
}
@@ -255,6 +258,7 @@ private void updateActions() {
@Override
public void undo()
throws CannotUndoException {
+ assert canUndo() : "undo() called with nothing to undo — check canUndo() first"; //NOSONAR java:S4274
undoOrRedoInProgress = true;
try {
super.undo();
@@ -272,6 +276,7 @@ public void undo()
@Override
public void redo()
throws CannotUndoException {
+ assert canRedo() : "redo() called with nothing to redo — check canRedo() first"; //NOSONAR java:S4274
undoOrRedoInProgress = true;
try {
super.redo();