/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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
 *
 *     https://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 org.apache.commons.configuration2.tree;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.configuration2.ex.ConfigurationRuntimeException;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * A special test class for {@code InMemoryNodeModel} which tests the facilities for tracking nodes.
 */
public class TestInMemoryNodeModelTrackedNodes {

    /** Constant for the name of a new table field. */
    private static final String NEW_FIELD = "newTableField";

    /** Constant for a test key. */
    private static final String TEST_KEY = "someTestKey";

    /** Constant for the key used by the test selector. */
    private static final String SELECTOR_KEY = "tables.table(1)";

    /** The root node for the test hierarchy. */
    private static ImmutableNode root;

    /** A default node selector initialized with a test key. */
    private static NodeSelector selector;

    /**
     * Checks whether a fields node was correctly changed by an update operation.
     *
     * @param nodeFields the fields node
     * @param idx the index of the changed node
     */
    private static void checkedForChangedField(final ImmutableNode nodeFields, final int idx) {
        assertEquals(NodeStructureHelper.fieldsLength(1), nodeFields.getChildren().size());
        int childIndex = 0;
        for (final ImmutableNode field : nodeFields) {
            final String expName = childIndex == idx ? NEW_FIELD : NodeStructureHelper.field(1, childIndex);
            checkFieldNode(field, expName);
            childIndex++;
        }
    }

    /**
     * Checks whether a field node has the expected content.
     *
     * @param nodeField the field node to be checked
     * @param name the expected name of this field
     */
    private static void checkFieldNode(final ImmutableNode nodeField, final String name) {
        assertEquals("field", nodeField.getNodeName());
        assertEquals(1, nodeField.getChildren().size());
        final ImmutableNode nodeName = nodeField.getChildren().get(0);
        assertEquals("name", nodeName.getNodeName());
        assertEquals(name, nodeName.getValue());
    }

    /**
     * Tests whether a field node was added.
     *
     * @param nodeFields the fields node
     */
    private static void checkForAddedField(final ImmutableNode nodeFields) {
        assertEquals(NodeStructureHelper.fieldsLength(1) + 1, nodeFields.getChildren().size());
        final ImmutableNode nodeField = nodeFields.getChildren().get(NodeStructureHelper.fieldsLength(1));
        checkFieldNode(nodeField, NEW_FIELD);
    }

    /**
     * Helper method for checking whether the expected field node was removed.
     *
     * @param nodeFields the fields node
     * @param idx the index of the removed field
     */
    private static void checkForRemovedField(final ImmutableNode nodeFields, final int idx) {
        assertEquals(NodeStructureHelper.fieldsLength(1) - 1, nodeFields.getChildren().size());
        final Set<String> expectedNames = new HashSet<>();
        final Set<String> actualNames = new HashSet<>();
        for (int i = 0; i < NodeStructureHelper.fieldsLength(1); i++) {
            if (idx != i) {
                expectedNames.add(NodeStructureHelper.field(1, i));
            }
        }
        for (final ImmutableNode field : nodeFields) {
            final ImmutableNode nodeName = field.getChildren().get(0);
            actualNames.add(String.valueOf(nodeName.getValue()));
        }
        assertEquals(expectedNames, actualNames);
    }

    /**
     * Creates a default resolver which supports arbitrary queries on a target node.
     *
     * @return the resolver
     */
    private static NodeKeyResolver<ImmutableNode> createResolver() {
        final NodeKeyResolver<ImmutableNode> resolver = NodeStructureHelper.createResolverMock();
        NodeStructureHelper.prepareResolveKeyForQueries(resolver);
        return resolver;
    }

    /**
     * Prepares a mock for a resolver to handle keys for update operations. Support is limited. It is expected that only a
     * single value is changed.
     *
     * @param resolver the {@code NodeKeyResolver} mock
     */
    private static void prepareResolverForUpdateKeys(final NodeKeyResolver<ImmutableNode> resolver) {
        when(resolver.resolveUpdateKey(any(), any(), any(), any())).thenAnswer(invocation -> {
            final ImmutableNode root = invocation.getArgument(0, ImmutableNode.class);
            final String key = invocation.getArgument(1, String.class);
            final TreeData handler = invocation.getArgument(3, TreeData.class);
            final List<QueryResult<ImmutableNode>> results = DefaultExpressionEngine.INSTANCE.query(root, key, handler);
            assertEquals(1, results.size());
            return new NodeUpdateData<>(Collections.singletonMap(results.get(0), invocation.getArgument(2)), null, null, null);
        });
    }

    @BeforeAll
    public static void setUpBeforeClass() throws Exception {
        root = new ImmutableNode.Builder(1).addChild(NodeStructureHelper.ROOT_TABLES_TREE).create();
        selector = new NodeSelector(SELECTOR_KEY);
    }

    /** The model to be tested. */
    private InMemoryNodeModel model;

    /**
     * Helper method for testing whether a tracked node can be replaced.
     */
    private void checkReplaceTrackedNode() {
        final ImmutableNode newNode = new ImmutableNode.Builder().name("newNode").create();
        model.replaceTrackedNode(selector, newNode);
        assertSame(newNode, model.getTrackedNode(selector));
        assertTrue(model.isTrackedNodeDetached(selector));
    }

    /**
     * Checks trackChildNodes() if the passed in key has a result set which causes the operation to be aborted.
     *
     * @param queryResult the result set of the key
     */
    private void checkTrackChildNodesNoResult(final List<ImmutableNode> queryResult) {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(queryResult);

        final TreeData oldData = model.getTreeData();

        assertTrue(model.trackChildNodes(TEST_KEY, resolver).isEmpty());
        assertSame(oldData, model.getTreeData());
    }

    /**
     * Helper method for testing trackChildNodeWithCreation() if invalid query results are generated.
     *
     * @param queryResult the result set of the key
     */
    private void checkTrackChildNodeWithCreationInvalidKey(final List<ImmutableNode> queryResult) {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        when(resolver.resolveNodeKey(model.getRootNode(), TEST_KEY, model.getNodeHandler())).thenReturn(queryResult);

        assertThrows(ConfigurationRuntimeException.class, () -> model.trackChildNodeWithCreation(TEST_KEY, "someChild", resolver));
    }

    /**
     * Returns the fields node from the model.
     *
     * @return the fields node
     */
    private ImmutableNode fieldsNodeFromModel() {
        return NodeStructureHelper.nodeForKey(model, "tables/table(1)/fields");
    }

    /**
     * Returns the fields node from a tracked node.
     *
     * @return the fields node
     */
    private ImmutableNode fieldsNodeFromTrackedNode() {
        return NodeStructureHelper.nodeForKey(model.getTrackedNode(selector), "fields");
    }

    /**
     * Produces a tracked node with the default selector and executes an operation which detaches this node.
     *
     * @param resolver the {@code NodeKeyResolver}
     */
    private void initDetachedNode(final NodeKeyResolver<ImmutableNode> resolver) {
        model.trackNode(selector, resolver);
        model.clearTree("tables.table(0)", resolver);
    }

    /**
     * Prepares the resolver mock to expect a nodeKey() request.
     *
     * @param resolver the {@code NodeKeyResolver}
     * @param node the node whose name is to be resolved
     * @param key the key to be returned for this node
     */
    private void prepareNodeKey(final NodeKeyResolver<ImmutableNode> resolver, final ImmutableNode node, final String key) {
        final Map<ImmutableNode, String> cache = new HashMap<>();
        when(resolver.nodeKey(node, cache, model.getNodeHandler())).thenReturn(key);
    }

    @BeforeEach
    public void setUp() throws Exception {
        model = new InMemoryNodeModel(root);
    }

    /**
     * Tests an addNodes() operation on a tracked node that is detached.
     */
    @Test
    void testAddNodesOnDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        NodeStructureHelper.prepareResolveAddKeys(resolver);
        model.trackNode(selector, resolver);
        initDetachedNode(resolver);
        final ImmutableNode rootNode = model.getRootNode();
        model.addNodes("fields", selector, Collections.singleton(NodeStructureHelper.createFieldNode(NEW_FIELD)), resolver);
        assertSame(rootNode, model.getRootNode());
        checkForAddedField(fieldsNodeFromTrackedNode());
    }

    /**
     * Tests whether an addNodes() operation works on a tracked node.
     */
    @Test
    void testAddNodesOnTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        NodeStructureHelper.prepareResolveAddKeys(resolver);
        model.trackNode(selector, resolver);
        model.addNodes("fields", selector, Collections.singleton(NodeStructureHelper.createFieldNode(NEW_FIELD)), resolver);
        checkForAddedField(fieldsNodeFromModel());
        checkForAddedField(fieldsNodeFromTrackedNode());
    }

    /**
     * Tests an addProperty() operation on a tracked node that is detached.
     */
    @Test
    void testAddPropertyOnDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        NodeStructureHelper.prepareResolveAddKeys(resolver);
        model.trackNode(selector, resolver);
        initDetachedNode(resolver);
        final ImmutableNode rootNode = model.getRootNode();
        model.addProperty("fields.field(-1).name", selector, Collections.singleton(NEW_FIELD), resolver);
        assertSame(rootNode, model.getRootNode());
        checkForAddedField(fieldsNodeFromTrackedNode());
    }

    /**
     * Tests whether an addProperty() operation works on a tracked node.
     */
    @Test
    void testAddPropertyOnTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        NodeStructureHelper.prepareResolveAddKeys(resolver);
        model.trackNode(selector, resolver);
        model.addProperty("fields.field(-1).name", selector, Collections.singleton(NEW_FIELD), resolver);
        checkForAddedField(fieldsNodeFromModel());
        checkForAddedField(fieldsNodeFromTrackedNode());
    }

    /**
     * Tests a clearProperty() operation on a tracked node which is detached.
     */
    @Test
    void testClearPropertyOnDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        initDetachedNode(resolver);
        final ImmutableNode rootNode = model.getRootNode();
        model.clearProperty("fields.field(0).name", selector, resolver);
        assertSame(rootNode, model.getRootNode());
        final ImmutableNode nodeFields = fieldsNodeFromTrackedNode();
        checkForRemovedField(nodeFields, 0);
    }

    /**
     * Tests whether clearProperty() can operate on a tracked node.
     */
    @Test
    void testClearPropertyOnTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearProperty("fields.field(0).name", selector, resolver);
        final ImmutableNode nodeFields = fieldsNodeFromModel();
        checkForRemovedField(nodeFields, 0);
    }

    /**
     * Tests a clearTree() operation on a tracked node which is detached.
     */
    @Test
    void testClearTreeOnDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        initDetachedNode(resolver);
        final ImmutableNode rootNode = model.getRootNode();
        model.clearTree("fields.field(1)", selector, resolver);
        assertSame(rootNode, model.getRootNode());
        final ImmutableNode nodeFields = fieldsNodeFromTrackedNode();
        checkForRemovedField(nodeFields, 1);
    }

    /**
     * Tests whether clearTree() can operate on a tracked node.
     */
    @Test
    void testClearTreeOnTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearTree("fields.field(1)", selector, resolver);
        final ImmutableNode nodeFields = fieldsNodeFromModel();
        checkForRemovedField(nodeFields, 1);
    }

    /**
     * Tests whether a tracked node can be queried even after the model was cleared.
     */
    @Test
    void testGetTrackedNodeAfterClear() {
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, "tables/table(1)");
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clear(resolver);
        assertSame(node, model.getTrackedNode(selector));
    }

    /**
     * Tests whether a tracked node can be queried after the root node was changed.
     */
    @Test
    void testGetTrackedNodeAfterSetRootNode() {
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, "tables/table(1)");
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.setRootNode(root);
        assertSame(node, model.getTrackedNode(selector));
    }

    /**
     * Tests whether a tracked node survives updates of the node model.
     */
    @Test
    void testGetTrackedNodeAfterUpdate() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearProperty("tables.table(1).fields.field(1).name", resolver);
        final ImmutableNode node = model.getTrackedNode(selector);
        assertEquals(NodeStructureHelper.table(1), node.getChildren().get(0).getValue());
    }

    /**
     * Tests whether a tracked node can be queried even if it was removed from the structure.
     */
    @Test
    void testGetTrackedNodeAfterUpdateNoLongerExisting() {
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, "tables/table(1)");
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        initDetachedNode(resolver);
        assertSame(node, model.getTrackedNode(selector));
    }

    /**
     * Tests whether a tracked node can be queried.
     */
    @Test
    void testGetTrackedNodeExisting() {
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, "tables/table(1)");
        model.trackNode(selector, createResolver());
        assertSame(node, model.getTrackedNode(selector));
    }

    /**
     * Tests whether a node handler for a tracked node can be queried which is still active.
     */
    @Test
    void testGetTrackedNodeHandlerActive() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        final NodeHandler<ImmutableNode> handler = model.getTrackedNodeHandler(selector);
        final TrackedNodeHandler tnh = assertInstanceOf(TrackedNodeHandler.class, handler);
        assertSame(model.getTrackedNode(selector), handler.getRootNode());
        assertSame(model.getTreeData(), tnh.getParentHandler());
    }

    /**
     * Tests whether a node handler for a detached tracked node can be queried.
     */
    @Test
    void testGetTrackedNodeHandlerDetached() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        initDetachedNode(resolver);
        final NodeHandler<ImmutableNode> handler = model.getTrackedNodeHandler(selector);
        assertSame(model.getTrackedNode(selector), handler.getRootNode());
        assertInstanceOf(TreeData.class, handler);
        assertNotSame(model.getNodeHandler(), handler);
    }

    /**
     * Tries to obtain a tracked node which is unknown.
     */
    @Test
    void testGetTrackedNodeNonExisting() {
        assertThrows(ConfigurationRuntimeException.class, () -> model.getTrackedNode(selector));
    }

    /**
     * Tests whether a clear() operation causes nodes to be detached.
     */
    @Test
    void testIsDetachedAfterClear() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clear(resolver);
        assertTrue(model.isTrackedNodeDetached(selector));
    }

    /**
     * Tests whether tracked nodes become detached when a new root node is set.
     */
    @Test
    void testIsDetachedAfterSetRoot() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearProperty("tables.table(1).fields.field(1).name", resolver);
        model.setRootNode(root);
        assertTrue(model.isTrackedNodeDetached(selector));
    }

    /**
     * Tests isDetached() for a life node.
     */
    @Test
    void testIsDetachedFalseAfterUpdate() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearProperty("tables.table(1).fields.field(1).name", resolver);
        assertFalse(model.isTrackedNodeDetached(selector));
    }

    /**
     * Tests isDetached() for a node which has just been tracked.
     */
    @Test
    void testIsDetachedFalseNoUpdates() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        assertFalse(model.isTrackedNodeDetached(selector));
    }

    /**
     * Tests isDetached() for an actually detached node.
     */
    @Test
    void testIsDetachedTrue() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        initDetachedNode(resolver);
        assertTrue(model.isTrackedNodeDetached(selector));
    }

    /**
     * Tests whether an active tracked node can be replaced.
     */
    @Test
    void testReplaceTrackedNodeForActiveTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        checkReplaceTrackedNode();
    }

    /**
     * Tests whether a detached tracked node can be replaced.
     */
    @Test
    void testReplaceTrackedNodeForDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        initDetachedNode(resolver);
        checkReplaceTrackedNode();
    }

    /**
     * Tries to replace a tracked node with a null node.
     */
    @Test
    void testReplaceTrackedNodeNull() {
        model.trackNode(selector, createResolver());
        assertThrows(IllegalArgumentException.class, () -> model.replaceTrackedNode(selector, null));
    }

    /**
     * Tests whether tracked nodes can be created from a key.
     */
    @Test
    void testSelectAndTrackNodes() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        final String nodeKey1 = "tables/table(0)";
        final String nodeKey2 = "tables/table(1)";
        final ImmutableNode node1 = NodeStructureHelper.nodeForKey(root, nodeKey1);
        final ImmutableNode node2 = NodeStructureHelper.nodeForKey(root, nodeKey2);

        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Arrays.asList(node1, node2));
        prepareNodeKey(resolver, node1, nodeKey1);
        prepareNodeKey(resolver, node2, nodeKey2);

        final Collection<NodeSelector> selectors = model.selectAndTrackNodes(TEST_KEY, resolver);
        final Iterator<NodeSelector> it = selectors.iterator();
        NodeSelector sel = it.next();
        assertEquals(new NodeSelector(nodeKey1), sel);
        assertSame(node1, model.getTrackedNode(sel));
        sel = it.next();
        assertEquals(new NodeSelector(nodeKey2), sel);
        assertSame(node2, model.getTrackedNode(sel));
        assertFalse(it.hasNext());
    }

    /**
     * Tests whether selectAndTrackNodes() works for nodes that are already tracked.
     */
    @Test
    void testSelectAndTrackNodesNodeAlreadyTracked() {
        NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        resolver = createResolver();
        final ImmutableNode node = model.getTrackedNode(selector);

        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Collections.singletonList(node));
        prepareNodeKey(resolver, node, SELECTOR_KEY);

        final Collection<NodeSelector> selectors = model.selectAndTrackNodes(TEST_KEY, resolver);
        assertEquals(1, selectors.size());
        assertEquals(selector, selectors.iterator().next());
        model.untrackNode(selector);
        assertSame(node, model.getTrackedNode(selector));
    }

    /**
     * Tests selectAndTrackNodes() if the key does not select any nodes.
     */
    @Test
    void testSelectAndTrackNodesNoSelection() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();

        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Collections.<ImmutableNode>emptyList());

        assertTrue(model.selectAndTrackNodes(TEST_KEY, resolver).isEmpty());
    }

    /**
     * Tests a setProperty() operation on a tracked node that is detached.
     */
    @Test
    void testSetPropertyOnDetachedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        prepareResolverForUpdateKeys(resolver);
        model.trackNode(selector, resolver);
        initDetachedNode(resolver);
        final ImmutableNode rootNode = model.getRootNode();
        model.setProperty("fields.field(0).name", selector, NEW_FIELD, resolver);
        assertSame(rootNode, model.getRootNode());
        checkedForChangedField(fieldsNodeFromTrackedNode(), 0);
    }

    /**
     * Tests whether a setProperty() operation works on a tracked node.
     */
    @Test
    void testSetPropertyOnTrackedNode() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        prepareResolverForUpdateKeys(resolver);
        model.trackNode(selector, resolver);
        model.setProperty("fields.field(0).name", selector, NEW_FIELD, resolver);
        checkedForChangedField(fieldsNodeFromModel(), 0);
        checkedForChangedField(fieldsNodeFromTrackedNode(), 0);
    }

    /**
     * Tests whether all children of a node can be tracked at once.
     */
    @Test
    void testTrackChildNodes() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        final ImmutableNode node = NodeStructureHelper.nodeForKey(root, "tables");
        final String[] keys = new String[node.getChildren().size()];

        for (int i = 0; i < keys.length; i++) {
            final ImmutableNode child = node.getChildren().get(i);
            keys[i] = String.format("%s.%s(%d)", node.getNodeName(), child.getNodeName(), i);
            prepareNodeKey(resolver, child, keys[i]);
        }
        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Collections.singletonList(node));

        final Collection<NodeSelector> selectors = model.trackChildNodes(TEST_KEY, resolver);
        assertEquals(node.getChildren().size(), selectors.size());
        int idx = 0;
        for (final NodeSelector sel : selectors) {
            assertEquals(new NodeSelector(keys[idx]), sel);
            assertEquals(node.getChildren().get(idx), model.getTrackedNode(sel), "Wrong tracked node for " + sel);
            idx++;
        }
    }

    /**
     * Tests trackChildNodes() for a key that returns more than a single result.
     */
    @Test
    void testTrackChildNodesMultipleResults() {
        checkTrackChildNodesNoResult(
            Arrays.asList(NodeStructureHelper.nodeForKey(root, "tables/table(0)"), NodeStructureHelper.nodeForKey(root, "tables/table(1)")));
    }

    /**
     * Tests trackChildNodes() for a key pointing to a node with no children.
     */
    @Test
    void testTrackChildNodesNodeWithNoChildren() {
        checkTrackChildNodesNoResult(Collections.singletonList(NodeStructureHelper.nodeForKey(root, "tables/table(0)/name")));
    }

    /**
     * Tests trackChildNodes() for a key that does not return any results.
     */
    @Test
    void testTrackChildNodesNoResults() {
        checkTrackChildNodesNoResult(Collections.<ImmutableNode>emptyList());
    }

    /**
     * Tests whether an existing child of a selected node can be tracked.
     */
    @Test
    void testTrackChildNodeWithCreationExisting() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        final String childName = "name";
        final String parentKey = "tables/table(0)";
        final String childKey = parentKey + "/" + childName;
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, parentKey);
        final ImmutableNode child = NodeStructureHelper.nodeForKey(node, childName);

        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Collections.singletonList(node));
        prepareNodeKey(resolver, child, childKey);

        final NodeSelector childSelector = model.trackChildNodeWithCreation(TEST_KEY, childName, resolver);
        assertEquals(new NodeSelector(childKey), childSelector);
        assertSame(child, model.getTrackedNode(childSelector));
    }

    /**
     * Tests trackChildNodeWithCreation() if the passed in key selects multiple nodes.
     */
    @Test
    void testTrackChildNodeWithCreationMultipleResults() {
        final List<ImmutableNode> nodes = Arrays.asList(NodeStructureHelper.nodeForKey(root, "tables/table(0)"),
            NodeStructureHelper.nodeForKey(root, "tables/table(1)"));
        checkTrackChildNodeWithCreationInvalidKey(nodes);
    }

    /**
     * Tests whether a child node to be tracked is created if necessary.
     */
    @Test
    void testTrackChildNodeWithCreationNonExisting() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        final String childName = "space";
        final String parentKey = "tables/table(0)";
        final String childKey = parentKey + "/" + childName;
        final ImmutableNode node = NodeStructureHelper.nodeForKey(model, parentKey);

        when(resolver.resolveNodeKey(root, TEST_KEY, model.getNodeHandler())).thenReturn(Collections.singletonList(node));
        when(resolver.nodeKey(any(), eq(new HashMap<>()), any())).thenReturn(childKey);

        final NodeSelector childSelector = model.trackChildNodeWithCreation(TEST_KEY, childName, resolver);
        assertEquals(new NodeSelector(childKey), childSelector);
        final ImmutableNode child = model.getTrackedNode(childSelector);
        assertEquals(childName, child.getNodeName());
        assertNull(child.getValue());
        final ImmutableNode parent = model.getNodeHandler().getParent(child);
        assertEquals("table", parent.getNodeName());
        assertEquals(child, NodeStructureHelper.nodeForKey(model, childKey));
    }

    /**
     * Tests trackChildNodeWithCreation() if the passed in key does not select a node.
     */
    @Test
    void testTrackChildNodeWithCreationNoResults() {
        checkTrackChildNodeWithCreationInvalidKey(new ArrayList<>());
    }

    /**
     * Tests whether a tracked node is handled correctly if an operation is executed on this node which causes the node to
     * be detached. In this case, the node should be cleared (it makes no sense to use the last defined node instance).
     */
    @Test
    void testTrackedNodeClearedInOperation() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.clearTree(null, selector, resolver);
        assertTrue(model.isTrackedNodeDetached(selector));
        final ImmutableNode node = model.getTrackedNode(selector);
        assertEquals("table", node.getNodeName());
        assertFalse(model.getNodeHandler().isDefined(node));
    }

    /**
     * Tries to call trackNode() with a key that selects multiple results.
     */
    @Test
    void testTrackNodeKeyMultipleResults() {
        final NodeSelector nodeSelector = new NodeSelector("tables.table.fields.field.name");
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        assertThrows(ConfigurationRuntimeException.class, () -> model.trackNode(nodeSelector, resolver));
    }

    /**
     * Tries to call trackNode() with a key that does not yield any results.
     */
    @Test
    void testTrackNodeKeyNoResults() {
        final NodeSelector nodeSelector = new NodeSelector("tables.unknown");
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        assertThrows(ConfigurationRuntimeException.class, () -> model.trackNode(nodeSelector, resolver));
    }

    /**
     * Tests whether a single node can be tracked multiple times.
     */
    @Test
    void testTrackNodeMultipleTimes() {
        final NodeKeyResolver<ImmutableNode> resolver = createResolver();
        model.trackNode(selector, resolver);
        model.trackNode(selector, resolver);
        model.untrackNode(selector);
        assertNotNull(model.getTrackedNode(selector));
    }

    /**
     * Tests whether tracking of a node can be stopped.
     */
    @Test
    void testUntrackNode() {
        model.trackNode(selector, createResolver());
        model.untrackNode(selector);
        assertThrows(ConfigurationRuntimeException.class, () -> model.getTrackedNode(selector));
    }

    /**
     * Tries to stop tracking of a node which is not tracked.
     */
    @Test
    void testUntrackNodeNonExisting() {
        assertThrows(ConfigurationRuntimeException.class, () -> model.untrackNode(selector));
    }
}
