/*
* Copyright 2003-2017 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jetbrains.mps.smodel;
import jetbrains.mps.project.Project;
import jetbrains.mps.smodel.TestModelFactory.TestModelAccess;
import jetbrains.mps.smodel.TestModelFactory.TestRepository;
import jetbrains.mps.smodel.references.UnregisteredNodes;
import jetbrains.mps.smodel.undo.UndoContext;
import jetbrains.mps.util.Computable;
import jetbrains.mps.util.IterableUtil;
import org.jetbrains.mps.openapi.model.SModel;
import org.jetbrains.mps.openapi.model.SNode;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ErrorCollector;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
import static jetbrains.mps.smodel.TestModelFactory.countTreeNodes;
import static jetbrains.mps.smodel.TestModelFactory.ourRole;
import static org.hamcrest.CoreMatchers.equalTo;
/**
* Test undo/redo for model modifications.
* 'Detached' node refers to a node that has been attached to a model at some point and was detached during the command.
* 'Free' node refers to a free-floating, usually newly created node not yet attached to any model.
* <p/>
* In tests here, we stick to single undo step as it's not our goal to check complete undo mechanism. Rather, we focus on
* SNode/SModel interaction with UndoHelper/UndoHandler (i.e. how and if commands are added to undo stack), and for that,
* single undo level is pretty sufficient.
*
* @author Artem Tikhomirov
*/
public class ModelUndoTest {
@Rule
public ErrorCollector myErrors = new ErrorCollector();
private final TestModelAccess myModelAccess = new TestModelAccess();
private final TestRepository myRepo = new TestRepository(myModelAccess);
private final TestUndoHandler myUndo = new TestUndoHandler();
@Before
public void setUp() {
UndoHelper.getInstance().setUndoHandler(myUndo);
}
/**
* Model M, node Nc from M.
* New node Np is constructed, floating free. Nc is removed from M ('detached') and added as child of Np.
* Np is added back to M.
* Ensure undo/redo for the change brings Nc back and doesn't fail.
*/
@Test
public void testChangeFreeNodeChangedWithDetached() {
final TestModelFactory m1f = new TestModelFactory();
SModel m1 = m1f.createModel(3, 2);
myModelAccess.enterCommand();
m1f.attachTo(myRepo);
final int initialNodeCount = m1f.countModelNodes();
//
UnregisteredNodes.instance().enable(); // mimic beforeCommand listener behavior
final SNode r1 = m1f.getRoot(1);
final SNode r1c2 = r1.getChildren().iterator().next().getNextSibling();
final SNode freeFloatNode = m1f.createNode();
// detach r1c2 from its parent
r1c2.delete();
Assert.assertEquals(1, myUndo.actualUndoActionCount());
freeFloatNode.addChild(ourRole, r1c2);
Assert.assertEquals(2, myUndo.actualUndoActionCount());
r1.addChild(ourRole, freeFloatNode);
Assert.assertEquals(3, myUndo.actualUndoActionCount());
myUndo.flushCommand(null);
UnregisteredNodes.instance().disable(); // mimic afterCommand listener behavior
Assert.assertEquals(initialNodeCount + 1, m1f.countModelNodes());
Assert.assertNotNull(m1.getNode(freeFloatNode.getNodeId()));
//
Assert.assertEquals(1, myUndo.myUndoStack.size()); // 1 command
final UndoUnit undoElement = myUndo.myUndoStack.peek();
undoElement.undo();
Assert.assertEquals(initialNodeCount, m1f.countModelNodes());
Assert.assertNull(m1.getNode(freeFloatNode.getNodeId()));
undoElement.redo();
Assert.assertEquals(initialNodeCount + 1, m1f.countModelNodes());
Assert.assertNotNull(m1.getNode(freeFloatNode.getNodeId()));
Assert.assertEquals(2, countTreeNodes(Collections.singleton(freeFloatNode)));
}
/**
* Ensure we don't track undo/redo until it comes to user changes.
*/
@Test
public void testNoUndoDuringModelConstruction() {
final TestModelFactory m1f = new TestModelFactory();
SModel m1 = m1f.createModel(3, 1);
myModelAccess.enterCommand();
m1f.attachTo(myRepo);
final jetbrains.mps.smodel.SModel modelData = (jetbrains.mps.smodel.SModel) m1f.getModelData();
final SNode r1 = m1.getRootNodes().iterator().next();
modelData.enterUpdateMode();
// update mode is on, add 1+3 nodes, observe no undo
r1.addChild(ourRole, m1f.createNode(3));
Assert.assertEquals(0, myUndo.actualUndoActionCount());
myUndo.flushCommand(null);
Assert.assertEquals(0, myUndo.myUndoStack.size());
Assert.assertEquals(6 + 4, countTreeNodes(m1.getRootNodes()));
modelData.leaveUpdateMode();
// update is over, modify and see undo/redo commands do get collected
r1.addChild(ourRole, m1f.createNode(3));
Assert.assertEquals(1, myUndo.actualUndoActionCount());
myUndo.flushCommand(null);
Assert.assertEquals(1, myUndo.myUndoStack.size());
Assert.assertEquals(6 + 4 + 4, countTreeNodes(m1.getRootNodes()));
}
/**
* Ensure we don't track undo/redo for free-floating nodes
*/
@Test
public void testNoUndoForFreeNode() {
myModelAccess.disableRead();
// create a free-floating node
final TestModelFactory m1f = new TestModelFactory();
SNode n = m1f.createNode(3);
Assert.assertEquals(0, myUndo.actualUndoActionCount());
myUndo.flushCommand(null);
Assert.assertEquals(0, myUndo.myUndoStack.size());
// modify free-floating further
n.setProperty(SNodeUtil.property_INamedConcept_name, "XXX");
n.addChild(ourRole, m1f.createNode(5));
Assert.assertEquals(0, myUndo.actualUndoActionCount());
myUndo.flushCommand(null);
Assert.assertEquals(0, myUndo.myUndoStack.size());
}
/**
* Regular undo/redo check. Delete child case is not special, we check addition in the #testNoUndoDuringModelConstruction()
* thus delete is the operation we picked for a regular change.
*/
@Test
public void testNodeDeleteUndoRedo() {
final TestModelFactory m1f = new TestModelFactory();
m1f.createModel(3, 5, 2, 3);
myModelAccess.enterCommand();
m1f.attachTo(myRepo);
final int initialNodeCount = m1f.countModelNodes();
SNode r1c2 = m1f.getRoot(1).getFirstChild().getNextSibling();
Assert.assertEquals(2, IterableUtil.asCollection(r1c2.getChildren()).size());
// remove one of two nodes under second child of the first root. Deleted node has 3 children, total number of removed nodes is 4.
final SNode toRemove = r1c2.getChildren().iterator().next();
toRemove.delete();
final int expectedNodeCount = initialNodeCount - 4;
myUndo.flushCommand(null);
//
Assert.assertEquals(1, IterableUtil.asCollection(r1c2.getChildren()).size());
Assert.assertEquals(1, myUndo.myUndoStack.size()); // 1 command
final UndoUnit undoElement = myUndo.myUndoStack.peek();
Assert.assertEquals(1, undoElement.myActions.size()); // with 1 undo action in there
final int withRemovedNodeCount = m1f.countModelNodes();
Assert.assertEquals(expectedNodeCount, withRemovedNodeCount);
//
undoElement.undo();
Assert.assertEquals(1, myUndo.myUndoStack.size()); // still 1 command
Assert.assertEquals(2, IterableUtil.asCollection(r1c2.getChildren()).size());
Assert.assertEquals(initialNodeCount, m1f.countModelNodes());
//
undoElement.redo();
Assert.assertEquals(1, myUndo.myUndoStack.size()); // still 1 command
Assert.assertEquals(1, IterableUtil.asCollection(r1c2.getChildren()).size());
Assert.assertEquals(expectedNodeCount, m1f.countModelNodes());
}
/**
* With SNodeOwner, we need to make sure owner of a child removed from a detached tree is the one that is fine with undo
* Tree A->B->C. First, remove B, then remove C from B.
*/
@Test
public void testRemoveChildOfRemoved() {
final TestModelFactory m1f = new TestModelFactory();
m1f.createModel(1, 1, 1);
myModelAccess.enterCommand();
m1f.attachTo(myRepo);
SNode r1 = m1f.getRoot(1);
SNode r1c1 = r1.getFirstChild();
r1.removeChild(r1c1);
final SNode c = r1c1.getFirstChild();
r1c1.removeChild(c);
myUndo.flushCommand(null);
Assert.assertEquals(1, m1f.countModelNodes());
final UndoUnit undoElement = myUndo.myUndoStack.peek();
undoElement.undo();
Assert.assertEquals(3, m1f.countModelNodes());
undoElement.redo();
Assert.assertEquals(1, m1f.countModelNodes());
}
/**
* RemoveChildUndoableAction used to rely on SModelOperations' insertAfter operation.
* OpenAPI SNode provides only insertAfter. The test ensures transition is ok.
*/
@Test
public void testRemoveChildAnchor() {
final TestModelFactory m1f = new TestModelFactory();
m1f.createModel(3, 3);
final int initialNodeCount = m1f.countModelNodes();
myModelAccess.enterCommand();
m1f.attachTo(myRepo);
// one with anchor == null
SNode r1c1 = m1f.getRoot(1).getFirstChild();
// one with anchor != null
SNode r2c2 = m1f.getRoot(2).getFirstChild().getNextSibling();
// one with anchor == last element in the list
SNode r3c3 = m1f.getRoot(3).getLastChild();
r1c1.delete();
myUndo.flushCommand(null);
r2c2.delete();
myUndo.flushCommand(null);
r3c3.delete();
myUndo.flushCommand(null);
Assert.assertEquals(3, myUndo.actualStackSize());
Assert.assertEquals(initialNodeCount - 3, m1f.countModelNodes()); // -number of deleted nodes
myUndo.myUndoStack.pop().undo();
myUndo.myUndoStack.pop().undo();
myUndo.myUndoStack.pop().undo();
myErrors.checkThat(m1f.getRoot(1).getFirstChild(), equalTo(r1c1));
myErrors.checkThat(m1f.getRoot(2).getFirstChild().getNextSibling(), equalTo(r2c2));
myErrors.checkThat(m1f.getRoot(3).getLastChild(), equalTo(r3c3));
Assert.assertEquals(initialNodeCount, m1f.countModelNodes());
}
/*package*/ static class TestUndoHandler implements UndoHandler {
private final Deque<SNodeUndoableAction> myActions = new ArrayDeque<SNodeUndoableAction>();
public final Deque<UndoUnit> myUndoStack = new ArrayDeque<UndoUnit>();
// to keep tests simple, assume model modifications run inside a command.
private boolean myIsInsideCommand = true;
private boolean myIsUndoBlocked = false;
private boolean myNeedUndo = true;
@Override
public void addUndoableAction(SNodeUndoableAction action) {
if (myNeedUndo && myIsInsideCommand && !myIsUndoBlocked) {
myActions.add(action);
}
}
@Override
public <T> T runNonUndoableAction(Computable<T> t) {
final boolean oldValue = myIsUndoBlocked;
try {
myIsUndoBlocked = true;
return t.compute();
} finally {
myIsUndoBlocked = oldValue;
}
}
@Override
public void flushCommand(Project p) {
if (myActions.isEmpty()) {
return;
}
myUndoStack.push(new UndoUnit(new ArrayList<>(myActions), this));
myActions.clear();
}
@Override
public void startCommand(UndoContext context) {
}
/**
* Mimics undo-transparent command - instead of recording undo actions, just discards all of the.
* Approach is dubious: ModelAccess.runUndoTransparentCommand doesn't affect UndoHelper.needRegisterUndo (isInsideUndoableCommand)
* and recorded actions are merely ignored once command is over. The core defect seems to be independence of ModelAccess and UndoHelper
* FIXME likely, UndoHelper.needRegisterUndo shall track 'undo transparent' commands and reply accordingly. Alas, SNode.assertCanChange
* asks isInsideUndoableCommand directly (not needRegisterUndo), and we might need to change this check as well.
*/
/*package*/ void discard() {
myActions.clear();
}
/*package*/ int actualUndoActionCount() {
return myActions.size();
}
/*package*/ int actualStackSize() {
return myUndoStack.size();
}
/*package*/ void needsUndo(boolean needsUndo) {
myNeedUndo = needsUndo;
}
/*package*/ void setInsideCommand(boolean insideCommand) {
myIsInsideCommand = insideCommand;
}
}
private static class UndoUnit {
public final List<SNodeUndoableAction> myActions;
// FIXME Unfortunately, can't use UndoHelper.getInstance().runNonUndoableAction as long as
// UndoHelper goes to ModelAccess.instance(), and there's no way to affect that nor to override ModelAccess instance
// (latter is of course possible, but just too much of superfluous work).
private TestUndoHandler myHandler;
public UndoUnit(List<SNodeUndoableAction> actions, TestUndoHandler uh) {
myActions = actions;
myHandler = uh;
}
public void undo() {
UnregisteredNodes.instance().enable();
final ArrayList<SNodeUndoableAction> reversed = new ArrayList<SNodeUndoableAction>(myActions);
Collections.reverse(reversed);
for (SNodeUndoableAction a : reversed) {
a.undo();
}
myHandler.discard();
UnregisteredNodes.instance().disable();
}
public void redo() {
UnregisteredNodes.instance().enable();
for (SNodeUndoableAction a : myActions) {
a.redo();
}
myHandler.discard();
UnregisteredNodes.instance().disable();
}
}
}