├── .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 |
--------------------------------------------------------------------------------