Included connect point port number in definition of UiLinkId.
Added dumpString() to ModelCache / UiTopology.
Added more unit tests for ModelCache.

Change-Id: I842bb418b25cc901bd12bc28c6660c836f7235bc
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 bb59752..3ed9da0 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
@@ -87,6 +87,16 @@
     }
 
     /**
+     * Returns the identifier of the region to which this device belongs.
+     * This will be null if the device does not belong to any region.
+     *
+     * @return region identity
+     */
+    public RegionId regionId() {
+        return regionId;
+    }
+
+    /**
      * Returns the UI region to which this device belongs.
      *
      * @return the UI region
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 b386c5e..916944c 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
@@ -177,4 +177,42 @@
         edgeLink = elink;
         edgeDevice = elink.hostLocation().deviceId();
     }
+
+
+    /**
+     * Returns the identity of device A.
+     *
+     * @return device A ID
+     */
+    public DeviceId deviceA() {
+        return deviceA;
+    }
+
+    /**
+     * Returns the identity of device B.
+     *
+     * @return device B ID
+     */
+    public DeviceId deviceB() {
+        return deviceB;
+    }
+
+    /**
+     * Returns backing link from A to B.
+     *
+     * @return backing link A to B
+     */
+    public Link linkAtoB() {
+        return linkAtoB;
+    }
+
+    /**
+     * Returns backing link from B to A.
+     *
+     * @return backing link B to A
+     */
+    public Link linkBtoA() {
+        return linkBtoA;
+    }
+
 }
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiLinkId.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiLinkId.java
index 070052d..e530db4 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiLinkId.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiLinkId.java
@@ -19,6 +19,7 @@
 import org.onosproject.net.ConnectPoint;
 import org.onosproject.net.ElementId;
 import org.onosproject.net.Link;
+import org.onosproject.net.PortNumber;
 
 /**
  * A canonical representation of an identifier for {@link UiLink}s.
@@ -33,10 +34,14 @@
         B_TO_A
     }
 
-    private static final String ID_DELIMITER = "~";
+    private static final String CP_DELIMITER = "~";
+    private static final String ID_PORT_DELIMITER = "/";
 
     private final ElementId idA;
+    private final PortNumber portA;
     private final ElementId idB;
+    private final PortNumber portB;
+
     private final String idStr;
 
     /**
@@ -46,13 +51,18 @@
      * underlying link.
      *
      * @param a first element ID
+     * @param pa first element port
      * @param b second element ID
+     * @param pb second element port
      */
-    private UiLinkId(ElementId a, ElementId b) {
+    private UiLinkId(ElementId a, PortNumber pa, ElementId b, PortNumber pb) {
         idA = a;
+        portA = pa;
         idB = b;
+        portB = pb;
 
-        idStr = a.toString() + ID_DELIMITER + b.toString();
+        idStr = a + ID_PORT_DELIMITER + pa + CP_DELIMITER +
+                b + ID_PORT_DELIMITER + pb;
     }
 
     @Override
@@ -70,6 +80,15 @@
     }
 
     /**
+     * Returns the port of the first element.
+     *
+     * @return first element port
+     */
+    public PortNumber portA() {
+        return portA;
+    }
+
+    /**
      * Returns the identifier of the second element.
      *
      * @return second element identity
@@ -78,6 +97,15 @@
         return idB;
     }
 
+    /**
+     * Returns the port of the second element.
+     *
+     * @return second element port
+     */
+    public PortNumber portB() {
+        return portB;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
@@ -127,13 +155,10 @@
 
         ElementId srcId = src.elementId();
         ElementId dstId = dst.elementId();
-        if (srcId == null || dstId == null) {
-            throw new NullPointerException("null element ID in connect point: " + link);
-        }
 
         // canonicalize
         int comp = srcId.toString().compareTo(dstId.toString());
-        return comp <= 0 ? new UiLinkId(srcId, dstId)
-                : new UiLinkId(dstId, srcId);
+        return comp <= 0 ? new UiLinkId(srcId, src.port(), dstId, dst.port())
+                : new UiLinkId(dstId, dst.port(), srcId, src.port());
     }
 }
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 0bcaa58..9776216 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
@@ -16,6 +16,7 @@
 
 package org.onosproject.ui.model.topo;
 
+import com.google.common.collect.ImmutableSet;
 import org.onosproject.net.DeviceId;
 import org.onosproject.net.HostId;
 import org.onosproject.net.region.Region;
@@ -117,6 +118,15 @@
     }
 
     /**
+     * Returns the set of device identifiers for this region.
+     *
+     * @return device identifiers for this region
+     */
+    public Set<DeviceId> deviceIds() {
+        return ImmutableSet.copyOf(deviceIds);
+    }
+
+    /**
      * Returns the devices in this region.
      *
      * @return the devices in this region
@@ -126,6 +136,15 @@
     }
 
     /**
+     * Returns the set of host identifiers for this region.
+     *
+     * @return host identifiers for this region
+     */
+    public Set<HostId> hostIds() {
+        return ImmutableSet.copyOf(hostIds);
+    }
+
+    /**
      * Returns the hosts in this region.
      *
      * @return the hosts in this region
@@ -135,6 +154,15 @@
     }
 
     /**
+     * Returns the set of link identifiers for this region.
+     *
+     * @return link identifiers for this region
+     */
+    public Set<UiLinkId> linkIds() {
+        return ImmutableSet.copyOf(uiLinkIds);
+    }
+
+    /**
      * Returns the links in this region.
      *
      * @return the links in this region
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 3a11fcf..9555e3a 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
@@ -35,6 +35,10 @@
  */
 public class UiTopology extends UiElement {
 
+    private static final String INDENT_1 = "  ";
+    private static final String INDENT_2 = "    ";
+    private static final String EOL = String.format("%n");
+
     private static final String E_UNMAPPED =
             "Attempting to retrieve unmapped {}: {}";
 
@@ -133,15 +137,6 @@
     }
 
     /**
-     * Returns the number of regions configured in the topology.
-     *
-     * @return number of regions
-     */
-    public int regionCount() {
-        return regionLookup.size();
-    }
-
-    /**
      * Adds the given region to the topology model.
      *
      * @param uiRegion region to add
@@ -156,7 +151,19 @@
      * @param uiRegion region to remove
      */
     public void remove(UiRegion uiRegion) {
-        regionLookup.remove(uiRegion.id());
+        UiRegion r = regionLookup.remove(uiRegion.id());
+        if (r != null) {
+            r.destroy();
+        }
+    }
+
+    /**
+     * Returns the number of regions configured in the topology.
+     *
+     * @return number of regions
+     */
+    public int regionCount() {
+        return regionLookup.size();
     }
 
     /**
@@ -192,6 +199,15 @@
     }
 
     /**
+     * Returns the number of devices configured in the topology.
+     *
+     * @return number of devices
+     */
+    public int deviceCount() {
+        return deviceLookup.size();
+    }
+
+    /**
      * Returns the link with the specified identifier, or null if no such
      * link exists.
      *
@@ -217,13 +233,22 @@
      * @param uiLink link to remove
      */
     public void remove(UiLink uiLink) {
-        UiLink link = linkLookup.get(uiLink.id());
+        UiLink link = linkLookup.remove(uiLink.id());
         if (link != null) {
             link.destroy();
         }
     }
 
     /**
+     * Returns the number of links configured in the topology.
+     *
+     * @return number of links
+     */
+    public int linkCount() {
+        return linkLookup.size();
+    }
+
+    /**
      * Returns the host with the specified identifier, or null if no such
      * host exists.
      *
@@ -255,6 +280,16 @@
         }
     }
 
+    /**
+     * Returns the number of hosts configured in the topology.
+     *
+     * @return number of hosts
+     */
+    public int hostCount() {
+        return hostLookup.size();
+    }
+
+
     // ==
     // package private methods for supporting linkage amongst topology entities
     // ==
@@ -316,4 +351,42 @@
         return uiLinks;
     }
 
+    /**
+     * Returns a detailed (multi-line) string showing the contents of the
+     * topology.
+     *
+     * @return detailed string
+     */
+    public String dumpString() {
+        StringBuilder sb = new StringBuilder("Topology:").append(EOL);
+
+        sb.append(INDENT_1).append("Cluster Members").append(EOL);
+        for (UiClusterMember m : cnodeLookup.values()) {
+            sb.append(INDENT_2).append(m).append(EOL);
+        }
+
+        sb.append(INDENT_1).append("Regions").append(EOL);
+        for (UiRegion r : regionLookup.values()) {
+            sb.append(INDENT_2).append(r).append(EOL);
+        }
+
+        sb.append(INDENT_1).append("Devices").append(EOL);
+        for (UiDevice d : deviceLookup.values()) {
+            sb.append(INDENT_2).append(d).append(EOL);
+        }
+
+        sb.append(INDENT_1).append("Hosts").append(EOL);
+        for (UiHost h : hostLookup.values()) {
+            sb.append(INDENT_2).append(h).append(EOL);
+        }
+
+        sb.append(INDENT_1).append("Links").append(EOL);
+        for (UiLink link : linkLookup.values()) {
+            sb.append(INDENT_2).append(link).append(EOL);
+        }
+        sb.append("------").append(EOL);
+
+        return sb.toString();
+    }
+
 }
diff --git a/core/api/src/test/java/org/onosproject/ui/model/topo/UiLinkIdTest.java b/core/api/src/test/java/org/onosproject/ui/model/topo/UiLinkIdTest.java
index 51389d1..e129e26 100644
--- a/core/api/src/test/java/org/onosproject/ui/model/topo/UiLinkIdTest.java
+++ b/core/api/src/test/java/org/onosproject/ui/model/topo/UiLinkIdTest.java
@@ -26,6 +26,7 @@
 import org.onosproject.ui.model.AbstractUiModelTest;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
 import static org.onosproject.net.DeviceId.deviceId;
 import static org.onosproject.net.PortNumber.portNumber;
 
@@ -34,28 +35,34 @@
  */
 public class UiLinkIdTest extends AbstractUiModelTest {
 
-
-    private static final ProviderId PROVIDER_ID = ProviderId.NONE;
-
     private static final DeviceId DEV_X = deviceId("device-X");
     private static final DeviceId DEV_Y = deviceId("device-Y");
     private static final PortNumber P1 = portNumber(1);
     private static final PortNumber P2 = portNumber(2);
+    private static final PortNumber P3 = portNumber(3);
 
-    private static final ConnectPoint CP_X = new ConnectPoint(DEV_X, P1);
-    private static final ConnectPoint CP_Y = new ConnectPoint(DEV_Y, P2);
+    private static final ConnectPoint CP_X1 = new ConnectPoint(DEV_X, P1);
+    private static final ConnectPoint CP_Y2 = new ConnectPoint(DEV_Y, P2);
+    private static final ConnectPoint CP_Y3 = new ConnectPoint(DEV_Y, P3);
 
-    private static final Link LINK_X_TO_Y = DefaultLink.builder()
+    private static final Link LINK_X1_TO_Y2 = DefaultLink.builder()
             .providerId(ProviderId.NONE)
-            .src(CP_X)
-            .dst(CP_Y)
+            .src(CP_X1)
+            .dst(CP_Y2)
             .type(Link.Type.DIRECT)
             .build();
 
-    private static final Link LINK_Y_TO_X = DefaultLink.builder()
+    private static final Link LINK_Y2_TO_X1 = DefaultLink.builder()
             .providerId(ProviderId.NONE)
-            .src(CP_Y)
-            .dst(CP_X)
+            .src(CP_Y2)
+            .dst(CP_X1)
+            .type(Link.Type.DIRECT)
+            .build();
+
+    private static final Link LINK_X1_TO_Y3 = DefaultLink.builder()
+            .providerId(ProviderId.NONE)
+            .src(CP_X1)
+            .dst(CP_Y3)
             .type(Link.Type.DIRECT)
             .build();
 
@@ -63,10 +70,20 @@
     @Test
     public void canonical() {
         title("canonical");
-        UiLinkId one = UiLinkId.uiLinkId(LINK_X_TO_Y);
-        UiLinkId two = UiLinkId.uiLinkId(LINK_Y_TO_X);
+        UiLinkId one = UiLinkId.uiLinkId(LINK_X1_TO_Y2);
+        UiLinkId two = UiLinkId.uiLinkId(LINK_Y2_TO_X1);
         print("link one: %s", one);
         print("link two: %s", two);
         assertEquals("not equiv", one, two);
     }
+
+    @Test
+    public void sameDevsDiffPorts() {
+        title("sameDevsDiffPorts");
+        UiLinkId one = UiLinkId.uiLinkId(LINK_X1_TO_Y2);
+        UiLinkId other = UiLinkId.uiLinkId(LINK_X1_TO_Y3);
+        print("link one: %s", one);
+        print("link other: %s", other);
+        assertNotEquals("equiv?", one, other);
+    }
 }
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 a9981e7..19ab015 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
@@ -197,6 +197,11 @@
         postEvent(REGION_ADDED_OR_UPDATED, uiRegion);
     }
 
+    // package private for unit test access
+    UiRegion accessRegion(RegionId id) {
+        return uiTopology.findRegion(id);
+    }
+
     // invoked from UiSharedTopologyModel region listener
     void removeRegion(Region region) {
         RegionId id = region.id();
@@ -241,6 +246,11 @@
         postEvent(DEVICE_ADDED_OR_UPDATED, uiDevice);
     }
 
+    // package private for unit test access
+    UiDevice accessDevice(DeviceId id) {
+        return uiTopology.findDevice(id);
+    }
+
     // invoked from UiSharedTopologyModel device listener
     void removeDevice(Device device) {
         DeviceId id = device.id();
@@ -290,6 +300,11 @@
         postEvent(LINK_ADDED_OR_UPDATED, uiLink);
     }
 
+    // package private for unit test access
+    UiLink accessLink(UiLinkId id) {
+        return uiTopology.findLink(id);
+    }
+
     // invoked from UiSharedTopologyModel link listener
     void removeLink(Link link) {
         UiLinkId id = uiLinkId(link);
@@ -310,36 +325,55 @@
 
     // === HOSTS
 
+    private EdgeLink synthesizeLink(Host h) {
+        return createEdgeLink(h, true);
+    }
+
     private UiHost addNewHost(Host h) {
         UiHost host = new UiHost(uiTopology, h);
         uiTopology.add(host);
 
-        UiLink edgeLink = addNewEdgeLink(host);
-        host.setEdgeLinkId(edgeLink.id());
+        EdgeLink elink = synthesizeLink(h);
+        UiLinkId elinkId = uiLinkId(elink);
+        host.setEdgeLinkId(elinkId);
+
+        // add synthesized edge link to the topology
+        UiLink edgeLink = addNewLink(elinkId);
+        edgeLink.attachEdgeLink(elink);
 
         return host;
     }
 
-    private void removeOldEdgeLink(UiHost uiHost) {
-        UiLink old = uiTopology.findLink(uiHost.edgeLinkId());
-        if (old != null) {
-            uiTopology.remove(old);
-        }
-    }
+    private void insertNewUiLink(UiLinkId id, EdgeLink e) {
+        UiLink newEdgeLink = addNewLink(id);
+        newEdgeLink.attachEdgeLink(e);
 
-    private UiLink addNewEdgeLink(UiHost uiHost) {
-        EdgeLink elink = createEdgeLink(uiHost.backingHost(), true);
-        UiLinkId elinkId = UiLinkId.uiLinkId(elink);
-        UiLink uiLink = addNewLink(elinkId);
-        uiLink.attachEdgeLink(elink);
-        return uiLink;
     }
 
     private void updateHost(UiHost uiHost, Host h) {
-        removeOldEdgeLink(uiHost);
+        UiLink existing = uiTopology.findLink(uiHost.edgeLinkId());
+
+        EdgeLink currentElink = synthesizeLink(h);
+        UiLinkId currentElinkId = uiLinkId(currentElink);
+
+        if (existing != null) {
+            if (!currentElinkId.equals(existing.id())) {
+                // edge link has changed
+                insertNewUiLink(currentElinkId, currentElink);
+                uiHost.setEdgeLinkId(currentElinkId);
+
+                uiTopology.remove(existing);
+            }
+
+        } else {
+            // no previously existing edge link
+            insertNewUiLink(currentElinkId, currentElink);
+            uiHost.setEdgeLinkId(currentElinkId);
+
+        }
+
         HostLocation hloc = h.location();
         uiHost.setLocation(hloc.deviceId(), hloc.port());
-        addNewEdgeLink(uiHost);
     }
 
     private void loadHosts() {
@@ -364,9 +398,17 @@
     // invoked from UiSharedTopologyModel host listener
     void moveHost(Host host, Host prevHost) {
         UiHost uiHost = uiTopology.findHost(prevHost.id());
-        updateHost(uiHost, host);
+        if (uiHost != null) {
+            updateHost(uiHost, host);
+            postEvent(HOST_MOVED, uiHost);
+        } else {
+            log.warn(E_NO_ELEMENT, "host", prevHost.id());
+        }
+    }
 
-        postEvent(HOST_MOVED, uiHost);
+    // package private for unit test access
+    UiHost accessHost(HostId id) {
+        return uiTopology.findHost(id);
     }
 
     // invoked from UiSharedTopologyModel host listener
@@ -374,8 +416,9 @@
         HostId id = host.id();
         UiHost uiHost = uiTopology.findHost(id);
         if (uiHost != null) {
+            UiLink edgeLink = uiTopology.findLink(uiHost.edgeLinkId());
+            uiTopology.remove(edgeLink);
             uiTopology.remove(uiHost);
-            removeOldEdgeLink(uiHost);
             postEvent(HOST_REMOVED, uiHost);
         } else {
             log.warn(E_NO_ELEMENT, "host", id);
@@ -386,6 +429,15 @@
     // === CACHE STATISTICS
 
     /**
+     * Returns a detailed (multi-line) string showing the contents of the cache.
+     *
+     * @return detailed string
+     */
+    public String dumpString() {
+        return uiTopology.dumpString();
+    }
+
+    /**
      * Returns the number of members in the cluster.
      *
      * @return number of cluster members
@@ -402,4 +454,31 @@
     public int regionCount() {
         return uiTopology.regionCount();
     }
+
+    /**
+     * Returns the number of devices in the topology.
+     *
+     * @return number of devices
+     */
+    public int deviceCount() {
+        return uiTopology.deviceCount();
+    }
+
+    /**
+     * Returns the number of links in the topology.
+     *
+     * @return number of links
+     */
+    public int linkCount() {
+        return uiTopology.linkCount();
+    }
+
+    /**
+     * Returns the number of hosts in the topology.
+     *
+     * @return number of hosts
+     */
+    public int hostCount() {
+        return uiTopology.hostCount();
+    }
 }
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
index 98fc07d..9fad51c 100644
--- 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
@@ -136,15 +136,15 @@
     protected static final Set<Region> REGION_SET =
             ImmutableSet.of(REGION_1, REGION_2, REGION_3);
 
-    protected static final String D1 = "D1";
-    protected static final String D2 = "D2";
-    protected static final String D3 = "D3";
-    protected static final String D4 = "D4";
-    protected static final String D5 = "D5";
-    protected static final String D6 = "D6";
-    protected static final String D7 = "D7";
-    protected static final String D8 = "D8";
-    protected static final String D9 = "D9";
+    protected static final String D1 = "d1";
+    protected static final String D2 = "d2";
+    protected static final String D3 = "d3";
+    protected static final String D4 = "d4";
+    protected static final String D5 = "d5";
+    protected static final String D6 = "d6";
+    protected static final String D7 = "d7";
+    protected static final String D8 = "d8";
+    protected static final String D9 = "d9";
 
     protected static final String MFR = "Mfr";
     protected static final String HW = "h/w";
@@ -303,7 +303,6 @@
     private static final HostService MOCK_HOST = new MockHostService();
 
 
-
     private static class MockClusterService extends ClusterServiceAdapter {
         private final Map<NodeId, ControllerNode> nodes = new HashMap<>();
         private final Map<NodeId, ControllerNode.State> states = new HashMap<>();
@@ -470,38 +469,46 @@
 
     }
 
+    /**
+     * Synthesizes a pair of unidirectional links between two devices. The
+     * string array should be of the form:
+     * <pre>
+     *     { "device-A-id", "device-A-port", "device-B-id", "device-B-port" }
+     * </pre>
+     *
+     * @param linkPairData device ids and ports
+     * @return pair of synthesized links
+     */
+    protected static List<Link> makeLinkPair(String[] linkPairData) {
+        DeviceId devA = deviceId(linkPairData[0]);
+        PortNumber portA = portNumber(Long.valueOf(linkPairData[1]));
+        DeviceId devB = deviceId(linkPairData[2]);
+        PortNumber portB = portNumber(Long.valueOf(linkPairData[3]));
+
+        Link linkA = DefaultLink.builder()
+                .providerId(ProviderId.NONE)
+                .type(Link.Type.DIRECT)
+                .src(new ConnectPoint(devA, portA))
+                .dst(new ConnectPoint(devB, portB))
+                .build();
+
+        Link linkB = DefaultLink.builder()
+                .providerId(ProviderId.NONE)
+                .type(Link.Type.DIRECT)
+                .src(new ConnectPoint(devB, portB))
+                .dst(new ConnectPoint(devA, portA))
+                .build();
+
+        return ImmutableList.of(linkA, linkB);
+    }
 
     private static class MockLinkService extends LinkServiceAdapter {
         private final Set<Link> links = new HashSet<>();
 
         MockLinkService() {
             for (String[] linkPair : LINK_CONNECT_DATA) {
-                links.addAll(makeLinks(linkPair));
+                links.addAll(makeLinkPair(linkPair));
             }
-
-        }
-
-        private Set<Link> makeLinks(String[] linkPair) {
-            DeviceId devA = deviceId(linkPair[0]);
-            PortNumber portA = portNumber(Long.valueOf(linkPair[1]));
-            DeviceId devB = deviceId(linkPair[2]);
-            PortNumber portB = portNumber(Long.valueOf(linkPair[3]));
-
-            Link linkA = DefaultLink.builder()
-                    .providerId(ProviderId.NONE)
-                    .type(Link.Type.DIRECT)
-                    .src(new ConnectPoint(devA, portA))
-                    .dst(new ConnectPoint(devB, portB))
-                    .build();
-
-            Link linkB = DefaultLink.builder()
-                    .providerId(ProviderId.NONE)
-                    .type(Link.Type.DIRECT)
-                    .src(new ConnectPoint(devB, portB))
-                    .dst(new ConnectPoint(devA, portA))
-                    .build();
-
-            return ImmutableSet.of(linkA, linkB);
         }
 
         @Override
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 752f28b..5ab7343 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
@@ -20,16 +20,27 @@
 import org.junit.Test;
 import org.onosproject.event.Event;
 import org.onosproject.event.EventDispatcher;
+import org.onosproject.net.Device;
 import org.onosproject.net.DeviceId;
+import org.onosproject.net.Link;
+import org.onosproject.net.region.Region;
 import org.onosproject.ui.impl.topo.model.UiModelEvent.Type;
 import org.onosproject.ui.model.topo.UiClusterMember;
+import org.onosproject.ui.model.topo.UiDevice;
 import org.onosproject.ui.model.topo.UiElement;
+import org.onosproject.ui.model.topo.UiLink;
+import org.onosproject.ui.model.topo.UiLinkId;
+import org.onosproject.ui.model.topo.UiRegion;
+
+import java.util.Collection;
+import java.util.Iterator;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 import static org.onosproject.cluster.NodeId.nodeId;
+import static org.onosproject.ui.model.topo.UiLinkId.uiLinkId;
 
 /**
  * Unit tests for {@link ModelCache}.
@@ -64,6 +75,12 @@
 
     private ModelCache cache;
 
+    private void assertContains(String msg, Collection<?> coll, Object... things) {
+        for (Object o : things) {
+            assertTrue(msg, coll.contains(o));
+        }
+    }
+
     @Before
     public void setUp() {
         cache = new ModelCache(MOCK_SERVICES, dispatcher);
@@ -98,6 +115,20 @@
     }
 
     @Test
+    public void nonExistentClusterMember() {
+        title("nonExistentClusterMember");
+        cache.addOrUpdateClusterMember(CNODE_1);
+        print(cache);
+        assertEquals("unex # members", 1, cache.clusterMemberCount());
+        dispatcher.assertEventCount(1);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C1);
+
+        cache.removeClusterMember(CNODE_2);
+        assertEquals("unex # members", 1, cache.clusterMemberCount());
+        dispatcher.assertEventCount(1);
+    }
+
+    @Test
     public void createThreeNodeCluster() {
         title("createThreeNodeCluster");
         cache.addOrUpdateClusterMember(CNODE_1);
@@ -146,23 +177,145 @@
     public void addNodeAndDevices() {
         title("addNodeAndDevices");
         cache.addOrUpdateClusterMember(CNODE_1);
+        dispatcher.assertLast(Type.CLUSTER_MEMBER_ADDED_OR_UPDATED, C1);
         cache.addOrUpdateDevice(DEV_1);
+        dispatcher.assertLast(Type.DEVICE_ADDED_OR_UPDATED, D1);
         cache.addOrUpdateDevice(DEV_2);
+        dispatcher.assertLast(Type.DEVICE_ADDED_OR_UPDATED, D2);
         cache.addOrUpdateDevice(DEV_3);
+        dispatcher.assertLast(Type.DEVICE_ADDED_OR_UPDATED, D3);
+        dispatcher.assertEventCount(4);
         print(cache);
+
+        assertEquals("unex # nodes", 1, cache.clusterMemberCount());
+        assertEquals("unex # devices", 3, cache.deviceCount());
+        cache.removeDevice(DEV_4);
+        assertEquals("unex # devices", 3, cache.deviceCount());
+        dispatcher.assertEventCount(4);
+
+        cache.removeDevice(DEV_2);
+        dispatcher.assertLast(Type.DEVICE_REMOVED, D2);
+        dispatcher.assertEventCount(5);
+
+        // check out details of device
+        UiDevice dev = cache.accessDevice(DEVID_1);
+        assertEquals("wrong id", D1, dev.idAsString());
+        assertEquals("wrong region", R1, dev.regionId().toString());
+        Device d = dev.backingDevice();
+        assertEquals("wrong serial", SERIAL, d.serialNumber());
     }
 
     @Test
     public void addRegions() {
         title("addRegions");
         cache.addOrUpdateRegion(REGION_1);
+        dispatcher.assertLast(Type.REGION_ADDED_OR_UPDATED, R1);
+        dispatcher.assertEventCount(1);
+        assertEquals("unex # regions", 1, cache.regionCount());
+
+        cache.addOrUpdateRegion(REGION_2);
+        dispatcher.assertLast(Type.REGION_ADDED_OR_UPDATED, R2);
+        dispatcher.assertEventCount(2);
+        assertEquals("unex # regions", 2, cache.regionCount());
+
         print(cache);
+
+        cache.removeRegion(REGION_3);
+        dispatcher.assertEventCount(2);
+        assertEquals("unex # regions", 2, cache.regionCount());
+
+        cache.removeRegion(REGION_1);
+        dispatcher.assertLast(Type.REGION_REMOVED, R1);
+        dispatcher.assertEventCount(3);
+        assertEquals("unex # regions", 1, cache.regionCount());
+
+        print(cache);
+
+        UiRegion region = cache.accessRegion(REGION_2.id());
+        assertEquals("wrong id", REGION_2.id(), region.id());
+        assertEquals("unex # device IDs", 3, region.deviceIds().size());
+        assertContains("missing ID", region.deviceIds(), DEVID_4, DEVID_5, DEVID_6);
+        Region r = region.backingRegion();
+        print(r);
+        assertEquals("wrong region name", "Region-R2", r.name());
+    }
+
+    private static final String[] LINKS_2_7 = {D2, "27", D7, "72"};
+
+    @Test
+    public void addLinks() {
+        title("addLinks");
+
+        Iterator<Link> iter = makeLinkPair(LINKS_2_7).iterator();
+        Link link1 = iter.next();
+        Link link2 = iter.next();
+        print(link1);
+        print(link2);
+
+        UiLinkId idA2B = uiLinkId(link1);
+        UiLinkId idB2A = uiLinkId(link2);
+        // remember, link IDs are canonicalized
+        assertEquals("not same link ID", idA2B, idB2A);
+
+        // we've established that the ID is the same for both
+        UiLinkId linkId = idA2B;
+
+        cache.addOrUpdateLink(link1);
+        dispatcher.assertLast(Type.LINK_ADDED_OR_UPDATED, linkId.toString());
+        dispatcher.assertEventCount(1);
+        assertEquals("unex # links", 1, cache.linkCount());
+
+        UiLink link = cache.accessLink(linkId);
+        assertEquals("dev A not d2", DEVID_2, link.deviceA());
+        assertEquals("dev B not d7", DEVID_7, link.deviceB());
+        assertEquals("wrong backing link A-B", link1, link.linkAtoB());
+        assertEquals("backing link B-A?", null, link.linkBtoA());
+
+        cache.addOrUpdateLink(link2);
+        dispatcher.assertLast(Type.LINK_ADDED_OR_UPDATED, linkId.toString());
+        dispatcher.assertEventCount(2);
+        // NOTE: yes! expect 1 UiLink
+        assertEquals("unex # links", 1, cache.linkCount());
+
+        link = cache.accessLink(linkId);
+        assertEquals("dev A not d2", DEVID_2, link.deviceA());
+        assertEquals("dev B not d7", DEVID_7, link.deviceB());
+        assertEquals("wrong backing link A-B", link1, link.linkAtoB());
+        assertEquals("wrong backing link B-A", link2, link.linkBtoA());
+
+        // now remove links one at a time
+        cache.removeLink(link1);
+        // NOTE: yes! ADD_OR_UPDATE, since the link was updated
+        dispatcher.assertLast(Type.LINK_ADDED_OR_UPDATED, linkId.toString());
+        dispatcher.assertEventCount(3);
+        // NOTE: yes! expect 1 UiLink (still)
+        assertEquals("unex # links", 1, cache.linkCount());
+
+        link = cache.accessLink(linkId);
+        assertEquals("dev A not d2", DEVID_2, link.deviceA());
+        assertEquals("dev B not d7", DEVID_7, link.deviceB());
+        assertEquals("backing link A-B?", null, link.linkAtoB());
+        assertEquals("wrong backing link B-A", link2, link.linkBtoA());
+
+        // remove final link
+        cache.removeLink(link2);
+        dispatcher.assertLast(Type.LINK_REMOVED, linkId.toString());
+        dispatcher.assertEventCount(4);
+        // NOTE: finally link should be removed from cache
+        assertEquals("unex # links", 0, cache.linkCount());
     }
 
     @Test
     public void load() {
         title("load");
         cache.load();
-        print(cache);
+        print(cache.dumpString());
+
+        // See mock service bundle for expected values (AbstractTopoModelTest)
+        assertEquals("unex # cnodes", 3, cache.clusterMemberCount());
+        assertEquals("unex # regions", 3, cache.regionCount());
+        assertEquals("unex # devices", 9, cache.deviceCount());
+        assertEquals("unex # hosts", 18, cache.hostCount());
+        assertEquals("unex # hosts", 26, cache.linkCount());
     }
 }