The start of a test framework for Angular.
authorPeter Haight <peterh@giantrabbit.com>
Sun, 30 Nov 2014 00:04:29 +0000 (16:04 -0800)
committerPeter Haight <peterh@giantrabbit.com>
Tue, 23 Dec 2014 02:40:46 +0000 (18:40 -0800)
To run:
npm install
npm test

The beginnings of some tests for the new Angular code. Includes some tests
for the case type stuff and the new mailing stuff.

14 files changed:
.gitignore
CRM/Core/Page/Angular.php
bower.json [new file with mode: 0644]
js/angular-crmApp.js [new file with mode: 0644]
js/angular-crmCaseType.js
js/angular-crmMailing.js
package.json [new file with mode: 0644]
templates/CRM/Core/Page/Angular.tpl
tests/karma.conf.js [new file with mode: 0644]
tests/karma/lib/crmJsonComparitor.js [new file with mode: 0644]
tests/karma/modules.js [new file with mode: 0644]
tests/karma/unit/crmCaseTypeSpec.js [new file with mode: 0644]
tests/karma/unit/crmJsonComparitor.js [new file with mode: 0644]
tests/karma/unit/crmMailingSpec.js [new file with mode: 0644]

index 29d3ae37142f600526ba909a2d961c53f39a2fa9..e1901e79e238d4277d248b6181fdfa2fcfa6d6bb 100644 (file)
@@ -1,5 +1,6 @@
 *~
 *.bak
+bower_components
 CRM/ACL/DAO
 CRM/Activity/DAO
 CRM/Auction/DAO
@@ -113,6 +114,7 @@ civicrm-version.php
 civicrm-version.txt
 civicrm.config.php
 install/langs.php
+node_modules
 packages/.channels
 packages/.depdb
 packages/.depdblock
index 0ab871872cd1707ce5d2147251837124dbcc52ca..8c6f73203153e3639b3b41aa427d5b82d80f2b50 100644 (file)
@@ -40,8 +40,8 @@ class CRM_Core_Page_Angular extends CRM_Core_Page {
       );
     });
 
-    $res->addScriptFile('civicrm', 'packages/bower_components/angular/angular.min.js', 100, 'html-header', FALSE);
-    $res->addScriptFile('civicrm', 'packages/bower_components/angular-route/angular-route.min.js', 110, 'html-header', FALSE);
+    $res->addScriptFile('civicrm', 'bower_components/angular/angular.min.js', 100, 'html-header', FALSE);
+    $res->addScriptFile('civicrm', 'bower_components/angular-route/angular-route.min.js', 110, 'html-header', FALSE);
     $headOffset = 0;
     foreach ($modules as $module) {
       if (!empty($module['css'])) {
@@ -64,16 +64,19 @@ class CRM_Core_Page_Angular extends CRM_Core_Page {
    */
   public static function getAngularModules() {
     $angularModules = array();
-    $angularModules['ui.utils'] = array('ext' => 'civicrm', 'js' => array('packages/bower_components/angular-ui-utils/ui-utils.min.js'));
-    $angularModules['ui.sortable'] = array('ext' => 'civicrm', 'js' => array('packages/bower_components/angular-ui-sortable/sortable.min.js'));
-    $angularModules['unsavedChanges'] = array('ext' => 'civicrm', 'js' => array('packages/bower_components/angular-unsavedChanges/dist/unsavedChanges.min.js'));
+    $angularModules['ui.utils'] = array('ext' => 'civicrm', 'js' => array('bower_components/angular-ui-utils/ui-utils.min.js'));
+    $angularModules['ui.sortable'] = array('ext' => 'civicrm', 'js' => array('bower_components/angular-ui-sortable/sortable.min.js'));
+    $angularModules['unsavedChanges'] = array('ext' => 'civicrm', 'js' => array('bower_components/angular-unsavedChanges/dist/unsavedChanges.min.js'));
     // https://github.com/jwstadler/angular-jquery-dialog-service
-    $angularModules['angularFileUpload'] = array('ext' => 'civicrm', 'js' => array('packages/bower_components/angular-file-upload/angular-file-upload.min.js'));
-    $angularModules['dialogService'] = array('ext' => 'civicrm' , 'js' => array('packages/bower_components/angular-jquery-dialog-service/dialog-service.js'));
+    $angularModules['angularFileUpload'] = array('ext' => 'civicrm', 'js' => array('bower_components/angular-file-upload/angular-file-upload.min.js'));
+    $angularModules['dialogService'] = array('ext' => 'civicrm' , 'js' => array('bower_components/angular-jquery-dialog-service/dialog-service.js'));
+    $angularModules['crmApp'] = array('ext' => 'civicrm', 'js' => array('js/angular-crmApp.js'));
     $angularModules['crmAttachment'] = array('ext' => 'civicrm', 'js' => array('js/angular-crmAttachment.js'), 'css' => array('css/angular-crmAttachment.css'));
     $angularModules['crmUi'] = array('ext' => 'civicrm', 'js' => array('js/angular-crm-ui.js', 'packages/ckeditor/ckeditor.js'));
     $angularModules['crmUtil'] = array('ext' => 'civicrm', 'js' => array('js/angular-crm-util.js'));
     $angularModules['ngSanitize'] = array('ext' => 'civicrm', 'js' => array('js/angular-sanitize.js'));
+    $angularModules['unsavedChanges'] = array('ext' => 'civicrm', 'js' => array('bower_components/angular-unsavedChanges/dist/unsavedChanges.min.js'));
+    $angularModules['crmUi'] = array('ext' => 'civicrm', 'js' => array('js/angular-crm-ui.js'));
 
     foreach (CRM_Core_Component::getEnabledComponents() as $component) {
       $angularModules = array_merge($angularModules, $component->getAngularModules());
diff --git a/bower.json b/bower.json
new file mode 100644 (file)
index 0000000..cbae39c
--- /dev/null
@@ -0,0 +1,17 @@
+{
+  "name": "civicrm",
+  "description": "CiviCRM",
+  "version": "5.0.0",
+  "license": "AGPL-3.0",
+  "private": true,
+  "dependencies": {
+    "angular": "1.3.x",
+    "angular-file-upload": "~1.1.5",
+    "angular-jquery-dialog-service": "totten/angular-jquery-dialog-service#jquery-closure",
+    "angular-mocks": "1.3.x",
+    "angular-route": "1.3.x",
+    "angular-ui-sortable": "0.12.x",
+    "angular-ui-utils": "0.1.x",
+    "angular-unsavedChanges": "~0.1.1"
+  }
+}
diff --git a/js/angular-crmApp.js b/js/angular-crmApp.js
new file mode 100644 (file)
index 0000000..ed7d1c2
--- /dev/null
@@ -0,0 +1,30 @@
+(function(angular, CRM) {
+  var crmApp = angular.module('crmApp', CRM.angular.modules);
+  crmApp.config(['$routeProvider',
+    function($routeProvider) {
+      $routeProvider.otherwise({
+        template: ts('Unknown path')
+      });
+    }
+  ]);
+  crmApp.factory('crmApi', function() {
+    return function(entity, action, params, message) {
+      // JSON serialization in CRM.api3 is not aware of Angular metadata like $$hash
+      if (CRM._.isObject(entity)) {
+        return CRM.api3(eval('('+angular.toJson(entity)+')'), message);
+      } else {
+        return CRM.api3(entity, action, eval('('+angular.toJson(params)+')'), message);
+      }
+    };
+  });
+  crmApp.factory('crmLegacy', function() {
+    return CRM;
+  });
+  crmApp.factory('crmNavigator', ['$window', function($window) {
+    return {
+      redirect: function(path) {
+        $window.location.href = path;
+      }
+    };
+  }]);
+})(angular, CRM);
index d27c9655e55af073b551bfeda55cce4d7d69f025..61951372725b092eb6c5c3a48ba9e1e86a82ed2c 100644 (file)
@@ -4,7 +4,7 @@
     return CRM.resourceUrls['civicrm'] + '/partials/crmCaseType/' + relPath;
   };
 
-  var crmCaseType = angular.module('crmCaseType', ['ngRoute', 'ui.utils', 'crmUi', 'unsavedChanges']);
+  var crmCaseType = angular.module('crmCaseType', ['ngRoute', 'ui.utils', 'crmUi', 'unsavedChanges', 'crmApp']);
 
   // Note: This template will be passed to cloneDeep(), so don't put any funny stuff in here!
   var newCaseTypeTemplate = {
index b383acb5717544fa050b6326133ef90409163a8b..7595d67abd9970642fe876531f312d5c9898548c 100644 (file)
@@ -4,7 +4,7 @@
   };
 
   angular.module('crmMailing', [
-    'crmUtil', 'crmAttachment', 'ngRoute', 'ui.utils', 'crmUi', 'dialogService'
+    'crmUtil', 'crmAttachment', 'ngRoute', 'ui.utils', 'crmUi', 'dialogService', 'crmApp'
   ]); // TODO ngSanitize, unsavedChanges
 
   // Time to wait before triggering AJAX update to recipients list
     }
   ]);
 
-  angular.module('crmMailing').controller('ListMailingsCtrl', function ListMailingsCtrl() {
+  angular.module('crmMailing').controller('ListMailingsCtrl', ['crmLegacy', 'crmNavigator', function ListMailingsCtrl(crmLegacy, crmNavigator) {
     // We haven't implemented this in Angular, but some users may get clever
     // about typing URLs, so we'll provide a redirect.
-    window.location = CRM.url('civicrm/mailing/browse/unscheduled', {
-      reset: 1,
-      scheduled: 'false'
-    });
-  });
+    var new_url = crmLegacy.url('civicrm/mailing/browse/unscheduled', {reset: 1, scheduled: 'false'});
+    crmNavigator.redirect(new_url);
+  }]);
 
   angular.module('crmMailing').controller('EditMailingCtrl', function EditMailingCtrl($scope, selectedMail, $location, crmMailingMgr, crmStatus, CrmAttachments, crmMailingPreviewMgr) {
     $scope.mailing = selectedMail;
diff --git a/package.json b/package.json
new file mode 100644 (file)
index 0000000..f1cb2e0
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "description": "CiviCRM",
+  "main": "index.js",
+  "license": "MIT",
+  "name": "civicrm",
+  "version": "5.0.0",
+  "devDependencies": {
+    "bower": "^1.3.1",
+    "karma": "^0.12.16",
+    "karma-chrome-launcher": "^0.1.4",
+    "jasmine-core": "~2.1.2",
+    "karma-jasmine": "~0.3.2"
+  },
+  "scripts": {
+    "postinstall": "bower install",
+    "test": "node node_modules/karma/bin/karma start tests/karma.conf.js"
+  }
+}
index 706a810d0cfc323260c66905c77dcd3c3a2f3785..63eb5e3e45e81338dade6eb9301b3a7af3927d58 100644 (file)
@@ -2,47 +2,4 @@
 <div ng-app="crmApp">
   <div ng-view></div>
 </div>
-
-<script type="text/javascript">
-  (function(angular, _) {
-    var crmApp = angular.module('crmApp', CRM.angular.modules);
-    crmApp.config(['$routeProvider',
-      function($routeProvider) {
-        $routeProvider.otherwise({
-          template: ts('Unknown path')
-        });
-      }
-    ]);
-    // crmApi is a function(entity,action,params,message) which is similar to CRM.api3, but
-    // it follows Angular conventions (e.g. it's an injectable service which returns a $q promise)
-    crmApp.factory('crmApi', function($q) {
-      return function(entity, action, params, message) {
-        // JSON serialization in CRM.api3 is not aware of Angular metadata like $$hash, so use angular.toJson()
-        var deferred = $q.defer();
-        var p;
-        if (_.isObject(entity)) {
-          p = CRM.api3(eval('('+angular.toJson(entity)+')'), message);
-        } else {
-          p = CRM.api3(entity, action, eval('('+angular.toJson(params)+')'), message);
-        }
-        // CRM.api3 returns a promise, but the promise doesn't really represent errors as errors, so we
-        // convert them
-        p.then(
-          function(result) {
-            if (result.is_error) {
-              deferred.reject(result);
-            } else {
-              deferred.resolve(result);
-            }
-          },
-          function(error) {
-            deferred.reject(error);
-          }
-        );
-        return deferred.promise;
-      };
-    });
-  })(angular, CRM._);
-</script>
-
-{/literal}
\ No newline at end of file
+{/literal}
diff --git a/tests/karma.conf.js b/tests/karma.conf.js
new file mode 100644 (file)
index 0000000..1f977dc
--- /dev/null
@@ -0,0 +1,36 @@
+module.exports = function(config) {
+  config.set({
+    autoWatch: true,
+    basePath: '..',
+    browsers: ['Chrome'],
+    exclude: [
+    ],
+    files: [
+      'bower_components/jquery/dist/jquery.min.js',
+      'bower_components/jquery-ui/jquery-ui.min.js',
+      'packages/backbone/lodash.compat.min.js',
+      'packages/jquery/plugins/select2/select2.min.js',
+      'packages/jquery/plugins/jquery.blockUI.js',
+      'packages/jquery/plugins/jquery.validate.js',
+      'js/Common.js',
+      'bower_components/angular/angular.js',
+      'bower_components/angular-file-upload/angular-file-upload.js',
+      'bower_components/angular-jquery-dialog-service/dialog-service.js',
+      'bower_components/angular-route/angular-route.js',
+      'bower_components/angular-mocks/angular-mocks.js',
+      'bower_components/angular-ui-sortable/sortable.js',
+      'bower_components/angular-ui-utils/ui-utils.js',
+      'bower_components/angular-unsavedChanges/dist/unsavedChanges.js',
+      'tests/karma/modules.js',
+      'js/crm.ajax.js',
+      'js/angular-*.js',
+      'tests/karma/lib/*.js',
+      'tests/karma/**/*.js',
+    ],
+    frameworks: ['jasmine'],
+    logLevel: config.LOG_INFO,
+    port: 9876,
+    reporters: ['progress'],
+    singleRun: false
+  });
+};
diff --git a/tests/karma/lib/crmJsonComparitor.js b/tests/karma/lib/crmJsonComparitor.js
new file mode 100644 (file)
index 0000000..44a3207
--- /dev/null
@@ -0,0 +1,89 @@
+'use strict';
+
+(function(root) {
+  var Comparitor = function() {};
+  Comparitor.prototype = {
+    compare: function(actual, expected) {
+      this.result = {
+        'pass': true,
+      };
+      this.internal_compare('root', actual, expected);
+      return this.result;
+    },
+    internal_compare: function(context, actual, expected) {
+      if (expected instanceof Array) {
+        return this.internal_compare_array(context, actual, expected);
+      } else if (expected instanceof Object) {
+        return this.internal_compare_object(context, actual, expected);
+      } else {
+        return this.internal_compare_value(context, actual, expected);
+      }
+      return true;
+    },
+    internal_compare_array: function(context, actual, expected) {
+      if (!(actual instanceof Array)) {
+        this.result.pass = false;
+        this.result.message = "The expected data has an array at " + context + ", but the actual data has something else (" + actual + ")";
+        return false;
+      }
+      if (expected.length != actual.length) {
+        this.result.pass = false;
+        this.result.message = "The expected data has an array with " + expected.length + " items in it, but the actual data has " + actual.length + " items.";
+        return false;
+      }
+      for (var i = 0; i < expected.length; i++) {
+        var still_matches = this.internal_compare(context + "[" + i + "]", actual[i], expected[i]);
+        if (!still_matches) {
+          return false;
+        }
+      }
+      return true;
+    },
+    internal_compare_object: function(context, actual, expected) {
+      if (!(actual instanceof Object) || actual instanceof Array) {
+        this.result.pass = false;
+        this.result.message = "The expected data has an object at root, but the actual data has something else (" + actual + ")";
+        return false;
+      }
+      for (var key in expected) {
+        if (!(key in actual)) {
+          this.result.pass = false;
+          this.result.message = "Could not find key '" + key + "' in actual data at " + context + ".";
+          return false;
+        }
+        var still_matches = this.internal_compare(context + "[" + key + "]", actual[key], expected[key]);
+        if (!still_matches) {
+          return false;
+        }
+      }
+      for (var key in actual) {
+        if (!(key in expected)) {
+          this.result.pass = false;
+          this.result.message = "Did not expect key " + key + " in actual data at " + context + ".";
+          return false;
+        }
+      }
+      return true;
+    },
+    internal_compare_value: function(context, actual, expected) {
+      if (expected === actual) {
+        return true;
+      }
+      this.result.pass = false;
+      this.result.message = "Expected '" + actual + "' to be '" + expected + "' at " + context + ".";
+      return false;
+    },
+    register: function(jasmine) {
+      var comparitor = this;
+      jasmine.addMatchers({
+        toEqualData: function(expected) {
+          return {
+            compare: $.proxy(comparitor.compare, comparitor)
+          }
+        }
+      });
+    }
+  };
+  var module = angular.module('crmJsonComparitor', []);
+  module.service('crmJsonComparitor', Comparitor);
+})(angular);
diff --git a/tests/karma/modules.js b/tests/karma/modules.js
new file mode 100644 (file)
index 0000000..2841938
--- /dev/null
@@ -0,0 +1,15 @@
+CRM.angular = {
+  modules: [
+    'ngRoute',
+    'ui.utils',
+    'ui.sortable',
+    'unsavedChanges',
+    'angularFileUpload',
+    'dialogService',
+    'crmApp',
+    'crmAttachment',
+    'crmUi',
+    'crmUtil',
+    'ngSanitize',
+  ]
+};
diff --git a/tests/karma/unit/crmCaseTypeSpec.js b/tests/karma/unit/crmCaseTypeSpec.js
new file mode 100644 (file)
index 0000000..ff826a4
--- /dev/null
@@ -0,0 +1,197 @@
+'use strict';
+
+describe('crmCaseType', function() {
+
+  beforeEach(function() {
+    CRM.resourceUrls = {
+      'civicrm': ''
+    };
+    CRM.crmCaseType = {
+      'REL_TYPE_CNAME': 'label_b_a'
+    };
+    module('crmCaseType');
+    module('crmJsonComparitor');
+    inject(function(crmJsonComparitor) {
+      crmJsonComparitor.register(jasmine);
+    });
+  });
+
+  describe('CaseTypeCtrl', function() {
+    var apiCalls;
+    var ctrl;
+    var compile;
+    var $httpBackend;
+    var scope;
+    var timeout;
+
+    beforeEach(inject(function(_$httpBackend_, $rootScope, $controller, $compile, $timeout) {
+      $httpBackend = _$httpBackend_;
+      scope = $rootScope.$new();
+      compile = $compile;
+      timeout = $timeout;
+      apiCalls = {
+        'actStatuses': {
+          'values': {
+            "272": {
+              "id": "272",
+              "option_group_id": "25",
+              "label": "Scheduled",
+              "value": "1",
+              "name": "Scheduled",
+              "filter": "0",
+              "is_default": "1",
+              "weight": "1",
+              "is_optgroup": "0",
+              "is_reserved": "1",
+              "is_active": "1"
+            },
+            "273": {
+              "id": "273",
+              "option_group_id": "25",
+              "label": "Completed",
+              "value": "2",
+              "name": "Completed",
+              "filter": "0",
+              "weight": "2",
+              "is_optgroup": "0",
+              "is_reserved": "1",
+              "is_active": "1"
+            }
+          }
+        },
+        'actTypes': {
+          'values': {
+            "784": {
+              "id": "784",
+              "option_group_id": "2",
+              "label": "ADC referral",
+              "value": "62", 
+              "name": "ADC referral",
+              "filter": "0",
+              "is_default": "0",
+              "weight": "64",
+              "is_optgroup": "0",
+              "is_reserved": "0",
+              "is_active": "1",
+              "component_id": "7"
+            },
+            "32": {
+              "id": "32",
+              "option_group_id": "2",
+              "label": "Add Client To Case",
+              "value": "27",
+              "name": "Add Client To Case",
+              "filter": "0",
+              "is_default": "0",
+              "weight": "26",
+              "description": "",
+              "is_optgroup": "0",
+              "is_reserved": "1",
+              "is_active": "1",
+              "component_id": "7"
+            } 
+          }
+        },
+        'relTypes': {
+          'values' : {
+            "14": {
+              "id": "14",
+              "name_a_b": "Benefits Specialist is",
+              "label_a_b": "Benefits Specialist is",
+              "name_b_a": "Benefits Specialist",
+              "label_b_a": "Benefits Specialist",
+              "description": "Benefits Specialist",
+              "contact_type_a": "Individual",
+              "contact_type_b": "Individual",
+              "is_reserved": "0",
+              "is_active": "1"
+            },
+            "9": {
+              "id": "9",
+              "name_a_b": "Case Coordinator is",
+              "label_a_b": "Case Coordinator is",
+              "name_b_a": "Case Coordinator",
+              "label_b_a": "Case Coordinator",
+              "description": "Case Coordinator",
+              "contact_type_a": "Individual",
+              "contact_type_b": "Individual",
+              "is_reserved": "0",
+              "is_active": "1"
+            }
+          }
+        },
+        "caseType": {
+          "id": "1",
+          "name": "housing_support",
+          "title": "Housing Support",
+          "description": "Help homeless individuals obtain temporary and long-term housing",
+          "is_active": "1",
+          "is_reserved": "0",
+          "weight": "1",
+          "is_forkable": "1",
+          "is_forked": "",
+          "definition": {
+            "activityTypes": [
+              {"name": "Open Case", "max_instances": "1"}
+            ],
+            "activitySets": [
+              {
+                "name": "standard_timeline", 
+                "label": "Standard Timeline",
+                "timeline": "1",
+                "activityTypes": [
+                  {
+                    "name": "Open Case",
+                    "status": "Completed"
+                  },
+                  {
+                    "name": "Medical evaluation",
+                    "reference_activity": "Open Case",
+                    "reference_offset": "1",
+                    "reference_select": "newest"
+                  }
+                ]
+              }
+            ],
+            "caseRoles": [
+              {
+                "name": "Homeless Services Coordinator",
+                "creator": "1",
+                "manager": "1"
+              }
+            ]
+          }
+        }
+      };
+      ctrl = $controller('CaseTypeCtrl', {$scope: scope, apiCalls: apiCalls});
+    }));
+
+    it('should load activity statuses', function() {
+      expect(scope.activityStatuses).toEqualData([apiCalls['actStatuses']['values']['272'], apiCalls['actStatuses']['values']['273']]);
+    });
+
+    it('should load activity types', function() {
+      expect(scope.activityTypes).toEqualData(apiCalls['actTypes']['values']);
+    });
+
+    it('addActivitySet should add an activitySet to the case type', function() {
+      scope.addActivitySet('timeline');
+      var activitySets = scope.caseType.definition.activitySets;
+      var newSet = activitySets[activitySets.length - 1];
+      expect(newSet.name).toBe('timeline_1');
+      expect(newSet.timeline).toBe('1');
+      expect(newSet.label).toBe('Timeline');
+    });
+
+    it('addActivitySet handles second timeline correctly', function() {
+      scope.addActivitySet('timeline');
+      scope.addActivitySet('timeline');
+      var activitySets = scope.caseType.definition.activitySets;
+      var newSet = activitySets[activitySets.length - 1];
+      expect(newSet.name).toBe('timeline_2');
+      expect(newSet.timeline).toBe('1');
+      expect(newSet.label).toBe('Timeline #2');
+    });
+
+  });
+});
diff --git a/tests/karma/unit/crmJsonComparitor.js b/tests/karma/unit/crmJsonComparitor.js
new file mode 100644 (file)
index 0000000..653a34b
--- /dev/null
@@ -0,0 +1,76 @@
+'use strict';
+
+describe('crmJsonComparitor', function() {
+  var comparitor;
+
+  beforeEach(function() {
+    module('crmJsonComparitor');
+  });
+
+  beforeEach(function() {
+    inject(function(crmJsonComparitor) {  
+      comparitor = crmJsonComparitor;
+    }); 
+  });
+
+  it('should return false when comparing different objects', function() {
+    var result = comparitor.compare({'foo': 'bar'}, {'bar': 'foo'});
+    expect(result.pass).toBe(false);
+  });
+
+  it('should return true when comparing equal objects', function() {
+    var result = comparitor.compare({'bar': 'foo'}, {'bar': 'foo'});
+    expect(result.pass).toBe(true);
+  });
+
+  it('should explain what part of the comparison failed when comparing objects', function() { 
+    var result = comparitor.compare({'foo': 'bar'}, {'bar': 'foo'});
+    expect(result.message).toBe('Could not find key \'bar\' in actual data at root.');
+  });
+
+  it('should handle nested objects', function() {
+     var result = comparitor.compare({'foo': {'bif': 'bam'}}, {'foo': {'bif': 'bam'}});
+    expect(result.pass).toBe(true);
+  });
+
+  it('should handle differences in nested objects', function() {
+     var result = comparitor.compare({'foo': {'bif': 'bam'}}, {'foo': {'bif': 'bop'}});
+    expect(result.pass).toBe(false);
+    expect(result.message).toBe("Expected 'bam' to be 'bop' at root[foo][bif].");
+  });
+
+  it('should handle arrays', function() {
+    var result = comparitor.compare([1, 2, 3, 4], [1, 2, 3, 4]);
+    expect(result.pass).toBe(true);
+  });
+
+  it('should handle arrays with differences', function() {
+    var result = comparitor.compare([1, 2, 2, 4], [1, 2, 3, 4]);
+    expect(result.pass).toBe(false);
+    expect(result.message).toBe("Expected '2' to be '3' at root[2].");
+  });
+
+  it('should handle nested arrays and objects', function() {
+    var result = comparitor.compare([1, 2, {'foo': 'bar'}, 4], [1, 2, {'foo': 'bar'}, 4]);
+    expect(result.pass).toBe(true);
+  });
+
+  it('should handle nested arrays and objects with differences', function() {
+    var result = comparitor.compare([1, 2, {'foo': 'bar'}, 4], [1, 2, {'foo': 'bif'}, 4]);
+    expect(result.pass).toBe(false);
+    expect(result.message).toBe("Expected 'bar' to be 'bif' at root[2][foo].");
+  });
+
+  it('should complain when comparing an object to an array', function() {
+    var result = comparitor.compare({'foo': 'bar'}, [1, 2, 3]);
+    expect(result.pass).toBe(false);
+    expect(result.message).toBe("The expected data has an array at root, but the actual data has something else ([object Object])");
+  });
+
+  it('should complain when comparing an array to an object', function() {
+    var result = comparitor.compare([1, 2, 3], {'foo': 'bar'});
+    expect(result.pass).toBe(false);
+    expect(result.message).toBe("The expected data has an object at root, but the actual data has something else (1,2,3)");
+  });
+
+});
diff --git a/tests/karma/unit/crmMailingSpec.js b/tests/karma/unit/crmMailingSpec.js
new file mode 100644 (file)
index 0000000..67382e9
--- /dev/null
@@ -0,0 +1,31 @@
+'use strict';
+
+describe('crmMailing', function() {
+
+  beforeEach(function() {
+    module('crmApp');
+    module('crmMailing');
+  });
+
+  describe('ListMailingsCtrl', function() {
+    var ctrl;
+    var navigator;
+
+    beforeEach(function() {
+      navigator = jasmine.createSpyObj('crmNavigator', ['redirect']);
+      module(function ($provide) {
+        $provide.value('crmNavigator', navigator) 
+      });
+      inject(['crmLegacy', function(crmLegacy) {
+        crmLegacy.url({back: '/*path*?*query*', front: '/*path*?*query*'});
+      }]);
+      inject(['$controller', function($controller) {
+        ctrl = $controller('ListMailingsCtrl', {});
+      }]);
+    });
+
+    it('should redirect to unscheduled', function() {
+      expect(navigator.redirect).toHaveBeenCalled();
+    });
+  });
+});