├── .gitignore ├── .travis.yml ├── .babelrc ├── Makefile ├── package.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── src └── remixin.js └── test └── remixin.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .nyc_output/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | script: make test 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | ["@babel/plugin-transform-modules-umd", { 5 | "globals": { 6 | "underscore": "_" 7 | } 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | babel := ./node_modules/.bin/babel 2 | mocha := ./node_modules/.bin/mocha 3 | nyc := ./node_modules/.bin/nyc 4 | outputFiles := $(patsubst src/%,dist/%,$(wildcard src/*.js)) 5 | 6 | .PHONY: all clean test coverage 7 | 8 | all: $(outputFiles) 9 | 10 | clean: 11 | rm -rf dist coverage .nyc_output 12 | 13 | test: node_modules 14 | $(nyc) --reporter=text --reporter=html --require=@babel/register $(mocha) 15 | 16 | coverage: test 17 | open $@/index.html 18 | 19 | node_modules: package.json 20 | npm install 21 | touch $@ 22 | 23 | dist/%.js: src/%.js node_modules 24 | mkdir -p $(@D) 25 | $(babel) $< -o $@ 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remixin", 3 | "version": "2.0.0", 4 | "description": "Aspect-oriented, mixin library", 5 | "repository": "soundcloud/remixin", 6 | "author": "SoundCloud", 7 | "license": "MIT", 8 | "main": "dist/remixin", 9 | "files": [ 10 | "dist" 11 | ], 12 | "keywords": [ 13 | "mixin", 14 | "aspect-oriented", 15 | "oop" 16 | ], 17 | "scripts": { 18 | "prepublishOnly": "make" 19 | }, 20 | "dependencies": { 21 | "underscore": "^1.9.1" 22 | }, 23 | "devDependencies": { 24 | "@babel/cli": "7.4.4", 25 | "@babel/core": "7.4.5", 26 | "@babel/plugin-transform-modules-umd": "7.2.0", 27 | "@babel/preset-env": "7.4.5", 28 | "@babel/register": "7.4.4", 29 | "expect.js": "0.3.1", 30 | "mocha": "5.2.0", 31 | "nyc": "12.0.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 (2019-08-06) 2 | 3 | - Modernize and transpile Remixin's syntax. 4 | - Stop including multiple build files in the npm package and version control. 5 | - Replace the `__DEBUG__` global variable (that is used to toggle some debugging behavior) with a `debug` static property. 6 | 7 | ## 1.0.2 (2016-11-17) 8 | 9 | - Optimize function calls by avoiding passing the `arguments` object around. 10 | 11 | ## 1.0.1 (2015-01-28) 12 | 13 | - `merge` will not modify objects present on the target, rather it will create a new object or array and reassign the value. This fixes a bug whereby shared objects (for example, those on a parent class's prototype) were being mutated. 14 | - `requires` takes into account properties which are defined in the prototype chain 15 | - `defaults` will overwrite properties which are defined in the prototype chain 16 | 17 | ## 1.0.0 (2015-01-18) 18 | 19 | - Initial release. 0.x.x is for wimps. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 SoundCloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Remixin [![version][npm badge]][npm] [![build status][travis badge]][travis] 2 | 3 | Remixin is the aspect-oriented mixin library developed and in use at [SoundCloud][soundcloud]. It is inspired by Twitter's [advice.js][advice] and [Joose][joose]. 4 | 5 | For an introduction about why you'd want to use a mixin library, Angus Croll and Dan Webb from Twitter gave a good [talk about the concept][slides] and Angus [blogged on the subject][blog]. 6 | 7 | ## Installation 8 | 9 | Install the package via npm: 10 | 11 | ```shell 12 | npm install remixin 13 | ``` 14 | 15 | And then import it: 16 | 17 | ```js 18 | import { Mixin } from 'remixin'; 19 | ``` 20 | 21 | Alternatively, download a browser-ready version from the unpkg CDN: 22 | 23 | ```html 24 | 25 | 26 | ``` 27 | 28 | ([Underscore.js][underscore] is a dependency and needs to be included first.) 29 | 30 | ## Usage 31 | 32 | - Create a new mixin using `mixin = new Mixin(modifiers)` 33 | - Apply a mixin to an object, using `mixin.applyTo(object)` 34 | - Pass options to a mixin which has a custom apply method using `mixin.applyTo(object, options)` 35 | - Curry options into a mixin using `curried = mixin.withOptions(options)` 36 | - Combine mixins by using `combined = new Mixin(mixin1, [mixin2, ...], modifiers)` 37 | 38 | ### Modifiers 39 | 40 | When defining a mixin, there are several key words to define method modifiers: 41 | 42 | - `before`: `{Object.}` 43 | - defines methods to be executed before the original function. It has the same function signature (it is given the 44 | same arguments list) as the original, but can not modify the arguments passed to the original, nor change whether 45 | the function is executed. 46 | - `after`: `{Object.}` 47 | - The same as `before`, this has the same signature, but can not modify the return value of the function. 48 | - `around`: `{Object.}` 49 | - defines methods to be executed 'around' the original. The original function is passed as the first argument, 50 | followed by the original arguments. The modifier function may change the arguments to be passed to the original, 51 | may modify the return value, and even can decide not to execute the original. Given the power that this provides, 52 | use with care! 53 | - `requires`: `{Array.}` 54 | - an array of property names which must exist on the target object (or its prototype). Basically defines an expected 55 | interface. 56 | - `requirePrototype`: `{Object}` 57 | - this prototype should be present on the target object's prototype chain. can be used to specify what 'class' 58 | target should be or from what prototype it should inherit from. 59 | - `override`: `{Object.}` 60 | - properties or methods which specifically should override the values already defined on the target object. 61 | - `defaults`: `{Object.}` 62 | - properties or methods which should be applied to the target object only if they do not already exist on that 63 | object. Properties defined in the prototype chain will be overridden. 64 | - `merge`: `{Object. { 23 | this[fnName](obj, props[fnName]); 24 | }); 25 | 26 | if (props.applyTo) { 27 | props.applyTo.call(this, obj, options); 28 | } 29 | } 30 | 31 | withOptions(options) { 32 | return new CurriedMixin(this, options); 33 | } 34 | 35 | before(obj, methods) { 36 | // apply the befores 37 | _.each(methods, (modifierFn, prop) => { 38 | if (Mixin.debug) { 39 | __assertFunction__(obj, prop); 40 | } 41 | const origFn = obj[prop]; 42 | obj[prop] = function (...args) { 43 | modifierFn.apply(this, args); 44 | return origFn.apply(this, args); 45 | }; 46 | }); 47 | } 48 | 49 | after(obj, methods) { 50 | // apply the afters 51 | _.each(methods, (modifierFn, prop) => { 52 | if (Mixin.debug) { 53 | __assertFunction__(obj, prop); 54 | } 55 | const origFn = obj[prop]; 56 | obj[prop] = function (...args) { 57 | const ret = origFn.apply(this, args); 58 | modifierFn.apply(this, args); 59 | return ret; 60 | }; 61 | }); 62 | } 63 | 64 | around(obj, methods) { 65 | // apply the arounds 66 | _.each(methods, (modifierFn, prop) => { 67 | if (Mixin.debug) { 68 | __assertFunction__(obj, prop); 69 | } 70 | const origFn = obj[prop]; 71 | obj[prop] = function () { 72 | const args = [origFn.bind(this), ...arguments]; 73 | return modifierFn.apply(this, args); 74 | }; 75 | }); 76 | } 77 | 78 | override(obj, properties) { 79 | // apply the override properties 80 | _.extend(obj, properties); 81 | } 82 | 83 | defaults(obj, properties) { 84 | _.each(properties, (value, prop) => { 85 | if (!obj.hasOwnProperty(prop)) { 86 | obj[prop] = value; 87 | } 88 | }); 89 | } 90 | 91 | merge(obj, properties) { 92 | _.each(properties, (value, prop) => { 93 | if (value == null) { 94 | return; 95 | } 96 | if (Mixin.debug) { 97 | __assertValidMergeValue__(value); 98 | } 99 | const existingVal = obj[prop]; 100 | obj[prop] = _.isArray(value) ? mergeArrays(existingVal, value) 101 | : _.isString(value) ? mergeTokenList(existingVal, value) 102 | : mergeObjects(existingVal, value); 103 | }); 104 | } 105 | 106 | extend(obj, properties) { 107 | // apply the regular properties 108 | const toCopy = _.omit(properties, SPECIAL_KEYS); 109 | if (Mixin.debug) { 110 | Object.keys(toCopy).forEach((prop) => { 111 | if (obj[prop] != null) { 112 | throw new Error(`Mixin overrides existing property "${prop}"`); 113 | } 114 | }); 115 | } 116 | _.extend(obj, toCopy); 117 | } 118 | 119 | requires(obj, requires) { 120 | if (!Mixin.debug) return; 121 | 122 | // check the requires -- this is only checked in debug mode. 123 | if (requires) { 124 | if (!_.isArray(requires)) { 125 | throw new Error('requires should be an array of required property names'); 126 | } 127 | 128 | const errors = _.compact(requires.map((prop) => { 129 | if (!(prop in obj)) { 130 | return prop; 131 | } 132 | })); 133 | if (errors.length) { 134 | throw new Error(`Object is missing required properties: "${errors.join('", "')}"`); 135 | } 136 | } 137 | } 138 | 139 | requirePrototype(obj, requirePrototype) { 140 | if (!Mixin.debug) return; 141 | 142 | // check the required prototypes -- this is only checked in debug mode. 143 | if (requirePrototype) { 144 | if (!_.isObject(requirePrototype)) { 145 | throw new Error('requirePrototype should be an object'); 146 | } 147 | if (!(requirePrototype === obj || requirePrototype.isPrototypeOf(obj))) { 148 | throw new Error('Object does not inherit from required prototype'); 149 | } 150 | } 151 | } 152 | } 153 | 154 | class CurriedMixin extends Mixin { 155 | constructor(mixin, options) { 156 | super(mixin, options); 157 | this.applyTo = (obj) => { mixin.applyTo(obj, options) }; 158 | } 159 | } 160 | 161 | /** 162 | * Combine two arrays, ensuring uniqueness of the new values being added. 163 | * @param {?*} existingVal 164 | * @param {Array} value 165 | * @return {Array} 166 | */ 167 | function mergeArrays(existingVal, value) { 168 | return existingVal == null 169 | ? value.slice() 170 | : uniqueConcat(lift(existingVal), value); 171 | } 172 | 173 | /** 174 | * Concatenate two arrays, but only including values from the second array not present in the first. 175 | * This returns a new object: it does not modify either array. 176 | * @param {Array} arr1 177 | * @param {Array} arr2 178 | * @return {Array} 179 | */ 180 | function uniqueConcat(arr1, arr2) { 181 | return arr1.concat(_.difference(arr2, arr1)); 182 | } 183 | 184 | /** 185 | * Combine two strings, treating them as a space separated list of tokens. 186 | * @param {?String} existingVal 187 | * @param {String} value 188 | * @return {String} 189 | */ 190 | function mergeTokenList(existingVal, value) { 191 | return existingVal == null 192 | ? value 193 | : mergeArrays(tokenize(existingVal), tokenize(value)).join(' '); 194 | } 195 | 196 | /** 197 | * Create a new object which has all the properties of the two passed in object, preferring the first object when 198 | * there is a key collision. 199 | * @param {?Object} existingVal 200 | * @param {?Object} value 201 | * @return {Object} 202 | */ 203 | function mergeObjects(existingVal, value) { 204 | return _.extend({}, value, existingVal); 205 | } 206 | 207 | /** 208 | * Convert a string of space separated tokens into an array of tokens. 209 | * @param {String} str 210 | * @return {Array.} 211 | */ 212 | function tokenize(str) { 213 | return _.compact(str.split(/\s+/)); 214 | } 215 | 216 | /** 217 | * Lift a value into an array, if it is not already one. 218 | * @param {*} value 219 | * @return {Array} 220 | */ 221 | function lift(value) { 222 | return _.isArray(value) ? value : [ value ]; 223 | } 224 | 225 | function __assertValidMergeValue__(value) { 226 | const isInvalid = (!_.isObject(value) && !_.isString(value)) || ['isRegExp', 'isDate', 'isFunction'].some((fnName) => ( 227 | _[fnName](value) 228 | )); 229 | if (isInvalid) { 230 | throw new Error('Unsupported data type for merge'); 231 | } 232 | } 233 | 234 | function __assertFunction__(obj, property) { 235 | if (!_.isFunction(obj[property])) { 236 | throw new Error(`Object is missing function property "${property}"`); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /test/remixin.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect.js'; 2 | import _ from 'underscore'; 3 | import { Mixin } from '../src/remixin'; 4 | 5 | Mixin.debug = true; 6 | 7 | describe('Remixin', () => { 8 | it('can be applied to objects', () => { 9 | const obj = { 10 | foo: 'FOO' 11 | }; 12 | 13 | const hasBaz = new Mixin({ 14 | bar: _.noop, 15 | baz: 'BAZ' 16 | }); 17 | 18 | hasBaz.applyTo(obj); 19 | 20 | expect(obj.baz).to.be('BAZ'); // The mixin should have added the new property 21 | expect(obj.bar).to.be(_.noop); // The mixin should have added the new method 22 | expect(obj.foo).to.be('FOO'); // The mixin should not have modified the old property 23 | }); 24 | 25 | it('are able to define before/after method modifiers', () => { 26 | const fooOrder = []; 27 | 28 | const obj = { 29 | foo() { 30 | fooOrder.push('b'); 31 | } 32 | }; 33 | 34 | const mixin = new Mixin({ 35 | before: { 36 | foo() { 37 | fooOrder.push('a'); 38 | } 39 | }, 40 | after: { 41 | foo() { 42 | fooOrder.push('c'); 43 | } 44 | } 45 | }); 46 | 47 | mixin.applyTo(obj); 48 | 49 | obj.foo(); 50 | 51 | expect(fooOrder).to.eql(['a', 'b', 'c']); 52 | }); 53 | 54 | it('does not allow befores/afters to modify arguments or return values', () => { 55 | const fooOrder = []; 56 | 57 | const obj = { 58 | foo(arg) { 59 | fooOrder.push(`b${arg}`); 60 | return arg; 61 | } 62 | }; 63 | 64 | const mixin = new Mixin({ 65 | before: { 66 | foo(arg) { 67 | fooOrder.push(`a${arg}`); 68 | return 'before'; 69 | } 70 | }, 71 | after: { 72 | foo(arg) { 73 | fooOrder.push(`c${arg}`); 74 | return 'after'; 75 | } 76 | } 77 | }); 78 | 79 | mixin.applyTo(obj); 80 | 81 | const ret = obj.foo(1); 82 | 83 | expect(fooOrder).to.eql(['a1', 'b1', 'c1']); // The argument should have been passed to each modifier 84 | expect(ret).to.be(1); // The return values of the modifiers should have been ignored 85 | }); 86 | 87 | it('can apply functions around other functions', () => { 88 | let receivedArg, context; 89 | 90 | const obj = { 91 | foo(arg) { 92 | context = this; // save the context 93 | receivedArg = arg; // save the argument passed in 94 | return arg + 1; 95 | } 96 | }; 97 | 98 | const mixin = new Mixin({ 99 | around: { 100 | foo(fn, arg) { 101 | expect(fn).to.be.a('function'); // The first argument should be the original function 102 | const fooRet = fn(arg + 1); // note that no context is being passed to the fn 103 | return fooRet + 1; 104 | } 105 | } 106 | }); 107 | 108 | mixin.applyTo(obj); 109 | 110 | const ret = obj.foo(1); 111 | 112 | expect(context).to.be(obj); // The object function should have received the correct context 113 | expect(receivedArg).to.be(2); // The function should have received a modified argument 114 | expect(ret).to.be(4); // The modifier should have modified the return value 115 | }); 116 | 117 | it('Arounds are applied after befores/afters', () => { 118 | const fooOrder = []; 119 | 120 | const obj = { 121 | foo() { 122 | fooOrder.push('main'); 123 | } 124 | }; 125 | 126 | const mixin = new Mixin({ 127 | around: { 128 | foo(fn) { 129 | fooOrder.push('arounda'); 130 | fn(); 131 | fooOrder.push('aroundb'); 132 | } 133 | }, 134 | before: { 135 | foo() { 136 | fooOrder.push('before'); 137 | } 138 | }, 139 | after: { 140 | foo() { 141 | fooOrder.push('after'); 142 | } 143 | } 144 | }); 145 | 146 | mixin.applyTo(obj); 147 | 148 | obj.foo(); 149 | 150 | // The around function should be applied after the befores and afters 151 | expect(fooOrder).to.eql(['arounda', 'before', 'main', 'after', 'aroundb']); 152 | }); 153 | 154 | it('can apply multiple modifiers', () => { 155 | const fooOrder = []; 156 | 157 | const obj = { 158 | foo() { 159 | fooOrder.push('main'); 160 | } 161 | }; 162 | 163 | const mixin1 = new Mixin({ 164 | before: { 165 | foo() { 166 | fooOrder.push('before1'); 167 | } 168 | }, 169 | after: { 170 | foo() { 171 | fooOrder.push('after1'); 172 | } 173 | }, 174 | around: { 175 | foo(fn) { 176 | fooOrder.push('around1a'); 177 | fn(); 178 | fooOrder.push('around1b'); 179 | } 180 | } 181 | }); 182 | 183 | const mixin2 = new Mixin({ 184 | before: { 185 | foo() { 186 | fooOrder.push('before2'); 187 | } 188 | }, 189 | after: { 190 | foo() { 191 | fooOrder.push('after2'); 192 | } 193 | }, 194 | around: { 195 | foo(fn) { 196 | fooOrder.push('around2a'); 197 | fn(); 198 | fooOrder.push('around2b'); 199 | } 200 | } 201 | }); 202 | 203 | mixin1.applyTo(obj); 204 | mixin2.applyTo(obj); 205 | 206 | obj.foo(); 207 | 208 | expect(fooOrder).to.eql( 209 | ['around2a', 'before2', 'around1a', 'before1', 'main', 'after1', 'around1b', 'after2', 'around2b'] 210 | ); 211 | }); 212 | 213 | it('can override properties if explicitly stated', () => { 214 | const obj = { 215 | someProp: 'original', 216 | someFunc: () => 'original' 217 | }; 218 | 219 | const mixin = new Mixin({ 220 | override: { 221 | someProp: 'modified', 222 | someFunc: () => 'modified' 223 | } 224 | }); 225 | 226 | mixin.applyTo(obj); 227 | 228 | expect(obj.someProp).to.be('modified'); // The property should have been overridden 229 | expect(obj.someFunc()).to.be('modified'); // The method should have been overridden 230 | }); 231 | 232 | it('can merge objects', () => { 233 | const obj = { 234 | defaults: {}, 235 | events: { 236 | 'click': 'onClick' 237 | } 238 | }; 239 | 240 | const mixin = new Mixin({ 241 | merge: { 242 | defaults: { 243 | 'title': 'foo' 244 | }, 245 | events: { 246 | 'click': 'mixinOnClick', 247 | 'mouseover': 'onMouseover' 248 | }, 249 | element2selector: { 250 | 'link': 'a' 251 | } 252 | } 253 | }); 254 | mixin.applyTo(obj); 255 | 256 | // Existing objects should be merged 257 | expect(obj.defaults).to.eql({ 'title': 'foo' }); 258 | 259 | // New keys should be added, and existing keys should not be overridden 260 | expect(obj.events).to.eql({ 'click': 'onClick', 'mouseover': 'onMouseover' }); 261 | 262 | // New properties should be created 263 | expect(obj.element2selector).to.eql({ 'link': 'a' }); 264 | }); 265 | 266 | it('can merge arrays', () => { 267 | const obj = { 268 | css: ['button.css'], 269 | requiredAttributes: [], 270 | observedAttributes: { 271 | sound: ['title'], 272 | playlist: ['tracks'] 273 | } 274 | }; 275 | 276 | const mixin = new Mixin({ 277 | merge: { 278 | css: ['colors.css', 'button.css'], 279 | requiredAttributes: ['purchase_url'], 280 | observedAttributes: { 281 | sound: ['artwork_url'] 282 | }, 283 | myList: ['foo', 'bar'] 284 | } 285 | }); 286 | 287 | mixin.applyTo(obj); 288 | 289 | // Existing arrays are extended 290 | expect(obj.requiredAttributes).to.eql(['purchase_url']); 291 | 292 | // Only unique values are added to the target 293 | expect(obj.css).to.eql(['button.css', 'colors.css']); 294 | 295 | // Extension is only shallow 296 | expect(obj.observedAttributes.sound).to.eql(['title']); 297 | 298 | // New properties are added to the target 299 | expect(obj.myList).to.eql(['foo', 'bar']); 300 | }); 301 | 302 | it('will lift values into an array for merge', () => { 303 | const obj = { 304 | css: 'buttons.css' 305 | }; 306 | const mixin = new Mixin({ 307 | merge: { 308 | css: ['colors.css'] 309 | } 310 | }); 311 | mixin.applyTo(obj); 312 | expect(obj.css).to.eql(['buttons.css', 'colors.css']); 313 | }); 314 | 315 | it('will merge strings as a token list', () => { 316 | const mixin = new Mixin({ 317 | merge: { 318 | 'className': 'sc-button', 319 | 'foo': 'bar baz', 320 | 'quux': 'fuzbar' 321 | } 322 | }); 323 | 324 | const obj = { 325 | 'className': 'myView', 326 | 'foo': 'baz' 327 | }; 328 | 329 | mixin.applyTo(obj); 330 | 331 | // Existing strings are extended with spaces 332 | expect(obj.className).to.be('myView sc-button'); 333 | 334 | // Only unique tokens should be added 335 | expect(obj.foo).to.be('baz bar'); 336 | 337 | // New properties should be added 338 | expect(obj.quux).to.be('fuzbar'); 339 | }); 340 | 341 | it('ignore nullish values in merge', () => { 342 | const mixin = new Mixin({ 343 | merge: { 344 | nullVal: null, 345 | undefVal: undefined 346 | } 347 | }); 348 | const obj = {}; 349 | 350 | mixin.applyTo(obj); 351 | expect(obj).not.to.have.property('nullVal'); 352 | expect(obj).not.to.have.property('undefVal'); 353 | }); 354 | 355 | it(`won't affect prototype objects when merging`, () => { 356 | // this checks that the target object is not mutated when using merge; rather, a new object is returned 357 | 358 | class Cls { 359 | constructor() { 360 | this.foo = { a: 1 }; 361 | this.bar = [ 1 ]; 362 | } 363 | } 364 | 365 | const obj = new Cls(); 366 | const mixin = new Mixin({ 367 | merge: { 368 | foo: { a: 2, b: 2 }, 369 | bar: [ 2 ] 370 | } 371 | }); 372 | 373 | mixin.applyTo(obj); 374 | expect(obj.foo).to.eql({ a: 1, b: 2}); 375 | expect(obj.bar).to.eql([1, 2]); 376 | 377 | const obj2 = new Cls(); 378 | expect(obj2.foo).to.eql({ a: 1 }); 379 | expect(obj2.bar).to.eql([1]); 380 | }); 381 | 382 | it('Custom applyTo exposes its interface', () => { 383 | let ranExpectations = false; 384 | 385 | const obj = {}; 386 | 387 | const mixin = new Mixin({ 388 | applyTo(o) { 389 | expect(o).to.be(obj); // The object should have been passed through 390 | expect(this.before) .to.be.a('function'); 391 | expect(this.after) .to.be.a('function'); 392 | expect(this.around) .to.be.a('function'); 393 | expect(this.override).to.be.a('function'); 394 | expect(this.extend) .to.be.a('function'); 395 | expect(this.requires).to.be.a('function'); 396 | expect(this.defaults).to.be.a('function'); 397 | expect(this.merge) .to.be.a('function'); 398 | ranExpectations = true; 399 | } 400 | }); 401 | 402 | mixin.applyTo(obj); 403 | expect(ranExpectations).to.be(true); 404 | }); 405 | 406 | it('Custom applyTo can be mixed with shortcut methods', () => { 407 | let afterFoo = false; 408 | 409 | const obj = { 410 | foo() {} 411 | }; 412 | 413 | const mixin = new Mixin({ 414 | after: { 415 | foo() { 416 | afterFoo = true; 417 | } 418 | }, 419 | applyTo(o, options) { 420 | this.extend(o, { 421 | size: 1, 422 | zoom() { 423 | this.size *= options.zoomLevel; 424 | } 425 | }); 426 | } 427 | }); 428 | 429 | mixin.applyTo(obj, { zoomLevel: 5 }); 430 | 431 | expect(obj.size).to.be(1); // The property should have been applied 432 | 433 | obj.zoom(); 434 | 435 | expect(obj.size).to.be(5); // A custom zoom method should have been applied 436 | 437 | obj.foo(); 438 | 439 | expect(afterFoo).to.be(true); // The after should have been applied 440 | }); 441 | 442 | it('can be curried with options for shorthand syntax', () => { 443 | const mix = new Mixin({ 444 | applyTo(target, options) { 445 | target.foo = options.foo; 446 | } 447 | }); 448 | 449 | const curriedMixin = mix.withOptions({ foo: 'bar' }); 450 | expect(curriedMixin).to.be.a(Mixin); // The curried mixin is also an instance of Mixin class 451 | 452 | const obj = {}; 453 | curriedMixin.applyTo(obj); 454 | 455 | expect(obj.foo).to.be('bar'); // The mixin should have been applied with the curried options 456 | }); 457 | 458 | /////////////////////////////////////////////////////////////////////////////// 459 | 460 | describe('error checking', () => { 461 | function applyMixinWithMergeValue(val, obj = {}) { 462 | return () => { 463 | const mixin = new Mixin({ 464 | merge: { 465 | key: val 466 | } 467 | }); 468 | mixin.applyTo(obj); 469 | }; 470 | } 471 | 472 | it('enforces applying modifiers only to functions', () => { 473 | // mixin which defines all three modifiers 474 | const mixin = new Mixin({ 475 | before: { foo() {} }, 476 | after : { bar() {} }, 477 | around: { baz() {} } 478 | }); 479 | 480 | // three object, each missing one required property 481 | const noBefore = { 482 | bar() {}, 483 | baz() {} 484 | }; 485 | const noAfter = { 486 | foo() {}, 487 | bar: 1, // exists, not a function though 488 | baz() {} 489 | }; 490 | const noAround = { 491 | foo() {}, 492 | bar() {}, 493 | baz: /abc/ 494 | }; 495 | 496 | // The before method should be required 497 | expect(mixin.applyTo.bind(mixin, noBefore)).to.throwError(/Object is missing function property "foo"/); 498 | // The after method should be required 499 | expect(mixin.applyTo.bind(mixin, noAfter)).to.throwError(/Object is missing function property "bar"/); 500 | // The around method should be required 501 | expect(mixin.applyTo.bind(mixin, noAround)).to.throwError(/Object is missing function property "baz"/); 502 | }); 503 | 504 | it('disallows overriding existing properties', () => { 505 | const obj = { 506 | foo: 'FOO' 507 | }; 508 | 509 | const hasFoo = new Mixin({ 510 | foo: 'fuuuuuu' 511 | }); 512 | 513 | // Mixins should not override existing properties 514 | expect(hasFoo.applyTo.bind(hasFoo, obj)).to.throwError(/Mixin overrides existing property "foo"/); 515 | }); 516 | 517 | it('disallows overriding existing properties defined in the prototype', () => { 518 | class Cls { 519 | constructor() { 520 | this.foo = 'FOO'; 521 | } 522 | }; 523 | 524 | const obj = new Cls(); 525 | 526 | const mixin = new Mixin({ 527 | foo: 'fuuuuuuuu' 528 | }); 529 | 530 | // Mixins should not override properties even of the prototype 531 | expect(mixin.applyTo.bind(mixin, obj)).to.throwError(/Mixin overrides existing property "foo"/); 532 | }); 533 | 534 | it('enforces required properties', () => { 535 | const obj = { 536 | foo: 1 537 | }; 538 | 539 | const mixin = new Mixin({ 540 | requires: ['foo', 'bar'] 541 | }); 542 | 543 | // Mixins should be able to define required properties 544 | expect(mixin.applyTo.bind(mixin, obj)).to.throwError(/Object is missing required properties: "bar"/); 545 | }); 546 | 547 | it('warns about all missing required properties', () => { 548 | const obj = {}; 549 | 550 | const mixin = new Mixin({ 551 | requires: ['foo', 'bar'] 552 | }); 553 | 554 | // Mixins should report all missing required properties 555 | expect(mixin.applyTo.bind(mixin, obj)).to.throwError(/Object is missing required properties: "foo", "bar"/); 556 | }); 557 | 558 | it('checks that requires must be an array', () => { 559 | const obj = {}; 560 | 561 | const mixin = new Mixin({ 562 | requires: 'foo' 563 | }); 564 | 565 | // requires must be an array 566 | expect(mixin.applyTo.bind(mixin, obj)).to.throwError(/requires should be an array of required property names/); 567 | }); 568 | 569 | it('will allow for properties defined on the prototype', () => { 570 | const obj = {}; 571 | 572 | const mixin = new Mixin({ 573 | requires: ['toString'] 574 | }); 575 | 576 | expect(mixin.applyTo.bind(mixin, obj)).not.to.throwError(); 577 | }); 578 | 579 | it('can enforce a required prototype', () => { 580 | class Car {}; 581 | class Animal {}; 582 | class Dog extends Animal {}; 583 | class Beagle extends Animal {}; 584 | 585 | const Life = new Mixin({ 586 | requirePrototype: Animal.prototype 587 | }); 588 | 589 | // Mixins should be able to define required prototype 590 | expect(Life.applyTo.bind(Life, Car.prototype)).to.throwError(/Object does not inherit from required prototype/); 591 | 592 | // Required prototype can be exact class 593 | expect(Life.applyTo.bind(Life, Animal.prototype)).to.not.throwError(); 594 | 595 | // Required prototype can be parent class 596 | expect(Life.applyTo.bind(Life, Dog.prototype)).to.not.throwError(); 597 | 598 | // Required prototype can be any ancestor class 599 | expect(Life.applyTo.bind(Life, Beagle.prototype)).to.not.throwError(); 600 | }); 601 | 602 | it('enforces that requirePrototype be an object', () => { 603 | expect(() => { 604 | const myMixin = new Mixin({ 605 | requirePrototype: 'abc' 606 | }); 607 | myMixin.applyTo({}); 608 | }).to.throwError(/requirePrototype should be an object/); 609 | }); 610 | 611 | it('will reject non-array and non-object properties from `merge`', () => { 612 | expect(applyMixinWithMergeValue(1)).to.throwError(/Unsupported data type for merge/); 613 | expect(applyMixinWithMergeValue(/abc/)).to.throwError(/Unsupported data type for merge/); 614 | expect(applyMixinWithMergeValue(new Date())).to.throwError(/Unsupported data type for merge/); 615 | expect(applyMixinWithMergeValue(_.noop)).to.throwError(/Unsupported data type for merge/); 616 | expect(applyMixinWithMergeValue(false)).to.throwError(/Unsupported data type for merge/); 617 | expect(applyMixinWithMergeValue(null)).to.not.throwError(); 618 | expect(applyMixinWithMergeValue(undefined)).to.not.throwError(); 619 | }); 620 | }); 621 | 622 | describe('when combining mixins', () => { 623 | it('can be combine two mixins', () => { 624 | const M1 = new Mixin({ 625 | propertyA: 'a' 626 | }); 627 | 628 | const M2 = new Mixin(M1, { 629 | propertyB: 'b' 630 | }); 631 | 632 | const obj = {}; 633 | 634 | M2.applyTo(obj); 635 | 636 | expect(obj).to.eql({ propertyA: 'a', propertyB: 'b' }); 637 | }); 638 | 639 | it('applies around, before, after modifiers to the target object', () => { 640 | const output = []; 641 | 642 | const M1 = new Mixin(createMixinConfig('m1', 'foo', output)); 643 | 644 | const M2 = new Mixin(M1, createMixinConfig('m2', 'foo', output)); 645 | 646 | const obj = { 647 | foo(arg) { 648 | output.push(`obj-foo ${arg}`); 649 | } 650 | }; 651 | 652 | M2.applyTo(obj); 653 | obj.foo('bar'); 654 | expect(output).to.eql([ 655 | 'm2-around-foo-before bar', 656 | 'm2-before-foo bar', 657 | 'm1-around-foo-before bar', 658 | 'm1-before-foo bar', 659 | 'obj-foo bar', 660 | 'm1-after-foo bar', 661 | 'm1-around-foo-after bar', 662 | 'm2-after-foo bar', 663 | 'm2-around-foo-after bar' 664 | ]); 665 | }); 666 | 667 | it('copies properties prior to executing modifiers', () => { 668 | let output = []; 669 | let obj = {}; 670 | let M1 = new Mixin(createMixinConfig('m1', 'foo', output)); 671 | let M2 = new Mixin(M1, createMixinConfig('m2', 'foo', output)); 672 | M2.properties.foo = (arg) => { 673 | output.push(`m2-foo ${arg}`); 674 | }; 675 | 676 | M2.applyTo(obj); 677 | obj.foo('bar'); 678 | expect(output).to.eql([ 679 | 'm2-around-foo-before bar', 680 | 'm2-before-foo bar', 681 | 'm1-around-foo-before bar', 682 | 'm1-before-foo bar', 683 | 'm2-foo bar', 684 | 'm1-after-foo bar', 685 | 'm1-around-foo-after bar', 686 | 'm2-after-foo bar', 687 | 'm2-around-foo-after bar' 688 | ]); 689 | 690 | output = []; 691 | obj = {}; 692 | M1 = new Mixin(createMixinConfig('m1', 'foo', output)); 693 | M2 = new Mixin(M1, createMixinConfig('m2', 'foo', output)); 694 | M1.properties.foo = (arg) => { 695 | output.push(`m1-foo ${arg}`); 696 | }; 697 | 698 | M2.applyTo(obj); 699 | obj.foo('bar'); 700 | expect(output).to.eql([ 701 | 'm2-around-foo-before bar', 702 | 'm2-before-foo bar', 703 | 'm1-around-foo-before bar', 704 | 'm1-before-foo bar', 705 | 'm1-foo bar', 706 | 'm1-after-foo bar', 707 | 'm1-around-foo-after bar', 708 | 'm2-after-foo bar', 709 | 'm2-around-foo-after bar' 710 | ]); 711 | }); 712 | 713 | it('applies defaults and override modifiers to the target object', () => { 714 | const M1 = new Mixin({ 715 | defaults: { 716 | foo: 'm1-default' 717 | }, 718 | override: { 719 | bar: 'm1-override' 720 | } 721 | }); 722 | 723 | const M2 = new Mixin(M1, { 724 | defaults: { 725 | foo: 'm2-default' 726 | }, 727 | override: { 728 | bar: 'm2-override' 729 | } 730 | }); 731 | 732 | const obj = {}; 733 | 734 | M2.applyTo(obj); 735 | 736 | expect(obj.foo).to.be('m2-default'); 737 | expect(obj.bar).to.be('m2-override'); 738 | }); 739 | 740 | it('will apply defaults to objects whose prototype defines that property', () => { 741 | class Cls { 742 | foo() { 743 | return 'super foo'; 744 | } 745 | 746 | bar() { 747 | return 'super bar'; 748 | } 749 | }; 750 | 751 | const obj = new Cls(); 752 | obj.foo = () => 'sub foo'; 753 | 754 | const mixin = new Mixin({ 755 | defaults: { 756 | foo: () => 'mixin foo', 757 | bar: () => 'mixin bar', 758 | baz: () => 'mixin baz' 759 | } 760 | }); 761 | 762 | mixin.applyTo(obj); 763 | 764 | expect(obj.foo()).to.be('sub foo'); // foo was defined on the object so it should not have been overwritten 765 | expect(obj.bar()).to.be('mixin bar'); // bar was not defined on the object so it should have been overwritten 766 | expect(obj.baz()).to.be('mixin baz'); // baz was not defined at all, so it should have been applied 767 | }); 768 | 769 | it('applies requires modifiers to the target object', () => { 770 | const M1 = new Mixin({}); 771 | const M2 = new Mixin(M1, { 772 | requires: ['bazM2'] 773 | }); 774 | 775 | const obj = {}; 776 | 777 | expect(M2.applyTo.bind(M2, obj)).to.throwError(/Object is missing required properties: "bazM2"/); 778 | 779 | M1.properties.requires = ['bazM1']; 780 | 781 | expect(M2.applyTo.bind(M2, obj)).to.throwError(/Object is missing required properties: "bazM1"/); 782 | }); 783 | 784 | it('allows required properties can be defined in other mixins', () => { 785 | let M1 = new Mixin({ 786 | foo() {} 787 | }); 788 | 789 | let M2 = new Mixin(M1, { 790 | requires: ['foo'] 791 | }); 792 | 793 | let obj = {}; 794 | expect(M2.applyTo.bind(M2, obj)).not.to.throwError(); 795 | 796 | M1 = new Mixin({ 797 | requires: ['foo'] 798 | }); 799 | 800 | M2 = new Mixin(M1, { 801 | foo() {} 802 | }); 803 | 804 | obj = {}; 805 | expect(M2.applyTo.bind(M2, obj)).not.to.throwError(); 806 | }); 807 | 808 | it('will take the last mixin\'s override instead of the others', () => { 809 | const output = []; 810 | 811 | const M1 = new Mixin({ 812 | override: { 813 | foo(arg) { 814 | output.push(`m1 ${arg}`); 815 | } 816 | } 817 | }); 818 | 819 | const M2 = new Mixin(M1, { 820 | override: { 821 | foo(arg) { 822 | output.push(`m2 ${arg}`); 823 | } 824 | } 825 | }); 826 | 827 | const obj = { 828 | foo(arg) { 829 | output.push(`obj ${arg}`); 830 | } 831 | }; 832 | 833 | M2.applyTo(obj); 834 | 835 | obj.foo('bar'); 836 | 837 | expect(output).to.eql(['m2 bar']); 838 | }); 839 | 840 | it('can combine already-combined mixins', () => { 841 | const M1 = new Mixin({ 842 | propertyA: 'a' 843 | }); 844 | const M2 = new Mixin(M1, { 845 | propertyB: 'b' 846 | }); 847 | const M3 = new Mixin(M2, { 848 | propertyC: 'c' 849 | }); 850 | 851 | const obj = {}; 852 | 853 | M3.applyTo(obj); 854 | 855 | expect(obj.propertyA).to.be('a'); 856 | expect(obj.propertyB).to.be('b'); 857 | expect(obj.propertyC).to.be('c'); 858 | }); 859 | 860 | it('can combine multiple mixins', () => { 861 | const M1 = new Mixin({ 862 | propertyA: 'a' 863 | }); 864 | const M2 = new Mixin({ 865 | propertyB: 'b' 866 | }); 867 | const M3 = new Mixin(M1, M2, { 868 | propertyC: 'c' 869 | }); 870 | 871 | const obj = {}; 872 | 873 | M3.applyTo(obj); 874 | 875 | expect(obj.propertyA).to.be('a'); 876 | expect(obj.propertyB).to.be('b'); 877 | expect(obj.propertyC).to.be('c'); 878 | }); 879 | }); 880 | }); 881 | 882 | /** 883 | * @param {String} mixinName 884 | * @param {String} functionName 885 | * @param {Array} output 886 | */ 887 | function createMixinConfig(mixinName, functionName, output) { 888 | return { 889 | before: { 890 | [functionName](arg) { 891 | output.push(`${mixinName}-before-foo ${arg}`); 892 | } 893 | }, 894 | after: { 895 | [functionName](arg) { 896 | output.push(`${mixinName}-after-foo ${arg}`); 897 | } 898 | }, 899 | around: { 900 | [functionName](fn, arg) { 901 | output.push(`${mixinName}-around-foo-before ${arg}`); 902 | fn(arg); 903 | output.push(`${mixinName}-around-foo-after ${arg}`); 904 | } 905 | } 906 | }; 907 | } 908 | --------------------------------------------------------------------------------