GUI -- TopoView - Migrated helper functions to topoModel.js.
- moved randomized functions to random.js (so we can mock them).

Change-Id: Ic56ce64c036d36f34798f0df9f03a7d09335a2ab
diff --git a/web/gui/src/main/webapp/app/fw/util/random.js b/web/gui/src/main/webapp/app/fw/util/random.js
new file mode 100644
index 0000000..2298a94
--- /dev/null
+++ b/web/gui/src/main/webapp/app/fw/util/random.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2014,2015 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.
+ */
+
+/*
+ ONOS GUI -- Random -- Encapsulated randomness
+ */
+(function () {
+    'use strict';
+
+    var $log, fs;
+
+    var halfRoot2 = 0.7071;
+
+    // given some value, s, returns an integer between -s/2 and s/2
+    // e.g. s = 100; result in the range [-50..50)
+    function spread(s) {
+        return Math.floor((Math.random() * s) - s / 2);
+    }
+
+    // for a given dimension, d, choose a random value somewhere between
+    // 0 and d where the value is within (d / (2 * sqrt(2))) of d/2.
+    function randDim(d) {
+        return d / 2 + spread(d * halfRoot2);
+    }
+
+    angular.module('onosUtil')
+        .factory('RandomService', ['$log', 'FnService',
+
+        function (_$log_, _fs_) {
+            $log = _$log_;
+            fs = _fs_;
+
+            return {
+                spread: spread,
+                randDim: randDim
+            };
+        }]);
+}());
diff --git a/web/gui/src/main/webapp/app/index.html b/web/gui/src/main/webapp/app/index.html
index c0e9228..b7b8af0 100644
--- a/web/gui/src/main/webapp/app/index.html
+++ b/web/gui/src/main/webapp/app/index.html
@@ -35,6 +35,7 @@
 
     <script src="fw/util/util.js"></script>
     <script src="fw/util/fn.js"></script>
+    <script src="fw/util/random.js"></script>
     <script src="fw/util/theme.js"></script>
     <script src="fw/util/keys.js"></script>
 
@@ -81,6 +82,7 @@
     <script src="view/topo/topo.js"></script>
     <script src="view/topo/topoEvent.js"></script>
     <script src="view/topo/topoForce.js"></script>
+    <script src="view/topo/topoModel.js"></script>
     <script src="view/topo/topoPanel.js"></script>
     <script src="view/topo/topoInst.js"></script>
     <script src="view/device/device.js"></script>
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.js b/web/gui/src/main/webapp/app/view/topo/topo.js
index f227290..a512704 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -130,8 +130,8 @@
 
 
     // callback invoked when the SVG view has been resized..
-    function svgResized(dim) {
-        tfs.resize(dim);
+    function svgResized(s) {
+        tfs.newDim([s.width, s.height]);
     }
 
     // --- Background Map ------------------------------------------------
@@ -203,6 +203,7 @@
                   _ks_, _zs_, _gs_, _ms_, _sus_, tes, _tfs_, tps, _tis_) {
             var self = this,
                 projection,
+                dim,
                 uplink = {
                     // provides function calls back into this space
                     showNoDevs: showNoDevs,
@@ -230,6 +231,7 @@
                 tes.closeSock();
                 tps.destroyPanels();
                 tis.destroyInst();
+                tfs.destroyForce();
             });
 
             // svg layer and initialization of components
@@ -237,6 +239,7 @@
             svg = ovtopo.select('svg');
             // set the svg size to match that of the window, less the masthead
             svg.attr(fs.windowSize(mast.mastHeight()));
+            dim = [svg.attr('width'), svg.attr('height')];
 
             setUpKeys();
             setUpDefs();
@@ -250,7 +253,7 @@
             );
 
             forceG = zoomLayer.append('g').attr('id', 'topo-force');
-            tfs.initForce(forceG, uplink, svg.attr('width'), svg.attr('height'));
+            tfs.initForce(forceG, uplink, dim);
             tis.initInst();
             tps.initPanels();
             tes.openSock();
diff --git a/web/gui/src/main/webapp/app/view/topo/topoForce.js b/web/gui/src/main/webapp/app/view/topo/topoForce.js
index e189ef1..e29c6c0 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -15,15 +15,15 @@
  */
 
 /*
- ONOS GUI -- Topology Event Module.
- Defines event handling for events received from the server.
+ ONOS GUI -- Topology Force Module.
+ Visualization of the topology in an SVG layer, using a D3 Force Layout.
  */
 
 (function () {
     'use strict';
 
     // injected refs
-    var $log, fs, sus, is, ts, flash, tis, icfg, uplink;
+    var $log, fs, sus, is, ts, flash, tis, tms, icfg, uplink;
 
     // configuration
     var labelConfig = {
@@ -48,7 +48,7 @@
         light: {
             baseColor: '#666',
             inColor: '#66f',
-            outColor: '#f00',
+            outColor: '#f00'
         },
         dark: {
             baseColor: '#aaa',
@@ -76,7 +76,7 @@
         showOffline = true,     // whether offline devices are displayed
         oblique = false,        // whether we are in the oblique view
         nodeLock = false,       // whether nodes can be dragged or not (locked)
-        width, height,          // the width and height of the force layout
+        dim,                    // the dimensions of the force layout [w,h]
         hovered,                // the node over which the mouse is hovering
         selections = {},        // what is currently selected
         selectOrder = [];       // the order in which we made selections
@@ -131,7 +131,7 @@
             return;
         }
 
-        d = createDeviceNode(data);
+        d = tms.createDeviceNode(data);
         network.nodes.push(d);
         lu[id] = d;
 
@@ -149,7 +149,7 @@
         if (d) {
             wasOnline = d.online;
             angular.extend(d, data);
-            if (positionNode(d, true)) {
+            if (tms.positionNode(d, true)) {
                 sendUpdateMeta(d);
             }
             updateNodes();
@@ -185,7 +185,7 @@
             return;
         }
 
-        d = createHostNode(data);
+        d = tms.createHostNode(data);
         network.nodes.push(d);
         lu[id] = d;
 
@@ -193,7 +193,7 @@
 
         updateNodes();
 
-        lnk = createHostLink(data);
+        lnk = tms.createHostLink(data);
         if (lnk) {
 
             $log.debug("Created new host-link.. ", lnk.key);
@@ -213,7 +213,7 @@
             d = lu[id];
         if (d) {
             angular.extend(d, data);
-            if (positionNode(d, true)) {
+            if (tms.positionNode(d, true)) {
                 sendUpdateMeta(d);
             }
             updateNodes();
@@ -251,7 +251,7 @@
         }
 
         // no backing store link yet
-        d = createLink(data);
+        d = tms.createLink(data);
         if (d) {
             network.links.push(d);
             lu[d.key] = d;
@@ -290,42 +290,6 @@
         restyleLinkElement(ldata);
     }
 
-    function createLink(link) {
-        var lnk = linkEndPoints(link.src, link.dst);
-
-        if (!lnk) {
-            return null;
-        }
-
-        angular.extend(lnk, {
-            key: link.id,
-            class: 'link',
-            fromSource: link,
-
-            // functions to aggregate dual link state
-            type: function () {
-                var s = lnk.fromSource,
-                    t = lnk.fromTarget;
-                return (s && s.type) || (t && t.type) || defaultLinkType;
-            },
-            online: function () {
-                var s = lnk.fromSource,
-                    t = lnk.fromTarget,
-                    both = lnk.source.online && lnk.target.online;
-                return both && ((s && s.online) || (t && t.online));
-            },
-            linkWidth: function () {
-                var s = lnk.fromSource,
-                    t = lnk.fromTarget,
-                    ws = (s && s.linkWidth) || 0,
-                    wt = (t && t.linkWidth) || 0;
-                return Math.max(ws, wt);
-            }
-        });
-        return lnk;
-    }
-
-
     function makeNodeKey(d, what) {
         var port = what + 'Port';
         return d[what] + '/' + d[port];
@@ -342,8 +306,7 @@
             .domain([1, 12])
             .range([widthRatio, 12 * widthRatio])
             .clamp(true),
-        allLinkTypes = 'direct indirect optical tunnel',
-        defaultLinkType = 'direct';
+        allLinkTypes = 'direct indirect optical tunnel';
 
     function restyleLinkElement(ldata) {
         // this fn's job is to look at raw links and decide what svg classes
@@ -568,7 +531,7 @@
         // if we are not clearing the position data (unpinning),
         // attach the x, y, longitude, latitude...
         if (!clearPos) {
-            ll = lngLatFromCoord([d.x, d.y]);
+            ll = tms.lngLatFromCoord([d.x, d.y]);
             metaUi = {
                 x: d.x,
                 y: d.y,
@@ -588,171 +551,11 @@
         $log.debug('TODO: requestTrafficForMode()...');
     }
 
-    // ==========================
-    // === Devices and hosts - helper functions
-
-    function coordFromLngLat(loc) {
-        var p = uplink.projection();
-        return p ? p([loc.lng, loc.lat]) : [0, 0];
-    }
-
-    function lngLatFromCoord(coord) {
-        var p = uplink.projection();
-        return p ? p.invert(coord) : [0, 0];
-    }
-
-    function positionNode(node, forUpdate) {
-        var meta = node.metaUi,
-            x = meta && meta.x,
-            y = meta && meta.y,
-            xy;
-
-        // If we have [x,y] already, use that...
-        if (x && y) {
-            node.fixed = true;
-            node.px = node.x = x;
-            node.py = node.y = y;
-            return;
-        }
-
-        var location = node.location,
-            coord;
-
-        if (location && location.type === 'latlng') {
-            coord = coordFromLngLat(location);
-            node.fixed = true;
-            node.px = node.x = coord[0];
-            node.py = node.y = coord[1];
-            return true;
-        }
-
-        // if this is a node update (not a node add).. skip randomizer
-        if (forUpdate) {
-            return;
-        }
-
-        // Note: Placing incoming unpinned nodes at exactly the same point
-        //        (center of the view) causes them to explode outwards when
-        //        the force layout kicks in. So, we spread them out a bit
-        //        initially, to provide a more serene layout convergence.
-        //       Additionally, if the node is a host, we place it near
-        //        the device it is connected to.
-
-        function spread(s) {
-            return Math.floor((Math.random() * s) - s/2);
-        }
-
-        function randDim(dim) {
-            return dim / 2 + spread(dim * 0.7071);
-        }
-
-        function rand() {
-            return {
-                x: randDim(width),
-                y: randDim(height)
-            };
-        }
-
-        function near(node) {
-            var min = 12,
-                dx = spread(12),
-                dy = spread(12);
-            return {
-                x: node.x + min + dx,
-                y: node.y + min + dy
-            };
-        }
-
-        function getDevice(cp) {
-            var d = lu[cp.device];
-            return d || rand();
-        }
-
-        xy = (node.class === 'host') ? near(getDevice(node.cp)) : rand();
-        angular.extend(node, xy);
-    }
-
-    function createDeviceNode(device) {
-        // start with the object as is
-        var node = device,
-            type = device.type,
-            svgCls = type ? 'node device ' + type : 'node device';
-
-        // Augment as needed...
-        node.class = 'device';
-        node.svgClass = device.online ? svgCls + ' online' : svgCls;
-        positionNode(node);
-        return node;
-    }
-
-    function createHostNode(host) {
-        var node = host;
-
-        // Augment as needed...
-        node.class = 'host';
-        if (!node.type) {
-            node.type = 'endstation';
-        }
-        node.svgClass = 'node host ' + node.type;
-        positionNode(node);
-        return node;
-    }
-
-    function createHostLink(host) {
-        var src = host.id,
-            dst = host.cp.device,
-            id = host.ingress,
-            lnk = linkEndPoints(src, dst);
-
-        if (!lnk) {
-            return null;
-        }
-
-        // Synthesize link ...
-        angular.extend(lnk, {
-            key: id,
-            class: 'link',
-
-            type: function () { return 'hostLink'; },
-            online: function () {
-                // hostlink target is edge switch
-                return lnk.target.online;
-            },
-            linkWidth: function () { return 1; }
-        });
-        return lnk;
-    }
-
-    function linkEndPoints(srcId, dstId) {
-        var srcNode = lu[srcId],
-            dstNode = lu[dstId],
-            sMiss = !srcNode ? missMsg('src', srcId) : '',
-            dMiss = !dstNode ? missMsg('dst', dstId) : '';
-
-        if (sMiss || dMiss) {
-            $log.error('Node(s) not on map for link:\n' + sMiss + dMiss);
-            //logicError('Node(s) not on map for link:\n' + sMiss + dMiss);
-            return null;
-        }
-        return {
-            source: srcNode,
-            target: dstNode,
-            x1: srcNode.x,
-            y1: srcNode.y,
-            x2: dstNode.x,
-            y2: dstNode.y
-        };
-    }
-
-    function missMsg(what, id) {
-        return '\n[' + what + '] "' + id + '" missing ';
-    }
 
     // ==========================
     // === Devices and hosts - D3 rendering
 
     function nodeMouseOver(m) {
-        // TODO
         if (!m.dragStarted) {
             $log.debug("MouseOver()...", m);
             if (hovered != m) {
@@ -763,7 +566,6 @@
     }
 
     function nodeMouseOut(m) {
-        // TODO
         if (!m.dragStarted) {
             if (hovered) {
                 hovered = null;
@@ -1031,12 +833,12 @@
         var node = d.el;
         node.classed('online', d.online);
         updateDeviceLabel(d);
-        positionNode(d, true);
+        tms.positionNode(d, true);
     }
 
     function hostExisting(d) {
         updateHostLabel(d);
-        positionNode(d, true);
+        tms.positionNode(d, true);
     }
 
     function deviceEnter(d) {
@@ -1424,9 +1226,9 @@
     angular.module('ovTopo')
     .factory('TopoForceService',
         ['$log', 'FnService', 'SvgUtilService', 'IconService', 'ThemeService',
-            'FlashService', 'TopoInstService',
+            'FlashService', 'TopoInstService', 'TopoModelService',
 
-        function (_$log_, _fs_, _sus_, _is_, _ts_, _flash_, _tis_) {
+        function (_$log_, _fs_, _sus_, _is_, _ts_, _flash_, _tis_, _tms_) {
             $log = _$log_;
             fs = _fs_;
             sus = _sus_;
@@ -1434,18 +1236,24 @@
             ts = _ts_;
             flash = _flash_;
             tis = _tis_;
+            tms = _tms_;
 
             icfg = is.iconConfig();
 
             // forceG is the SVG group to display the force layout in
             // xlink is the cross-link api from the main topo source file
-            // w, h are the initial dimensions of the SVG
+            // dim is the initial dimensions of the SVG as [w,h]
             // opts are, well, optional :)
-            function initForce(forceG, _uplink_, w, h, opts) {
-                $log.debug('initForce().. WxH = ' + w + 'x' + h);
+            function initForce(forceG, _uplink_, _dim_, opts) {
                 uplink = _uplink_;
-                width = w;
-                height = h;
+                dim = _dim_;
+
+                $log.debug('initForce().. dim = ' + dim);
+
+                tms.initModel({
+                    projection: uplink.projection,
+                    lookup: network.lookup
+                }, dim);
 
                 settings = angular.extend({}, defaultSettings, opts);
 
@@ -1458,7 +1266,7 @@
                 node = nodeG.selectAll('.node');
 
                 force = d3.layout.force()
-                    .size([w, h])
+                    .size(dim)
                     .nodes(network.nodes)
                     .links(network.links)
                     .gravity(settings.gravity)
@@ -1472,16 +1280,21 @@
                     selectObject, atDragEnd, dragEnabled, clickEnabled);
             }
 
-            function resize(dim) {
-                width = dim.width;
-                height = dim.height;
-                force.size([width, height]);
+            function newDim(_dim_) {
+                dim = _dim_;
+                force.size(dim);
+                tms.newDim(dim);
                 // Review -- do we need to nudge the layout ?
             }
 
+            function destroyForce() {
+
+            }
+
             return {
                 initForce: initForce,
-                resize: resize,
+                newDim: newDim,
+                destroyForce: destroyForce,
 
                 updateDeviceColors: updateDeviceColors,
                 toggleHosts: toggleHosts,
diff --git a/web/gui/src/main/webapp/app/view/topo/topoModel.js b/web/gui/src/main/webapp/app/view/topo/topoModel.js
new file mode 100644
index 0000000..015fbdd
--- /dev/null
+++ b/web/gui/src/main/webapp/app/view/topo/topoModel.js
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2015 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.
+ */
+
+/*
+ ONOS GUI -- Topology Model Module.
+ Auxiliary functions for the model of the topology; that is, our internal
+  representations of devices, hosts, links, etc.
+ */
+
+(function () {
+    'use strict';
+
+    // injected refs
+    var $log, fs, rnd, api;
+
+    var dim;    // dimensions of layout, as [w,h]
+
+    // configuration 'constants'
+    var defaultLinkType = 'direct',
+        nearDist = 15;
+
+
+    function coordFromLngLat(loc) {
+        var p = api.projection();
+        return p ? p([loc.lng, loc.lat]) : [0, 0];
+    }
+
+    function lngLatFromCoord(coord) {
+        var p = api.projection();
+        return p ? p.invert(coord) : [0, 0];
+    }
+
+    function positionNode(node, forUpdate) {
+        var meta = node.metaUi,
+            x = meta && meta.x,
+            y = meta && meta.y,
+            xy;
+
+        // If we have [x,y] already, use that...
+        if (x && y) {
+            node.fixed = true;
+            node.px = node.x = x;
+            node.py = node.y = y;
+            return;
+        }
+
+        var location = node.location,
+            coord;
+
+        if (location && location.type === 'latlng') {
+            coord = coordFromLngLat(location);
+            node.fixed = true;
+            node.px = node.x = coord[0];
+            node.py = node.y = coord[1];
+            return true;
+        }
+
+        // if this is a node update (not a node add).. skip randomizer
+        if (forUpdate) {
+            return;
+        }
+
+        // Note: Placing incoming unpinned nodes at exactly the same point
+        //        (center of the view) causes them to explode outwards when
+        //        the force layout kicks in. So, we spread them out a bit
+        //        initially, to provide a more serene layout convergence.
+        //       Additionally, if the node is a host, we place it near
+        //        the device it is connected to.
+
+        function rand() {
+            return {
+                x: rnd.randDim(dim[0]),
+                y: rnd.randDim(dim[1])
+            };
+        }
+
+        function near(node) {
+            return {
+                x: node.x + nearDist + rnd.spread(nearDist),
+                y: node.y + nearDist + rnd.spread(nearDist)
+            };
+        }
+
+        function getDevice(cp) {
+            var d = api.lookup[cp.device];
+            return d || rand();
+        }
+
+        xy = (node.class === 'host') ? near(getDevice(node.cp)) : rand();
+        angular.extend(node, xy);
+    }
+
+    function mkSvgCls(dh, t, on) {
+        var ndh = 'node ' + dh,
+            ndht = t ? ndh + ' ' + t : ndh;
+        return on ? ndht + ' online' : ndht;
+    }
+
+    function createDeviceNode(device) {
+        var node = device;
+
+        // Augment as needed...
+        node.class = 'device';
+        node.svgClass = mkSvgCls('device', device.type, device.online);
+        positionNode(node);
+        return node;
+    }
+
+    function createHostNode(host) {
+        var node = host;
+
+        // Augment as needed...
+        node.class = 'host';
+        if (!node.type) {
+            node.type = 'endstation';
+        }
+        node.svgClass = mkSvgCls('host', node.type);
+        positionNode(node);
+        return node;
+    }
+
+    function createHostLink(host) {
+        var src = host.id,
+            dst = host.cp.device,
+            id = host.ingress,
+            lnk = linkEndPoints(src, dst);
+
+        if (!lnk) {
+            return null;
+        }
+
+        // Synthesize link ...
+        angular.extend(lnk, {
+            key: id,
+            class: 'link',
+
+            type: function () { return 'hostLink'; },
+            online: function () {
+                // hostlink target is edge switch
+                return lnk.target.online;
+            },
+            linkWidth: function () { return 1; }
+        });
+        return lnk;
+    }
+
+    function createLink(link) {
+        var lnk = linkEndPoints(link.src, link.dst);
+
+        if (!lnk) {
+            return null;
+        }
+
+        angular.extend(lnk, {
+            key: link.id,
+            class: 'link',
+            fromSource: link,
+
+            // functions to aggregate dual link state
+            type: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget;
+                return (s && s.type) || (t && t.type) || defaultLinkType;
+            },
+            online: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget,
+                    both = lnk.source.online && lnk.target.online;
+                return both && ((s && s.online) || (t && t.online));
+            },
+            linkWidth: function () {
+                var s = lnk.fromSource,
+                    t = lnk.fromTarget,
+                    ws = (s && s.linkWidth) || 0,
+                    wt = (t && t.linkWidth) || 0;
+                return Math.max(ws, wt);
+            }
+        });
+        return lnk;
+    }
+
+
+    function linkEndPoints(srcId, dstId) {
+        var srcNode = api.lookup[srcId],
+            dstNode = api.lookup[dstId],
+            sMiss = !srcNode ? missMsg('src', srcId) : '',
+            dMiss = !dstNode ? missMsg('dst', dstId) : '';
+
+        if (sMiss || dMiss) {
+            $log.error('Node(s) not on map for link:' + sMiss + dMiss);
+            //logicError('Node(s) not on map for link:\n' + sMiss + dMiss);
+            return null;
+        }
+        return {
+            source: srcNode,
+            target: dstNode,
+            x1: srcNode.x,
+            y1: srcNode.y,
+            x2: dstNode.x,
+            y2: dstNode.y
+        };
+    }
+
+    function missMsg(what, id) {
+        return '\n[' + what + '] "' + id + '" missing';
+    }
+
+    // ==========================
+    // Module definition
+
+    angular.module('ovTopo')
+        .factory('TopoModelService',
+        ['$log', 'FnService', 'RandomService',
+
+        function (_$log_, _fs_, _rnd_) {
+            $log = _$log_;
+            fs = _fs_;
+            rnd = _rnd_;
+
+            function initModel(_api_, _dim_) {
+                api = _api_;
+                dim = _dim_;
+            }
+
+            function newDim(_dim_) {
+                dim = _dim_;
+            }
+
+            return {
+                initModel: initModel,
+                newDim: newDim,
+
+                positionNode: positionNode,
+                createDeviceNode: createDeviceNode,
+                createHostNode: createHostNode,
+                createHostLink: createHostLink,
+                createLink: createLink,
+                coordFromLngLat: coordFromLngLat,
+                lngLatFromCoord: lngLatFromCoord,
+            }
+        }]);
+}());
diff --git a/web/gui/src/main/webapp/tests/app/fw/util/random-spec.js b/web/gui/src/main/webapp/tests/app/fw/util/random-spec.js
new file mode 100644
index 0000000..c4c61f1
--- /dev/null
+++ b/web/gui/src/main/webapp/tests/app/fw/util/random-spec.js
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2014,2015 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.
+ */
+
+/*
+ ONOS GUI -- Util -- Random Service - Unit Tests
+ */
+describe('factory: fw/util/random.js', function() {
+    var rnd, $log, fs;
+
+    beforeEach(module('onosUtil'));
+
+    beforeEach(inject(function (RandomService, _$log_, FnService) {
+        rnd = RandomService;
+        $log = _$log_;
+        fs = FnService;
+    }));
+
+    // interesting use of a custom matcher...
+    beforeEach(function () {
+        jasmine.addMatchers({
+            toBeWithinOf: function () {
+                return {
+                    compare: function (actual, distance, base) {
+                        var lower = base - distance,
+                            upper = base + distance,
+                            result = {};
+
+                        result.pass = Math.abs(actual - base) <= distance;
+
+                        if (result.pass) {
+                            // for negation with ".not"
+                            result.message = 'Expected ' + actual +
+                                ' to be outside ' + lower + ' and ' +
+                                upper + ' (inclusive)';
+                        } else {
+                            result.message = 'Expected ' + actual +
+                            ' to be between ' + lower + ' and ' +
+                            upper + ' (inclusive)';
+                        }
+                        return result;
+                    }
+                }
+            }
+        });
+    });
+
+    it('should define RandomService', function () {
+        expect(rnd).toBeDefined();
+    });
+
+    it('should define api functions', function () {
+        expect(fs.areFunctions(rnd, [
+            'spread', 'randDim'
+        ])).toBeTruthy();
+    });
+
+    // really, can only do this heuristically.. hope this doesn't break
+    it('should spread results across the range', function () {
+        var load = 1000,
+            s = 12,
+            low = 0,
+            high = 0,
+            i, res,
+            which = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+            minCount = load / s * 0.5;  // generous error
+
+        for (i=0; i<load; i++) {
+            res = rnd.spread(s);
+            if (res < low) low = res;
+            if (res > high) high = res;
+            which[res + s/2]++;
+        }
+        expect(low).toBe(-6);
+        expect(high).toBe(5);
+
+        // check we got a good number of hits in each bucket
+        for (i=0; i<s; i++) {
+            expect(which[i]).toBeGreaterThan(minCount);
+        }
+    });
+
+    // really, can only do this heuristically.. hope this doesn't break
+    it('should choose results across the dimension', function () {
+        var load = 1000,
+            dim = 100,
+            low = 999,
+            high = 0,
+            i, res;
+
+        for (i=0; i<load; i++) {
+            res = rnd.randDim(dim);
+            if (res < low) low = res;
+            if (res > high) high = res;
+            expect(res).toBeWithinOf(36, 50);
+        }
+    });
+});
diff --git a/web/gui/src/main/webapp/tests/app/fw/util/theme-spec.js b/web/gui/src/main/webapp/tests/app/fw/util/theme-spec.js
index cf1841b..1d400ed 100644
--- a/web/gui/src/main/webapp/tests/app/fw/util/theme-spec.js
+++ b/web/gui/src/main/webapp/tests/app/fw/util/theme-spec.js
@@ -29,7 +29,7 @@
         ts.init();
     }));
 
-    it('should define MapService', function () {
+    it('should define ThemeService', function () {
         expect(ts).toBeDefined();
     });
 
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
index dfaebf5..546f2f9 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
@@ -34,8 +34,11 @@
 
     it('should define api functions', function () {
         expect(fs.areFunctions(tfs, [
-            'initForce', 'resize', 'updateDeviceColors',
-            'toggleHosts', 'toggleOffline','cycleDeviceLabels', 'unpin',
+            'initForce', 'newDim', 'destroyForce',
+
+            'updateDeviceColors', 'toggleHosts', 'toggleOffline',
+            'cycleDeviceLabels', 'unpin',
+
             'addDevice', 'updateDevice', 'removeDevice',
             'addHost', 'updateHost', 'removeHost',
             'addLink', 'updateLink', 'removeLink'
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js
new file mode 100644
index 0000000..a0d488b
--- /dev/null
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js
@@ -0,0 +1,403 @@
+/*
+ * Copyright 2015 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.
+ */
+
+/*
+ ONOS GUI -- Topo View -- Topo Model Service - Unit Tests
+ */
+describe('factory: view/topo/topoModel.js', function() {
+    var $log, fs, rnd, tms;
+
+    // stop random numbers from being quite so random
+    var mockRandom = {
+        // mock spread returns s + 1
+        spread: function (s) {
+            return s + 1;
+        },
+        // mock random dimension returns d / 2 - 1
+        randDim: function (d) {
+            return d/2 - 1;
+        },
+        mock: 'yup'
+    };
+
+    // to mock out the [lng,lat] <=> [x,y] transformations, we will
+    // add/subtract 2000, 3000 respectively:
+    //   lng:2005 === x:5,   lat:3004 === y:4
+
+    var mockProjection = function (lnglat) {
+        return [lnglat[0] - 2000, lnglat[1] - 3000];
+    };
+
+    mockProjection.invert = function (xy) {
+        return [xy[0] + 2000, xy[1] + 3000];
+    };
+
+    // our test device lookup
+    var lu = {
+        dev1: {
+            'class': 'device',
+            id: 'dev1',
+            x: 17,
+            y: 27,
+            online: true
+        },
+        dev2: {
+            'class': 'device',
+            id: 'dev2',
+            x: 18,
+            y: 28,
+            online: true
+        },
+        host1: {
+            'class': 'host',
+            id: 'host1',
+            x: 23,
+            y: 33,
+            cp: {
+                device: 'dev1',
+                port: 7
+            },
+            ingress: 'dev1/7-host1'
+        },
+        host2: {
+            'class': 'host',
+            id: 'host2',
+            x: 24,
+            y: 34,
+            cp: {
+                device: 'dev0',
+                port: 0
+            },
+            ingress: 'dev0/0-host2'
+        }
+    };
+
+    // our test api
+    var api = {
+        projection: function () { return mockProjection; },
+        lookup: lu
+    };
+
+    // our test dimensions and well known locations..
+    var dim = [20, 40],
+        randLoc = [9, 19],          // random location using randDim(): d/2-1
+        randHostLoc = [40, 50],     // host "near" random location
+                                    //  given that 'nearDist' = 15
+                                    //  and spread(15) = 16
+                                    //  9 + 15 + 16 = 40; 19 + 15 + 16 = 50
+        nearDev1 = [48,58],         // [17+15+16, 27+15+16]
+        dev1Loc = [17,27],
+        dev2Loc = [18,28],
+        host1Loc = [23,33],
+        host2Loc = [24,34];
+
+    // implement some custom matchers...
+    beforeEach(function () {
+        jasmine.addMatchers({
+            toBePositionedAt: function () {
+                return {
+                    compare: function (actual, xy) {
+                        var result = {},
+                            actCoord = [actual.x, actual.y];
+
+                        result.pass = (actual.x === xy[0]) && (actual.y === xy[1]);
+
+                        if (result.pass) {
+                            // for negation with ".not"
+                            result.message = 'Expected [' + actCoord +
+                            '] NOT to be positioned at [' + xy + ']';
+                        } else {
+                            result.message = 'Expected [' + actCoord +
+                            '] to be positioned at [' + xy + ']';
+                        }
+                        return result;
+                    }
+                }
+            },
+            toHaveEndPoints: function () {
+                return {
+                    compare: function (actual, xy1, xy2) {
+                        var result = {};
+
+                        result.pass = (actual.x1 === xy1[0]) && (actual.y1 === xy1[1]) &&
+                                      (actual.x2 === xy2[0]) && (actual.y2 === xy2[1]);
+
+                        if (result.pass) {
+                            // for negation with ".not"
+                            result.message = 'Expected ' + actual +
+                            ' NOT to have endpoints [' + xy1 + ']-[' + xy2 + ']';
+                        } else {
+                            result.message = 'Expected ' + actual +
+                            ' to have endpoints [' + xy1 + ']-[' + xy2 + ']';
+                        }
+                        return result;
+                    }
+                }
+            },
+            toBeFixed: function () {
+                return {
+                    compare: function (actual) {
+                        var result = {
+                            pass: actual.fixed
+                        };
+                        if (result.pass) {
+                            result.message = 'Expected ' + actual +
+                            ' NOT to be fixed!';
+                        } else {
+                            result.message = 'Expected ' + actual +
+                            ' to be fixed!';
+                        }
+                        return result;
+                    }
+                }
+            }
+        });
+    });
+
+    beforeEach(module('ovTopo', 'onosUtil'));
+
+    beforeEach(function () {
+        module(function ($provide) {
+            $provide.value('RandomService', mockRandom);
+        });
+    });
+
+    beforeEach(inject(function (_$log_, FnService, RandomService, TopoModelService) {
+        $log = _$log_;
+        fs = FnService;
+        rnd = RandomService;
+        tms = TopoModelService;
+        tms.initModel(api, dim);
+    }));
+
+
+    it('should install the mock random service', function () {
+        expect(rnd.mock).toBe('yup');
+        expect(rnd.spread(4)).toBe(5);
+        expect(rnd.randDim(8)).toBe(3);
+    });
+
+    it('should install the mock projection', function () {
+        expect(tms.coordFromLngLat({lng: 2005, lat: 3004})).toEqual([5,4]);
+        expect(tms.lngLatFromCoord([5,4])).toEqual([2005,3004]);
+    });
+
+    it('should define TopoModelService', function () {
+        expect(tms).toBeDefined();
+    });
+
+    it('should define api functions', function () {
+        expect(fs.areFunctions(tms, [
+            'initModel', 'newDim',
+            'positionNode', 'createDeviceNode', 'createHostNode',
+            'createHostLink', 'createLink',
+            'coordFromLngLat', 'lngLatFromCoord'
+        ])).toBeTruthy();
+    });
+
+    // === unit tests for positionNode()
+
+    it('should position a node using meta x/y', function () {
+        var node = {
+            metaUi: { x:37, y:48 }
+        };
+        tms.positionNode(node);
+        expect(node).toBePositionedAt([37,48]);
+        expect(node).toBeFixed();
+    });
+
+    it('should position a node by translating lng/lat', function () {
+        var node = {
+            location: {
+                type: 'latlng',
+                lng: 2008,
+                lat: 3009
+            }
+        };
+        tms.positionNode(node);
+        expect(node).toBePositionedAt([8,9]);
+        expect(node).toBeFixed();
+    });
+
+    it('should position a device with no location randomly', function () {
+        var node = { 'class': 'device' };
+        tms.positionNode(node);
+        expect(node).toBePositionedAt(randLoc);
+        expect(node).not.toBeFixed();
+    });
+
+    it('should position a device randomly even if x/y set', function () {
+        var node = { 'class': 'device', x: 1, y: 2 };
+        tms.positionNode(node);
+        expect(node).toBePositionedAt(randLoc);
+        expect(node).not.toBeFixed();
+    });
+
+    it('should NOT reposition a device randomly on update', function () {
+        var node = { 'class': 'device', x: 1, y: 2 };
+        tms.positionNode(node, true);
+        expect(node).toBePositionedAt([1,2]);
+        expect(node).not.toBeFixed();
+    });
+
+    it('should position a host close to its device', function () {
+        var node = { 'class': 'host', cp: { device: 'dev1' } };
+        tms.positionNode(node);
+
+        // note: nearDist is 15; spread(15) adds 16; dev1 at [17,27]
+
+        expect(node).toBePositionedAt(nearDev1);
+        expect(node).not.toBeFixed();
+    });
+
+    it('should randomize host with no assoc device', function () {
+        var node = { 'class': 'host', cp: { device: 'dev0' } };
+        tms.positionNode(node);
+
+        // note: no device gives 'rand loc' [9,19]
+        //       nearDist is 15; spread(15) adds 16
+
+        expect(node).toBePositionedAt(randHostLoc);
+        expect(node).not.toBeFixed();
+    });
+
+    // === unit tests for createDeviceNode()
+
+    it('should create a basic device node', function () {
+        var node = tms.createDeviceNode({ id: 'foo' });
+        expect(node).toBePositionedAt(randLoc);
+        expect(node).not.toBeFixed();
+        expect(node.class).toEqual('device');
+        expect(node.svgClass).toEqual('node device');
+        expect(node.id).toEqual('foo');
+    });
+
+    it('should create device node with type', function () {
+        var node = tms.createDeviceNode({ id: 'foo', type: 'cool' });
+        expect(node).toBePositionedAt(randLoc);
+        expect(node).not.toBeFixed();
+        expect(node.class).toEqual('device');
+        expect(node.svgClass).toEqual('node device cool');
+        expect(node.id).toEqual('foo');
+    });
+
+    it('should create online device node with type', function () {
+        var node = tms.createDeviceNode({ id: 'foo', type: 'cool', online: true });
+        expect(node).toBePositionedAt(randLoc);
+        expect(node).not.toBeFixed();
+        expect(node.class).toEqual('device');
+        expect(node.svgClass).toEqual('node device cool online');
+        expect(node.id).toEqual('foo');
+    });
+
+    it('should create online device node with type and lng/lat', function () {
+        var node = tms.createDeviceNode({
+            id: 'foo',
+            type: 'yowser',
+            online: true,
+            location: {
+                type: 'latlng',
+                lng: 2048,
+                lat: 3096
+            }
+        });
+        expect(node).toBePositionedAt([48,96]);
+        expect(node).toBeFixed();
+        expect(node.class).toEqual('device');
+        expect(node.svgClass).toEqual('node device yowser online');
+        expect(node.id).toEqual('foo');
+    });
+
+    // === unit tests for createHostNode()
+
+    it('should create a basic host node', function () {
+        var node = tms.createHostNode({ id: 'bar', cp: { device: 'dev0' } });
+        expect(node).toBePositionedAt(randHostLoc);
+        expect(node).not.toBeFixed();
+        expect(node.class).toEqual('host');
+        expect(node.svgClass).toEqual('node host endstation');
+        expect(node.id).toEqual('bar');
+    });
+
+    it('should create a host with type', function () {
+        var node = tms.createHostNode({
+            id: 'bar',
+            type: 'classic',
+            cp: { device: 'dev1' }
+        });
+        expect(node).toBePositionedAt(nearDev1);
+        expect(node).not.toBeFixed();
+        expect(node.class).toEqual('host');
+        expect(node.svgClass).toEqual('node host classic');
+        expect(node.id).toEqual('bar');
+    });
+
+    // === unit tests for createHostLink()
+
+    it('should create a basic host link', function () {
+        var link = tms.createHostLink(lu.host1);
+        expect(link.source).toEqual(lu.host1);
+        expect(link.target).toEqual(lu.dev1);
+        expect(link).toHaveEndPoints(host1Loc, dev1Loc);
+        expect(link.key).toEqual('dev1/7-host1');
+        expect(link.class).toEqual('link');
+        expect(link.type()).toEqual('hostLink');
+        expect(link.linkWidth()).toEqual(1);
+        expect(link.online()).toEqual(true);
+    });
+
+    it('should return null for failed endpoint lookup', function () {
+        spyOn($log, 'error');
+        var link = tms.createHostLink(lu.host2);
+        expect(link).toBeNull();
+        expect($log.error).toHaveBeenCalledWith(
+            'Node(s) not on map for link:\n[dst] "dev0" missing'
+        );
+    });
+
+    // === unit tests for createLink()
+
+    it('should return null for missing endpoints', function () {
+        spyOn($log, 'error');
+        var link = tms.createLink({src: 'dev0', dst: 'dev00'});
+        expect(link).toBeNull();
+        expect($log.error).toHaveBeenCalledWith(
+            'Node(s) not on map for link:\n[src] "dev0" missing\n[dst] "dev00" missing'
+        );
+    });
+
+    it('should create a basic link', function () {
+        var linkData = {
+                src: 'dev1',
+                dst: 'dev2',
+                id: 'baz',
+                type: 'zoo',
+                online: true,
+                linkWidth: 1.5
+            },
+            link = tms.createLink(linkData);
+        expect(link.source).toEqual(lu.dev1);
+        expect(link.target).toEqual(lu.dev2);
+        expect(link).toHaveEndPoints(dev1Loc, dev2Loc);
+        expect(link.key).toEqual('baz');
+        expect(link.class).toEqual('link');
+        expect(link.fromSource).toBe(linkData);
+        expect(link.type()).toEqual('zoo');
+        expect(link.online()).toEqual(true);
+        expect(link.linkWidth()).toEqual(1.5);
+    });
+
+});