From 01b688c3472886dfa9254787822cd7f7052c7d11 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Mon, 18 Feb 2019 21:44:07 -0700 Subject: [PATCH] implement merge for nested objects and unit tests, see phetsims/phet-info#91 --- js/merge.js | 30 +++++ js/mergeTests.js | 251 ++++++++++++++++++++++++++++++++++++++++++ js/phet-core-tests.js | 1 + 3 files changed, 282 insertions(+) create mode 100644 js/merge.js create mode 100644 js/mergeTests.js diff --git a/js/merge.js b/js/merge.js new file mode 100644 index 0000000..5a059db --- /dev/null +++ b/js/merge.js @@ -0,0 +1,30 @@ +// Copyright 2019, University of Colorado Boulder + + +define( require => { + 'use strict'; + + // modules + const phetCore = require( 'PHET_CORE/phetCore' ); + + function merge( obj ) { + _.each( Array.prototype.slice.call( arguments, 1 ), function( source ) { + if ( source ) { + for ( var prop in source ) { + if ( prop.includes( 'Options' ) && obj.hasOwnProperty( prop ) ) { + // ensure that the ...Options property is a POJSO + assert && assert( Object.getPrototypeOf( source[ prop ] ) === Object.prototype, 'merge can only take place between Objects declared by {}' ); + assert && assert( !( Object.getOwnPropertyDescriptor( source, prop ).hasOwnProperty( 'get' ) ), 'cannot use merge with a getter' ); + Object.defineProperty( obj, prop, merge( obj[ prop ], source[ prop ] ) ); + } + else { + Object.defineProperty( obj, prop, Object.getOwnPropertyDescriptor( source, prop ) ); + } + } + } + } ); + return obj; + } + + return phetCore.register( 'merge', merge ); +} ); \ No newline at end of file diff --git a/js/mergeTests.js b/js/mergeTests.js new file mode 100644 index 0000000..bb8081c --- /dev/null +++ b/js/mergeTests.js @@ -0,0 +1,251 @@ +// Copyright 2019, University of Colorado Boulder + +define( require => { + 'use strict'; + + // modules + var merge = require( 'PHET_CORE/merge' ); + + QUnit.module( 'merge' ); + + // test proper merger for 2 objects + QUnit.test( 'merge two objects', function( assert ) { + var original = { + prop1: 'value1', + prop2: 'value2', + subcomponentOptions: { + subProp1: 'subValue1', + subProp2: 'subValue2' + }, + subcomponentOptions2: { + subSubcomponentOptions: { + subSubProp1: 'subSubValue1' + } + }, + prop3: 'value3' + }; + + var merge1 = { + subcomponentOptions: { + subProp1: 'subvalue1 changed', + subProp3: 'new subvalue' + }, + subcomponentOptions2: { + subSubcomponentOptions: { + subSubProp1: 'all gone now', + test: 'this is here too' + } + }, + prop3: 'new value3', + prop4: 'value4' + }; + var preMergeSourceCopy = Object.assign( {}, merge1 ); + var merged = merge( original, merge1 ); + + assert.equal( merged.prop1, 'value1', 'merge should not alter target keys that aren\'t in the source' ); + assert.equal( merged.prop4, 'value4', 'merge should not alter source keys that aren\'t in the target'); + + var shouldBe = { + subProp1: 'subvalue1 changed', + subProp2: 'subValue2', + subProp3: 'new subvalue' + }; + assert.deepEqual( merged.subcomponentOptions, shouldBe, 'merge should combine singly nested objects' ); + + shouldBe = { + prop1: 'value1', + prop2: 'value2', + subcomponentOptions: { + subProp1: 'subvalue1 changed', + subProp3: 'new subvalue', + subProp2: 'subValue2' + }, + subcomponentOptions2: { + subSubcomponentOptions: { + subSubProp1: 'all gone now', + test: 'this is here too' + } + }, + prop3: 'new value3', + prop4: 'value4' + }; + assert.deepEqual( merged, shouldBe, 'merge should combine arbitrarily nested objects' ); + assert.deepEqual( merge1, preMergeSourceCopy, 'merge should not alter sources' ); + } ); + + // test multiple objects + QUnit.test( 'test multiple objects', function( assert ) { + var original = { + prop1: 'value1', + prop2: 'value2', + subcomponentOptions: { + subProp1: 'subValue1', + subProp2: 'subValue2' + }, + subcomponentOptions2: { + subSubcomponentOptions: { + subSubProp1: 'subSubValue1' + } + }, + prop3: 'value3' + }; + + var merge1 = { + subcomponentOptions: { + subProp1: 'subvalue1 changed', + subProp3: 'new subvalue', + except: 'me' + }, + subcomponentOptions2: { + subSubcomponentOptions: { + subSubProp1: 'all gone now', + test: 'this is here too' + } + }, + prop3: 'new value3', + prop4: 'value4' + }; + + var merge2 = { + prop5: 'value5', + subcomponentOptions: { + subProp1: 'everything', + subProp2: 'here is', + subProp3: 'from', + subProp4: 'merge2' + } + }; + + var merge3 = { + prop6: 'value6', + prop5: 'value5 from merge3', + subcomponentOptions2: { + test2: ['test2', 'test3'], + subSubcomponentOptions: { + test: 'test form merge3', + subSubProp1: 'subSub from merge3' + } + } + }; + var merge1Copy = Object.assign( {}, merge1 ); + var merge2Copy = Object.assign( {}, merge2 ); + var merge3Copy = Object.assign( {}, merge3 ); + + + var merged = merge( original, merge1, merge2, merge3 ); + + var expected = { + prop1: 'value1', + prop2: 'value2', + subcomponentOptions: { + subProp1: 'everything', + subProp2: 'here is', + subProp3: 'from', + subProp4: 'merge2', + except: 'me' + }, + subcomponentOptions2: { + test2: ['test2', 'test3'], + subSubcomponentOptions: { + test: 'test form merge3', + subSubProp1: 'subSub from merge3' + } + }, + prop3: 'new value3', + prop4: 'value4', + prop5: 'value5 from merge3', + prop6: 'value6' + }; + assert.notEqual( merged, expected, 'sanity check: ensure merged and expected objects are not the same reference' ); + assert.deepEqual( merged, expected, 'merge should properly combine multiple objects' ); + assert.deepEqual( merge1, merge1Copy, 'merge should not alter source objects' ); + assert.deepEqual( merge2, merge2Copy, 'merge should not alter source objects' ); + assert.deepEqual( merge3, merge3Copy, 'merge should not alter source objects' ); + } ); + + // check that it errors loudly if something other than an object is used + QUnit.test( 'check for proper assertion errors', function( assert ) { + var original = { + subOptions: { + test: 'val', + test2: 'val2' + } + }; + + function TestClass() { + this.test = 'class'; + } + + var merges = { + a: { + subOptions: [ 'val', 'val2' ] + }, + b: { + subOptions: Object.create( { test: 'a', test1: 3 } ) + }, + c: { + subOptions: 'a string to test' + }, + d: { + subOptions: 42 + }, + e: { + subOptions: function() { this.a = 42; } + }, + f: { + subOptions: new TestClass() + } + }; + + var getterMerge = { + get subOptions() { + return { + test: 'should not work' + }; + } + }; + + if ( window.assert ) { + assert.throws( function() { merge( original, merges.a ); }, 'merge should not allow arrays to be merged' ); + assert.throws( function() { merge( original, merges.b ); }, 'merge should not allow inherited objects to be merged' ); + assert.throws( function() { merge( original, merges.f ); }, 'merge should not allow instances to be merged' ); + assert.throws( function() { merge( original, merges.c ); }, 'merge should not allow strings to be merged' ); + assert.throws( function() { merge( original, merges.d ); }, 'merge should not allow numbers to be merged' ); + assert.throws( function() { merge( original, merges.e ); }, 'merge should not allow functions to be merged' ); + assert.throws( function() { merge( original, getterMerge ); }, 'merge should not work with getters' ); + } + } ); + + QUnit.test( 'try a horribly nested case', function( assert ) { + var original = { + p1Options: { n1Options: { n2Options: { n3Options: { n4Options: { n5: 'overwrite me' } } } } }, + p2Options: { + n1Options: { + p3: 'keep me' + } + } + }; + var merge1 = { + p1Options: { n1Options: { n2Options: { n3Options: { n4Options: { n5: 'overwritten' } } } } }, + p2Options: { + n1Options: { + p4: 'p3 kept', + n2Options: { n3Options: { n4Options: { n5Options: { n6Options: { p5: 'never make options like this' } } } } } + } + } + }; + + var merged = merge( original, merge1 ); + var expected = { + p1Options: { n1Options: { n2Options: { n3Options: { n4Options: { n5: 'overwritten' } } } } }, + p2Options: { + n1Options: { + p3: 'keep me', + p4: 'p3 kept', + n2Options: { n3Options: { n4Options: { n5Options: { n6Options: { p5: 'never make options like this' } } } } } + } + } + }; + assert.deepEqual( merged, expected, 'merge should handle some deeply nested stuff' ); + } ); +} ); \ No newline at end of file diff --git a/js/phet-core-tests.js b/js/phet-core-tests.js index c032d18..f96bb86 100644 --- a/js/phet-core-tests.js +++ b/js/phet-core-tests.js @@ -25,6 +25,7 @@ define( function( require ) { require( 'PHET_CORE/arrayDifferenceTests' ); require( 'PHET_CORE/interleaveTests' ); require( 'PHET_CORE/EnumerationTests' ); + require( 'PHET_CORE/mergeTests' ); // Since our tests are loaded asynchronously, we must direct QUnit to begin the tests QUnit.start();