ONOS-4326: Focusing on add/remove cluster member. (WIP).

If reviewing this, please refer to http://tinyurl.com/onos-ui-topo-model

Change-Id: Ic6568074ac768ec828f9103e92caab5e9a06ade6
diff --git a/core/api/src/main/java/org/onosproject/ui/model/ServiceBundle.java b/core/api/src/main/java/org/onosproject/ui/model/ServiceBundle.java
new file mode 100644
index 0000000..61cf721
--- /dev/null
+++ b/core/api/src/main/java/org/onosproject/ui/model/ServiceBundle.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2016-present Open Networking Laboratory
+ *
+ * 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 org.onosproject.ui.model;
+
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.mastership.MastershipService;
+import org.onosproject.net.device.DeviceService;
+import org.onosproject.net.flow.FlowRuleService;
+import org.onosproject.net.host.HostService;
+import org.onosproject.net.intent.IntentService;
+import org.onosproject.net.link.LinkService;
+import org.onosproject.net.region.RegionService;
+
+/**
+ * A bundle of services to pass to elements that might need a reference to them.
+ */
+public interface ServiceBundle {
+    /**
+     * Reference to a cluster service implementation.
+     *
+     * @return cluster service
+     */
+    ClusterService cluster();
+
+    /**
+     * Reference to a mastership service implementation.
+     *
+     * @return mastership service
+     */
+    MastershipService mastership();
+
+    /**
+     * Reference to a region service implementation.
+     *
+     * @return region service
+     */
+    RegionService region();
+
+    /**
+     * Reference to a device service implementation.
+     *
+     * @return device service
+     */
+    DeviceService device();
+
+    /**
+     * Reference to a link service implementation.
+     *
+     * @return link service
+     */
+    LinkService link();
+
+    /**
+     * Reference to a host service implementation.
+     *
+     * @return host service
+     */
+    HostService host();
+
+    /**
+     * Reference to a intent service implementation.
+     *
+     * @return intent service
+     */
+    IntentService intent();
+
+    /**
+     * Reference to a flow service implementation.
+     *
+     * @return flow service
+     */
+    FlowRuleService flow();
+}
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiCluster.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiCluster.java
index a5de405..40de28c 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiCluster.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiCluster.java
@@ -28,12 +28,14 @@
  */
 class UiCluster extends UiElement {
 
+    private static final String DEFAULT_CLUSTER_ID = "CLUSTER-0";
+
     private final List<UiClusterMember> members = new ArrayList<>();
     private final Map<NodeId, UiClusterMember> lookup = new HashMap<>();
 
     @Override
     public String toString() {
-        return String.valueOf(members.size()) + "-member cluster";
+        return String.valueOf(size()) + "-member cluster";
     }
 
     /**
@@ -65,6 +67,16 @@
     }
 
     /**
+     * Removes the given member from the cluster.
+     *
+     * @param member member to remove
+     */
+    public void remove(UiClusterMember member) {
+        members.remove(member);
+        lookup.remove(member.id());
+    }
+
+    /**
      * Returns the number of members in the cluster.
      *
      * @return number of members
@@ -72,4 +84,9 @@
     public int size() {
         return members.size();
     }
+
+    @Override
+    public String idAsString() {
+        return DEFAULT_CLUSTER_ID;
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiClusterMember.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiClusterMember.java
index f89a9ae..1cc9234 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiClusterMember.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiClusterMember.java
@@ -16,9 +16,12 @@
 
 package org.onosproject.ui.model.topo;
 
+import org.onlab.packet.IpAddress;
 import org.onosproject.cluster.ControllerNode;
 import org.onosproject.cluster.NodeId;
 
+import static org.onosproject.cluster.ControllerNode.State.INACTIVE;
+
 /**
  * Represents an individual member of the cluster (ONOS instance).
  */
@@ -26,6 +29,9 @@
 
     private final ControllerNode cnode;
 
+    private int deviceCount = 0;
+    private ControllerNode.State state = INACTIVE;
+
     /**
      * Constructs a cluster member, with a reference to the specified
      * controller node instance.
@@ -36,13 +42,33 @@
         this.cnode = cnode;
     }
 
+    @Override
+    public String toString() {
+        return "UiClusterMember{" + cnode +
+                ", online=" + isOnline() +
+                ", ready=" + isReady() +
+                ", #devices=" + deviceCount +
+                "}";
+    }
+
+
     /**
-     * Updates the information about this cluster member.
+     * Sets the state of this cluster member.
      *
-     * @param cnode underlying controller node
+     * @param state the state
      */
-    public void update(ControllerNode cnode) {
-        // TODO: update our information cache appropriately
+    public void setState(ControllerNode.State state) {
+        this.state = state;
+    }
+
+
+    /**
+     * Sets the number of devices for which this cluster member is master.
+     *
+     * @param deviceCount number of devices
+     */
+    public void setDeviceCount(int deviceCount) {
+        this.deviceCount = deviceCount;
     }
 
     /**
@@ -53,4 +79,45 @@
     public NodeId id() {
         return cnode.id();
     }
+
+    /**
+     * Returns the IP address of the cluster member.
+     *
+     * @return the IP address
+     */
+    public IpAddress ip() {
+        return cnode.ip();
+    }
+
+    /**
+     * Returns true if this cluster member is online (active).
+     *
+     * @return true if online, false otherwise
+     */
+    public boolean isOnline() {
+        return state.isActive();
+    }
+
+    /**
+     * Returns true if this cluster member is considered ready.
+     *
+     * @return true if ready, false otherwise
+     */
+    public boolean isReady() {
+        return state.isReady();
+    }
+
+    /**
+     * Returns the number of devices for which this cluster member is master.
+     *
+     * @return number of devices for which this member is master
+     */
+    public int deviceCount() {
+        return deviceCount;
+    }
+
+    @Override
+    public String idAsString() {
+        return id().toString();
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiDevice.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiDevice.java
index 78f9d47..88d86d0 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiDevice.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiDevice.java
@@ -17,6 +17,7 @@
 package org.onosproject.ui.model.topo;
 
 import org.onosproject.net.Device;
+import org.onosproject.net.DeviceId;
 
 /**
  * Represents a device.
@@ -29,4 +30,18 @@
     protected void destroy() {
         device = null;
     }
+
+    /**
+     * Returns the identity of the device.
+     *
+     * @return device ID
+     */
+    public DeviceId id() {
+        return device.id();
+    }
+
+    @Override
+    public String idAsString() {
+        return id().toString();
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiElement.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiElement.java
index 5e6144d..f0c2684 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiElement.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiElement.java
@@ -19,7 +19,7 @@
 /**
  * Abstract base class of all elements in the UI topology model.
  */
-public class UiElement {
+public abstract class UiElement {
 
     /**
      * Removes all external references, and prepares the instance for
@@ -28,4 +28,11 @@
     protected void destroy() {
         // does nothing
     }
+
+    /**
+     * Returns a string representation of the element identifier.
+     *
+     * @return the element unique identifier
+     */
+    public abstract String idAsString();
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiHost.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiHost.java
index 933e469..9a9e24a 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiHost.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiHost.java
@@ -17,6 +17,7 @@
 package org.onosproject.ui.model.topo;
 
 import org.onosproject.net.Host;
+import org.onosproject.net.HostId;
 
 /**
  * Represents an end-station host.
@@ -29,4 +30,18 @@
     protected void destroy() {
         host = null;
     }
+
+    /**
+     * Returns the identity of the host.
+     *
+     * @return host ID
+     */
+    public HostId id() {
+        return host.id();
+    }
+
+    @Override
+    public String idAsString() {
+        return id().toString();
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiLink.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiLink.java
index f45a630..99d6144 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiLink.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiLink.java
@@ -54,4 +54,10 @@
             children = null;
         }
     }
+
+    @Override
+    public String idAsString() {
+        // TODO
+        return null;
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiRegion.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiRegion.java
index 9a3d6fb..488c37b 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiRegion.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiRegion.java
@@ -17,6 +17,7 @@
 package org.onosproject.ui.model.topo;
 
 import org.onosproject.net.region.Region;
+import org.onosproject.net.region.RegionId;
 
 import java.util.Set;
 import java.util.TreeSet;
@@ -46,4 +47,17 @@
         region = null;
     }
 
+    /**
+     * Returns the identity of the region.
+     *
+     * @return region ID
+     */
+    public RegionId id() {
+        return region.id();
+    }
+
+    @Override
+    public String idAsString() {
+        return id().toString();
+    }
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopology.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopology.java
index 898c8eb..a8b5b06 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopology.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopology.java
@@ -28,6 +28,8 @@
  */
 public class UiTopology extends UiElement {
 
+    private static final String DEFAULT_TOPOLOGY_ID = "TOPOLOGY-0";
+
     private static final Logger log = LoggerFactory.getLogger(UiTopology.class);
 
     private final UiCluster uiCluster = new UiCluster();
@@ -69,6 +71,15 @@
     }
 
     /**
+     * Removes the given cluster member from the topology model.
+     *
+     * @param member cluster member to remove
+     */
+    public void remove(UiClusterMember member) {
+        uiCluster.remove(member);
+    }
+
+    /**
      * Returns the number of members in the cluster.
      *
      * @return number of cluster members
@@ -85,4 +96,9 @@
     public int regionCount() {
         return uiRegions.size();
     }
+
+    @Override
+    public String idAsString() {
+        return DEFAULT_TOPOLOGY_ID;
+    }
 }
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java b/core/api/src/test/java/org/onosproject/ui/AbstractUiTest.java
similarity index 67%
copy from web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java
copy to core/api/src/test/java/org/onosproject/ui/AbstractUiTest.java
index 8cc047d..077d132 100644
--- a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java
+++ b/core/api/src/test/java/org/onosproject/ui/AbstractUiTest.java
@@ -14,12 +14,12 @@
  * limitations under the License.
  */
 
-package org.onosproject.ui.impl.topo.model;
+package org.onosproject.ui;
 
 /**
- * Base class for model test classes.
+ * Abstract base class for UI tests.
  */
-public abstract class AbstractModelTest {
+public abstract class AbstractUiTest {
 
     /**
      * System agnostic end-of-line character.
@@ -31,7 +31,7 @@
      *
      * @param s string to print
      */
-    protected void print(String s) {
+    protected static void print(String s) {
         System.out.println(s);
     }
 
@@ -40,8 +40,12 @@
      *
      * @param o object to print
      */
-    protected void print(Object o) {
-        print(o.toString());
+    protected static void print(Object o) {
+        if (o == null) {
+            print("<null>");
+        } else {
+            print(o.toString());
+        }
     }
 
     /**
@@ -51,8 +55,16 @@
      * @param params parameters
      * @see String#format(String, Object...)
      */
-    protected void print(String fmt, Object... params) {
+    protected static void print(String fmt, Object... params) {
         print(String.format(fmt, params));
     }
 
+    /**
+     * Prints a title, to delimit individual unit test output.
+     *
+     * @param s a title for the test
+     */
+    protected static void title(String s) {
+        print(EOL + "=== %s ===", s);
+    }
 }
diff --git a/core/api/src/test/java/org/onosproject/ui/model/AbstractUiModelTest.java b/core/api/src/test/java/org/onosproject/ui/model/AbstractUiModelTest.java
new file mode 100644
index 0000000..5037832
--- /dev/null
+++ b/core/api/src/test/java/org/onosproject/ui/model/AbstractUiModelTest.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2016-present Open Networking Laboratory
+ *
+ * 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 org.onosproject.ui.model;
+
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.cluster.ClusterServiceAdapter;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.mastership.MastershipService;
+import org.onosproject.net.device.DeviceService;
+import org.onosproject.net.flow.FlowRuleService;
+import org.onosproject.net.host.HostService;
+import org.onosproject.net.intent.IntentService;
+import org.onosproject.net.link.LinkService;
+import org.onosproject.net.region.RegionService;
+import org.onosproject.ui.AbstractUiTest;
+
+/**
+ * Base class for UI model unit tests.
+ */
+public class AbstractUiModelTest extends AbstractUiTest {
+
+    /**
+     * Returns canned results.
+     * At some future point, we may make this "programmable", so that
+     * it returns certain values based on element IDs etc.
+     */
+    protected static final ServiceBundle MOCK_SERVICES =
+            new ServiceBundle() {
+                @Override
+                public ClusterService cluster() {
+                    return MOCK_CLUSTER;
+                }
+
+                @Override
+                public MastershipService mastership() {
+                    return null;
+                }
+
+                @Override
+                public RegionService region() {
+                    return null;
+                }
+
+                @Override
+                public DeviceService device() {
+                    return null;
+                }
+
+                @Override
+                public LinkService link() {
+                    return null;
+                }
+
+                @Override
+                public HostService host() {
+                    return null;
+                }
+
+                @Override
+                public IntentService intent() {
+                    return null;
+                }
+
+                @Override
+                public FlowRuleService flow() {
+                    return null;
+                }
+            };
+
+    private static final ClusterService MOCK_CLUSTER = new MockClusterService();
+
+
+    private static class MockClusterService extends ClusterServiceAdapter {
+        @Override
+        public ControllerNode.State getState(NodeId nodeId) {
+            // For now, a hardcoded state of ACTIVE (but not READY)
+            // irrespective of the node ID.
+            return ControllerNode.State.ACTIVE;
+        }
+    }
+
+}
diff --git a/core/api/src/test/java/org/onosproject/ui/model/topo/UiClusterMemberTest.java b/core/api/src/test/java/org/onosproject/ui/model/topo/UiClusterMemberTest.java
new file mode 100644
index 0000000..db35b33
--- /dev/null
+++ b/core/api/src/test/java/org/onosproject/ui/model/topo/UiClusterMemberTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2016-present Open Networking Laboratory
+ *
+ * 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 org.onosproject.ui.model.topo;
+
+import org.junit.Test;
+import org.onlab.packet.IpAddress;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.DefaultControllerNode;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.ui.model.AbstractUiModelTest;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Unit tests for {@link UiClusterMember}.
+ */
+public class UiClusterMemberTest extends AbstractUiModelTest {
+
+    private static final NodeId NODE_ID = NodeId.nodeId("Node-1");
+    private static final IpAddress NODE_IP = IpAddress.valueOf("1.2.3.4");
+
+    private static final ControllerNode CNODE_1 =
+            new DefaultControllerNode(NODE_ID, NODE_IP);
+
+    private UiClusterMember member;
+
+    @Test
+    public void basic() {
+        title("basic");
+        member = new UiClusterMember(CNODE_1);
+        print(member);
+
+        assertEquals("wrong id", NODE_ID, member.id());
+        assertEquals("wrong IP", NODE_IP, member.ip());
+        assertEquals("unex. online", false, member.isOnline());
+        assertEquals("unex. ready", false, member.isReady());
+        assertEquals("unex. device count", 0, member.deviceCount());
+    }
+}
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/ModelCache.java b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/ModelCache.java
index 6d5de44..420768f 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/ModelCache.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/ModelCache.java
@@ -17,6 +17,7 @@
 package org.onosproject.ui.impl.topo.model;
 
 import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.NodeId;
 import org.onosproject.cluster.RoleInfo;
 import org.onosproject.event.EventDispatcher;
 import org.onosproject.net.Device;
@@ -24,11 +25,16 @@
 import org.onosproject.net.Host;
 import org.onosproject.net.Link;
 import org.onosproject.net.region.Region;
+import org.onosproject.ui.model.ServiceBundle;
 import org.onosproject.ui.model.topo.UiClusterMember;
 import org.onosproject.ui.model.topo.UiDevice;
 import org.onosproject.ui.model.topo.UiTopology;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-import static org.onosproject.ui.impl.topo.model.UiModelEvent.Type.DEVICE_ADDED;
+import static org.onosproject.ui.impl.topo.model.UiModelEvent.Type.CLUSTER_MEMBER_ADDED_OR_UPDATED;
+import static org.onosproject.ui.impl.topo.model.UiModelEvent.Type.CLUSTER_MEMBER_REMOVED;
+import static org.onosproject.ui.impl.topo.model.UiModelEvent.Type.DEVICE_ADDED_OR_UPDATED;
 import static org.onosproject.ui.impl.topo.model.UiModelEvent.Type.DEVICE_REMOVED;
 
 /**
@@ -36,10 +42,14 @@
  */
 class ModelCache {
 
+    private static final Logger log = LoggerFactory.getLogger(ModelCache.class);
+
+    private final ServiceBundle services;
     private final EventDispatcher dispatcher;
     private final UiTopology uiTopology = new UiTopology();
 
-    ModelCache(EventDispatcher eventDispatcher) {
+    ModelCache(ServiceBundle services, EventDispatcher eventDispatcher) {
+        this.services = services;
         this.dispatcher = eventDispatcher;
     }
 
@@ -59,6 +69,7 @@
      * Create our internal model of the global topology.
      */
     void load() {
+        // TODO - implement loading of initial state
 //        loadClusterMembers();
 //        loadRegions();
 //        loadDevices();
@@ -74,20 +85,36 @@
      * @param cnode controller node to be added/updated
      */
     void addOrUpdateClusterMember(ControllerNode cnode) {
-        UiClusterMember member = uiTopology.findClusterMember(cnode.id());
-        if (member != null) {
-            member.update(cnode);
-        } else {
+        NodeId id = cnode.id();
+        UiClusterMember member = uiTopology.findClusterMember(id);
+        if (member == null) {
             member = new UiClusterMember(cnode);
             uiTopology.add(member);
         }
 
-        // TODO: post event
+        // inject computed data about the cluster node, into the model object
+        ControllerNode.State state = services.cluster().getState(id);
+        member.setState(state);
+        member.setDeviceCount(services.mastership().getDevicesOf(id).size());
+        // NOTE: UI-attached is session-based data, not global
+
+        dispatcher.post(new UiModelEvent(CLUSTER_MEMBER_ADDED_OR_UPDATED, member));
     }
 
+    /**
+     * Removes from the model the specified controller node.
+     *
+     * @param cnode controller node to be removed
+     */
     void removeClusterMember(ControllerNode cnode) {
-        // TODO: find cluster member assoc. with parameter; remove from model
-        // TODO: post event
+        NodeId id = cnode.id();
+        UiClusterMember member = uiTopology.findClusterMember(id);
+        if (member != null) {
+            uiTopology.remove(member);
+            dispatcher.post(new UiModelEvent(CLUSTER_MEMBER_REMOVED, member));
+        } else {
+            log.warn("Tried to remove non-member cluster node {}", id);
+        }
     }
 
     void updateMasterships(DeviceId deviceId, RoleInfo roleInfo) {
@@ -111,7 +138,7 @@
         UiDevice uiDevice = new UiDevice();
 
         // TODO: post the (correct) event
-        dispatcher.post(new UiModelEvent(DEVICE_ADDED, uiDevice));
+        dispatcher.post(new UiModelEvent(DEVICE_ADDED_OR_UPDATED, uiDevice));
     }
 
     void removeDevice(Device device) {
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiModelEvent.java b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiModelEvent.java
index c37f27d..1a2c66c 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiModelEvent.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiModelEvent.java
@@ -29,8 +29,12 @@
     }
 
     enum Type {
-        DEVICE_ADDED,
+        CLUSTER_MEMBER_ADDED_OR_UPDATED,
+        CLUSTER_MEMBER_REMOVED,
+
+        DEVICE_ADDED_OR_UPDATED,
         DEVICE_REMOVED,
+
         // TODO...
     }
 }
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiSharedTopologyModel.java b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiSharedTopologyModel.java
index 0a4ce99..48ae42f 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiSharedTopologyModel.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/topo/model/UiSharedTopologyModel.java
@@ -59,6 +59,7 @@
 import org.onosproject.net.statistic.StatisticService;
 import org.onosproject.net.topology.TopologyService;
 import org.onosproject.ui.impl.topo.UiTopoSession;
+import org.onosproject.ui.model.ServiceBundle;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -122,7 +123,7 @@
 
     @Activate
     protected void activate() {
-        cache = new ModelCache(eventDispatcher);
+        cache = new ModelCache(new DefaultServiceBundle(), eventDispatcher);
 
         eventDispatcher.addSink(UiModelEvent.class, listenerRegistry);
 
@@ -180,6 +181,52 @@
         removeListener(session);
     }
 
+    /**
+     * Default implementation of service bundle to return references to our
+     * dynamically injected services.
+     */
+    private class DefaultServiceBundle implements ServiceBundle {
+        @Override
+        public ClusterService cluster() {
+            return clusterService;
+        }
+
+        @Override
+        public MastershipService mastership() {
+            return mastershipService;
+        }
+
+        @Override
+        public RegionService region() {
+            return regionService;
+        }
+
+        @Override
+        public DeviceService device() {
+            return deviceService;
+        }
+
+        @Override
+        public LinkService link() {
+            return linkService;
+        }
+
+        @Override
+        public HostService host() {
+            return hostService;
+        }
+
+        @Override
+        public IntentService intent() {
+            return intentService;
+        }
+
+        @Override
+        public FlowRuleService flow() {
+            return flowService;
+        }
+    }
+
 
     private class InternalClusterListener implements ClusterEventListener {
         @Override
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java b/web/gui/src/test/java/org/onosproject/ui/impl/AbstractUiImplTest.java
similarity index 76%
rename from web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java
rename to web/gui/src/test/java/org/onosproject/ui/impl/AbstractUiImplTest.java
index 8cc047d..80bdd88 100644
--- a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractModelTest.java
+++ b/web/gui/src/test/java/org/onosproject/ui/impl/AbstractUiImplTest.java
@@ -14,12 +14,12 @@
  * limitations under the License.
  */
 
-package org.onosproject.ui.impl.topo.model;
+package org.onosproject.ui.impl;
 
 /**
- * Base class for model test classes.
+ * Base class for unit tests.
  */
-public abstract class AbstractModelTest {
+public class AbstractUiImplTest {
 
     /**
      * System agnostic end-of-line character.
@@ -41,7 +41,11 @@
      * @param o object to print
      */
     protected void print(Object o) {
-        print(o.toString());
+        if (o == null) {
+            print("<null>");
+        } else {
+            print(o.toString());
+        }
     }
 
     /**
@@ -55,4 +59,12 @@
         print(String.format(fmt, params));
     }
 
+    /**
+     * Prints a title, to delimit individual unit test output.
+     *
+     * @param s a title for the test
+     */
+    protected void title(String s) {
+        print(EOL + "=== %s ===", s);
+    }
 }
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java
new file mode 100644
index 0000000..6fbb443
--- /dev/null
+++ b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2016-present Open Networking Laboratory
+ *
+ * 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 org.onosproject.ui.impl.topo.model;
+
+import com.google.common.collect.ImmutableSet;
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.cluster.ClusterServiceAdapter;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.mastership.MastershipService;
+import org.onosproject.mastership.MastershipServiceAdapter;
+import org.onosproject.net.DeviceId;
+import org.onosproject.net.device.DeviceService;
+import org.onosproject.net.flow.FlowRuleService;
+import org.onosproject.net.host.HostService;
+import org.onosproject.net.intent.IntentService;
+import org.onosproject.net.link.LinkService;
+import org.onosproject.net.region.RegionService;
+import org.onosproject.ui.impl.AbstractUiImplTest;
+import org.onosproject.ui.model.ServiceBundle;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.onosproject.net.DeviceId.deviceId;
+
+/**
+ * Base class for model test classes.
+ */
+abstract class AbstractTopoModelTest extends AbstractUiImplTest {
+
+    /**
+     * Returns canned results.
+     * At some future point, we may make this "programmable", so that
+     * it returns certain values based on element IDs etc.
+     */
+    protected static final ServiceBundle MOCK_SERVICES =
+            new ServiceBundle() {
+                @Override
+                public ClusterService cluster() {
+                    return MOCK_CLUSTER;
+                }
+
+                @Override
+                public MastershipService mastership() {
+                    return MOCK_MASTER;
+                }
+
+                @Override
+                public RegionService region() {
+                    return null;
+                }
+
+                @Override
+                public DeviceService device() {
+                    return null;
+                }
+
+                @Override
+                public LinkService link() {
+                    return null;
+                }
+
+                @Override
+                public HostService host() {
+                    return null;
+                }
+
+                @Override
+                public IntentService intent() {
+                    return null;
+                }
+
+                @Override
+                public FlowRuleService flow() {
+                    return null;
+                }
+            };
+
+    private static final ClusterService MOCK_CLUSTER = new MockClusterService();
+    private static final MastershipService MOCK_MASTER = new MockMasterService();
+    // TODO: fill out as necessary
+
+    /*
+      Our mock environment:
+
+      Three controllers: C1, C2, C3
+
+      Nine devices: D1 .. D9
+
+             D4 ---+              +--- D7
+                   |              |
+            D5 --- D1 --- D2 --- D3 --- D8
+                   |              |
+             D6 ---+              +--- D9
+
+      Twelve hosts (two per D4 ... D9)  H41, H42, H51, H52, ...
+
+      Regions:
+        R1 : D1, D2, D3
+        R2 : D4, D5, D6
+        R3 : D7, D8, D9
+
+      Mastership:
+        C1 : D1, D2, D3
+        C2 : D4, D5, D6
+        C3 : D7, D8, D9
+     */
+
+
+    private static class MockClusterService extends ClusterServiceAdapter {
+        private final Map<NodeId, ControllerNode.State> states = new HashMap<>();
+
+
+        @Override
+        public ControllerNode.State getState(NodeId nodeId) {
+            // For now, a hardcoded state of ACTIVE (but not READY)
+            // irrespective of the node ID.
+            return ControllerNode.State.ACTIVE;
+        }
+    }
+
+    protected static final DeviceId D1_ID = deviceId("D1");
+    protected static final DeviceId D2_ID = deviceId("D2");
+    protected static final DeviceId D3_ID = deviceId("D3");
+    protected static final DeviceId D4_ID = deviceId("D4");
+    protected static final DeviceId D5_ID = deviceId("D5");
+    protected static final DeviceId D6_ID = deviceId("D6");
+    protected static final DeviceId D7_ID = deviceId("D7");
+    protected static final DeviceId D8_ID = deviceId("D8");
+    protected static final DeviceId D9_ID = deviceId("D9");
+
+    private static class MockMasterService extends MastershipServiceAdapter {
+        @Override
+        public Set<DeviceId> getDevicesOf(NodeId nodeId) {
+            // For now, a hard coded set of two device IDs
+            // irrespective of the node ID.
+            return ImmutableSet.of(D1_ID, D2_ID);
+        }
+    }
+
+}
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/ModelCacheTest.java b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/ModelCacheTest.java
index 23a2b1b..c272087 100644
--- a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/ModelCacheTest.java
+++ b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/ModelCacheTest.java
@@ -16,28 +16,113 @@
 
 package org.onosproject.ui.impl.topo.model;
 
+import org.junit.Before;
 import org.junit.Test;
+import org.onlab.packet.IpAddress;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.DefaultControllerNode;
+import org.onosproject.event.Event;
 import org.onosproject.event.EventDispatcher;
+import org.onosproject.ui.impl.topo.model.UiModelEvent.Type;
+import org.onosproject.ui.model.topo.UiElement;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.onosproject.cluster.NodeId.nodeId;
 
 /**
  * Unit tests for {@link ModelCache}.
  */
-public class ModelCacheTest extends AbstractModelTest {
+public class ModelCacheTest extends AbstractTopoModelTest {
 
-    private static final EventDispatcher DISPATCHER = event -> {
-        // Do we care?
-    };
+    private class TestEvDisp implements EventDispatcher {
+
+        private Event<Type, UiElement> lastEvent = null;
+        private int eventCount = 0;
+
+        @Override
+        public void post(Event event) {
+            lastEvent = event;
+            eventCount++;
+//            print("Event dispatched: %s", event);
+        }
+
+        private void assertEventCount(int exp) {
+            assertEquals("unex event count", exp, eventCount);
+        }
+
+        private void assertLast(Type expEventType, String expId) {
+            assertNotNull("no last event", lastEvent);
+            assertEquals("unex event type", expEventType, lastEvent.type());
+            assertEquals("unex element ID", expId, lastEvent.subject().idAsString());
+        }
+    }
+
+    private static IpAddress ip(String s) {
+        return IpAddress.valueOf(s);
+    }
+
+    private static ControllerNode cnode(String id, String ip) {
+        return new DefaultControllerNode(nodeId(id), ip(ip));
+    }
+
+    private static final String C1 = "C1";
+    private static final String C2 = "C2";
+    private static final String C3 = "C3";
+
+    private static final ControllerNode NODE_1 = cnode(C1, "10.0.0.1");
+    private static final ControllerNode NODE_2 = cnode(C2, "10.0.0.2");
+    private static final ControllerNode NODE_3 = cnode(C3, "10.0.0.3");
+
+
+    private final TestEvDisp dispatcher = new TestEvDisp();
 
     private ModelCache cache;
 
+    @Before
+    public void setUp() {
+        cache = new ModelCache(MOCK_SERVICES, dispatcher);
+    }
+
     @Test
     public void basic() {
-        cache = new ModelCache(DISPATCHER);
+        title("basic");
         print(cache);
         assertEquals("unex # members", 0, cache.clusterMemberCount());
         assertEquals("unex # regions", 0, cache.regionCount());
     }
 
+    @Test
+    public void addAndRemoveClusterMember() {
+        title("addAndRemoveClusterMember");
+        print(cache);
+        assertEquals("unex # members", 0, cache.clusterMemberCount());
+        dispatcher.assertEventCount(0);
+
+        cache.addOrUpdateClusterMember(NODE_1);
+        print(cache);
+        assertEquals("unex # members", 1, cache.clusterMemberCount());
+        dispatcher.assertEventCount(1);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C1);
+
+        cache.removeClusterMember(NODE_1);
+        print(cache);
+        assertEquals("unex # members", 0, cache.clusterMemberCount());
+        dispatcher.assertEventCount(2);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_REMOVED, C1);
+    }
+
+    @Test
+    public void createThreeNodeCluster() {
+        title("createThreeNodeCluster");
+        cache.addOrUpdateClusterMember(NODE_1);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C1);
+        cache.addOrUpdateClusterMember(NODE_2);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C2);
+        cache.addOrUpdateClusterMember(NODE_3);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C3);
+        dispatcher.assertEventCount(3);
+        print(cache);
+    }
+
 }