diff --git a/src/main/java/net/kemitix/node/AbstractNodeItem.java b/src/main/java/net/kemitix/node/AbstractNodeItem.java new file mode 100644 index 0000000..6aa90e7 --- /dev/null +++ b/src/main/java/net/kemitix/node/AbstractNodeItem.java @@ -0,0 +1,157 @@ +package net.kemitix.node; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * An abstract node item, providing default implementations for most read-only + * operations. + * + * @param the type of data stored in each node + * + * @author pcampbell + */ +abstract class AbstractNodeItem implements Node { + + private T data; + + private String name; + + private Node parent; + + private final Set> children; + + protected AbstractNodeItem( + final T data, final String name, final Node parent, + final Set> children) { + this.data = data; + this.name = name; + this.parent = parent; + this.children = children; + } + + @Override + public String getName() { + return name; + } + + @Override + public Optional getData() { + return Optional.ofNullable(data); + } + + @Override + public boolean isEmpty() { + return data == null; + } + + @Override + public Optional> getParent() { + return Optional.ofNullable(parent); + } + + @Override + public Set> getChildren() { + return new HashSet<>(children); + } + + /** + * Fetches the node for the child if present. + * + * @param child the child's data to search for + * + * @return an {@link Optional} containing the child node if found + */ + @Override + public Optional> findChild(final T child) { + if (child == null) { + throw new NullPointerException("child"); + } + return children.stream().filter(node -> { + final Optional d = node.getData(); + return d.isPresent() && d.get().equals(child); + }).findAny(); + } + + @Override + public Node getChild(final T child) { + return findChild(child).orElseThrow( + () -> new NodeException("Child not found")); + } + + /** + * Checks if the node is an ancestor. + * + * @param node the potential ancestor + * + * @return true if the node is an ancestor + */ + @Override + public boolean isDescendantOf(final Node node) { + return parent != null && (node.equals(parent) || parent.isDescendantOf( + node)); + } + + /** + * Walks the node tree using the path to select each child. + * + * @param path the path to the desired child + * + * @return the child or null + */ + @Override + public Optional> findInPath(final List path) { + if (path == null) { + throw new NullPointerException("path"); + } + if (path.size() > 0) { + Optional> found = findChild(path.get(0)); + if (found.isPresent()) { + if (path.size() > 1) { + return found.get().findInPath(path.subList(1, path.size())); + } + return found; + } + } + return Optional.empty(); + } + + @Override + public Optional> findChildByName(final String named) { + if (named == null) { + throw new NullPointerException("name"); + } + return children.stream() + .filter(n -> n.getName().equals(named)) + .findAny(); + } + + @Override + public Node getChildByName(final String named) { + return findChildByName(named).orElseThrow( + () -> new NodeException("Named child not found")); + } + + @Override + public String drawTree(final int depth) { + final StringBuilder sb = new StringBuilder(); + final String unnamed = "(unnamed)"; + if (isNamed()) { + sb.append(String.format("[%1$" + (depth + name.length()) + "s]\n", + name)); + } else if (!children.isEmpty()) { + sb.append( + String.format("[%1$" + (depth + unnamed.length()) + "s]\n", + unnamed)); + } + getChildren().forEach(c -> sb.append(c.drawTree(depth + 1))); + return sb.toString(); + } + + @Override + public boolean isNamed() { + return name != null && name.length() > 0; + } +} diff --git a/src/main/java/net/kemitix/node/ImmutableNodeItem.java b/src/main/java/net/kemitix/node/ImmutableNodeItem.java new file mode 100644 index 0000000..7b80a59 --- /dev/null +++ b/src/main/java/net/kemitix/node/ImmutableNodeItem.java @@ -0,0 +1,95 @@ +package net.kemitix.node; + +import java.util.List; +import java.util.Set; + +/** + * Represents an immutable tree of nodes. + * + *

Due to the use of generics the data within a node may not be immutable. + * (We can't create a defensive copy.) So if a user were to use {@code + * getData()} they could then modify the original data within the node. This + * wouldn't affect the integrity of the node tree structure, however.

+ * + * @param the type of data stored in each node + * + * @author pcampbell + */ +final class ImmutableNodeItem extends AbstractNodeItem { + + private static final String IMMUTABLE_OBJECT = "Immutable object"; + + private ImmutableNodeItem( + final T data, final String name, final Node parent, + final Set> children) { + super(data, name, parent, children); + } + + static ImmutableNodeItem newRoot( + final T data, final String name, final Set> children) { + return new ImmutableNodeItem<>(data, name, null, children); + } + + static ImmutableNodeItem newChild( + final T data, final String name, final Node parent, + final Set> children) { + return new ImmutableNodeItem<>(data, name, parent, children); + } + + @Override + public void setName(final String name) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void setData(final T data) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void setParent(final Node parent) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void addChild(final Node child) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public Node createChild(final T child) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public Node createChild(final T child, final String name) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void createDescendantLine(final List descendants) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public Node findOrCreateChild(final T child) { + return findChild(child).orElseThrow( + () -> new UnsupportedOperationException(IMMUTABLE_OBJECT)); + } + + @Override + public void insertInPath(final Node node, final String... path) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void removeChild(final Node node) { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + + @Override + public void removeParent() { + throw new UnsupportedOperationException(IMMUTABLE_OBJECT); + } + +} diff --git a/src/main/java/net/kemitix/node/NodeItem.java b/src/main/java/net/kemitix/node/NodeItem.java index 1a83c73..e688355 100644 --- a/src/main/java/net/kemitix/node/NodeItem.java +++ b/src/main/java/net/kemitix/node/NodeItem.java @@ -14,7 +14,7 @@ import java.util.function.Function; * * @author pcampbell */ -public class NodeItem implements Node { +class NodeItem implements Node { private T data; diff --git a/src/main/java/net/kemitix/node/Nodes.java b/src/main/java/net/kemitix/node/Nodes.java index 24dc50e..ecc5f39 100644 --- a/src/main/java/net/kemitix/node/Nodes.java +++ b/src/main/java/net/kemitix/node/Nodes.java @@ -1,5 +1,9 @@ package net.kemitix.node; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + /** * Utility class for {@link Node} items. * @@ -52,7 +56,7 @@ public final class Nodes { * Creates a new named child node. * * @param data the data the node will contain - * @param name the name of the node + * @param name the name of the node * @param parent the parent of the node * @param the type of the data * @@ -63,4 +67,41 @@ public final class Nodes { return new NodeItem<>(data, name, parent); } + /** + * Creates an immutable copy of an existing node tree. + * + * @param root the root node of the source tree + * @param the type of the data + * + * @return the immutable copy of the tree + */ + public static Node asImmutable(final Node root) { + if (root.getParent().isPresent()) { + throw new IllegalArgumentException("source must be the root node"); + } + final Set> children = getImmutableChildren(root); + return ImmutableNodeItem.newRoot(root.getData().orElse(null), + root.getName(), children); + } + + private static Set> getImmutableChildren(final Node source) { + return source.getChildren() + .stream() + .map(Nodes::asImmutableChild) + .collect(Collectors.toSet()); + } + + private static Node asImmutableChild( + final Node source) { + final Optional> sourceParent = source.getParent(); + if (sourceParent.isPresent()) { + return ImmutableNodeItem.newChild(source.getData().orElse(null), + source.getName(), sourceParent.get(), + getImmutableChildren(source)); + } else { + throw new IllegalArgumentException( + "source must not be the root node"); + } + } + } diff --git a/src/test/java/net/kemitix/node/ImmutableNodeItemTest.java b/src/test/java/net/kemitix/node/ImmutableNodeItemTest.java new file mode 100644 index 0000000..9475ff5 --- /dev/null +++ b/src/test/java/net/kemitix/node/ImmutableNodeItemTest.java @@ -0,0 +1,449 @@ +package net.kemitix.node; + +import lombok.val; +import org.assertj.core.api.SoftAssertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +/** + * Test for {@link ImmutableNodeItem}. + * + * @author pcampbell + */ +public class ImmutableNodeItemTest { + + private static final String IMMUTABLE_OBJECT = "Immutable object"; + + @Rule + public ExpectedException exception = ExpectedException.none(); + + private Node immutableNode; + + private void expectImmutableException() { + exception.expect(UnsupportedOperationException.class); + exception.expectMessage(IMMUTABLE_OBJECT); + } + + @Test + public void getDataReturnsData() { + //given + val data = "this immutableNode data"; + //when + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(data)); + //then + assertThat(immutableNode.getData()).as( + "can get the data from a immutableNode"). + contains(data); + } + + @Test + public void canCreateAnEmptyAndUnnamedNode() { + //when + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + //then + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(immutableNode.isEmpty()) + .as("immutableNode is empty") + .isTrue(); + softly.assertThat(immutableNode.isNamed()) + .as("immutableNode is unnamed") + .isFalse(); + softly.assertAll(); + } + + @Test + public void shouldThrowExceptionOnSetName() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + expectImmutableException(); + //when + immutableNode.setName("named"); + } + + @Test + public void rootNodeShouldHaveNoParent() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("data")); + //then + assertThat(immutableNode.getParent()).as( + "immutableNode created without a parent has no parent") + .isEmpty(); + } + + @Test + public void shouldContainImmutableCopyOfChild() { + //given + val parent = Nodes.unnamedRoot("root"); + val child = Nodes.namedChild("child", "child", parent); + //when + immutableNode = Nodes.asImmutable(parent); + //then + val immutableChild = immutableNode.getChildByName("child"); + assertThat(immutableChild).isNotSameAs(child); + assertThat(immutableChild.getName()).isEqualTo("child"); + } + + @Test + public void childShouldHaveImmutableParent() { + //given + val parent = Nodes.namedRoot("parent", "root"); + Nodes.namedChild("subject", "child", parent); + //when + immutableNode = Nodes.asImmutable(parent); + //then + // get the immutable node's child's parent + val immutableChild = immutableNode.getChildByName("child"); + final Optional> optionalParent + = immutableChild.getParent(); + if (optionalParent.isPresent()) { + val p = optionalParent.get(); + assertThat(p).hasFieldOrPropertyWithValue("name", "root") + .hasFieldOrPropertyWithValue("data", + Optional.of("parent")); + } + } + + @Test + public void shouldNotBeAbleToAddChildToImmutableTree() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("root")); + expectImmutableException(); + //when + Nodes.unnamedChild("child", immutableNode); + } + + @Test + public void shouldThrowExceptionWhenSetParent() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("subject")); + expectImmutableException(); + //when + immutableNode.setParent(null); + } + + @Test + public void shouldThrowExceptionWhenAddingChild() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("subject")); + expectImmutableException(); + //when + immutableNode.addChild(Nodes.unnamedRoot("child")); + } + + /** + * Test that we can walk a tree to the target node. + */ + @Test + public void shouldWalkTreeToNode() { + //given + val root = Nodes.unnamedRoot("root"); + Nodes.namedChild("child", "child", Nodes.unnamedChild("parent", root)); + immutableNode = Nodes.asImmutable(root); + //when + val result = immutableNode.findInPath(Arrays.asList("parent", "child")); + //then + assertThat(result.isPresent()).isTrue(); + if (result.isPresent()) { + assertThat(result.get().getName()).isEqualTo("child"); + } + } + + /** + * Test that we get an empty {@link Optional} when walking a path that + * doesn't exist. + */ + @Test + public void shouldNotFindNonExistentChildNode() { + //given + val root = Nodes.unnamedRoot("root"); + Nodes.unnamedChild("child", Nodes.unnamedChild("parent", root)); + immutableNode = Nodes.asImmutable(root); + //when + val result = immutableNode.findInPath( + Arrays.asList("parent", "no child")); + //then + assertThat(result.isPresent()).isFalse(); + } + + /** + * Test that when we pass null we get an exception. + */ + @Test + public void shouldThrowNEWhenWalkTreeNull() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("subject")); + exception.expect(NullPointerException.class); + exception.expectMessage("path"); + //when + immutableNode.findInPath(null); + } + + /** + * Test that when we pass an empty path we get and empty {@link Optional} as + * a result. + */ + @Test + public void shouldReturnEmptyForEmptyWalkTreePath() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("subject")); + //when + val result = immutableNode.findInPath(Collections.emptyList()); + //then + assertThat(result).isEmpty(); + } + + /** + * Test that we can find a child of a immutableNode. + */ + @Test + public void shouldFindExistingChildNode() { + //given + val root = Nodes.unnamedRoot("root"); + Nodes.unnamedChild("child", root); + immutableNode = Nodes.asImmutable(root); + //when + val result = immutableNode.findChild("child"); + //then + assertThat(result.isPresent()).isTrue(); + if (result.isPresent()) { + assertThat(result.get().getData()).contains("child"); + } + } + + /** + * Test that if we pass null we get an exception. + */ + @Test + public void findOrCreateChildShouldThrowNPEFWhenChildIsNull() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("subject")); + exception.expect(NullPointerException.class); + exception.expectMessage("child"); + //when + immutableNode.findOrCreateChild(null); + } + + /** + * Test that we throw an exception when passed null. + */ + @Test + public void getChildShouldThrowNPEWhenThereIsNoChild() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("data")); + exception.expect(NullPointerException.class); + exception.expectMessage("child"); + //when + immutableNode.findChild(null); + } + + @Test + public void getChildNamedFindsChild() { + //given + val root = Nodes.namedRoot("root data", "root"); + val alpha = Nodes.namedRoot("alpha data", "alpha"); + val beta = Nodes.namedRoot("beta data", "beta"); + root.addChild(alpha); + root.addChild(beta); + immutableNode = Nodes.asImmutable(root); + //when + val result = immutableNode.getChildByName("alpha"); + //then + assertThat(result.getName()).isEqualTo(alpha.getName()); + } + + @Test + public void getChildNamedFindsNothing() { + //given + val root = Nodes.namedRoot("root data", "root"); + val alpha = Nodes.namedRoot("alpha data", "alpha"); + val beta = Nodes.namedRoot("beta data", "beta"); + root.addChild(alpha); + root.addChild(beta); + exception.expect(NodeException.class); + exception.expectMessage("Named child not found"); + immutableNode = Nodes.asImmutable(root); + //when + immutableNode.getChildByName("gamma"); + } + + @Test + public void removingParentThrowsException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + expectImmutableException(); + //when + immutableNode.removeParent(); + } + + @Test + public void findChildNamedShouldThrowNPEWhenNameIsNull() { + //given + exception.expect(NullPointerException.class); + exception.expectMessage("name"); + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + //when + immutableNode.findChildByName(null); + } + + @Test + public void isNamedNull() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + //then + assertThat(immutableNode.isNamed()).isFalse(); + } + + @Test + public void isNamedEmpty() { + //given + immutableNode = Nodes.asImmutable(Nodes.namedRoot(null, "")); + //then + assertThat(immutableNode.isNamed()).isFalse(); + } + + @Test + public void isNamedNamed() { + //given + immutableNode = Nodes.asImmutable(Nodes.namedRoot(null, "named")); + //then + assertThat(immutableNode.isNamed()).isTrue(); + } + + @Test + public void removeChildThrowsExceptions() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + expectImmutableException(); + //then + immutableNode.removeChild(null); + } + + @Test + public void drawTreeIsCorrect() { + //given + val root = Nodes.namedRoot("root data", "root"); + val bob = Nodes.namedChild("bob data", "bob", root); + val alice = Nodes.namedChild("alice data", "alice", root); + Nodes.namedChild("dave data", "dave", alice); + Nodes.unnamedChild("bob's child's data", + bob); // has no name and no children so no included + val kim = Nodes.unnamedChild("kim data", root); // nameless mother + Nodes.namedChild("lucy data", "lucy", kim); + immutableNode = Nodes.asImmutable(root); + //when + val tree = immutableNode.drawTree(0); + //then + String[] lines = tree.split("\n"); + assertThat(lines).contains("[root]", "[ alice]", "[ dave]", + "[ (unnamed)]", "[ lucy]", "[ bob]"); + assertThat(lines).containsSubsequence("[root]", "[ alice]", "[ dave]"); + assertThat(lines).containsSubsequence("[root]", "[ (unnamed)]", + "[ lucy]"); + assertThat(lines).containsSubsequence("[root]", "[ bob]"); + } + + @Test + public void setDataShouldThrowException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("initial")); + expectImmutableException(); + //when + immutableNode.setData("updated"); + } + + @Test + public void createChildThrowsException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot(null)); + expectImmutableException(); + //when + immutableNode.createChild("child data", "child name"); + } + + @Test + public void canGetChildWhenFound() { + //given + val root = Nodes.unnamedRoot("data"); + val child = Nodes.namedChild("child data", "child name", root); + immutableNode = Nodes.asImmutable(root); + //when + val found = immutableNode.getChild("child data"); + //then + assertThat(found.getName()).isEqualTo(child.getName()); + } + + @Test + public void canGetChildWhenNotFound() { + //given + exception.expect(NodeException.class); + exception.expectMessage("Child not found"); + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("data")); + //when + immutableNode.getChild("child data"); + } + + @Test + public void canSafelyHandleFindChildWhenAChildHasNoData() { + //given + val root = Nodes.unnamedRoot(""); + Nodes.unnamedChild(null, root); + immutableNode = Nodes.asImmutable(root); + //when + immutableNode.findChild("data"); + } + + @Test + public void createChildShouldThrowException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("")); + expectImmutableException(); + //when + immutableNode.createChild("child"); + } + + @Test + public void createDescendantLineShouldThrowException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("")); + expectImmutableException(); + //when + immutableNode.createDescendantLine( + Arrays.asList("child", "grandchild")); + } + + @Test + public void insertInPathShouldThrowException() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("")); + expectImmutableException(); + //when + immutableNode.insertInPath(null, ""); + } + + @Test + public void findOrCreateChildShouldReturnChildWhenChildIsFound() { + //given + val root = Nodes.unnamedRoot(""); + Nodes.namedChild("child", "child", root); + immutableNode = Nodes.asImmutable(root); + //when + val found = immutableNode.findOrCreateChild("child"); + assertThat(found).extracting(Node::getName).contains("child"); + } + + @Test + public void findOrCreateChildShouldThrowExceptionWhenChildNotFound() { + //given + immutableNode = Nodes.asImmutable(Nodes.unnamedRoot("")); + expectImmutableException(); + //when + immutableNode.findOrCreateChild("child"); + } +}