├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .gitreview ├── .mailmap ├── AUTHORS.txt ├── CONTRIBUTING.md ├── Gruntfile.js ├── History.md ├── LICENSE-MIT ├── README.md ├── jsdoc.json ├── karma.conf.js ├── karma.conf.sauce.js ├── package-lock.json ├── package.json ├── src ├── EmitterList.js ├── EventEmitter.js ├── Factory.js ├── Registry.js ├── SortedEmitterList.js ├── banner.txt ├── core.js ├── export.js ├── intro.js.txt ├── outro.js.txt └── util.js └── tests ├── index.html ├── karma.conf.base.js ├── setup-browser.js ├── setup-node.js └── unit ├── EmitterList.test.js ├── EventEmitter.test.js ├── Factory.test.js ├── Registry.test.js ├── SortedEmitterList.test.js ├── core.test.js └── util.test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /docs 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "wikimedia/common", 4 | "globals": { 5 | "OO": false 6 | }, 7 | "overrides": [ 8 | { 9 | "files": [ 10 | "src/**/*.js", 11 | "tests/setup-browser.js" 12 | ], 13 | "extends": "wikimedia/client-es6", 14 | "rules": { 15 | "no-implicit-globals": "off" 16 | } 17 | }, 18 | { 19 | "files": [ 20 | "tests/**/*.js" 21 | ], 22 | "extends": [ 23 | "wikimedia/client", 24 | "wikimedia/qunit" 25 | ] 26 | }, 27 | { 28 | "files": [ 29 | "Gruntfile.js", 30 | "karma*.js" 31 | ], 32 | "extends": "wikimedia/server" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist/* 3 | /docs 4 | node_modules 5 | npm-debug.log 6 | .eslintcache 7 | -------------------------------------------------------------------------------- /.gitreview: -------------------------------------------------------------------------------- 1 | [gerrit] 2 | host=gerrit.wikimedia.org 3 | port=29418 4 | project=oojs/core.git 5 | track=1 6 | defaultrebase=0 7 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Timo Tijhof 2 | David Chan 3 | David Chan 4 | Kunal Mehta 5 | Kunal Mehta 6 | Paladox 7 | DannyS712 8 | Libraryupgrader 9 | Bartosz Dziewoński 10 | Ed Sanders 11 | Thiemo Kreuz 12 | Thalia Chan 13 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Andrew Green 2 | Bartosz Dziewoński 3 | C. Scott Ananian 4 | DannyS712 5 | David Chan 6 | David Lynch 7 | Ed Sanders 8 | James D. Forrester 9 | Kosta Harlan 10 | Kunal Mehta 11 | Moriel Schottlender 12 | Ori Livneh 13 | Paladox 14 | Prateek Saxena 15 | Roan Kattouw 16 | Thalia Chan 17 | Thiemo Kreuz 18 | Timo Tijhof 19 | Trevor Parscal 20 | Umherirrender 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to OOjs 2 | 3 | ## Commit messages 4 | 5 | If you're a new contributor, don't worry if you're unsure about 6 | the commit message. Maintainers will adjust or write these as needed 7 | as part of code review and merge activities. If you're a regular 8 | contributor, do try to follow this structure to help speed up the 9 | process. Thanks! 10 | 11 | Structure: 12 | 13 | ``` 14 | Component: Short subject line about what is changing 15 | 16 | Additional details about the commit are placed after a new line 17 | in the commit message body. That's this paragraph here. 18 | 19 | Bug: T123 20 | ``` 21 | 22 | The subject line should use the [imperative mood](https://en.wikipedia.org/wiki/Imperative_mood), 23 | and start with one of the following components: 24 | 25 | * `core` 26 | * `EmitterList` 27 | * `EventEmitter` 28 | * `Factory` 29 | * `Registry` 30 | * `docs` 31 | * `release` 32 | * `build` 33 | * `tests` 34 | 35 | See also [Commit message guidelines](https://www.mediawiki.org/wiki/Gerrit/Commit_message_guidelines). 36 | 37 | ## Release process 38 | 39 | 1. Create or reset your `release` branch to the latest head of the repository 40 | ``` 41 | git remote update && git checkout -B release -t origin/HEAD 42 | ``` 43 | 44 | 2. Ensure build and tests pass locally. 45 | NOTE: This does not require privileges and should be run in isolation. 46 | ``` 47 | npm ci && npm test 48 | ``` 49 | 50 | 3. Prepare the release commit 51 | - Add release notes to a new section on top of [History.md](./History.md). 52 | ``` 53 | git log --format='* %s (%aN)' --no-merges --reverse v$(node -e 'console.log(require("./package.json").version);')...HEAD | sort | grep -vE '^\* (build|docs?|tests?):' 54 | ``` 55 | - Update AUTHORS.txt and preview the diff. 56 | If duplicates emerge, add entries to `.mailmap` as needed and re-run the command. 57 | ``` 58 | npm run authors 59 | ``` 60 | - Set the next release version in [package.json](./package.json). 61 | ``` 62 | # 'patch' increments to vA.B.C+1; substitute 'minor' for vA.B+1.0 and 'major' for vA+1.0.0 63 | npm version patch --git-tag-version=false 64 | ``` 65 | - Review and stage your commit: 66 | ``` 67 | git add -p 68 | ``` 69 | - Save your commit and push for review. 70 | ``` 71 | git commit -m "Release vX.Y.Z" 72 | git review 73 | ``` 74 | 75 | After the release commit has been merged by CI, perform the actual release: 76 | 77 | 1. Update and reset your `release` branch, confirm it is at your merged commit. 78 | ``` 79 | git remote update && git checkout -B release -t origin/HEAD 80 | # … 81 | git show 82 | # Release vX.Y.Z 83 | # … 84 | ``` 85 | 86 | 3. Create a signed tag and push it to the Git server: 87 | ``` 88 | git tag -s "vX.Y.Z" 89 | git push --tags 90 | ``` 91 | 92 | 4. Run the build and review the release file (e.g. proper release version header 93 | in the header, and not a development build). 94 | NOTE: This does not require privileges and should be run in isolation. 95 | ``` 96 | npm run build-release 97 | # … 98 | head dist/oojs.js 99 | # OOjs v5.0.0 100 | # … 101 | ``` 102 | 103 | 5. Publish to npm: 104 | ``` 105 | npm publish 106 | ``` 107 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Grunt file 3 | * 4 | * To use the automated Sauce Labs setup (as we do in Jenkins), set 5 | * the SAUCE_USERNAME and SAUCE_ACCESS_KEY environment variables (either one-time 6 | * via export, or from your bashrc). Then, run 'grunt ci'. 7 | * Sign up for free at https://saucelabs.com/signup/plan/free. 8 | */ 9 | 10 | 'use strict'; 11 | 12 | module.exports = function ( grunt ) { 13 | const concatFiles = [ 14 | 'src/intro.js.txt', 15 | 'src/core.js', 16 | 'src/util.js', 17 | 'src/EventEmitter.js', 18 | 'src/EmitterList.js', 19 | 'src/SortedEmitterList.js', 20 | 'src/Registry.js', 21 | 'src/Factory.js', 22 | 'src/export.js', 23 | 'src/outro.js.txt' 24 | ]; 25 | 26 | grunt.loadNpmTasks( 'grunt-contrib-clean' ); 27 | grunt.loadNpmTasks( 'grunt-contrib-concat' ); 28 | grunt.loadNpmTasks( 'grunt-contrib-uglify' ); 29 | 30 | grunt.initConfig( { 31 | clean: { 32 | dist: [ 'dist', 'coverage' ] 33 | }, 34 | concat: { 35 | release: { 36 | options: { 37 | banner: grunt.file.read( 'src/banner.txt' ) 38 | }, 39 | dest: 'dist/oojs.js', 40 | src: concatFiles 41 | }, 42 | dev: { 43 | options: { 44 | banner: grunt.file.read( 'src/banner.txt' ), 45 | sourceMap: true 46 | }, 47 | dest: 'dist/oojs.js', 48 | src: concatFiles 49 | } 50 | }, 51 | uglify: { 52 | options: { 53 | banner: '/*! OOjs v<%= build.version %> | License: MIT */', 54 | sourceMap: true, 55 | sourceMapIncludeSources: true, 56 | report: 'gzip' 57 | }, 58 | js: { 59 | expand: true, 60 | src: 'dist/*.js', 61 | ext: '.min.js', 62 | extDot: 'last' 63 | } 64 | } 65 | } ); 66 | 67 | grunt.registerTask( 'set-meta', () => { 68 | grunt.config.set( 'build.version', require( './package.json' ).version ); 69 | } ); 70 | 71 | grunt.registerTask( 'set-dev', () => { 72 | grunt.config.set( 'build.version', grunt.config( 'build.version' ) + '-dev' ); 73 | } ); 74 | 75 | grunt.registerTask( 'build-release', [ 'set-meta', 'clean', 'concat:release', 'uglify' ] ); 76 | grunt.registerTask( 'build-dev', [ 'set-meta', 'set-dev', 'clean', 'concat:dev' ] ); 77 | }; 78 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | # OOjs Release History 2 | 3 | ## v7.0.1 / 2023-06-08 4 | * release: Update `/dist/` in the npm package. 5 | The oojs@7.0.0 package on npm includes a distribution for an earlier commit 6 | between 6.0.0 and 7.0.0. 7 | 8 | ## v7.0.0 / 2023-06-08 9 | 10 | * [BREAKING CHANGE] Remove support for ES5 browsers such as IE11 (Timo Tijhof) 11 | * Factory: Add support to register and create objects from ES6 classes (Timo Tijhof) [T284935](https://phabricator.wikimedia.org/T284935) 12 | * Factory: Add support to register by `Class.key` (Timo Tijhof) [T96640](https://phabricator.wikimedia.org/T96640) 13 | * core: Make `OO.unique()` faster by using ES6 Set methods (Bartosz Dziewoński) 14 | 15 | ## v6.0.0 / 2021-04-22 16 | This release removes an (optional) dependency on jQuery. 17 | 18 | * [BREAKING CHANGE] release: Remove special "jquery" build (Timo Tijhof) 19 | 20 | ## v5.0.0 / 2020-05-05 21 | * [BREAKING CHANGE] Use jQuery v3.5.1, up from v3.5.0 (James D. Forrester) 22 | 23 | ## v4.0.0 / 2020-04-30 24 | * [BREAKING CHANGE] Use jQuery v3.5.0, up from v3.4.1 (James D. Forrester) 25 | 26 | ## v3.0.1 / 2020-02-24 27 | * EmitterList: Call OO.initClass for consistency with other classes (Thalia Chan) 28 | * EventEmitter: Improve documentation for variadic args (Thalia Chan) 29 | * docs: Fix typo in EventEmitter documentation (Thalia Chan) 30 | * docs: Point to gerrit, not Phabricator Diffusion (James D. Forrester) 31 | 32 | ## v3.0.0 / 2019-08-26 33 | * [BREAKING CHANGE] Update jQuery 3.2.1 -> 3.4.1 (Ed Sanders; James D. Forrester) 34 | * [BREAKING CHANGE] EventEmitter.emit: catch exceptions from listeners (David Chan) 35 | * EmitterList: Throw error on null/undefined item (Kosta Harlan) 36 | * EventEmitter: Minor code simplifications (Timo Tijhof) 37 | * Factory: Improve unit tests (Timo Tijhof) 38 | * Factory: Support registration by name (Timo Tijhof) 39 | * core: Use "OO" internally too, instead of "oo" (Prateek Saxena) 40 | * docs: Convert from using JSDuck to JSDoc 3 (Prateek Saxena) 41 | 42 | ## v2.2.2 / 2018-06-14 43 | * release: Add AUTHORS.txt back to package root (Timo Tijhof) 44 | * release: Remove text files from dist/ (Timo Tijhof) 45 | 46 | ## v2.2.1 / 2018-06-14 47 | * release: Add 'files' whitelist to package.json (Timo Tijhof) 48 | 49 | ## v2.2.0 / 2018-04-03 50 | * [DEPRECATING CHANGE] Drop more code supporting ES3 / IE<=9 (Ed Sanders) 51 | * core: Fix deleteProp() case where root object is empty (Ed Sanders) 52 | * docs: Add intro for OO.Registry (Prateek Saxena) 53 | * docs: Fix valid-jsdoc issues in EventEmitter and SortedEmitterList (Ed Sanders) 54 | 55 | ## v2.1.0 / 2017-05-31 56 | * EmitterList: Fix moving an item to a lower index (Andrew Green) 57 | * EventEmitter: Document disconnect() behaviour regarding array matching (Timo Tijhof) 58 | * EventEmitter: Support passing once() handler to off() (Timo Tijhof) 59 | * util: Simplify isPlainObject and fix two false positives (Timo Tijhof) 60 | * release: Add README/AUTHORS/LICENCE to dist (James D. Forrester) 61 | 62 | ## v2.0.0 / 2017-04-04 63 | 64 | This release drops support for ES3 environments. Where previously OOjs was 65 | supported in IE 6-8 with an ES5 shim, it is no longer. 66 | 67 | * [BREAKING CHANGE] core: Drop support for ES3 environments (James D. Forrester) 68 | * core: Add `OO.isSubclass`, to test class inheritance (David Chan) 69 | * core: Guard `OO.setProp()` against insufficient arguments (Ed Sanders) 70 | * core: Implement `OO.deleteProp` (Ed Sanders) 71 | * core: Improve error message for `inheritClass`/`mixinClass` called with undefined (Bartosz Dziewoński) 72 | * core: Switch from `.parent` hack to use `.super` directly (James D. Forrester) 73 | * EmitterList: Change insertItem from `@private` to `@protected` (Moriel Schottlender) 74 | * SortedEmitterList: Emit the actual inserted index (Ed Sanders) 75 | * docs: Make OO uppercase (Prateek Saxena) 76 | * docs: Update Phabricator URL (James D. Forrester) 77 | * docs: Replace git.wikimedia.org URL with Phabricator one (Paladox) 78 | * release: Bump file copyright notices for year change (James D. Forrester) 79 | 80 | ## v1.1.10 / 2015-11-11 81 | * EventEmitter: Allow disconnecting event handlers given by array (Moriel Schottlender) 82 | * Add EmitterList class (Moriel Schottlender) 83 | * Add SortedEmitterList class (Moriel Schottlender) 84 | * core: Add binarySearch() utility from VisualEditor (Ed Sanders) 85 | * build: Bump various devDependencies to latest (James D. Forrester) 86 | * tests: Add QUnit web interface (Moriel Schottlender) 87 | * AUTHORS: Update for the past few months (James D. Forrester) 88 | 89 | ## v1.1.9 / 2015-08-25 90 | * build: Fix the build by downgrading Karma and removing testing of Safari 5 (Timo Tijhof) 91 | * core: Remove dependency on Object.create (Bartosz Dziewoński) 92 | * test: Don't use QUnit.supportsES5 (Bartosz Dziewoński) 93 | 94 | ## v1.1.8 / 2015-07-23 95 | * EventEmitter: Remove TODO about return value of #emit and tweak tests (Bartosz Dziewoński) 96 | * build: Add explicit dependency upon grunt-cli (Kunal Mehta) 97 | * build: Various fixes for cdnjs support (James D. Forrester) 98 | 99 | ## v1.1.7 / 2015-04-28 100 | * Factory: Remove unused, undocumented 'entries' property (Ed Sanders) 101 | * Registry: Provide an unregister method (Ed Sanders) 102 | 103 | ## v1.1.6 / 2015-03-18 104 | * core: Improve class related unit tests (Timo Tijhof) 105 | * jsduck: Set --processes=0 to fix warnings-exit-nonzero (Timo Tijhof) 106 | * core: Provide OO.unique for removing duplicates from arrays (Ed Sanders) 107 | 108 | ## v1.1.5 / 2015-02-25 109 | * EventEmitter: Remove unneeded Array.prototype.slice call (Timo Tijhof) 110 | * core: Use Node#isEqualNode to compare node objects (Ori Livneh) 111 | * core: Recurse more frugally in OO.compare (David Chan) 112 | 113 | ## v1.1.4 / 2015-01-23 114 | * util: Fix typo "siuch" in comment (Timo Tijhof) 115 | * Factory: Enable v8 optimisation for `#create` (Ori Livneh) 116 | * EventEmitter: Enable v8 optimisation for `#emit` (Ori Livneh) 117 | 118 | ## v1.1.3 / 2014-11-17 119 | * core: Explicitly bypass undefined values in OO.compare() (Roan Kattouw) 120 | * core: Add getProp() and setProp() methods (Roan Kattouw) 121 | 122 | ## v1.1.2 / 2014-11-05 123 | * EventEmitter: Use hasOwn check in `#emit` (Ed Sanders) 124 | * EventEmitter: Use hasOwn check in `#off` (Timo Tijhof) 125 | 126 | ## v1.1.1 / 2014-09-10 127 | * core: Make OO.compare cover boolean as well as number and string primitives (James D. Forrester) 128 | 129 | ## v1.1.0 / 2014-08-31 130 | * EventEmitter: Make #validateMethod private (Roan Kattouw) 131 | 132 | ## v1.0.12 / 2014-08-20 133 | 134 | * release: Tell people which version they're using (James D. Forrester) 135 | * Registry: Guard against Object prototype keys in lookup() (Ed Sanders) 136 | * core: Add new OO.copy callback for all nodes, not just leaves (C. Scott Ananian) 137 | * core: Use empty object as fallback when comparing to null/undefined (Ed Sanders) 138 | * EventEmitter: Look up callbacks by name at call time (divec) 139 | 140 | ## v1.0.11 / 2014-07-23 141 | 142 | * EventEmitter: Remove dead code that claims to prevent double bindings (Timo Tijhof) 143 | * EventEmitter: Fix bug in disconnect loop for double un-bindings (Ed Sanders) 144 | * EventEmitter: Support events named "hasOwnProperty" (Timo Tijhof) 145 | 146 | ## v1.0.10 / 2014-06-19 147 | 148 | * test: Update qunitjs to v1.14.0 (Timo Tijhof) 149 | * release: Implement build target optimised for jQuery (Timo Tijhof) 150 | * core: Use bracket notation for 'super' for ES3 compatibility (James D. Forrester) 151 | * core: Implement support for ES3 browsers (Timo Tijhof) 152 | 153 | ## v1.0.9 / 2014-04-01 154 | * core: Add initClass method for initializing static in base classes (Ed Sanders) 155 | 156 | ## v1.0.8 / 2014-03-11 157 | * Factory: Use Class.super instead of hard coding parent class (Timo Tijhof) 158 | * Registry: Remove redundant type validation logic in #register (Timo Tijhof) 159 | * core: Use Class.super instead of this.constructor.super (Timo Tijhof) 160 | * core: Add a 'super' property to inheriting classes (Timo Tijhof) 161 | * docs: Improve overall documentation and fix minor issues (Timo Tijhof) 162 | 163 | ## v1.0.7 / 2014-01-21 164 | * release: Update dist build header and license file for 2014 (James D. Forrester) 165 | 166 | ## v1.0.6 / 2013-12-10 167 | * docs: Change display name from OOJS to OOjs (Timo Tijhof) 168 | * docs: Update references from GitHub to Wikimedia (Timo Tijhof) 169 | 170 | ## v1.0.5 / 2013-10-23 171 | 172 | * core: Add simpleArrayUnion, simpleArrayIntersection and simpleArrayDifference (Timo Tijhof) 173 | * core: Remove unused code for tracking mixins (Timo Tijhof) 174 | 175 | ## v1.0.4 / 2013-10-10 176 | 177 | * core: Add getHash to core (Trevor Parscal) 178 | 179 | ## v1.0.3 / 2013-10-10 180 | 181 | * core: Add OO.Registry and OO.Factory (Trevor Parscal) 182 | * EventEmitter: Re-use #off in #connect and add context argument to #off (Trevor Parscal) 183 | * docs: Add npm install and npm test to release process (Timo Tijhof) 184 | 185 | ## v1.0.2 / 2013-07-25 186 | 187 | * core: Optimise OO.compare when a and b are equal by reference (Timo Tijhof) 188 | * core: Make "constructor" non-enumerable in OO.inheritClass (Timo Tijhof) 189 | 190 | ## v1.0.1 / 2013-06-06 191 | 192 | * docs: Refer to OOJS Team and other contributors (Timo Tijhof) 193 | 194 | ## v1.0.0 / 2013-06-06 195 | 196 | * core: Don't copy non-plain objects in OO.copy (Timo Tijhof) 197 | * core: Implement OO.isPlainObject (Timo Tijhof) 198 | * core: Optimise reference to hasOwnProperty (Timo Tijhof) 199 | * core: Apply asymmetrical recursively in OO.compare (Timo Tijhof) 200 | * core: Rename OO.compareObjects to OO.compare (Timo Tijhof) 201 | * core: Remove obsolete OO.createObject (Timo Tijhof) 202 | * docs: Document release process (Timo Tijhof) 203 | * docs: Add categories and include builtin classes (Timo Tijhof) 204 | 205 | ## v0.1.0 / 2013-06-05 206 | 207 | * core: Remove Object.create polyfill (Timo Tijhof) 208 | * Initial import of utility functions and EventEmitter class (Trevor Parscal) 209 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright Wikimedia Foundation and other contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/oojs.svg?style=flat)](https://www.npmjs.com/package/oojs) 2 | 3 | # OOjs 4 | 5 | OOjs is a JavaScript library for working with objects. 6 | 7 | Key features include inheritance, mixins and utilities for working with objects. 8 | 9 |
10 | /* Example */
11 | function Animal() {}
12 | function Magic() {}
13 | function Unicorn() {
14 |     Animal.call( this );
15 |     Magic.call( this );
16 | }
17 | OO.inheritClass( Unicorn, Animal );
18 | OO.mixinClass( Unicorn, Magic );
19 | 
20 | 21 | ## Quick start 22 | 23 | This library is available as an [npm](https://npmjs.org/) package! Install it right away: 24 | 25 |
26 | npm install oojs
27 | 
28 | 29 | Or clone the repo, `git clone https://gerrit.wikimedia.org/r/oojs/core`. 30 | 31 | ## Browser support 32 | 33 | We officially support these browsers, aligned with [MediaWiki's compatibility guideline](https://www.mediawiki.org/wiki/Compatibility#Browsers): 34 | 35 | * Firefox: last three years (Firefox 78+, 2020) 36 | * Chrome: last three years (Chrome 80+, 2020) 37 | * Edge: last three years (Edge 80+, 2020) 38 | * Opera: last thee years (Opera 67+, 2020) 39 | * iOS: 11.3+ 40 | 41 | OOjs requires a modern ES2015 (ECMAScript 6) environment. To support older browsers with ECMAScript 5 engines (such as IE 11), use the last OOjs 6.x release. 42 | 43 | ## Bug tracker 44 | 45 | Found a bug? Please report it in the [issue tracker](https://phabricator.wikimedia.org/maniphest/task/edit/form/1/?projects=OOjs)! 46 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "opts": { 3 | "encoding": "utf8", 4 | "destination": "docs", 5 | "package": "package.json", 6 | "readme": "README.md", 7 | "recurse": true, 8 | "private": true, 9 | "template": "node_modules/jsdoc-wmf-theme", 10 | "class-hierarchy": { 11 | "showList": true 12 | } 13 | }, 14 | "plugins": [ 15 | "node_modules/jsdoc-wmf-theme/plugins/default" 16 | ], 17 | "source": { 18 | "include": [ "src" ] 19 | }, 20 | "templates": { 21 | "cleverLinks": true, 22 | "default": { 23 | "useLongnameInNav": true 24 | }, 25 | "wmf": { 26 | "maintitle": "OOjs", 27 | "repository": "https://gerrit.wikimedia.org/g/oojs/core/", 28 | "hideSections": [ "Events" ], 29 | "prefixMap": { 30 | "OO.": true 31 | }, 32 | "linkMap": { 33 | "function()": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function ( config ) { 4 | config.set( Object.assign( require( './tests/karma.conf.base.js' ), { 5 | browsers: [ 'FirefoxHeadless', 'ChromeCustom' ], 6 | preprocessors: { 7 | 'dist/*.js': [ 'coverage' ] 8 | }, 9 | reporters: [ 'dots', 'coverage', 'karma-remap-istanbul' ], 10 | coverageReporter: { 11 | // https://github.com/karma-runner/karma-coverage/blob/v1.1.1/docs/configuration.md#check 12 | type: 'in-memory', 13 | check: { global: { 14 | functions: 100, 15 | statements: 99, 16 | branches: 99, 17 | lines: 99 18 | } } 19 | }, 20 | remapIstanbulReporter: { 21 | reports: { 22 | 'text-summary': null, 23 | html: 'coverage/', 24 | lcovonly: 'coverage/lcov.info', 25 | clover: 'coverage/clover.xml' 26 | } 27 | } 28 | } ) ); 29 | }; 30 | -------------------------------------------------------------------------------- /karma.conf.sauce.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function ( config ) { 4 | config.set( Object.assign( require( './tests/karma.conf.base.js' ), { 5 | sauceLabs: { 6 | username: process.env.SAUCE_USERNAME || 'oojs', 7 | accessKey: process.env.SAUCE_ACCESS_KEY || '0e464279-3f2a-4ca0-9eb4-db220410bef0', 8 | recordScreenshots: false 9 | }, 10 | concurrency: 4, 11 | captureTimeout: 90000, 12 | browsers: [ 13 | 'slChromeLatest', 14 | 'slFirefoxESR', 15 | 'slEdgeLatest', 16 | 'slSafariLatest', 17 | 'slSafari12' 18 | ] 19 | } ) ); 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oojs", 3 | "version": "7.0.1", 4 | "description": "Power for object oriented JavaScript libraries.", 5 | "keywords": [ 6 | "oo", 7 | "oop", 8 | "class", 9 | "inheritance", 10 | "library" 11 | ], 12 | "homepage": "https://www.mediawiki.org/wiki/OOjs", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://gerrit.wikimedia.org/g/oojs/core" 16 | }, 17 | "license": "MIT", 18 | "main": "dist/oojs.js", 19 | "files": [ 20 | "dist/", 21 | "AUTHORS.txt", 22 | "README.md", 23 | "LICENSE-MIT" 24 | ], 25 | "scripts": { 26 | "authors": "git shortlog -se | sed 's/[[:space:]]*[0-9]*[[:space:]]//' | grep -vE 'jenkins|libraryupgrader' > AUTHORS.txt", 27 | "build-dev": "grunt build-dev", 28 | "build-release": "grunt build-release", 29 | "doc": "jsdoc -c jsdoc.json", 30 | "jenkins": "npm run test && karma start karma.conf.sauce.js && npm run doc", 31 | "lint": "eslint --cache .", 32 | "test": "npm run build-dev && karma start && qunit --require ./tests/setup-node tests/unit/ && npm run lint && npm run doc" 33 | }, 34 | "devDependencies": { 35 | "eslint-config-wikimedia": "0.29.1", 36 | "grunt": "1.6.1", 37 | "grunt-contrib-clean": "2.0.1", 38 | "grunt-contrib-concat": "2.1.0", 39 | "grunt-contrib-uglify": "5.2.2", 40 | "jsdoc": "4.0.4", 41 | "jsdoc-wmf-theme": "1.1.0", 42 | "karma": "6.3.18", 43 | "karma-chrome-launcher": "3.1.0", 44 | "karma-coverage": "2.0.3", 45 | "karma-firefox-launcher": "2.1.2", 46 | "karma-qunit": "4.1.2", 47 | "karma-remap-istanbul": "0.6.0", 48 | "karma-sauce-launcher": "4.3.6", 49 | "qunit": "2.24.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/EmitterList.js: -------------------------------------------------------------------------------- 1 | ( function () { 2 | 3 | /** 4 | * Contain and manage a list of {@link OO.EventEmitter} items. 5 | * 6 | * Aggregates and manages their events collectively. 7 | * 8 | * This mixin must be used in a class that also mixes in {@link OO.EventEmitter}. 9 | * 10 | * @abstract 11 | * @class 12 | */ 13 | OO.EmitterList = function OoEmitterList() { 14 | this.items = []; 15 | this.aggregateItemEvents = {}; 16 | }; 17 | 18 | OO.initClass( OO.EmitterList ); 19 | 20 | /* Events */ 21 | 22 | /** 23 | * Item has been added. 24 | * 25 | * @event OO.EmitterList#add 26 | * @param {OO.EventEmitter} item Added item 27 | * @param {number} index Index items were added at 28 | */ 29 | 30 | /** 31 | * Item has been moved to a new index. 32 | * 33 | * @event OO.EmitterList#move 34 | * @param {OO.EventEmitter} item Moved item 35 | * @param {number} index Index item was moved to 36 | * @param {number} oldIndex The original index the item was in 37 | */ 38 | 39 | /** 40 | * Item has been removed. 41 | * 42 | * @event OO.EmitterList#remove 43 | * @param {OO.EventEmitter} item Removed item 44 | * @param {number} index Index the item was removed from 45 | */ 46 | 47 | /** 48 | * The list has been cleared of items. 49 | * 50 | * @event OO.EmitterList#clear 51 | */ 52 | 53 | /* Methods */ 54 | 55 | /** 56 | * Normalize requested index to fit into the bounds of the given array. 57 | * 58 | * @private 59 | * @static 60 | * @param {Array} arr Given array 61 | * @param {number|undefined} index Requested index 62 | * @return {number} Normalized index 63 | */ 64 | function normalizeArrayIndex( arr, index ) { 65 | return ( index === undefined || index < 0 || index >= arr.length ) ? 66 | arr.length : 67 | index; 68 | } 69 | 70 | /** 71 | * Get all items. 72 | * 73 | * @return {OO.EventEmitter[]} Items in the list 74 | */ 75 | OO.EmitterList.prototype.getItems = function () { 76 | return this.items.slice( 0 ); 77 | }; 78 | 79 | /** 80 | * Get the index of a specific item. 81 | * 82 | * @param {OO.EventEmitter} item Requested item 83 | * @return {number} Index of the item 84 | */ 85 | OO.EmitterList.prototype.getItemIndex = function ( item ) { 86 | return this.items.indexOf( item ); 87 | }; 88 | 89 | /** 90 | * Get number of items. 91 | * 92 | * @return {number} Number of items in the list 93 | */ 94 | OO.EmitterList.prototype.getItemCount = function () { 95 | return this.items.length; 96 | }; 97 | 98 | /** 99 | * Check if a list contains no items. 100 | * 101 | * @return {boolean} Group is empty 102 | */ 103 | OO.EmitterList.prototype.isEmpty = function () { 104 | return !this.items.length; 105 | }; 106 | 107 | /** 108 | * Aggregate the events emitted by the group. 109 | * 110 | * When events are aggregated, the group will listen to all contained items for the event, 111 | * and then emit the event under a new name. The new event will contain an additional leading 112 | * parameter containing the item that emitted the original event. Other arguments emitted from 113 | * the original event are passed through. 114 | * 115 | * @param {Object} events An object keyed by the name of the event that 116 | * should be aggregated (e.g., ‘click’) and the value of the new name to use 117 | * (e.g., ‘groupClick’). A `null` value will remove aggregated events. 118 | * @throws {Error} If aggregation already exists 119 | */ 120 | OO.EmitterList.prototype.aggregate = function ( events ) { 121 | let i, item; 122 | for ( const itemEvent in events ) { 123 | const groupEvent = events[ itemEvent ]; 124 | 125 | // Remove existing aggregated event 126 | if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { 127 | // Don't allow duplicate aggregations 128 | if ( groupEvent ) { 129 | throw new Error( 'Duplicate item event aggregation for ' + itemEvent ); 130 | } 131 | // Remove event aggregation from existing items 132 | for ( i = 0; i < this.items.length; i++ ) { 133 | item = this.items[ i ]; 134 | if ( item.connect && item.disconnect ) { 135 | const remove = {}; 136 | remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; 137 | item.disconnect( this, remove ); 138 | } 139 | } 140 | // Prevent future items from aggregating event 141 | delete this.aggregateItemEvents[ itemEvent ]; 142 | } 143 | 144 | // Add new aggregate event 145 | if ( groupEvent ) { 146 | // Make future items aggregate event 147 | this.aggregateItemEvents[ itemEvent ] = groupEvent; 148 | // Add event aggregation to existing items 149 | for ( i = 0; i < this.items.length; i++ ) { 150 | item = this.items[ i ]; 151 | if ( item.connect && item.disconnect ) { 152 | const add = {}; 153 | add[ itemEvent ] = [ 'emit', groupEvent, item ]; 154 | item.connect( this, add ); 155 | } 156 | } 157 | } 158 | } 159 | }; 160 | 161 | /** 162 | * Add items to the list. 163 | * 164 | * @param {OO.EventEmitter|OO.EventEmitter[]} items Item to add or 165 | * an array of items to add 166 | * @param {number} [index] Index to add items at. If no index is 167 | * given, or if the index that is given is invalid, the item 168 | * will be added at the end of the list. 169 | * @return {OO.EmitterList} 170 | * @fires OO.EmitterList#add 171 | * @fires OO.EmitterList#move 172 | */ 173 | OO.EmitterList.prototype.addItems = function ( items, index ) { 174 | if ( !Array.isArray( items ) ) { 175 | items = [ items ]; 176 | } 177 | 178 | if ( items.length === 0 ) { 179 | return this; 180 | } 181 | 182 | index = normalizeArrayIndex( this.items, index ); 183 | for ( let i = 0; i < items.length; i++ ) { 184 | const oldIndex = this.items.indexOf( items[ i ] ); 185 | if ( oldIndex !== -1 ) { 186 | // Move item to new index 187 | index = this.moveItem( items[ i ], index ); 188 | this.emit( 'move', items[ i ], index, oldIndex ); 189 | } else { 190 | // insert item at index 191 | index = this.insertItem( items[ i ], index ); 192 | this.emit( 'add', items[ i ], index ); 193 | } 194 | index++; 195 | } 196 | 197 | return this; 198 | }; 199 | 200 | /** 201 | * Move an item from its current position to a new index. 202 | * 203 | * The item is expected to exist in the list. If it doesn't, 204 | * the method will throw an exception. 205 | * 206 | * @private 207 | * @param {OO.EventEmitter} item Items to add 208 | * @param {number} newIndex Index to move the item to 209 | * @return {number} The index the item was moved to 210 | * @throws {Error} If item is not in the list 211 | */ 212 | OO.EmitterList.prototype.moveItem = function ( item, newIndex ) { 213 | const existingIndex = this.items.indexOf( item ); 214 | 215 | if ( existingIndex === -1 ) { 216 | throw new Error( 'Item cannot be moved, because it is not in the list.' ); 217 | } 218 | 219 | newIndex = normalizeArrayIndex( this.items, newIndex ); 220 | 221 | // Remove the item from the current index 222 | this.items.splice( existingIndex, 1 ); 223 | 224 | // If necessary, adjust new index after removal 225 | if ( existingIndex < newIndex ) { 226 | newIndex--; 227 | } 228 | 229 | // Move the item to the new index 230 | this.items.splice( newIndex, 0, item ); 231 | 232 | return newIndex; 233 | }; 234 | 235 | /** 236 | * Utility method to insert an item into the list, and 237 | * connect it to aggregate events. 238 | * 239 | * Don't call this directly unless you know what you're doing. 240 | * Use {@link OO.EmitterList#addItems|addItems()} instead. 241 | * 242 | * This method can be extended in child classes to produce 243 | * different behavior when an item is inserted. For example, 244 | * inserted items may also be attached to the DOM or may 245 | * interact with some other nodes in certain ways. Extending 246 | * this method is allowed, but if overridden, the aggregation 247 | * of events must be preserved, or behavior of emitted events 248 | * will be broken. 249 | * 250 | * If you are extending this method, please make sure the 251 | * parent method is called. 252 | * 253 | * @protected 254 | * @param {OO.EventEmitter|Object} item Item to add 255 | * @param {number} index Index to add items at 256 | * @return {number} The index the item was added at 257 | */ 258 | OO.EmitterList.prototype.insertItem = function ( item, index ) { 259 | // Throw an error if null or item is not an object. 260 | if ( item === null || typeof item !== 'object' ) { 261 | throw new Error( 'Expected object, but item is ' + typeof item ); 262 | } 263 | 264 | // Add the item to event aggregation 265 | if ( item.connect && item.disconnect ) { 266 | const events = {}; 267 | for ( const event in this.aggregateItemEvents ) { 268 | events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ]; 269 | } 270 | item.connect( this, events ); 271 | } 272 | 273 | index = normalizeArrayIndex( this.items, index ); 274 | 275 | // Insert into items array 276 | this.items.splice( index, 0, item ); 277 | return index; 278 | }; 279 | 280 | /** 281 | * Remove items. 282 | * 283 | * @param {OO.EventEmitter|OO.EventEmitter[]} items Items to remove 284 | * @return {OO.EmitterList} 285 | * @fires OO.EmitterList#remove 286 | */ 287 | OO.EmitterList.prototype.removeItems = function ( items ) { 288 | if ( !Array.isArray( items ) ) { 289 | items = [ items ]; 290 | } 291 | 292 | if ( items.length === 0 ) { 293 | return this; 294 | } 295 | 296 | // Remove specific items 297 | for ( let i = 0; i < items.length; i++ ) { 298 | const item = items[ i ]; 299 | const index = this.items.indexOf( item ); 300 | if ( index !== -1 ) { 301 | if ( item.connect && item.disconnect ) { 302 | // Disconnect all listeners from the item 303 | item.disconnect( this ); 304 | } 305 | this.items.splice( index, 1 ); 306 | this.emit( 'remove', item, index ); 307 | } 308 | } 309 | 310 | return this; 311 | }; 312 | 313 | /** 314 | * Clear all items. 315 | * 316 | * @return {OO.EmitterList} 317 | * @fires OO.EmitterList#clear 318 | */ 319 | OO.EmitterList.prototype.clearItems = function () { 320 | const cleared = this.items.splice( 0, this.items.length ); 321 | 322 | // Disconnect all items 323 | for ( let i = 0; i < cleared.length; i++ ) { 324 | const item = cleared[ i ]; 325 | if ( item.connect && item.disconnect ) { 326 | item.disconnect( this ); 327 | } 328 | } 329 | 330 | this.emit( 'clear' ); 331 | 332 | return this; 333 | }; 334 | 335 | }() ); 336 | -------------------------------------------------------------------------------- /src/EventEmitter.js: -------------------------------------------------------------------------------- 1 | /* global hasOwn */ 2 | 3 | ( function () { 4 | 5 | /** 6 | * @class 7 | */ 8 | OO.EventEmitter = function OoEventEmitter() { 9 | // Properties 10 | 11 | /** 12 | * Storage of bound event handlers by event name. 13 | * 14 | * @private 15 | * @property {Object} bindings 16 | */ 17 | this.bindings = {}; 18 | }; 19 | 20 | OO.initClass( OO.EventEmitter ); 21 | 22 | /* Private helper functions */ 23 | 24 | /** 25 | * Validate a function or method call in a context 26 | * 27 | * For a method name, check that it names a function in the context object 28 | * 29 | * @private 30 | * @param {Function|string} method Function or method name 31 | * @param {any} context The context of the call 32 | * @throws {Error} A method name is given but there is no context 33 | * @throws {Error} In the context object, no property exists with the given name 34 | * @throws {Error} In the context object, the named property is not a function 35 | */ 36 | function validateMethod( method, context ) { 37 | // Validate method and context 38 | if ( typeof method === 'string' ) { 39 | // Validate method 40 | if ( context === undefined || context === null ) { 41 | throw new Error( 'Method name "' + method + '" has no context.' ); 42 | } 43 | if ( typeof context[ method ] !== 'function' ) { 44 | // Technically the property could be replaced by a function before 45 | // call time. But this probably signals a typo. 46 | throw new Error( 'Property "' + method + '" is not a function' ); 47 | } 48 | } else if ( typeof method !== 'function' ) { 49 | throw new Error( 'Invalid callback. Function or method name expected.' ); 50 | } 51 | } 52 | 53 | /** 54 | * @private 55 | * @param {OO.EventEmitter} eventEmitter Event emitter 56 | * @param {string} event Event name 57 | * @param {Object} binding 58 | */ 59 | function addBinding( eventEmitter, event, binding ) { 60 | let bindings; 61 | // Auto-initialize bindings list 62 | if ( hasOwn.call( eventEmitter.bindings, event ) ) { 63 | bindings = eventEmitter.bindings[ event ]; 64 | } else { 65 | bindings = eventEmitter.bindings[ event ] = []; 66 | } 67 | // Add binding 68 | bindings.push( binding ); 69 | } 70 | 71 | /* Methods */ 72 | 73 | /** 74 | * Add a listener to events of a specific event. 75 | * 76 | * The listener can be a function or the string name of a method; if the latter, then the 77 | * name lookup happens at the time the listener is called. 78 | * 79 | * @param {string} event Type of event to listen to 80 | * @param {Function|string} method Function or method name to call when event occurs 81 | * @param {Array} [args] Arguments to pass to listener, will be prepended to emitted arguments 82 | * @param {Object} [context=null] Context object for function or method call 83 | * @return {OO.EventEmitter} 84 | * @throws {Error} Listener argument is not a function or a valid method name 85 | */ 86 | OO.EventEmitter.prototype.on = function ( event, method, args, context ) { 87 | validateMethod( method, context ); 88 | 89 | // Ensure consistent object shape (optimisation) 90 | addBinding( this, event, { 91 | method: method, 92 | args: args, 93 | context: ( arguments.length < 4 ) ? null : context, 94 | once: false 95 | } ); 96 | return this; 97 | }; 98 | 99 | /** 100 | * Add a one-time listener to a specific event. 101 | * 102 | * @param {string} event Type of event to listen to 103 | * @param {Function} listener Listener to call when event occurs 104 | * @return {OO.EventEmitter} 105 | */ 106 | OO.EventEmitter.prototype.once = function ( event, listener ) { 107 | validateMethod( listener ); 108 | 109 | // Ensure consistent object shape (optimisation) 110 | addBinding( this, event, { 111 | method: listener, 112 | args: undefined, 113 | context: null, 114 | once: true 115 | } ); 116 | return this; 117 | }; 118 | 119 | /** 120 | * Remove a specific listener from a specific event. 121 | * 122 | * @param {string} event Type of event to remove listener from 123 | * @param {Function|string} [method] Listener to remove. Must be in the same form as was passed 124 | * to "on". Omit to remove all listeners. 125 | * @param {Object} [context=null] Context object function or method call 126 | * @return {OO.EventEmitter} 127 | * @throws {Error} Listener argument is not a function or a valid method name 128 | */ 129 | OO.EventEmitter.prototype.off = function ( event, method, context ) { 130 | if ( arguments.length === 1 ) { 131 | // Remove all bindings for event 132 | delete this.bindings[ event ]; 133 | return this; 134 | } 135 | 136 | validateMethod( method, context ); 137 | 138 | if ( !hasOwn.call( this.bindings, event ) || !this.bindings[ event ].length ) { 139 | // No matching bindings 140 | return this; 141 | } 142 | 143 | // Default to null context 144 | if ( arguments.length < 3 ) { 145 | context = null; 146 | } 147 | 148 | // Remove matching handlers 149 | const bindings = this.bindings[ event ]; 150 | let i = bindings.length; 151 | while ( i-- ) { 152 | if ( bindings[ i ].method === method && bindings[ i ].context === context ) { 153 | bindings.splice( i, 1 ); 154 | } 155 | } 156 | 157 | // Cleanup if now empty 158 | if ( bindings.length === 0 ) { 159 | delete this.bindings[ event ]; 160 | } 161 | return this; 162 | }; 163 | 164 | /** 165 | * Emit an event. 166 | * 167 | * All listeners for the event will be called synchronously, in an 168 | * unspecified order. If any listeners throw an exception, this won't 169 | * disrupt the calls to the remaining listeners; however, the exception 170 | * won't be thrown until the next tick. 171 | * 172 | * Listeners should avoid mutating the emitting object, as this is 173 | * something of an anti-pattern which can easily result in 174 | * hard-to-understand code with hidden side-effects and dependencies. 175 | * 176 | * @param {string} event Type of event 177 | * @param {...any} [args] Arguments passed to the event handler 178 | * @return {boolean} Whether the event was handled by at least one listener 179 | */ 180 | OO.EventEmitter.prototype.emit = function ( event, ...args ) { 181 | if ( !hasOwn.call( this.bindings, event ) ) { 182 | return false; 183 | } 184 | 185 | // Slicing ensures that we don't get tripped up by event 186 | // handlers that add/remove bindings 187 | const bindings = this.bindings[ event ].slice(); 188 | for ( let i = 0; i < bindings.length; i++ ) { 189 | const binding = bindings[ i ]; 190 | let method; 191 | if ( typeof binding.method === 'string' ) { 192 | // Lookup method by name (late binding) 193 | method = binding.context[ binding.method ]; 194 | } else { 195 | method = binding.method; 196 | } 197 | if ( binding.once ) { 198 | // Unbind before calling, to avoid any nested triggers. 199 | this.off( event, method ); 200 | } 201 | try { 202 | method.apply( 203 | binding.context, 204 | binding.args ? binding.args.concat( args ) : args 205 | ); 206 | } catch ( e ) { 207 | // If one listener has an unhandled error, don't have it 208 | // take down the emitter. But rethrow asynchronously so 209 | // debuggers can break with a full async stack trace. 210 | setTimeout( ( ( error ) => { 211 | throw error; 212 | } ).bind( null, e ) ); 213 | } 214 | 215 | } 216 | return true; 217 | }; 218 | 219 | /** 220 | * Emit an event, propagating the first exception some listener throws 221 | * 222 | * All listeners for the event will be called synchronously, in an 223 | * unspecified order. If any listener throws an exception, this won't 224 | * disrupt the calls to the remaining listeners. The first exception 225 | * thrown will be propagated back to the caller; any others won't be 226 | * thrown until the next tick. 227 | * 228 | * Listeners should avoid mutating the emitting object, as this is 229 | * something of an anti-pattern which can easily result in 230 | * hard-to-understand code with hidden side-effects and dependencies. 231 | * 232 | * @param {string} event Type of event 233 | * @param {...any} [args] Arguments passed to the event handler 234 | * @return {boolean} Whether the event was handled by at least one listener 235 | */ 236 | OO.EventEmitter.prototype.emitThrow = function ( event, ...args ) { 237 | // We tolerate code duplication with #emit, because the 238 | // alternative is an extra level of indirection which will 239 | // appear in very many stack traces. 240 | if ( !hasOwn.call( this.bindings, event ) ) { 241 | return false; 242 | } 243 | 244 | let firstError; 245 | // Slicing ensures that we don't get tripped up by event 246 | // handlers that add/remove bindings 247 | const bindings = this.bindings[ event ].slice(); 248 | for ( let i = 0; i < bindings.length; i++ ) { 249 | const binding = bindings[ i ]; 250 | let method; 251 | if ( typeof binding.method === 'string' ) { 252 | // Lookup method by name (late binding) 253 | method = binding.context[ binding.method ]; 254 | } else { 255 | method = binding.method; 256 | } 257 | if ( binding.once ) { 258 | // Unbind before calling, to avoid any nested triggers. 259 | this.off( event, method ); 260 | } 261 | try { 262 | method.apply( 263 | binding.context, 264 | binding.args ? binding.args.concat( args ) : args 265 | ); 266 | } catch ( e ) { 267 | if ( firstError === undefined ) { 268 | firstError = e; 269 | } else { 270 | // If one listener has an unhandled error, don't have it 271 | // take down the emitter. But rethrow asynchronously so 272 | // debuggers can break with a full async stack trace. 273 | setTimeout( ( ( error ) => { 274 | throw error; 275 | } ).bind( null, e ) ); 276 | } 277 | } 278 | 279 | } 280 | if ( firstError !== undefined ) { 281 | throw firstError; 282 | } 283 | return true; 284 | }; 285 | 286 | /** 287 | * Connect event handlers to an object. 288 | * 289 | * @param {Object} context Object to call methods on when events occur 290 | * @param {Object.|Object.|Object.} methods 291 | * List of event bindings keyed by event name containing either method names, functions or 292 | * arrays containing method name or function followed by a list of arguments to be passed to 293 | * callback before emitted arguments. 294 | * @return {OO.EventEmitter} 295 | */ 296 | OO.EventEmitter.prototype.connect = function ( context, methods ) { 297 | for ( const event in methods ) { 298 | let method = methods[ event ]; 299 | let args; 300 | // Allow providing additional args 301 | if ( Array.isArray( method ) ) { 302 | args = method.slice( 1 ); 303 | method = method[ 0 ]; 304 | } else { 305 | args = []; 306 | } 307 | // Add binding 308 | this.on( event, method, args, context ); 309 | } 310 | return this; 311 | }; 312 | 313 | /** 314 | * Disconnect event handlers from an object. 315 | * 316 | * @param {Object} context Object to disconnect methods from 317 | * @param {Object.|Object.|Object.} [methods] 318 | * List of event bindings keyed by event name. Values can be either method names, functions or 319 | * arrays containing a method name. 320 | * NOTE: To allow matching call sites with {@link OO.EventEmitter#connect|connect()}, array 321 | * values are allowed to contain the parameters as well, but only the method name is used to 322 | * find bindings. It is discouraged to have multiple bindings for the same event to the same 323 | * listener, but if used (and only the parameters vary), disconnecting one variation of 324 | * (event name, event listener, parameters) will disconnect other variations as well. 325 | * @return {OO.EventEmitter} 326 | */ 327 | OO.EventEmitter.prototype.disconnect = function ( context, methods ) { 328 | let event; 329 | if ( methods ) { 330 | // Remove specific connections to the context 331 | for ( event in methods ) { 332 | let method = methods[ event ]; 333 | if ( Array.isArray( method ) ) { 334 | method = method[ 0 ]; 335 | } 336 | this.off( event, method, context ); 337 | } 338 | } else { 339 | // Remove all connections to the context 340 | for ( event in this.bindings ) { 341 | const bindings = this.bindings[ event ]; 342 | let i = bindings.length; 343 | while ( i-- ) { 344 | // bindings[i] may have been removed by the previous step's 345 | // this.off so check it still exists 346 | if ( bindings[ i ] && bindings[ i ].context === context ) { 347 | this.off( event, bindings[ i ].method, context ); 348 | } 349 | } 350 | } 351 | } 352 | 353 | return this; 354 | }; 355 | 356 | }() ); 357 | -------------------------------------------------------------------------------- /src/Factory.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @extends OO.Registry 4 | */ 5 | OO.Factory = function OoFactory() { 6 | // Parent constructor 7 | OO.Factory.super.call( this ); 8 | }; 9 | 10 | /* Inheritance */ 11 | 12 | OO.inheritClass( OO.Factory, OO.Registry ); 13 | 14 | /* Methods */ 15 | 16 | /** 17 | * Register a class with the factory. 18 | * 19 | * function MyClass() {}; 20 | * OO.initClass( MyClass ); 21 | * MyClass.key = 'hello'; 22 | * 23 | * // Register class with the factory 24 | * factory.register( MyClass ); 25 | * 26 | * // Instantiate a class based on its registered key (also known as a "symbolic name") 27 | * factory.create( 'hello' ); 28 | * 29 | * @param {Function} constructor Class to use when creating an object 30 | * @param {string} [key] The key for {@link OO.Factory#create|create()}. 31 | * This parameter is usually omitted in favour of letting the class declare 32 | * its own key, through `MyClass.key`. 33 | * For backwards-compatiblity with OOjs 6.0 (2021) and older, it can also be declared 34 | * via `MyClass.static.name`. 35 | * @throws {Error} If a parameter is invalid 36 | */ 37 | OO.Factory.prototype.register = function ( constructor, key ) { 38 | if ( typeof constructor !== 'function' ) { 39 | throw new Error( 'constructor must be a function, got ' + typeof constructor ); 40 | } 41 | if ( arguments.length <= 1 ) { 42 | key = constructor.key || ( constructor.static && constructor.static.name ); 43 | } 44 | if ( typeof key !== 'string' || key === '' ) { 45 | throw new Error( 'key must be a non-empty string' ); 46 | } 47 | 48 | // Parent method 49 | OO.Factory.super.prototype.register.call( this, key, constructor ); 50 | }; 51 | 52 | /** 53 | * Unregister a class from the factory. 54 | * 55 | * @param {string|Function} key Constructor function or key to unregister 56 | * @throws {Error} If a parameter is invalid 57 | */ 58 | OO.Factory.prototype.unregister = function ( key ) { 59 | if ( typeof key === 'function' ) { 60 | key = key.key || ( key.static && key.static.name ); 61 | } 62 | if ( typeof key !== 'string' || key === '' ) { 63 | throw new Error( 'key must be a non-empty string' ); 64 | } 65 | 66 | // Parent method 67 | OO.Factory.super.prototype.unregister.call( this, key ); 68 | }; 69 | 70 | /** 71 | * Create an object based on a key. 72 | * 73 | * The key is used to look up the class to use, with any subsequent arguments passed to the 74 | * constructor function. 75 | * 76 | * @param {string} key Class key 77 | * @param {...any} [args] Arguments to pass to the constructor 78 | * @return {Object} The new object 79 | * @throws {Error} Unknown key 80 | */ 81 | OO.Factory.prototype.create = function ( key, ...args ) { 82 | const constructor = this.lookup( key ); 83 | if ( !constructor ) { 84 | throw new Error( 'No class registered by that key: ' + key ); 85 | } 86 | 87 | return new constructor( ...args ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/Registry.js: -------------------------------------------------------------------------------- 1 | /* global hasOwn */ 2 | 3 | /** 4 | * A map interface for associating arbitrary data with a symbolic name. Used in 5 | * place of a plain object to provide additional {@link OO.Registry#register registration} 6 | * or {@link OO.Registry#lookup lookup} functionality. 7 | * 8 | * See . 9 | * 10 | * @class 11 | * @mixes OO.EventEmitter 12 | */ 13 | OO.Registry = function OoRegistry() { 14 | // Mixin constructors 15 | OO.EventEmitter.call( this ); 16 | 17 | // Properties 18 | this.registry = {}; 19 | }; 20 | 21 | /* Inheritance */ 22 | 23 | OO.mixinClass( OO.Registry, OO.EventEmitter ); 24 | 25 | /* Events */ 26 | 27 | /** 28 | * @event OO.Registry#register 29 | * @param {string} name 30 | * @param {any} data 31 | */ 32 | 33 | /** 34 | * @event OO.Registry#unregister 35 | * @param {string} name 36 | * @param {any} data Data removed from registry 37 | */ 38 | 39 | /* Methods */ 40 | 41 | /** 42 | * Associate one or more symbolic names with some data. 43 | * 44 | * Any existing entry with the same name will be overridden. 45 | * 46 | * @param {string|string[]} name Symbolic name or list of symbolic names 47 | * @param {any} data Data to associate with symbolic name 48 | * @fires OO.Registry#register 49 | * @throws {Error} Name argument must be a string or array 50 | */ 51 | OO.Registry.prototype.register = function ( name, data ) { 52 | if ( typeof name === 'string' ) { 53 | this.registry[ name ] = data; 54 | this.emit( 'register', name, data ); 55 | } else if ( Array.isArray( name ) ) { 56 | for ( let i = 0, len = name.length; i < len; i++ ) { 57 | this.register( name[ i ], data ); 58 | } 59 | } else { 60 | throw new Error( 'Name must be a string or array, cannot be a ' + typeof name ); 61 | } 62 | }; 63 | 64 | /** 65 | * Remove one or more symbolic names from the registry. 66 | * 67 | * @param {string|string[]} name Symbolic name or list of symbolic names 68 | * @fires OO.Registry#unregister 69 | * @throws {Error} Name argument must be a string or array 70 | */ 71 | OO.Registry.prototype.unregister = function ( name ) { 72 | if ( typeof name === 'string' ) { 73 | const data = this.lookup( name ); 74 | if ( data !== undefined ) { 75 | delete this.registry[ name ]; 76 | this.emit( 'unregister', name, data ); 77 | } 78 | } else if ( Array.isArray( name ) ) { 79 | for ( let i = 0, len = name.length; i < len; i++ ) { 80 | this.unregister( name[ i ] ); 81 | } 82 | } else { 83 | throw new Error( 'Name must be a string or array, cannot be a ' + typeof name ); 84 | } 85 | }; 86 | 87 | /** 88 | * Get data for a given symbolic name. 89 | * 90 | * @param {string} name Symbolic name 91 | * @return {any|undefined} Data associated with symbolic name 92 | */ 93 | OO.Registry.prototype.lookup = function ( name ) { 94 | if ( hasOwn.call( this.registry, name ) ) { 95 | return this.registry[ name ]; 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /src/SortedEmitterList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manage a sorted list of {@link OO.EmitterList} objects. 3 | * 4 | * The sort order is based on a callback that compares two items. The return value of 5 | * callback( a, b ) must be less than zero if a < b, greater than zero if a > b, and zero 6 | * if a is equal to b. The callback should only return zero if the two objects are 7 | * considered equal. 8 | * 9 | * When an item changes in a way that could affect their sorting behavior, it must 10 | * emit the {@link OO.SortedEmitterList#event:itemSortChange itemSortChange} event. 11 | * This will cause it to be re-sorted automatically. 12 | * 13 | * This mixin must be used in a class that also mixes in {@link OO.EventEmitter}. 14 | * 15 | * @abstract 16 | * @class 17 | * @mixes OO.EmitterList 18 | * @param {Function} sortingCallback Callback that compares two items. 19 | */ 20 | OO.SortedEmitterList = function OoSortedEmitterList( sortingCallback ) { 21 | // Mixin constructors 22 | OO.EmitterList.call( this ); 23 | 24 | this.sortingCallback = sortingCallback; 25 | 26 | // Listen to sortChange event and make sure 27 | // we re-sort the changed item when that happens 28 | this.aggregate( { 29 | sortChange: 'itemSortChange' 30 | } ); 31 | 32 | this.connect( this, { 33 | itemSortChange: 'onItemSortChange' 34 | } ); 35 | }; 36 | 37 | OO.mixinClass( OO.SortedEmitterList, OO.EmitterList ); 38 | 39 | /* Events */ 40 | 41 | /** 42 | * An item has changed properties that affect its sort positioning 43 | * inside the list. 44 | * 45 | * @private 46 | * @event OO.SortedEmitterList#itemSortChange 47 | */ 48 | 49 | /* Methods */ 50 | 51 | /** 52 | * Handle a case where an item changed a property that relates 53 | * to its sorted order. 54 | * 55 | * @param {OO.EventEmitter} item Item in the list 56 | */ 57 | OO.SortedEmitterList.prototype.onItemSortChange = function ( item ) { 58 | // Remove the item 59 | this.removeItems( item ); 60 | // Re-add the item so it is in the correct place 61 | this.addItems( item ); 62 | }; 63 | 64 | /** 65 | * Change the sorting callback for this sorted list. 66 | * 67 | * The callback receives two items. The return value of callback(a, b) must be less than zero 68 | * if a < b, greater than zero if a > b, and zero if a is equal to b. 69 | * 70 | * @param {Function} sortingCallback Sorting callback 71 | */ 72 | OO.SortedEmitterList.prototype.setSortingCallback = function ( sortingCallback ) { 73 | const items = this.getItems(); 74 | 75 | this.sortingCallback = sortingCallback; 76 | 77 | // Empty the list 78 | this.clearItems(); 79 | // Re-add the items in the new order 80 | this.addItems( items ); 81 | }; 82 | 83 | /** 84 | * Add items to the sorted list. 85 | * 86 | * @param {OO.EventEmitter|OO.EventEmitter[]} items Item to add or 87 | * an array of items to add 88 | * @return {OO.SortedEmitterList} 89 | */ 90 | OO.SortedEmitterList.prototype.addItems = function ( items ) { 91 | if ( !Array.isArray( items ) ) { 92 | items = [ items ]; 93 | } 94 | 95 | if ( items.length === 0 ) { 96 | return this; 97 | } 98 | 99 | for ( let i = 0; i < items.length; i++ ) { 100 | // Find insertion index 101 | const insertionIndex = this.findInsertionIndex( items[ i ] ); 102 | 103 | // Check if the item exists using the sorting callback 104 | // and remove it first if it exists 105 | if ( 106 | // First make sure the insertion index is not at the end 107 | // of the list (which means it does not point to any actual 108 | // items) 109 | insertionIndex <= this.items.length && 110 | // Make sure there actually is an item in this index 111 | this.items[ insertionIndex ] && 112 | // The callback returns 0 if the items are equal 113 | this.sortingCallback( this.items[ insertionIndex ], items[ i ] ) === 0 114 | ) { 115 | // Remove the existing item 116 | this.removeItems( this.items[ insertionIndex ] ); 117 | } 118 | 119 | // Insert item at the insertion index 120 | const index = this.insertItem( items[ i ], insertionIndex ); 121 | this.emit( 'add', items[ i ], index ); 122 | } 123 | 124 | return this; 125 | }; 126 | 127 | /** 128 | * Find the index a given item should be inserted at. If the item is already 129 | * in the list, this will return the index where the item currently is. 130 | * 131 | * @param {OO.EventEmitter} item Items to insert 132 | * @return {number} The index the item should be inserted at 133 | */ 134 | OO.SortedEmitterList.prototype.findInsertionIndex = function ( item ) { 135 | return OO.binarySearch( 136 | this.items, 137 | // Fake a this.sortingCallback.bind( null, item ) call here 138 | // otherwise this doesn't pass tests in phantomJS 139 | ( otherItem ) => this.sortingCallback( item, otherItem ), 140 | true 141 | ); 142 | 143 | }; 144 | -------------------------------------------------------------------------------- /src/banner.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * OOjs v<%= build.version %> 3 | * https://www.mediawiki.org/wiki/OOjs 4 | * 5 | * Copyright Wikimedia Foundation and other contributors. 6 | * Released under the MIT license 7 | */ 8 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | /* exported slice, toString */ 2 | /** 3 | * Namespace for all classes, static methods and static properties. 4 | * 5 | * @namespace OO 6 | */ 7 | const 8 | // eslint-disable-next-line no-redeclare 9 | OO = {}, 10 | // Optimisation: Local reference to methods from a global prototype 11 | hasOwn = OO.hasOwnProperty, 12 | // eslint-disable-next-line no-redeclare 13 | toString = OO.toString; 14 | 15 | /* Class Methods */ 16 | 17 | /** 18 | * Utility to initialize a class for OO inheritance. 19 | * 20 | * Currently this just initializes an empty static object. 21 | * 22 | * @memberof OO 23 | * @method initClass 24 | * @param {Function} fn 25 | */ 26 | OO.initClass = function ( fn ) { 27 | fn.static = fn.static || {}; 28 | }; 29 | 30 | /** 31 | * Inherit from prototype to another using Object.create. 32 | * 33 | * Beware: This redefines the prototype, call before setting your prototypes. 34 | * 35 | * Beware: This redefines the prototype, can only be called once on a function. 36 | * If called multiple times on the same function, the previous prototype is lost. 37 | * This is how prototypal inheritance works, it can only be one straight chain 38 | * (just like classical inheritance in PHP for example). If you need to work with 39 | * multiple constructors consider storing an instance of the other constructor in a 40 | * property instead, or perhaps use a mixin (see {@link OO.mixinClass}). 41 | * 42 | * @example 43 | * function Thing() {} 44 | * Thing.prototype.exists = function () {}; 45 | * 46 | * function Person() { 47 | * Person.super.apply( this, arguments ); 48 | * } 49 | * OO.inheritClass( Person, Thing ); 50 | * Person.static.defaultEyeCount = 2; 51 | * Person.prototype.walk = function () {}; 52 | * 53 | * function Jumper() { 54 | * Jumper.super.apply( this, arguments ); 55 | * } 56 | * OO.inheritClass( Jumper, Person ); 57 | * Jumper.prototype.jump = function () {}; 58 | * 59 | * Jumper.static.defaultEyeCount === 2; 60 | * var x = new Jumper(); 61 | * x.jump(); 62 | * x.walk(); 63 | * x instanceof Thing && x instanceof Person && x instanceof Jumper; 64 | * 65 | * @memberof OO 66 | * @method inheritClass 67 | * @param {Function} targetFn 68 | * @param {Function} originFn 69 | * @throws {Error} If target already inherits from origin 70 | */ 71 | OO.inheritClass = function ( targetFn, originFn ) { 72 | if ( !originFn ) { 73 | throw new Error( 'inheritClass: Origin is not a function (actually ' + originFn + ')' ); 74 | } 75 | if ( targetFn.prototype instanceof originFn ) { 76 | throw new Error( 'inheritClass: Target already inherits from origin' ); 77 | } 78 | 79 | const targetConstructor = targetFn.prototype.constructor; 80 | 81 | // [DEPRECATED] Provide .parent as alias for code supporting older browsers which 82 | // allows people to comply with their style guide. 83 | targetFn.super = targetFn.parent = originFn; 84 | 85 | targetFn.prototype = Object.create( originFn.prototype, { 86 | // Restore constructor property of targetFn 87 | constructor: { 88 | value: targetConstructor, 89 | enumerable: false, 90 | writable: true, 91 | configurable: true 92 | } 93 | } ); 94 | 95 | // Extend static properties - always initialize both sides 96 | OO.initClass( originFn ); 97 | targetFn.static = Object.create( originFn.static ); 98 | }; 99 | 100 | /** 101 | * Copy over *own* prototype properties of a mixin. 102 | * 103 | * The 'constructor' (whether implicit or explicit) is not copied over. 104 | * 105 | * This does not create inheritance to the origin. If you need inheritance, 106 | * use {@link OO.inheritClass} instead. 107 | * 108 | * Beware: This can redefine a prototype property, call before setting your prototypes. 109 | * 110 | * Beware: Don't call before {@link OO.inheritClass}. 111 | * 112 | * @example 113 | * function Foo() {} 114 | * function Context() {} 115 | * 116 | * // Avoid repeating this code 117 | * function ContextLazyLoad() {} 118 | * ContextLazyLoad.prototype.getContext = function () { 119 | * if ( !this.context ) { 120 | * this.context = new Context(); 121 | * } 122 | * return this.context; 123 | * }; 124 | * 125 | * function FooBar() {} 126 | * OO.inheritClass( FooBar, Foo ); 127 | * OO.mixinClass( FooBar, ContextLazyLoad ); 128 | * 129 | * @memberof OO 130 | * @method mixinClass 131 | * @param {Function} targetFn 132 | * @param {Function} originFn 133 | */ 134 | OO.mixinClass = function ( targetFn, originFn ) { 135 | if ( !originFn ) { 136 | throw new Error( 'mixinClass: Origin is not a function (actually ' + originFn + ')' ); 137 | } 138 | 139 | let key; 140 | // Copy prototype properties 141 | for ( key in originFn.prototype ) { 142 | if ( key !== 'constructor' && hasOwn.call( originFn.prototype, key ) ) { 143 | targetFn.prototype[ key ] = originFn.prototype[ key ]; 144 | } 145 | } 146 | 147 | // Copy static properties - always initialize both sides 148 | OO.initClass( targetFn ); 149 | if ( originFn.static ) { 150 | for ( key in originFn.static ) { 151 | if ( hasOwn.call( originFn.static, key ) ) { 152 | targetFn.static[ key ] = originFn.static[ key ]; 153 | } 154 | } 155 | } else { 156 | OO.initClass( originFn ); 157 | } 158 | }; 159 | 160 | /** 161 | * Test whether one class is a subclass of another, without instantiating it. 162 | * 163 | * Every class is considered a subclass of Object and of itself. 164 | * 165 | * @memberof OO 166 | * @method isSubclass 167 | * @param {Function} testFn The class to be tested 168 | * @param {Function} baseFn The base class 169 | * @return {boolean} Whether testFn is a subclass of baseFn (or equal to it) 170 | */ 171 | OO.isSubclass = function ( testFn, baseFn ) { 172 | return testFn === baseFn || testFn.prototype instanceof baseFn; 173 | }; 174 | 175 | /* Object Methods */ 176 | 177 | /** 178 | * Get a deeply nested property of an object using variadic arguments, protecting against 179 | * undefined property errors. 180 | * 181 | * `quux = OO.getProp( obj, 'foo', 'bar', 'baz' );` is equivalent to `quux = obj.foo.bar.baz;` 182 | * except that the former protects against JS errors if one of the intermediate properties 183 | * is undefined. Instead of throwing an error, this function will return undefined in 184 | * that case. 185 | * 186 | * @memberof OO 187 | * @method getProp 188 | * @param {Object} obj 189 | * @param {...any} [keys] 190 | * @return {Object|undefined} obj[arguments[1]][arguments[2]].... or undefined 191 | */ 192 | OO.getProp = function ( obj, ...keys ) { 193 | let retval = obj; 194 | for ( let i = 0; i < keys.length; i++ ) { 195 | if ( retval === undefined || retval === null ) { 196 | // Trying to access a property of undefined or null causes an error 197 | return undefined; 198 | } 199 | retval = retval[ keys[ i ] ]; 200 | } 201 | return retval; 202 | }; 203 | 204 | /** 205 | * Set a deeply nested property of an object using variadic arguments, protecting against 206 | * undefined property errors. 207 | * 208 | * `OO.setProp( obj, 'foo', 'bar', 'baz' );` is equivalent to `obj.foo.bar = baz;` except that 209 | * the former protects against JS errors if one of the intermediate properties is 210 | * undefined. Instead of throwing an error, undefined intermediate properties will be 211 | * initialized to an empty object. If an intermediate property is not an object, or if obj itself 212 | * is not an object, this function will silently abort. 213 | * 214 | * @memberof OO 215 | * @method setProp 216 | * @param {Object} obj 217 | * @param {...any} [keys] 218 | * @param {any} [value] 219 | */ 220 | OO.setProp = function ( obj, ...keys ) { 221 | const value = keys.pop(); 222 | if ( Object( obj ) !== obj || !keys.length ) { 223 | return; 224 | } 225 | let prop = obj; 226 | for ( let i = 0; i < keys.length - 1; i++ ) { 227 | if ( prop[ keys[ i ] ] === undefined ) { 228 | prop[ keys[ i ] ] = {}; 229 | } 230 | if ( Object( prop[ keys[ i ] ] ) !== prop[ keys[ i ] ] ) { 231 | return; 232 | } 233 | prop = prop[ keys[ i ] ]; 234 | } 235 | prop[ keys[ keys.length - 1 ] ] = value; 236 | }; 237 | 238 | /** 239 | * Delete a deeply nested property of an object using variadic arguments, protecting against 240 | * undefined property errors, and deleting resulting empty objects. 241 | * 242 | * @memberof OO 243 | * @method deleteProp 244 | * @param {Object} obj 245 | * @param {...any} [keys] 246 | */ 247 | OO.deleteProp = function ( obj, ...keys ) { 248 | if ( Object( obj ) !== obj || !keys.length ) { 249 | return; 250 | } 251 | let prop = obj; 252 | const props = [ prop ]; 253 | let i = 0; 254 | for ( ; i < keys.length - 1; i++ ) { 255 | if ( 256 | prop[ keys[ i ] ] === undefined || 257 | Object( prop[ keys[ i ] ] ) !== prop[ keys[ i ] ] 258 | ) { 259 | return; 260 | } 261 | prop = prop[ keys[ i ] ]; 262 | props.push( prop ); 263 | } 264 | delete prop[ keys[ i ] ]; 265 | // Walk back through props removing any plain empty objects 266 | while ( 267 | props.length > 1 && 268 | ( prop = props.pop() ) && 269 | 270 | OO.isPlainObject( prop ) && !Object.keys( prop ).length 271 | ) { 272 | delete props[ props.length - 1 ][ keys[ props.length - 1 ] ]; 273 | } 274 | }; 275 | 276 | /** 277 | * Create a new object that is an instance of the same 278 | * constructor as the input, inherits from the same object 279 | * and contains the same own properties. 280 | * 281 | * This makes a shallow non-recursive copy of own properties. 282 | * To create a recursive copy of plain objects, use {@link .copy}. 283 | * 284 | * var foo = new Person( mom, dad ); 285 | * foo.setAge( 21 ); 286 | * var foo2 = OO.cloneObject( foo ); 287 | * foo.setAge( 22 ); 288 | * 289 | * // Then 290 | * foo2 !== foo; // true 291 | * foo2 instanceof Person; // true 292 | * foo2.getAge(); // 21 293 | * foo.getAge(); // 22 294 | * 295 | * @memberof OO 296 | * @method cloneObject 297 | * @param {Object} origin 298 | * @return {Object} Clone of origin 299 | */ 300 | OO.cloneObject = function ( origin ) { 301 | const r = Object.create( Object.getPrototypeOf( origin ) ); 302 | 303 | for ( const key in origin ) { 304 | if ( hasOwn.call( origin, key ) ) { 305 | r[ key ] = origin[ key ]; 306 | } 307 | } 308 | 309 | return r; 310 | }; 311 | 312 | /** 313 | * Get an array of all property values in an object. 314 | * 315 | * @memberof OO 316 | * @method getObjectValues 317 | * @param {Object} obj Object to get values from 318 | * @return {Array} List of object values 319 | */ 320 | OO.getObjectValues = function ( obj ) { 321 | if ( obj !== Object( obj ) ) { 322 | throw new TypeError( 'Called on non-object' ); 323 | } 324 | 325 | const values = []; 326 | for ( const key in obj ) { 327 | if ( hasOwn.call( obj, key ) ) { 328 | values[ values.length ] = obj[ key ]; 329 | } 330 | } 331 | 332 | return values; 333 | }; 334 | 335 | /** 336 | * Use binary search to locate an element in a sorted array. 337 | * 338 | * searchFunc is given an element from the array. `searchFunc(elem)` must return a number 339 | * above 0 if the element we're searching for is to the right of (has a higher index than) elem, 340 | * below 0 if it is to the left of elem, or zero if it's equal to elem. 341 | * 342 | * To search for a specific value with a comparator function (a `function cmp(a,b)` that returns 343 | * above 0 if `a > b`, below 0 if `a < b`, and 0 if `a == b`), you can use 344 | * `searchFunc = cmp.bind( null, value )`. 345 | * 346 | * @memberof OO 347 | * @method binarySearch 348 | * @param {Array} arr Array to search in 349 | * @param {Function} searchFunc Search function 350 | * @param {boolean} [forInsertion] If not found, return index where val could be inserted 351 | * @return {number|null} Index where val was found, or null if not found 352 | */ 353 | OO.binarySearch = function ( arr, searchFunc, forInsertion ) { 354 | let left = 0; 355 | let right = arr.length; 356 | while ( left < right ) { 357 | // Equivalent to Math.floor( ( left + right ) / 2 ) but much faster 358 | // eslint-disable-next-line no-bitwise 359 | const mid = ( left + right ) >> 1; 360 | const cmpResult = searchFunc( arr[ mid ] ); 361 | if ( cmpResult < 0 ) { 362 | right = mid; 363 | } else if ( cmpResult > 0 ) { 364 | left = mid + 1; 365 | } else { 366 | return mid; 367 | } 368 | } 369 | return forInsertion ? right : null; 370 | }; 371 | 372 | /** 373 | * Recursively compare properties between two objects. 374 | * 375 | * A false result may be caused by property inequality or by properties in one object missing from 376 | * the other. An asymmetrical test may also be performed, which checks only that properties in the 377 | * first object are present in the second object, but not the inverse. 378 | * 379 | * If either a or b is null or undefined it will be treated as an empty object. 380 | * 381 | * @memberof OO 382 | * @method compare 383 | * @param {Object|undefined|null} a First object to compare 384 | * @param {Object|undefined|null} b Second object to compare 385 | * @param {boolean} [asymmetrical] Whether to check only that a's values are equal to b's 386 | * (i.e. a is a subset of b) 387 | * @return {boolean} If the objects contain the same values as each other 388 | */ 389 | OO.compare = function ( a, b, asymmetrical ) { 390 | if ( a === b ) { 391 | return true; 392 | } 393 | 394 | a = a || {}; 395 | b = b || {}; 396 | 397 | if ( typeof a.nodeType === 'number' && typeof a.isEqualNode === 'function' ) { 398 | return a.isEqualNode( b ); 399 | } 400 | 401 | for ( const k in a ) { 402 | if ( !hasOwn.call( a, k ) || a[ k ] === undefined || a[ k ] === b[ k ] ) { 403 | // Ignore undefined values, because there is no conceptual difference between 404 | // a key that is absent and a key that is present but whose value is undefined. 405 | continue; 406 | } 407 | 408 | const aValue = a[ k ]; 409 | const bValue = b[ k ]; 410 | const aType = typeof aValue; 411 | const bType = typeof bValue; 412 | if ( aType !== bType || 413 | ( 414 | ( aType === 'string' || aType === 'number' || aType === 'boolean' ) && 415 | aValue !== bValue 416 | ) || 417 | ( aValue === Object( aValue ) && !OO.compare( aValue, bValue, true ) ) ) { 418 | return false; 419 | } 420 | } 421 | // If the check is not asymmetrical, recursing with the arguments swapped will verify our result 422 | return asymmetrical ? true : OO.compare( b, a, true ); 423 | }; 424 | 425 | /** 426 | * Create a plain deep copy of any kind of object. 427 | * 428 | * Copies are deep, and will either be an object or an array depending on `source`. 429 | * 430 | * @memberof OO 431 | * @method copy 432 | * @param {Object} source Object to copy 433 | * @param {Function} [leafCallback] Applied to leaf values after they are cloned but before they are 434 | * added to the clone 435 | * @param {Function} [nodeCallback] Applied to all values before they are cloned. If the 436 | * nodeCallback returns a value other than undefined, the returned value is used instead of 437 | * attempting to clone. 438 | * @return {Object} Copy of source object 439 | */ 440 | OO.copy = function ( source, leafCallback, nodeCallback ) { 441 | let destination; 442 | 443 | if ( nodeCallback ) { 444 | // Extensibility: check before attempting to clone source. 445 | destination = nodeCallback( source ); 446 | if ( destination !== undefined ) { 447 | return destination; 448 | } 449 | } 450 | 451 | if ( Array.isArray( source ) ) { 452 | // Array (fall through) 453 | destination = new Array( source.length ); 454 | } else if ( source && typeof source.clone === 'function' ) { 455 | // Duck type object with custom clone method 456 | return leafCallback ? leafCallback( source.clone() ) : source.clone(); 457 | } else if ( source && typeof source.cloneNode === 'function' ) { 458 | // DOM Node 459 | return leafCallback ? 460 | leafCallback( source.cloneNode( true ) ) : 461 | source.cloneNode( true ); 462 | } else if ( OO.isPlainObject( source ) ) { 463 | // Plain objects (fall through) 464 | destination = Object.create( Object.getPrototypeOf( source ) ); 465 | } else { 466 | // Non-plain objects (incl. functions) and primitive values 467 | return leafCallback ? leafCallback( source ) : source; 468 | } 469 | 470 | // source is an array or a plain object 471 | for ( const key in source ) { 472 | destination[ key ] = OO.copy( source[ key ], leafCallback, nodeCallback ); 473 | } 474 | 475 | // This is an internal node, so we don't apply the leafCallback. 476 | return destination; 477 | }; 478 | 479 | /** 480 | * Generate a hash of an object based on its name and data. 481 | * 482 | * Performance optimization: 483 | * 484 | * To avoid two objects with the same values generating different hashes, we utilize the replacer 485 | * argument of JSON.stringify and sort the object by key as it's being serialized. This may or may 486 | * not be the fastest way to do this; we should investigate this further. 487 | * 488 | * Objects and arrays are hashed recursively. When hashing an object that has a .getHash() 489 | * function, we call that function and use its return value rather than hashing the object 490 | * ourselves. This allows classes to define custom hashing. 491 | * 492 | * @memberof OO 493 | * @method getHash 494 | * @param {Object} val Object to generate hash for 495 | * @return {string} Hash of object 496 | */ 497 | OO.getHash = function ( val ) { 498 | return JSON.stringify( val, OO.getHash.keySortReplacer ); 499 | }; 500 | 501 | /** 502 | * Sort objects by key (helper function for {@link OO.getHash}). 503 | * 504 | * This is a callback passed into JSON.stringify. 505 | * 506 | * @memberof OO 507 | * @method getHash_keySortReplacer 508 | * @param {string} key Property name of value being replaced 509 | * @param {any} val Property value to replace 510 | * @return {any} Replacement value 511 | */ 512 | OO.getHash.keySortReplacer = function ( key, val ) { 513 | if ( val && typeof val.getHashObject === 'function' ) { 514 | // This object has its own custom hash function, use it 515 | val = val.getHashObject(); 516 | } 517 | if ( !Array.isArray( val ) && Object( val ) === val ) { 518 | // Only normalize objects when the key-order is ambiguous 519 | // (e.g. any object not an array). 520 | const normalized = {}; 521 | 522 | const keys = Object.keys( val ).sort(); 523 | for ( let i = 0, len = keys.length; i < len; i++ ) { 524 | normalized[ keys[ i ] ] = val[ keys[ i ] ]; 525 | } 526 | return normalized; 527 | } else { 528 | // Primitive values and arrays get stable hashes 529 | // by default. Lets those be stringified as-is. 530 | return val; 531 | } 532 | }; 533 | 534 | /** 535 | * Get the unique values of an array, removing duplicates. 536 | * 537 | * @memberof OO 538 | * @method unique 539 | * @param {Array} arr Array 540 | * @return {Array} Unique values in array 541 | */ 542 | OO.unique = function ( arr ) { 543 | return Array.from( new Set( arr ) ); 544 | }; 545 | 546 | /** 547 | * Compute the union (duplicate-free merge) of a set of arrays. 548 | * 549 | * @memberof OO 550 | * @method simpleArrayUnion 551 | * @param {Array} a First array 552 | * @param {...Array} rest Arrays to union 553 | * @return {Array} Union of the arrays 554 | */ 555 | OO.simpleArrayUnion = function ( a, ...rest ) { 556 | const set = new Set( a ); 557 | 558 | for ( let i = 0; i < rest.length; i++ ) { 559 | const arr = rest[ i ]; 560 | for ( let j = 0; j < arr.length; j++ ) { 561 | set.add( arr[ j ] ); 562 | } 563 | } 564 | 565 | return Array.from( set ); 566 | }; 567 | 568 | /** 569 | * Combine arrays (intersection or difference). 570 | * 571 | * An intersection checks the item exists in 'b' while difference checks it doesn't. 572 | * 573 | * @private 574 | * @memberof OO 575 | * @param {Array} a First array 576 | * @param {Array} b Second array 577 | * @param {boolean} includeB Whether to items in 'b' 578 | * @return {Array} Combination (intersection or difference) of arrays 579 | */ 580 | function simpleArrayCombine( a, b, includeB ) { 581 | const set = new Set( b ); 582 | const result = []; 583 | 584 | for ( let j = 0; j < a.length; j++ ) { 585 | const isInB = set.has( a[ j ] ); 586 | if ( isInB === includeB ) { 587 | result.push( a[ j ] ); 588 | } 589 | } 590 | 591 | return result; 592 | } 593 | 594 | /** 595 | * Compute the intersection of two arrays (items in both arrays). 596 | * 597 | * @memberof OO 598 | * @method simpleArrayIntersection 599 | * @param {Array} a First array 600 | * @param {Array} b Second array 601 | * @return {Array} Intersection of arrays 602 | */ 603 | OO.simpleArrayIntersection = function ( a, b ) { 604 | return simpleArrayCombine( a, b, true ); 605 | }; 606 | 607 | /** 608 | * Compute the difference of two arrays (items in 'a' but not 'b'). 609 | * 610 | * @memberof OO 611 | * @method simpleArrayDifference 612 | * @param {Array} a First array 613 | * @param {Array} b Second array 614 | * @return {Array} Intersection of arrays 615 | */ 616 | OO.simpleArrayDifference = function ( a, b ) { 617 | return simpleArrayCombine( a, b, false ); 618 | }; 619 | -------------------------------------------------------------------------------- /src/export.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | /* istanbul ignore next */ 4 | if ( typeof module !== 'undefined' && module.exports ) { 5 | module.exports = OO; 6 | } else { 7 | global.OO = OO; 8 | } 9 | -------------------------------------------------------------------------------- /src/intro.js.txt: -------------------------------------------------------------------------------- 1 | ( function ( global ) { 2 | 3 | 'use strict'; 4 | -------------------------------------------------------------------------------- /src/outro.js.txt: -------------------------------------------------------------------------------- 1 | }( this ) ); 2 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-redeclare */ 2 | /* global hasOwn, toString */ 3 | 4 | /** 5 | * Check whether a value is a plain object or not. 6 | * 7 | * @memberof OO 8 | * @param {any} obj 9 | * @return {boolean} 10 | */ 11 | OO.isPlainObject = function ( obj ) { 12 | // Optimise for common case where internal [[Class]] property is not "Object" 13 | if ( !obj || toString.call( obj ) !== '[object Object]' ) { 14 | return false; 15 | } 16 | 17 | const proto = Object.getPrototypeOf( obj ); 18 | 19 | // Objects without prototype (e.g., `Object.create( null )`) are considered plain 20 | if ( !proto ) { 21 | return true; 22 | } 23 | 24 | // The 'isPrototypeOf' method is set on Object.prototype. 25 | return hasOwn.call( proto, 'isPrototypeOf' ); 26 | }; 27 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OOjs Test Suite 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/karma.conf.base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | // Custom launchers 5 | // https://karma-runner.github.io/1.0/config/browsers.html 6 | // 7 | // SauceLabs platforms 8 | // https://saucelabs.com/products/supported-browsers-devices 9 | customLaunchers: { 10 | ChromeCustom: { 11 | base: 'ChromeHeadless', 12 | // Allow Docker/CI to set --no-sandbox if needed. 13 | flags: ( process.env.CHROMIUM_FLAGS || '' ).split( ' ' ) 14 | }, 15 | slChromeLatest: { 16 | base: 'SauceLabs', 17 | browserName: 'chrome', 18 | browserVersion: 'latest' 19 | }, 20 | slFirefoxESR: { 21 | base: 'SauceLabs', 22 | browserName: 'Firefox', 23 | browserVersion: '78' 24 | }, 25 | slEdgeLatest: { 26 | base: 'SauceLabs', 27 | platform: 'Windows 11', 28 | browserName: 'MicrosoftEdge', 29 | browserVersion: 'latest' 30 | }, 31 | slSafariLatest: { 32 | base: 'SauceLabs', 33 | platform: 'macOS 12', 34 | version: 'latest', 35 | browserName: 'safari' 36 | }, 37 | slSafari12: { 38 | // Oldest Safari that Sauce Labs provides 39 | base: 'SauceLabs', 40 | platform: 'macOS 11', 41 | browserName: 'safari', 42 | version: '14' 43 | } 44 | }, 45 | frameworks: [ 'qunit' ], 46 | files: [ 47 | 'dist/oojs.js', 48 | 'tests/setup-browser.js', 49 | 'tests/unit/*.js' 50 | ], 51 | singleRun: true, 52 | autoWatch: false, 53 | captureTimeout: 90000, 54 | // browsers: [], 55 | // preprocessors: {}, 56 | reporters: [ 'dots' ] 57 | }; 58 | -------------------------------------------------------------------------------- /tests/setup-browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility for creating iframes. 3 | * 4 | * @return {HTMLElement} 5 | */ 6 | QUnit.tmpIframe = function () { 7 | const iframe = document.createElement( 'iframe' ); 8 | document.getElementById( 'qunit-fixture' ).appendChild( iframe ); 9 | return iframe; 10 | }; 11 | -------------------------------------------------------------------------------- /tests/setup-node.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, es6 */ 2 | global.OO = require( '../dist/oojs.js' ); 3 | -------------------------------------------------------------------------------- /tests/unit/EmitterList.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo ) { 2 | 3 | // Define a test list object using the oo.EmitterList mixin 4 | function TestList() { 5 | // Mixin constructor 6 | oo.EventEmitter.call( this ); 7 | oo.EmitterList.call( this ); 8 | } 9 | oo.mixinClass( TestList, oo.EventEmitter ); 10 | oo.mixinClass( TestList, oo.EmitterList ); 11 | 12 | // Define a test item object 13 | function TestItem( content ) { 14 | // Mixin constructor 15 | oo.EventEmitter.call( this ); 16 | 17 | this.content = content; 18 | } 19 | oo.mixinClass( TestItem, oo.EventEmitter ); 20 | 21 | // Helper method to recognize items by their contents 22 | TestItem.prototype.getContent = function () { 23 | return this.content; 24 | }; 25 | 26 | // Helper method to get an array of item contents for testing 27 | function getContentArray( arr ) { 28 | return arr.map( ( item ) => { 29 | if ( typeof item.getContent === 'function' ) { 30 | return item.getContent(); 31 | } 32 | return JSON.stringify( item ); 33 | } ); 34 | } 35 | 36 | QUnit.module( 'EmitterList' ); 37 | 38 | QUnit.test( 'addItems', ( assert ) => { 39 | const initialItems = [ 40 | new TestItem( 'a' ), 41 | new TestItem( 'b' ), 42 | new TestItem( 'c' ) 43 | ], 44 | cases = [ 45 | { 46 | items: initialItems, 47 | expected: [ 'a', 'b', 'c' ], 48 | msg: 'Inserting items in order' 49 | }, 50 | { 51 | items: [], 52 | expected: [], 53 | msg: 'Inserting an empty array' 54 | }, 55 | { 56 | items: [ 57 | // 'a', 'b', 'c', 'a', 58 | initialItems[ 0 ], 59 | initialItems[ 1 ], 60 | initialItems[ 2 ], 61 | initialItems[ 0 ] 62 | ], 63 | expected: [ 'b', 'c', 'a' ], 64 | msg: 'Moving duplicates when inserting a batch of items' 65 | }, 66 | { 67 | items: initialItems, 68 | add: { 69 | items: [ initialItems[ 0 ] ], 70 | index: 2 71 | }, 72 | expected: [ 'b', 'a', 'c' ], 73 | msg: 'Moving duplicates when re-inserting an item at a higher index' 74 | }, 75 | { 76 | items: initialItems, 77 | add: { 78 | items: [ initialItems[ 2 ] ], 79 | index: 0 80 | }, 81 | expected: [ 'c', 'a', 'b' ], 82 | msg: 'Moving duplicates when re-inserting an item at a lower index' 83 | }, 84 | { 85 | items: initialItems, 86 | add: { 87 | items: [ 88 | new TestItem( 'd' ) 89 | ] 90 | }, 91 | expected: [ 'a', 'b', 'c', 'd' ], 92 | msg: 'Inserting an item without index defaults to the end' 93 | }, 94 | { 95 | items: initialItems, 96 | add: { 97 | items: [ 98 | new TestItem( 'd' ) 99 | ], 100 | index: 1 101 | }, 102 | expected: [ 'a', 'd', 'b', 'c' ], 103 | msg: 'Inserting an item at a known index' 104 | }, 105 | { 106 | items: initialItems, 107 | add: { 108 | items: [ 109 | new TestItem( 'd' ) 110 | ], 111 | index: 5 112 | }, 113 | expected: [ 'a', 'b', 'c', 'd' ], 114 | msg: 'Inserting an item at an invalid index' 115 | }, 116 | { 117 | items: initialItems, 118 | add: { 119 | items: [ {} ] 120 | }, 121 | expected: [ 'a', 'b', 'c', '{}' ], 122 | msg: 'Inserting an object does not break everything.' 123 | } 124 | ]; 125 | 126 | cases.forEach( ( test ) => { 127 | const list = new TestList(); 128 | list.addItems( test.items ); 129 | 130 | if ( test.add ) { 131 | list.addItems( test.add.items, test.add.index ); 132 | } 133 | 134 | assert.deepEqual( getContentArray( list.getItems() ), test.expected, test.msg ); 135 | } ); 136 | 137 | assert.throws( () => { 138 | const list = new TestList(); 139 | list.addItems( initialItems.concat( [ null ] ) ); 140 | }, 'throws when trying to add null item.' ); 141 | 142 | assert.throws( () => { 143 | const list = new TestList(); 144 | list.addItems( initialItems.concat( [ undefined ] ) ); 145 | }, 'throws when trying to add undefined item.' ); 146 | 147 | assert.throws( () => { 148 | const list = new TestList(); 149 | list.addItems( initialItems.concat( [ 3 ] ) ); 150 | }, 'throws when trying to add a number.' ); 151 | } ); 152 | 153 | QUnit.test( 'moveItem', ( assert ) => { 154 | const list = new TestList(), 155 | item = new TestItem( 'a' ); 156 | assert.throws( () => { 157 | list.moveItem( item, 0 ); 158 | }, 'Throw when trying to move an item not in the list' ); 159 | } ); 160 | 161 | QUnit.test( 'clearItems', ( assert ) => { 162 | const list = new TestList(); 163 | 164 | list.addItems( [ 165 | new TestItem( 'a' ), 166 | new TestItem( 'b' ), 167 | { not: 'connectable' }, 168 | new TestItem( 'c' ) 169 | ] ); 170 | assert.strictEqual( list.getItemCount(), 4, 'Items added' ); 171 | list.clearItems(); 172 | assert.strictEqual( list.getItemCount(), 0, 'Items cleared' ); 173 | assert.true( list.isEmpty(), 'List is empty' ); 174 | } ); 175 | 176 | QUnit.test( 'removeItems', ( assert ) => { 177 | const expected = [], 178 | list = new TestList(), 179 | plain = { not: 'connectable' }, 180 | items = [ 181 | new TestItem( 'a' ), 182 | new TestItem( 'b' ), 183 | new TestItem( 'c' ) 184 | ]; 185 | 186 | list.addItems( items ); 187 | assert.strictEqual( list.getItemCount(), 3, 'Items added' ); 188 | 189 | list.removeItems( [ items[ 2 ] ] ); 190 | assert.strictEqual( list.getItemCount(), 2, 'Item removed' ); 191 | assert.strictEqual( list.getItemIndex( items[ 2 ] ), -1, 'The correct item was removed' ); 192 | 193 | list.removeItems( [] ); 194 | assert.strictEqual( list.getItemCount(), 2, 'Removing empty array of items does nothing' ); 195 | 196 | // Remove an item with aggregate events 197 | list.aggregate( { change: 'itemChange' } ); 198 | list.on( 'itemChange', ( item ) => { 199 | expected.push( item.getContent() ); 200 | } ); 201 | 202 | list.removeItems( items[ 0 ] ); 203 | // 'a' - Should not be intercepted 204 | items[ 0 ].emit( 'change' ); 205 | // 'b' 206 | items[ 1 ].emit( 'change' ); 207 | assert.deepEqual( expected, [ 'b' ], 'Removing an item also removes its aggregate events' ); 208 | 209 | // Item without connect() method 210 | list.addItems( [ plain ] ); 211 | assert.strictEqual( list.getItemCount(), 2, 'Plain added' ); 212 | list.removeItems( [ plain ] ); 213 | assert.strictEqual( list.getItemCount(), 1, 'Plain removed' ); 214 | } ); 215 | 216 | QUnit.test( 'aggregate', ( assert ) => { 217 | const list = new TestList(), 218 | expectChange = [], 219 | expectEdit = [], 220 | plain = { not: 'connectable' }, 221 | items = [ 222 | new TestItem( 'a' ), 223 | new TestItem( 'b' ), 224 | new TestItem( 'c' ) 225 | ]; 226 | 227 | list.addItems( items ); 228 | 229 | list.aggregate( { 230 | change: 'itemChange', 231 | edit: 'itemEdit' 232 | } ); 233 | list.on( 'itemChange', ( item ) => { 234 | expectChange.push( item.getContent() ); 235 | } ); 236 | list.on( 'itemEdit', ( item ) => { 237 | expectEdit.push( item.getContent() ); 238 | } ); 239 | 240 | // Change 'b' 241 | items[ 1 ].emit( 'change' ); 242 | // Change 'a' 243 | items[ 0 ].emit( 'change' ); 244 | // Edit 'c' 245 | items[ 2 ].emit( 'edit' ); 246 | 247 | // Add an item after the fact 248 | const testItem = new TestItem( 'd' ); 249 | list.addItems( testItem ); 250 | testItem.emit( 'change' ); 251 | 252 | // Remove aggregate event 253 | list.aggregate( { edit: null } ); 254 | 255 | // Retry events 256 | items[ 1 ].emit( 'change' ); 257 | // 'a' - Edit should not be aggregated 258 | items[ 0 ].emit( 'edit' ); 259 | 260 | // Check that we have the desired result 261 | assert.deepEqual( expectChange, [ 'b', 'a', 'd', 'b' ], 'Change event aggregation intercepted in the correct order' ); 262 | assert.deepEqual( expectEdit, [ 'c' ], 'Edit event aggregation intercepted in the correct order' ); 263 | 264 | // Verify that aggregating duplicate events throws an exception 265 | assert.throws( () => { 266 | list.aggregate( { change: 'itemChangeDuplicate' } ); 267 | }, 'Duplicate event aggregation throws an error' ); 268 | 269 | // Items without connect() method are ignored 270 | list.addItems( plain ); 271 | list.aggregate( { spain: 'onThePlain' } ); 272 | list.aggregate( { spain: null } ); 273 | } ); 274 | 275 | QUnit.test( 'Events', ( assert ) => { 276 | const result = [], 277 | list = new TestList(), 278 | items = [ 279 | new TestItem( 'a' ), 280 | new TestItem( 'b' ), 281 | new TestItem( 'c' ) 282 | ], 283 | stringifyEvent = function ( type, item, index ) { 284 | let string = type; 285 | if ( item ) { 286 | string += ':' + item.getContent(); 287 | } 288 | if ( index !== undefined ) { 289 | string += '#' + index; 290 | } 291 | return string; 292 | }; 293 | 294 | // Register 295 | list.on( 'add', ( item, index ) => { 296 | result.push( stringifyEvent( 'add', item, index ) ); 297 | } ); 298 | list.on( 'move', ( item, index ) => { 299 | result.push( stringifyEvent( 'move', item, index ) ); 300 | } ); 301 | list.on( 'remove', ( item, index ) => { 302 | result.push( stringifyEvent( 'remove', item, index ) ); 303 | } ); 304 | list.on( 'clear', () => { 305 | result.push( stringifyEvent( 'clear' ) ); 306 | } ); 307 | 308 | // Trigger events 309 | list.addItems( items ); 310 | // Move the item; Bad index on purpose 311 | list.addItems( [ items[ 0 ] ], 10 ); 312 | list.removeItems( items[ 1 ] ); 313 | // Add array with a null item, should not result in an event. 314 | assert.throws( () => { 315 | list.addItems( [ null ] ); 316 | }, 'throw when adding items array with null content' ); 317 | 318 | // Nonexistent item 319 | list.removeItems( new TestItem( 'd' ) ); 320 | list.clearItems(); 321 | 322 | assert.deepEqual( result, [ 323 | // addItems 324 | 'add:a#0', 325 | 'add:b#1', 326 | 'add:c#2', 327 | // moveItems 328 | 'move:a#2', 329 | // removeItems 330 | 'remove:b#0', 331 | // clearItems 332 | 'clear' 333 | ], 'Correct events were emitted' ); 334 | } ); 335 | 336 | }( OO ) ); 337 | -------------------------------------------------------------------------------- /tests/unit/EventEmitter.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo, global ) { 2 | 3 | // In NodeJS the `this` as passed from the global scope to this closure is 4 | // (for NodeJS legacy reasons) not the global object but in fact a reference 5 | // to module.exports. The `this` inside this closure, however, is the 6 | // global object. 7 | global = global.window ? global : this; 8 | 9 | QUnit.module( 'EventEmitter' ); 10 | 11 | QUnit.test( 'on', ( assert ) => { 12 | const origSetTimeout = global.setTimeout, 13 | ee = new oo.EventEmitter(); 14 | 15 | assert.throws( () => { 16 | ee.on( 'nocallback' ); 17 | }, 'Throw when callback is missing' ); 18 | 19 | assert.throws( () => { 20 | ee.on( 'invalidcallback', {} ); 21 | }, 'Throw when callback is invalid' ); 22 | 23 | ee.on( 'callback', () => { 24 | assert.true( true, 'Callback ran' ); 25 | } ); 26 | ee.emit( 'callback' ); 27 | 28 | let seq = []; 29 | const callback = function ( data ) { 30 | seq.push( data ); 31 | }; 32 | 33 | ee.on( 'multiple', callback ); 34 | ee.on( 'multiple', callback ); 35 | ee.emit( 'multiple', 'x' ); 36 | assert.deepEqual( seq, [ 'x', 'x' ], 'Callbacks can be bound multiple times' ); 37 | seq = []; 38 | ee.emitThrow( 'multiple', 'x' ); 39 | assert.deepEqual( seq, [ 'x', 'x' ], 'Callbacks can be bound multiple times' ); 40 | 41 | let x, thrown; 42 | // Stub setTimeout for coverage purposes 43 | global.setTimeout = function ( fn ) { 44 | try { 45 | fn(); 46 | } catch ( e ) { 47 | thrown.push( e ); 48 | } 49 | }; 50 | 51 | try { 52 | x = 0; 53 | thrown = []; 54 | ee.on( 'multiple-error', () => { 55 | x += 1; 56 | } ); 57 | ee.on( 'multiple-error', () => { 58 | throw new Error( 'Unhandled error 1' ); 59 | } ); 60 | ee.on( 'multiple-error', () => { 61 | x += 10; 62 | } ); 63 | ee.on( 'multiple-error', () => { 64 | throw new Error( 'Unhandled error 2' ); 65 | } ); 66 | ee.on( 'multiple-error', () => { 67 | x += 100; 68 | } ); 69 | ee.emit( 'multiple-error' ); 70 | assert.strictEqual( thrown.length, 2, 'emit throws errors in callbacks asynchronously' ); 71 | assert.strictEqual( x, 111, 'emit runs every callback even if errors are thrown' ); 72 | x = 0; 73 | thrown = []; 74 | assert.throws( () => { 75 | ee.emitThrow( 'multiple-error' ); 76 | }, /Unhandled error 1/, 'emitThrow propagates the first error' ); 77 | assert.true( 78 | thrown.length === 1 && /Unhandled error 2/.test( thrown[ 0 ].message ), 79 | 'emitThrow throws subsequent errors asynchronously' 80 | ); 81 | assert.strictEqual( x, 111, 'emitThrow runs every callback even if errors are thrown' ); 82 | } finally { 83 | // Restore it 84 | global.setTimeout = origSetTimeout; 85 | } 86 | 87 | x = {}; 88 | ee.on( 'args', ( a ) => { 89 | assert.strictEqual( a, x, 'Arguments registered in binding passed to callback' ); 90 | }, [ x ] ); 91 | ee.emit( 'args' ); 92 | ee.emitThrow( 'args' ); 93 | 94 | ee.on( 'context-default', function () { 95 | assert.strictEqual( 96 | this, 97 | global, 98 | 'Default context for handlers in non-strict mode is global' 99 | ); 100 | } ); 101 | ee.emit( 'context-default' ); 102 | ee.emitThrow( 'context-default' ); 103 | 104 | x = { 105 | methodName: function () { 106 | seq.push( this ); 107 | } 108 | }; 109 | seq = []; 110 | ee.on( 'context-custom', 'methodName', [], x ); 111 | ee.emit( 'context-custom' ); 112 | assert.deepEqual( seq, [ x ], 'Custom context' ); 113 | seq = []; 114 | ee.emitThrow( 'context-custom' ); 115 | assert.deepEqual( seq, [ x ], 'Custom context' ); 116 | 117 | assert.throws( () => { 118 | ee.on( 'invalid-context', 'methodName', [], null ); 119 | }, 'invalid context' ); 120 | assert.throws( () => { 121 | ee.on( 'invalid-context', 'methodName', [], undefined ); 122 | }, 'invalid context' ); 123 | 124 | assert.deepEqual( ee.emit( 'hasOwnProperty' ), false, 'Event with name "hasOwnProperty" doesn\'t exist by default' ); 125 | 126 | ee.on( 'hasOwnProperty', () => { 127 | assert.true( true, 'Bind event with name "hasOwnProperty"' ); 128 | } ); 129 | ee.emit( 'hasOwnProperty' ); 130 | 131 | ee.on( 'post', () => { 132 | // Binding "hasOwnProperty" worked because the first time 'this.bindings.hasOwnProperty' 133 | // is what it should be (inherited from Object.prototype). But it used to break any 134 | // events bound after since EventEmitter#on used 'this.bindings.hasOwnProperty'. 135 | assert.true( true, 'Bind event after "hasOwnProperty" event exists' ); 136 | } ); 137 | ee.emit( 'post' ); 138 | } ); 139 | 140 | QUnit.test( 'once', ( assert ) => { 141 | const ee = new oo.EventEmitter(); 142 | 143 | const seq = []; 144 | ee.once( 'basic', () => { 145 | seq.push( 'call' ); 146 | } ); 147 | 148 | ee.emit( 'basic' ); 149 | ee.emit( 'basic' ); 150 | ee.emit( 'basic' ); 151 | 152 | assert.deepEqual( seq, [ 'call' ], 'Callback ran only once' ); 153 | } ); 154 | 155 | QUnit.test( 'once - nested', ( assert ) => { 156 | const seq = [], 157 | ee = new oo.EventEmitter(); 158 | 159 | ee.once( 'basic', ( value ) => { 160 | seq.push( value ); 161 | if ( value === 'one' ) { 162 | // Verify once is truly once, handler must be unbound 163 | // before handler runs. 164 | ee.emit( 'basic', 'nested' ); 165 | } 166 | } ); 167 | 168 | ee.emit( 'basic', 'one' ); 169 | ee.emit( 'basic', 'two' ); 170 | assert.deepEqual( seq, [ 'one' ], 'Callback ran only once' ); 171 | } ); 172 | 173 | QUnit.test( 'once - off', ( assert ) => { 174 | let seq = []; 175 | const ee = new oo.EventEmitter(); 176 | 177 | function handle() { 178 | seq.push( 'call' ); 179 | } 180 | 181 | ee.once( 'basic', handle ); 182 | ee.off( 'basic', handle ); 183 | ee.emit( 'basic' ); 184 | ee.emitThrow( 'basic' ); 185 | assert.deepEqual( seq, [], 'Handle is compatible with off()' ); 186 | 187 | seq = []; 188 | ee.once( 'basic', handle ); 189 | ee.emit( 'basic' ); 190 | assert.deepEqual( seq, [ 'call' ], 'Handle can be re-bound' ); 191 | seq = []; 192 | ee.once( 'basic', handle ); 193 | ee.emitThrow( 'basic' ); 194 | } ); 195 | 196 | QUnit.test( 'emit', ( assert ) => { 197 | const ee = new oo.EventEmitter(); 198 | 199 | assert.strictEqual( ee.emit( 'return' ), false, 'Return value when no handlers are registered' ); 200 | ee.on( 'return', () => {} ); 201 | assert.strictEqual( ee.emit( 'return' ), true, 'Return value when a handler is registered' ); 202 | ee.off( 'return' ); 203 | assert.strictEqual( ee.emit( 'return' ), false, 'Return value when handlers were removed' ); 204 | 205 | const data1 = {}; 206 | ee.on( 'dataParam', ( data ) => { 207 | assert.strictEqual( data, data1, 'Data is passed on to event handler' ); 208 | } ); 209 | ee.emit( 'dataParam', data1 ); 210 | 211 | const data2A = {}; 212 | const data2B = {}; 213 | const data2C = {}; 214 | 215 | ee.on( 'dataParams', ( a, b, c ) => { 216 | assert.strictEqual( a, data2A, 'Multiple data parameters (1) are passed on to event handler' ); 217 | assert.strictEqual( b, data2B, 'Multiple data parameters (2) are passed on to event handler' ); 218 | assert.strictEqual( c, data2C, 'Multiple data parameters (3) are passed on to event handler' ); 219 | } ); 220 | 221 | ee.emit( 'dataParams', data2A, data2B, data2C ); 222 | } ); 223 | 224 | QUnit.test( 'off', ( assert ) => { 225 | const ee = new oo.EventEmitter(); 226 | 227 | let hits = 0; 228 | ee.on( 'basic', () => { 229 | hits++; 230 | } ); 231 | 232 | ee.emit( 'basic' ); 233 | ee.emit( 'basic' ); 234 | ee.off( 'basic' ); 235 | ee.emit( 'basic' ); 236 | ee.emit( 'basic' ); 237 | 238 | assert.strictEqual( hits, 2, 'Callback unbound after unbinding with event name' ); 239 | 240 | hits = 0; 241 | const callback = function () { 242 | hits++; 243 | }; 244 | 245 | ee.on( 'fn', callback ); 246 | ee.emit( 'fn' ); 247 | ee.emit( 'fn' ); 248 | ee.off( 'fn', callback ); 249 | ee.emit( 'fn' ); 250 | ee.emit( 'fn' ); 251 | 252 | assert.strictEqual( hits, 2, 'Callback unbound after unbinding with function reference' ); 253 | 254 | ee.off( 'unknown' ); 255 | assert.true( true, 'Unbinding an unknown event' ); 256 | 257 | ee.off( 'unknown', callback ); 258 | assert.true( true, 'Unbinding an unknown callback' ); 259 | 260 | ee.off( 'hasOwnProperty', callback ); 261 | assert.true( true, 'Unbinding an unknown callback for event named "hasOwnProperty"' ); 262 | } ); 263 | 264 | QUnit.test( 'connect', ( assert ) => { 265 | const ee = new oo.EventEmitter(); 266 | 267 | const data1 = {}; 268 | 269 | const host = { 270 | onFoo: function () { 271 | assert.strictEqual( this, host, 'Callback context is connect host' ); 272 | }, 273 | barbara: function ( a ) { 274 | assert.strictEqual( a, data1, 'Connect takes variadic list of arguments to be passed' ); 275 | }, 276 | bazoon: [ 'not', 'a', 'function' ] 277 | }; 278 | 279 | ee.connect( host, { 280 | foo: 'onFoo', 281 | bar: [ 'barbara', data1 ], 282 | quux: function () { 283 | assert.true( true, 'Callback ran' ); 284 | } 285 | } ); 286 | 287 | ee.emit( 'foo' ); 288 | ee.emit( 'bar' ); 289 | ee.emit( 'quux' ); 290 | 291 | assert.throws( () => { 292 | ee.connect( host, { 293 | baz: 'onBaz' 294 | } ); 295 | }, 'Connecting to unknown method' ); 296 | 297 | assert.throws( () => { 298 | ee.connect( host, { 299 | baz: 'bazoon' 300 | } ); 301 | }, 'Connecting to invalid method' ); 302 | } ); 303 | 304 | QUnit.test( 'disconnect( host )', ( assert ) => { 305 | const hits = { foo: 0, bar: 0 }, 306 | ee = new oo.EventEmitter(); 307 | 308 | const host = { 309 | onFoo: function () { 310 | hits.foo++; 311 | }, 312 | onBar: function () { 313 | hits.bar++; 314 | } 315 | }; 316 | 317 | ee.connect( host, { 318 | foo: 'onFoo', 319 | bar: 'onBar' 320 | } ); 321 | ee.emit( 'foo' ); 322 | ee.emit( 'bar' ); 323 | 324 | ee.disconnect( host ); 325 | ee.emit( 'foo' ); 326 | ee.emit( 'bar' ); 327 | 328 | assert.deepEqual( hits, { foo: 1, bar: 1 } ); 329 | } ); 330 | 331 | QUnit.test( 'disconnect( host, methods )', ( assert ) => { 332 | const hits = { foo: 0, bar: 0 }, 333 | ee = new oo.EventEmitter(); 334 | 335 | const host = { 336 | onFoo: function () { 337 | hits.foo++; 338 | }, 339 | onBar: function () { 340 | hits.bar++; 341 | } 342 | }; 343 | 344 | ee.connect( host, { 345 | foo: 'onFoo', 346 | bar: 'onBar' 347 | } ); 348 | ee.emit( 'foo' ); 349 | ee.emit( 'bar' ); 350 | 351 | ee.disconnect( host, { foo: 'onFoo' } ); 352 | ee.emit( 'foo' ); 353 | ee.emit( 'bar' ); 354 | 355 | assert.deepEqual( hits, { foo: 1, bar: 2 } ); 356 | } ); 357 | 358 | QUnit.test( 'disconnect( host, array methods )', ( assert ) => { 359 | const hits = { foo: 0, barbara: 0, barbaraAlt: 0 }, 360 | ee = new oo.EventEmitter(); 361 | 362 | const host = { 363 | onFoo: function () { 364 | hits.foo++; 365 | }, 366 | barbara: function ( param ) { 367 | if ( param === 'alt' ) { 368 | hits.barbaraAlt++; 369 | } else { 370 | hits.barbara++; 371 | } 372 | } 373 | }; 374 | 375 | ee.connect( host, { 376 | foo: 'onFoo', 377 | bar: [ 'barbara', 'regular' ] 378 | } ); 379 | ee.emit( 'foo' ); 380 | ee.emit( 'bar' ); 381 | assert.deepEqual( hits, { foo: 1, barbara: 1, barbaraAlt: 0 } ); 382 | 383 | // disconnect finds "barbara" by method name, parameters not needed 384 | ee.disconnect( host, { bar: [ 'barbara' ] } ); 385 | ee.emit( 'foo' ); 386 | ee.emit( 'bar' ); 387 | assert.deepEqual( hits, { foo: 2, barbara: 1, barbaraAlt: 0 } ); 388 | 389 | ee.connect( host, { 390 | bar: [ 'barbara', 'regular' ] 391 | } ); 392 | ee.connect( host, { 393 | bar: [ 'barbara', 'alt' ] 394 | } ); 395 | ee.emit( 'bar' ); 396 | // both barbara's increase 397 | assert.deepEqual( hits, { foo: 2, barbara: 2, barbaraAlt: 1 } ); 398 | 399 | // disconnect finds both "barbara" by method name, parameters ignored 400 | ee.disconnect( host, { bar: [ 'barbara', 'ignored' ] } ); 401 | ee.emit( 'bar' ); 402 | 403 | // foo increased, but barabara not (following disconnect of both) 404 | assert.deepEqual( hits, { foo: 2, barbara: 2, barbaraAlt: 1 } ); 405 | } ); 406 | 407 | QUnit.test( 'disconnect( host, unbound methods )', ( assert ) => { 408 | let ee = new oo.EventEmitter(); 409 | 410 | const host = { 411 | onFoo: function () { 412 | }, 413 | onBar: function () { 414 | } 415 | }; 416 | 417 | // Verify that disconnect does not fail if there were no events bound yet 418 | ee = new oo.EventEmitter(); 419 | ee.disconnect( {} ); 420 | ee.disconnect( host, { foo: 'onFoo' } ); 421 | ee.disconnect( host ); 422 | 423 | assert.throws( () => { 424 | ee.disconnect( host, { notfound: 'onExample' } ); 425 | }, 'method must exist on host object even if event has no listeners' ); 426 | } ); 427 | 428 | QUnit.test( 'chainable', ( assert ) => { 429 | const fn = function () {}, 430 | ee = new oo.EventEmitter(); 431 | 432 | assert.strictEqual( ee.on( 'basic', fn ), ee, 'on() is chainable' ); 433 | assert.strictEqual( ee.once( 'basic', fn ), ee, 'once() is chainable' ); 434 | assert.strictEqual( ee.off( 'basic' ), ee, 'off() is chainable' ); 435 | assert.strictEqual( ee.connect( {}, {} ), ee, 'connect() is chainable' ); 436 | assert.strictEqual( ee.disconnect( {} ), ee, 'disconnect() is chainable' ); 437 | } ); 438 | 439 | }( OO, this ) ); 440 | -------------------------------------------------------------------------------- /tests/unit/Factory.test.js: -------------------------------------------------------------------------------- 1 | ( function () { 2 | 3 | QUnit.module( 'Factory' ); 4 | 5 | class Foo { 6 | constructor( a, b, c, d ) { 7 | this.a = a; 8 | this.b = b; 9 | this.c = c; 10 | this.d = d; 11 | } 12 | } 13 | Foo.key = 'my-foo'; 14 | 15 | function Bar( a, b, c, d ) { 16 | this.a = a; 17 | this.b = b; 18 | this.c = c; 19 | this.d = d; 20 | } 21 | OO.initClass( Bar ); 22 | Bar.static.name = 'my-bar'; 23 | 24 | function Quux( a, b, c, d ) { 25 | this.a = a; 26 | this.b = b; 27 | this.c = c; 28 | this.d = d; 29 | } 30 | OO.initClass( Quux ); 31 | Quux.key = 'my-quux'; 32 | Quux.static.name = 'not-quite-right'; 33 | 34 | QUnit.test( 'invalid registration', ( assert ) => { 35 | const factory = new OO.Factory(); 36 | 37 | assert.throws( 38 | () => { 39 | factory.register( 'not-a-function' ); 40 | }, 41 | Error, 42 | 'register non-function value' 43 | ); 44 | assert.throws( 45 | () => { 46 | factory.register( () => {} ); 47 | }, 48 | 'register class without a key' 49 | ); 50 | assert.throws( 51 | () => { 52 | factory.unregister( 42 ); 53 | }, 54 | Error, 55 | 'unregister non-function value' 56 | ); 57 | assert.throws( 58 | () => { 59 | factory.unregister( () => {} ); 60 | }, 61 | 'unregister class without a key' 62 | ); 63 | } ); 64 | 65 | QUnit.test.each( 'registeration and lookup', { 66 | 'Class.key': [ Foo, 'my-foo' ], 67 | 'Class.static.name': [ Bar, 'my-bar' ], 68 | 'key and name': [ Quux, 'my-quux' ] 69 | }, ( assert, data ) => { 70 | const Class = data[ 0 ]; 71 | const key = data[ 1 ]; 72 | 73 | const factory = new OO.Factory(); 74 | 75 | // Add and remove by constructor 76 | factory.register( Class ); 77 | assert.strictEqual( factory.lookup( key ), Class ); 78 | factory.unregister( Class ); 79 | assert.strictEqual( factory.lookup( key ), undefined ); 80 | 81 | // Add and remove by key 82 | factory.register( Class, 'different-key' ); 83 | assert.strictEqual( factory.lookup( key ), undefined ); 84 | assert.strictEqual( factory.lookup( 'different-key' ), Class ); 85 | factory.unregister( 'different-key' ); 86 | assert.strictEqual( factory.lookup( 'different-key' ), undefined ); 87 | } ); 88 | 89 | QUnit.test( 'registeration and lookup [unknown]', ( assert ) => { 90 | assert.expect( 0 ); 91 | 92 | // Unknown key should not throw 93 | const factory = new OO.Factory(); 94 | factory.unregister( 'not-registered' ); 95 | } ); 96 | 97 | QUnit.test( 'invalid creation', ( assert ) => { 98 | const factory = new OO.Factory(); 99 | 100 | assert.throws( 101 | () => { 102 | factory.create( 'my-foo', 23, 'foo', { bar: 'baz' } ); 103 | }, 104 | Error, 105 | 'try to create a object from an unregistered key' 106 | ); 107 | } ); 108 | 109 | QUnit.test( 'valid creation', ( assert ) => { 110 | const factory = new OO.Factory(); 111 | 112 | factory.register( Foo ); 113 | const obj = factory.create( 'my-foo', 16, 'foo', { baz: 'quux' }, 5 ); 114 | 115 | assert.true( obj instanceof Foo, 'object inherits constructor prototype' ); 116 | 117 | assert.deepEqual( 118 | obj, 119 | new Foo( 16, 'foo', { baz: 'quux' }, 5 ), 120 | 'constructor function ran' 121 | ); 122 | } ); 123 | 124 | }() ); 125 | -------------------------------------------------------------------------------- /tests/unit/Registry.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo ) { 2 | 3 | QUnit.module( 'Registry' ); 4 | 5 | QUnit.test( 'register/unregister', ( assert ) => { 6 | const registry = new oo.Registry(); 7 | 8 | registry.register( 'registry-item-1', 1 ); 9 | registry.register( [ 'registry-item-2', 'registry-item-3' ], 23 ); 10 | 11 | assert.strictEqual( registry.lookup( 'registry-item-1' ), 1 ); 12 | assert.strictEqual( registry.lookup( 'registry-item-2' ), 23 ); 13 | assert.strictEqual( registry.lookup( 'registry-item-3' ), 23 ); 14 | 15 | registry.unregister( 'registry-item-1' ); 16 | assert.strictEqual( registry.lookup( 'registry-item-1' ), undefined ); 17 | assert.strictEqual( registry.lookup( 'registry-item-2' ), 23 ); 18 | 19 | registry.unregister( [ 'registry-item-2', 'registry-item-3' ] ); 20 | assert.strictEqual( registry.lookup( 'registry-item-2' ), undefined ); 21 | assert.strictEqual( registry.lookup( 'registry-item-3' ), undefined ); 22 | 23 | // Unknown name should not throw 24 | registry.unregister( 'not-registered' ); 25 | 26 | assert.throws( 27 | () => { 28 | registry.register( null ); 29 | }, 30 | 'Invalid name' 31 | ); 32 | assert.throws( 33 | () => { 34 | registry.unregister( null ); 35 | }, 36 | 'Invalid name' 37 | ); 38 | } ); 39 | 40 | QUnit.test( 'lookup', ( assert ) => { 41 | const registry = new oo.Registry(); 42 | 43 | registry.register( 'registry-item-1', 1 ); 44 | 45 | assert.strictEqual( registry.lookup( 'registry-item-1' ), 1 ); 46 | assert.strictEqual( registry.lookup( 'registry-item-2' ), undefined ); 47 | 48 | assert.strictEqual( registry.lookup( 'hasOwnProperty' ), undefined ); 49 | assert.strictEqual( registry.lookup( 'prototype' ), undefined ); 50 | 51 | registry.register( 'hasOwnProperty', 50 ); 52 | assert.strictEqual( registry.lookup( 'hasOwnProperty' ), 50 ); 53 | 54 | registry.register( 'prototype', 20 ); 55 | assert.strictEqual( registry.lookup( 'prototype' ), 20 ); 56 | } ); 57 | 58 | }( OO ) ); 59 | -------------------------------------------------------------------------------- /tests/unit/SortedEmitterList.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo ) { 2 | 3 | // Define a test list object using the oo.SortedEmitterList mixin 4 | function SortedTestList() { 5 | // Mixin constructor 6 | oo.EventEmitter.call( this ); 7 | // Mixin constructor 8 | oo.SortedEmitterList.call( 9 | this, 10 | ( a, b ) => a.getContent() < b.getContent() ? -1 : ( 11 | a.getContent() > b.getContent() ? 1 : 0 12 | ) 13 | ); 14 | } 15 | oo.mixinClass( SortedTestList, oo.EventEmitter ); 16 | oo.mixinClass( SortedTestList, oo.SortedEmitterList ); 17 | 18 | // Define a test item object 19 | function TestItem( content, id ) { 20 | // Mixin constructor 21 | oo.EventEmitter.call( this ); 22 | 23 | this.content = content; 24 | this.id = id; 25 | } 26 | TestItem.prototype.equals = function ( other ) { 27 | return this.getContent() === other.getContent(); 28 | }; 29 | oo.mixinClass( TestItem, oo.EventEmitter ); 30 | 31 | // Helper method to recognize items by their contents 32 | TestItem.prototype.getContent = function () { 33 | return this.content; 34 | }; 35 | 36 | // Method to distinguish "equal" items 37 | TestItem.prototype.getIdentity = function () { 38 | return this.content + ( this.id ? this.id : '' ); 39 | }; 40 | 41 | // Helper method to get an array of item contents 42 | // for testing 43 | function getIdentityArray( arr ) { 44 | return arr.map( ( item ) => item.getIdentity() ); 45 | } 46 | 47 | QUnit.module( 'SortedEmitterList' ); 48 | 49 | QUnit.test( 'addItems', function ( assert ) { 50 | const initialItems = [ 51 | new TestItem( 'aa' ), 52 | new TestItem( 'bb' ), 53 | new TestItem( 'cc' ) 54 | ], 55 | cases = [ 56 | { 57 | items: [ 58 | initialItems[ 1 ], // bb 59 | initialItems[ 0 ], // aa 60 | initialItems[ 2 ] // cc 61 | ], 62 | expected: [ 'aa', 'bb', 'cc' ], 63 | msg: 'Inserts items in sorted order.' 64 | }, 65 | { 66 | items: initialItems, 67 | add: { 68 | items: [ 69 | new TestItem( 'ba' ), 70 | new TestItem( 'ab' ), 71 | new TestItem( 'cd' ), 72 | new TestItem( 'bc' ) 73 | ] 74 | }, 75 | expected: [ 'aa', 'ab', 'ba', 'bb', 'bc', 'cc', 'cd' ], 76 | msg: 'Inserts items into the correct places in an existing list' 77 | }, 78 | { 79 | items: initialItems, 80 | add: { 81 | items: new TestItem( 'ab' ) 82 | }, 83 | expected: [ 'aa', 'ab', 'bb', 'cc' ], 84 | msg: 'Passing an item instead of an array to addItems' 85 | }, 86 | { 87 | items: initialItems, 88 | newSortingCallback: function ( a, b ) { 89 | // Flip the sort 90 | return a.getContent() > b.getContent() ? -1 : ( 91 | a.getContent() < b.getContent() ? 1 : 0 92 | ); 93 | }, 94 | add: { 95 | items: [ 96 | new TestItem( 'ab' ) 97 | ] 98 | }, 99 | // In this case we expect the entire list to be re-sorted 100 | // according to the new (flipped) sorting callback 101 | expected: [ 'cc', 'bb', 'ab', 'aa' ], 102 | msg: 'Flipping sort order and adding a new item' 103 | }, 104 | { 105 | items: initialItems, 106 | add: { 107 | items: [ 108 | new TestItem( 'bb', '2' ) 109 | ] 110 | }, 111 | expected: [ 'aa', 'bb2', 'cc' ], 112 | msg: 'Adding duplicate replaces original item' 113 | } 114 | ]; 115 | 116 | cases.forEach( ( test ) => { 117 | const list = new SortedTestList(); 118 | // Sort by content 119 | list.setSortingCallback( ( a, b ) => a.getContent() > b.getContent() ? 1 : ( 120 | a.getContent() < b.getContent() ? -1 : 0 121 | ) ); 122 | 123 | list.addItems( test.items ); 124 | 125 | if ( test.newSortingCallback ) { 126 | list.setSortingCallback( test.newSortingCallback ); 127 | } 128 | 129 | if ( test.add ) { 130 | list.addItems( test.add.items, test.add.index ); 131 | } 132 | 133 | assert.deepEqual( getIdentityArray( list.getItems() ), test.expected, test.msg ); 134 | }, this ); 135 | 136 | assert.throws( () => { 137 | const list = new SortedTestList(); 138 | list.addItems( initialItems.concat( [ null ] ) ); 139 | }, 'throws when adding null item to list' ); 140 | 141 | assert.throws( () => { 142 | const list = new SortedTestList(); 143 | list.addItems( initialItems.concat( [ undefined ] ) ); 144 | }, 'throws when adding undefined item to list' ); 145 | 146 | } ); 147 | 148 | QUnit.test( 'Events', ( assert ) => { 149 | const result = [], 150 | list = new SortedTestList(), 151 | items = [ 152 | new TestItem( 'aa' ), 153 | new TestItem( 'bb' ), 154 | new TestItem( 'cc' ), 155 | new TestItem( 'dd' ) 156 | ], 157 | stringifyEvent = function ( type, item, index ) { 158 | let string = type; 159 | if ( item ) { 160 | string += ':' + item.getIdentity(); 161 | } 162 | if ( index !== undefined ) { 163 | string += '#' + index; 164 | } 165 | return string; 166 | }; 167 | 168 | // Register 169 | list.on( 'add', ( item, index ) => { 170 | result.push( stringifyEvent( 'add', item, index ) ); 171 | } ); 172 | list.on( 'move', ( item, index ) => { 173 | result.push( stringifyEvent( 'move', item, index ) ); 174 | } ); 175 | list.on( 'remove', ( item, index ) => { 176 | result.push( stringifyEvent( 'remove', item, index ) ); 177 | } ); 178 | list.on( 'clear', () => { 179 | result.push( stringifyEvent( 'clear' ) ); 180 | } ); 181 | 182 | // Trigger events 183 | list.addItems( items ); 184 | list.addItems( [ new TestItem( 'ca' ) ] ); 185 | list.removeItems( items[ 2 ] ); 186 | list.addItems( [ 187 | new TestItem( 'ab' ), 188 | new TestItem( 'ba' ), 189 | new TestItem( 'bc' ), 190 | new TestItem( 'cd' ) 191 | ] ); 192 | list.clearItems(); 193 | // Add items out of sequence 194 | list.addItems( [ 195 | new TestItem( 'dd' ), 196 | new TestItem( 'bb' ), 197 | new TestItem( 'aa' ), 198 | new TestItem( 'cc' ) 199 | ] ); 200 | // Change the sorting callback to a flipped sort 201 | list.setSortingCallback( ( a, b ) => a.getContent() > b.getContent() ? -1 : ( 202 | a.getContent() < b.getContent() ? 1 : 0 203 | ) ); 204 | list.items[ 1 ].content = 'ee'; 205 | list.items[ 1 ].emit( 'sortChange' ); 206 | 207 | assert.deepEqual( result, [ 208 | // addItems 209 | 'add:aa#0', // [ aa ] 210 | 'add:bb#1', // [ aa, bb ] 211 | 'add:cc#2', // [ aa, bb, cc ] 212 | 'add:dd#3', // [ aa, bb, cc, dd ] 213 | // addItems 214 | 'add:ca#2', // [ aa, bb, ca, cc, dd ] 215 | // removeItems 216 | 'remove:cc#3', // [ aa, bb, ca, dd ] 217 | // addItems 218 | 'add:ab#1', // [ aa, ab, bb, ca, dd ] 219 | 'add:ba#2', // [ aa, ab, ba, bb, ca, dd ] 220 | 'add:bc#4', // [ aa, ab, ba, bb, bc, ca, dd ] 221 | 'add:cd#6', // [ aa, ab, ba, bb, bc, ca, cd, dd ] 222 | 'clear', 223 | 'add:dd#0', // [ dd ] 224 | 'add:bb#0', // [ bb, dd] 225 | 'add:aa#0', // [ aa, bb, dd] 226 | 'add:cc#2', // [ aa, bb, cc, dd] 227 | // Changing the sorting callback 228 | 'clear', 229 | 'add:aa#0', // [ aa ] 230 | 'add:bb#0', // [ bb, aa ] 231 | 'add:cc#0', // [ cc, bb, aa ] 232 | 'add:dd#0', // [ dd, cc, bb, aa ] 233 | 'remove:ee#1', // [ dd, bb, aa ] 234 | 'add:ee#0' // [ ee, dd, bb, aa ] 235 | ], 'Events intercepted successfully.' ); 236 | } ); 237 | }( OO ) ); 238 | -------------------------------------------------------------------------------- /tests/unit/core.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo, global ) { 2 | 3 | QUnit.module( 'core' ); 4 | 5 | QUnit.test( 'initClass', ( assert ) => { 6 | function Foo() { 7 | } 8 | oo.initClass( Foo ); 9 | 10 | assert.deepEqual( Foo.static, {}, 'A "static" property (empty object) is created' ); 11 | } ); 12 | 13 | QUnit.test( 'inheritClass', ( assert ) => { 14 | function InitA() {} 15 | function InitB() {} 16 | oo.inheritClass( InitB, InitA ); 17 | 18 | assert.deepEqual( InitA.static, {}, 'initialise static of parent class' ); 19 | assert.deepEqual( InitB.static, {}, 'initialise static of child class' ); 20 | assert.notStrictEqual( InitA.static, InitB.static, 'static container is unique' ); 21 | 22 | function Base() { 23 | this.instanceA = 'Base'; 24 | this.instanceB = 'Base'; 25 | } 26 | oo.initClass( Base ); 27 | Base.static.stat = 'Base'; 28 | Base.prototype.protoA = 'Base'; 29 | Base.prototype.protoB = function () { 30 | return 'Base'; 31 | }; 32 | Base.prototype.protoC = function () { 33 | return 'Base'; 34 | }; 35 | const base = new Base(); 36 | 37 | function Child() { 38 | Child.super.call( this ); 39 | this.instanceB = 'Child'; 40 | this.instanceC = 'Child'; 41 | } 42 | oo.inheritClass( Child, Base ); 43 | Child.prototype.protoC = function () { 44 | return 'Child'; 45 | }; 46 | const child = new Child(); 47 | 48 | assert.true( base instanceof Object, 'base instance of Object' ); 49 | assert.true( base instanceof Base, 'base instance of Base' ); 50 | assert.false( base instanceof Child, 'base not instance of Child' ); 51 | assert.true( child instanceof Object, 'base instance of Object' ); 52 | assert.true( child instanceof Base, 'base instance of Base' ); 53 | assert.true( child instanceof Child, 'base instance of Child' ); 54 | 55 | assert.strictEqual( Child.static.stat, 'Base', 'inherit static' ); 56 | 57 | Child.static.stat = 'Child'; 58 | 59 | assert.strictEqual( Base.static.stat, 'Base', 'Change to Child static does not affect Base' ); 60 | 61 | assert.strictEqual( base.constructor, Base, 'preserve Base constructor property' ); 62 | assert.strictEqual( base.instanceA, 'Base', 'constructor function ran' ); 63 | assert.strictEqual( base.instanceC, undefined, 'child constructor did not run' ); 64 | 65 | assert.strictEqual( child.constructor, Child, 'preserve Child constructor property' ); 66 | assert.strictEqual( Child.super, Base, 'super property refers to parent class' ); 67 | assert.strictEqual( Child.parent, Base, 'parent property refers to parent class (deprecated)' ); 68 | assert.strictEqual( child.instanceA, 'Base', 'parent constructor ran' ); 69 | assert.strictEqual( child.instanceB, 'Child', 'original constructor ran after parent' ); 70 | assert.strictEqual( child.instanceC, 'Child', 'original constructor ran' ); 71 | assert.strictEqual( child.protoB(), 'Base', 'inherit parent prototype (function value)' ); 72 | assert.strictEqual( child.protoA, 'Base', 'inherit parent prototype (non-function value)' ); 73 | assert.strictEqual( child.protoC(), 'Child', 'own properties go first' ); 74 | 75 | assert.throws( () => { 76 | oo.inheritClass( Child, Base ); 77 | }, 'Throw if target already inherits from source (from an earlier call)' ); 78 | 79 | assert.throws( () => { 80 | oo.inheritClass( Child, Object ); 81 | }, 'Throw if target already inherits from source (naturally, Object)' ); 82 | 83 | assert.throws( () => { 84 | oo.inheritClass( Child, undefined ); 85 | }, /Origin is not a function/, 'Throw if source is undefined (e.g. due to missing dependency)' ); 86 | 87 | const enumKeys = []; 88 | for ( const key in child ) { 89 | enumKeys.push( key ); 90 | } 91 | 92 | assert.strictEqual( 93 | enumKeys.indexOf( 'constructor' ), 94 | -1, 95 | 'The restored "constructor" property should not be enumerable' 96 | ); 97 | 98 | Base.prototype.protoD = function () { 99 | return 'Base'; 100 | }; 101 | Child.prototype.protoB = function () { 102 | return 'Child'; 103 | }; 104 | 105 | assert.strictEqual( child.protoD(), 'Base', 'inheritance is live (adding a new method deeper in the chain)' ); 106 | assert.strictEqual( child.protoB(), 'Child', 'inheritance is live (overwriting an inherited method)' ); 107 | } ); 108 | 109 | QUnit.test( 'mixinClass', ( assert ) => { 110 | function Init() {} 111 | function Init2() {} 112 | OO.mixinClass( Init2, Init ); 113 | 114 | assert.deepEqual( Init.static, {}, 'initialise static of parent class' ); 115 | assert.deepEqual( Init2.static, {}, 'initialise static of child class' ); 116 | 117 | function Base() {} 118 | oo.initClass( Base ); 119 | Base.static.stat = 'Base'; 120 | Base.prototype.protoFunction = function () { 121 | return 'Base'; 122 | }; 123 | 124 | function Mixin() {} 125 | oo.initClass( Mixin ); 126 | 127 | function Child() {} 128 | oo.inheritClass( Child, Base ); 129 | oo.mixinClass( Child, Mixin ); 130 | Child.static.stat2 = 'Child'; 131 | Child.prototype.protoFunction2 = function () { 132 | return 'Child'; 133 | }; 134 | 135 | function Mixer() {} 136 | oo.mixinClass( Mixer, Child ); 137 | 138 | assert.strictEqual( 139 | Mixer.prototype.protoFunction, 140 | undefined, 141 | 'mixin does not copy inherited prototype' 142 | ); 143 | 144 | assert.strictEqual( 145 | Mixer.static.stat, 146 | undefined, 147 | 'mixin does not copy inherited static' 148 | ); 149 | 150 | assert.strictEqual( 151 | Mixer.prototype.constructor, 152 | Mixer, 153 | 'mixin preserves constructor property' 154 | ); 155 | 156 | assert.strictEqual( 157 | Object.prototype.hasOwnProperty.call( Mixer.prototype, 'protoFunction2' ), 158 | true, 159 | 'mixin copies method' 160 | ); 161 | 162 | assert.strictEqual( 163 | Object.prototype.hasOwnProperty.call( Mixer.static, 'stat2' ), 164 | true, 165 | 'mixin copies static member' 166 | ); 167 | 168 | const obj = new Mixer(); 169 | 170 | assert.strictEqual( obj.protoFunction2(), 'Child', 'method works as expected' ); 171 | 172 | assert.throws( () => { 173 | oo.mixinClass( Mixer, undefined ); 174 | }, /Origin is not a function/, 'Throw if source is undefined (e.g. due to missing dependency)' ); 175 | } ); 176 | 177 | QUnit.test( 'isSubclass', ( assert ) => { 178 | function Base() {} 179 | function Child() {} 180 | function GrandChild() {} 181 | function Unrelated() {} 182 | oo.initClass( Base ); 183 | oo.inheritClass( Child, Base ); 184 | oo.inheritClass( GrandChild, Child ); 185 | oo.initClass( Unrelated ); 186 | assert.strictEqual( oo.isSubclass( Base, Object ), true, 'Base is subclass of Object' ); 187 | assert.strictEqual( oo.isSubclass( Base, Base ), true, 'Base is subclass of Base' ); 188 | assert.strictEqual( oo.isSubclass( Base, Child ), false, 'Base not subclass of Child' ); 189 | assert.strictEqual( oo.isSubclass( Base, GrandChild ), false, 'Base not subclass of GrandChild' ); 190 | 191 | assert.strictEqual( oo.isSubclass( GrandChild, Base ), true, 'GrandChild is subclass of Base' ); 192 | assert.strictEqual( oo.isSubclass( GrandChild, Child ), true, 'GrandChild is subclass of Child' ); 193 | assert.strictEqual( oo.isSubclass( GrandChild, GrandChild ), true, 'GrandChild is subclass of GrandChild' ); 194 | assert.strictEqual( oo.isSubclass( Unrelated, Base ), false, 'Unrelated not subclass of Base' ); 195 | } ); 196 | 197 | ( function () { 198 | const runners = {}; 199 | 200 | runners.testGetProp = function ( type, obj ) { 201 | QUnit.test( 'getProp( ' + type + ' )', ( assert ) => { 202 | assert.strictEqual( 203 | oo.getProp( obj, 'foo' ), 204 | 3, 205 | 'single key' 206 | ); 207 | assert.deepEqual( 208 | oo.getProp( obj, 'bar' ), 209 | { baz: null, quux: { whee: 'yay' } }, 210 | 'single key, returns object' 211 | ); 212 | assert.strictEqual( 213 | oo.getProp( obj, 'bar', 'baz' ), 214 | null, 215 | 'two keys, returns null' 216 | ); 217 | assert.strictEqual( 218 | oo.getProp( obj, 'bar', 'quux', 'whee' ), 219 | 'yay', 220 | 'three keys' 221 | ); 222 | assert.strictEqual( 223 | oo.getProp( obj, 'x' ), 224 | undefined, 225 | 'missing property returns undefined' 226 | ); 227 | assert.strictEqual( 228 | oo.getProp( obj, 'foo', 'bar' ), 229 | undefined, 230 | 'missing 2nd-level property returns undefined' 231 | ); 232 | assert.strictEqual( 233 | oo.getProp( obj, 'foo', 'bar', 'baz', 'quux', 'whee' ), 234 | undefined, 235 | 'multiple missing properties don\'t cause an error' 236 | ); 237 | assert.strictEqual( 238 | oo.getProp( obj, 'bar', 'baz', 'quux' ), 239 | undefined, 240 | 'accessing property of null returns undefined, doesn\'t cause an error' 241 | ); 242 | assert.strictEqual( 243 | oo.getProp( obj, 'bar', 'baz', 'quux', 'whee', 'yay' ), 244 | undefined, 245 | 'accessing multiple properties of null' 246 | ); 247 | } ); 248 | }; 249 | 250 | runners.testSetProp = function ( type, obj ) { 251 | QUnit.test( 'setProp( ' + type + ' )', ( assert ) => { 252 | const emptyObj = {}; 253 | 254 | oo.setProp( emptyObj ); 255 | assert.deepEqual( emptyObj, {}, 'setting with insufficient arguments is a no-op' ); 256 | 257 | oo.setProp( obj, 'foo', 4 ); 258 | assert.strictEqual( obj.foo, 4, 'setting an existing key with depth 1' ); 259 | 260 | oo.setProp( obj, 'test', 'TEST' ); 261 | assert.strictEqual( obj.test, 'TEST', 'setting a new key with depth 1' ); 262 | 263 | oo.setProp( obj, 'bar', 'quux', 'whee', 'YAY' ); 264 | assert.strictEqual( obj.bar.quux.whee, 'YAY', 'setting an existing key with depth 3' ); 265 | 266 | oo.setProp( obj, 'bar', 'a', 'b', 'c' ); 267 | assert.strictEqual( obj.bar.a.b, 'c', 'setting two new keys within an existing key' ); 268 | 269 | oo.setProp( obj, 'a', 'b', 'c', 'd', 'e', 'f' ); 270 | assert.strictEqual( obj.a.b.c.d.e, 'f', 'setting new keys with depth 5' ); 271 | 272 | oo.setProp( obj, 'bar', 'baz', 'whee', 'wheee', 'wheeee' ); 273 | assert.strictEqual( obj.bar.baz, null, 'descending into null fails silently' ); 274 | 275 | oo.setProp( obj, 'foo', 'bar', 5 ); 276 | assert.strictEqual( obj.foo, 4, 'descending into primitive (number) preserves fails silently' ); 277 | } ); 278 | }; 279 | 280 | runners.testDeleteProp = function ( type, obj ) { 281 | QUnit.test( 'deleteProp( ' + type + ' )', ( assert ) => { 282 | const clone = OO.copy( obj ), 283 | hasOwn = Object.prototype.hasOwnProperty; 284 | 285 | oo.deleteProp( clone ); 286 | assert.deepEqual( clone, obj, 'deleting with insufficient arguments is a no-op' ); 287 | 288 | oo.deleteProp( obj, 'foo' ); 289 | assert.strictEqual( hasOwn.call( obj, 'foo' ), false, 'deleting an existing key with depth 1' ); 290 | oo.setProp( obj, 'foo', 3 ); 291 | 292 | oo.deleteProp( obj, 'test' ); 293 | assert.strictEqual( hasOwn.call( obj, 'test' ), false, 'deleting an non-exsiting key is a silent no-op' ); 294 | 295 | oo.deleteProp( obj, 'bar', 'quux', 'whee' ); 296 | assert.strictEqual( hasOwn.call( obj.bar, 'quux' ), false, 'deleting an existing key with depth 3 cleans up empty object' ); 297 | // Reset 298 | oo.setProp( obj, 'bar', 'quux', 'whee', 'yay' ); 299 | 300 | oo.deleteProp( obj, 'bar', 'baz' ); 301 | oo.deleteProp( obj, 'bar', 'quux', 'whee' ); 302 | assert.strictEqual( hasOwn.call( obj, 'bar' ), false, 'deleting an existing key causes two cleanups' ); 303 | 304 | oo.deleteProp( obj, 'foo', 'bar' ); 305 | assert.strictEqual( hasOwn.call( obj, 'foo' ), true, 'descending into primitive (number) preserves fails silently' ); 306 | 307 | // Remove siblings 308 | oo.deleteProp( obj, 'foo' ); 309 | oo.deleteProp( obj, 'bar', 'baz' ); 310 | // Reset 311 | oo.setProp( obj, 'bar', 'quux', 'whee', 'yay' ); 312 | oo.deleteProp( obj.bar, 'quux', 'whee' ); 313 | assert.strictEqual( hasOwn.call( obj, 'bar' ), true, 'empty object not deleted if not part of the arguments list' ); 314 | 315 | } ); 316 | }; 317 | 318 | for ( const method in runners ) { 319 | const plainObj = { 320 | foo: 3, 321 | bar: { 322 | baz: null, 323 | quux: { 324 | whee: 'yay' 325 | } 326 | } 327 | }; 328 | const funcObj = function abc( d ) { 329 | return d; 330 | }; 331 | funcObj.foo = 3; 332 | funcObj.bar = { 333 | baz: null, 334 | quux: { 335 | whee: 'yay' 336 | } 337 | }; 338 | const arrObj = [ 'a', 'b', 'c' ]; 339 | arrObj.foo = 3; 340 | arrObj.bar = { 341 | baz: null, 342 | quux: { 343 | whee: 'yay' 344 | } 345 | }; 346 | 347 | runners[ method ]( 'Object', plainObj ); 348 | runners[ method ]( 'Function', funcObj ); 349 | runners[ method ]( 'Array', arrObj ); 350 | } 351 | }() ); 352 | 353 | QUnit.test( 'cloneObject', ( assert ) => { 354 | const hasOwn = Object.prototype.hasOwnProperty; 355 | 356 | function Foo( x ) { 357 | this.x = x; 358 | } 359 | Foo.prototype.x = 'default'; 360 | Foo.prototype.aFn = function () { 361 | return 'proto of Foo'; 362 | }; 363 | 364 | const myfoo = new Foo( 10 ); 365 | const myfooClone = oo.cloneObject( myfoo ); 366 | 367 | assert.notStrictEqual( myfoo, myfooClone, 'clone is not equal when compared by reference' ); 368 | assert.deepEqual( myfoo, myfooClone, 'clone is equal when recursively compared by value' ); 369 | 370 | const expected = { 371 | x: 10, 372 | aFn: 'proto of Foo', 373 | constructor: Foo, 374 | instanceOf: true, 375 | own: { 376 | x: true, 377 | aFn: false, 378 | constructor: false 379 | } 380 | }; 381 | 382 | assert.deepEqual( 383 | { 384 | x: myfoo.x, 385 | aFn: myfoo.aFn(), 386 | constructor: myfoo.constructor, 387 | instanceOf: myfoo instanceof Foo, 388 | own: { 389 | x: hasOwn.call( myfoo, 'x' ), 390 | aFn: hasOwn.call( myfoo, 'aFn' ), 391 | constructor: hasOwn.call( myfoo, 'constructor' ) 392 | } 393 | }, 394 | expected, 395 | 'original looks as expected' 396 | ); 397 | 398 | assert.deepEqual( 399 | { 400 | x: myfooClone.x, 401 | aFn: myfooClone.aFn(), 402 | constructor: myfooClone.constructor, 403 | instanceOf: myfooClone instanceof Foo, 404 | own: { 405 | x: hasOwn.call( myfooClone, 'x' ), 406 | aFn: hasOwn.call( myfooClone, 'aFn' ), 407 | constructor: hasOwn.call( myfoo, 'constructor' ) 408 | } 409 | }, 410 | expected, 411 | 'clone looks as expected' 412 | ); 413 | assert.deepEqual( 414 | // eslint-disable-next-line no-proto 415 | oo.cloneObject( Object.create( null ) ).__proto__, 416 | undefined, 417 | 'clone works correctly on an object with a null prototype' 418 | ); 419 | 420 | } ); 421 | 422 | QUnit.test( 'getObjectValues', ( assert ) => { 423 | assert.deepEqual( 424 | oo.getObjectValues( { a: 1, b: false, foo: 'bar' } ), 425 | [ 1, false, 'bar' ], 426 | 'Plain object with primitive values' 427 | ); 428 | assert.deepEqual( 429 | oo.getObjectValues( [ 1, false, 'bar' ] ), 430 | [ 1, false, 'bar' ], 431 | 'Array with primitive values' 432 | ); 433 | 434 | const tmpFunc = function () { 435 | this.isTest = true; 436 | 437 | return this; 438 | }; 439 | tmpFunc.a = 1; 440 | tmpFunc.b = false; 441 | tmpFunc.foo = 'bar'; 442 | 443 | assert.deepEqual( 444 | oo.getObjectValues( tmpFunc ), 445 | [ 1, false, 'bar' ], 446 | 'Function with properties' 447 | ); 448 | 449 | const tmpObj = Object.create( { a: 1, b: false, foo: 'bar' } ); 450 | tmpObj.b = true; 451 | tmpObj.bar = 'quux'; 452 | 453 | assert.deepEqual( 454 | oo.getObjectValues( tmpObj ), 455 | [ true, 'quux' ], 456 | 'Only own properties' 457 | ); 458 | 459 | assert.throws( 460 | () => { 461 | oo.getObjectValues( 'hello' ); 462 | }, 463 | /^TypeError/, 464 | 'Throw exception for non-object (string)' 465 | ); 466 | 467 | assert.throws( 468 | () => { 469 | oo.getObjectValues( null ); 470 | }, 471 | /^TypeError/, 472 | 'Throw exception for non-object (null)' 473 | ); 474 | } ); 475 | 476 | QUnit.test( 'binarySearch', ( assert ) => { 477 | const data = [ -42, -10, 0, 2, 5, 7, 12, 21, 42, 70, 144, 1001 ]; 478 | 479 | function dir( target, item ) { 480 | return target > item ? 1 : ( target < item ? -1 : 0 ); 481 | } 482 | 483 | function assertSearch( target, expectedPath, expectedRet ) { 484 | const path = []; 485 | 486 | const ret = oo.binarySearch( data, ( item ) => { 487 | path.push( item ); 488 | return dir( target, item ); 489 | } ); 490 | 491 | assert.deepEqual( path, expectedPath, 'Search ' + target ); 492 | assert.strictEqual( ret, expectedRet, 'Search ' + target + ' (index)' ); 493 | } 494 | 495 | assertSearch( 12, [ 12 ], 6 ); 496 | assertSearch( -42, [ 12, 2, -10, -42 ], 0 ); 497 | assertSearch( 42, [ 12, 70, 42 ], 8 ); 498 | 499 | // Out of bounds 500 | assertSearch( -2000, [ 12, 2, -10, -42 ], null ); 501 | assertSearch( 2000, [ 12, 70, 1001 ], null ); 502 | 503 | assert.strictEqual( 504 | oo.binarySearch( data, ( item ) => dir( -2000, item ), true ), 505 | 0, 506 | 'forInsertion at start' 507 | ); 508 | 509 | assert.strictEqual( 510 | oo.binarySearch( [ 1, 2, 4, 5 ], ( item ) => dir( 3, item ), true ), 511 | 2, 512 | 'forInsertion in the middle' 513 | ); 514 | 515 | assert.strictEqual( 516 | oo.binarySearch( data, ( item ) => dir( 2000, item ), true ), 517 | 12, 518 | 'forInsertion at end' 519 | ); 520 | 521 | } ); 522 | 523 | QUnit.test( 'compare', ( assert ) => { 524 | assert.strictEqual( 525 | oo.compare( [], [] ), 526 | true, 527 | 'Empty array' 528 | ); 529 | 530 | assert.strictEqual( 531 | oo.compare( {}, {} ), 532 | true, 533 | 'Empty plain object' 534 | ); 535 | 536 | assert.strictEqual( 537 | oo.compare( {}, null ), 538 | true, 539 | 'Empty plain object against null' 540 | ); 541 | 542 | assert.strictEqual( 543 | oo.compare( {}, undefined ), 544 | true, 545 | 'Empty plain object against undefined' 546 | ); 547 | 548 | assert.strictEqual( 549 | oo.compare( null, {} ), 550 | true, 551 | 'Null against empty plain object' 552 | ); 553 | 554 | assert.strictEqual( 555 | oo.compare( [ 1, 2, undefined ], [ 1, 2 ] ), 556 | true, 557 | 'Implicit undefined against explicit undefined' 558 | ); 559 | 560 | assert.strictEqual( 561 | oo.compare( [], [ undefined ] ), 562 | true, 563 | 'Implicit undefined against explicit undefined (empty array)' 564 | ); 565 | 566 | assert.strictEqual( 567 | oo.compare( { a: 1 }, null ), 568 | false, 569 | 'Plain object against null' 570 | ); 571 | 572 | assert.strictEqual( 573 | oo.compare( { a: 1 }, undefined ), 574 | false, 575 | 'Plain object against null' 576 | ); 577 | 578 | assert.strictEqual( 579 | oo.compare( [ undefined ], [ undefined ] ), 580 | true, 581 | 'Undefined in array' 582 | ); 583 | 584 | assert.strictEqual( 585 | oo.compare( [ null ], [ null ] ), 586 | true, 587 | 'Null in array' 588 | ); 589 | 590 | assert.strictEqual( 591 | oo.compare( [ true ], [ true ] ), 592 | true, 593 | 'boolean in array' 594 | ); 595 | 596 | assert.strictEqual( 597 | oo.compare( [ true ], [ false ] ), 598 | false, 599 | 'different booleans in array' 600 | ); 601 | 602 | assert.strictEqual( 603 | oo.compare( [ 42 ], [ 42 ] ), 604 | true, 605 | 'number in array' 606 | ); 607 | 608 | assert.strictEqual( 609 | oo.compare( [ 42 ], [ 32 ] ), 610 | false, 611 | 'different number in array' 612 | ); 613 | 614 | assert.strictEqual( 615 | oo.compare( [ 'foo' ], [ 'foo' ] ), 616 | true, 617 | 'string in array' 618 | ); 619 | 620 | assert.strictEqual( 621 | oo.compare( [ 'foo' ], [ 'bar' ] ), 622 | false, 623 | 'different string in array' 624 | ); 625 | 626 | assert.strictEqual( 627 | oo.compare( [], {} ), 628 | true, 629 | 'Empty array equals empty plain object' 630 | ); 631 | 632 | assert.strictEqual( 633 | oo.compare( { a: 5 }, { a: 5, b: undefined } ), 634 | true, 635 | 'Missing key and undefined are treated the same' 636 | ); 637 | 638 | assert.strictEqual( 639 | oo.compare( 640 | { 641 | foo: [ true, 42 ], 642 | bar: [ { 643 | x: {}, 644 | y: [ 'test' ] 645 | } ] 646 | }, 647 | { 648 | foo: [ true, 42 ], 649 | bar: [ { 650 | x: {}, 651 | y: [ 'test' ] 652 | } ] 653 | } 654 | ), 655 | true, 656 | 'Nested structure with no difference' 657 | ); 658 | 659 | let x = { a: 1 }; 660 | 661 | assert.strictEqual( 662 | oo.compare( x, x ), 663 | true, 664 | 'Compare object to itself' 665 | ); 666 | 667 | x = Object.create( { foo: 1, map: function () { } } ); 668 | x.foo = 2; 669 | x.bar = true; 670 | 671 | assert.strictEqual( 672 | oo.compare( x, { foo: 2, bar: true } ), 673 | true, 674 | 'Ignore inherited properties and methods of a' 675 | ); 676 | 677 | assert.strictEqual( 678 | oo.compare( { foo: 2, bar: true }, x ), 679 | true, 680 | 'Ignore inherited properties and methods of b' 681 | ); 682 | 683 | assert.strictEqual( 684 | oo.compare( 685 | { 686 | foo: [ true, 42 ], 687 | bar: [ { 688 | x: {}, 689 | y: [ 'test' ] 690 | } ] 691 | }, 692 | { 693 | foo: [ 1, 42 ], 694 | bar: [ { 695 | x: {}, 696 | y: [ 'test' ] 697 | } ] 698 | } 699 | ), 700 | false, 701 | 'Nested structure with difference' 702 | ); 703 | 704 | // Give each function a different number of specified arguments to 705 | // also change the 'length' property of a function. 706 | 707 | x = function X( a ) { 708 | this.name = a || 'X'; 709 | }; 710 | x.foo = [ true ]; 711 | 712 | const y = function Y( a, b ) { 713 | this.name = b || 'Y'; 714 | }; 715 | y.foo = [ true ]; 716 | 717 | const z = function Z( a, b, c ) { 718 | this.name = c || 'Z'; 719 | }; 720 | z.foo = [ 1 ]; 721 | 722 | // oo.compare ignores the function body. It treats them 723 | // like regular object containers. 724 | assert.strictEqual( 725 | oo.compare( x, y ), 726 | true, 727 | 'Different functions with the same properties' 728 | ); 729 | 730 | assert.strictEqual( 731 | oo.compare( x, z ), 732 | false, 733 | 'Different functions with different properties' 734 | ); 735 | } ); 736 | 737 | QUnit.test( 'compare( Node, Node )', ( assert ) => { 738 | const a = { 739 | id: '1', 740 | nodeType: 0, 741 | isEqualNode: function ( other ) { 742 | return this.id === other.id; 743 | } 744 | }; 745 | const b = { 746 | id: '2', 747 | nodeType: 0, 748 | isEqualNode: function ( other ) { 749 | return this.id === other.id; 750 | } 751 | }; 752 | const c = { 753 | id: '2', 754 | nodeType: 0, 755 | isEqualNode: function ( other ) { 756 | return this.id === other.id; 757 | } 758 | }; 759 | 760 | assert.strictEqual( 761 | oo.compare( a, a ), 762 | true, 763 | 'same Node object' 764 | ); 765 | assert.strictEqual( 766 | oo.compare( a, b ), 767 | false, 768 | 'different Node (isEqualNode returns false)' 769 | ); 770 | assert.strictEqual( 771 | oo.compare( b, c ), 772 | true, 773 | 'equal Node (isEqualNode returns true)' 774 | ); 775 | 776 | assert.strictEqual( 777 | oo.compare( { obj: a }, { obj: a } ), 778 | true, 779 | '(nested) same Node object' 780 | ); 781 | assert.strictEqual( 782 | oo.compare( { obj: a }, { obj: b } ), 783 | false, 784 | '(nested) different Node (isEqualNode returns false)' 785 | ); 786 | assert.strictEqual( 787 | oo.compare( { obj: b }, { obj: c } ), 788 | true, 789 | '(nested) equal Node (isEqualNode returns true)' 790 | ); 791 | } ); 792 | 793 | QUnit.test( 'compare( Object, Object, Boolean asymmetrical )', ( assert ) => { 794 | let x = { 795 | foo: [ true, 42 ], 796 | baz: undefined 797 | }; 798 | let y = { 799 | foo: [ true, 42, 10 ], 800 | bar: [ { 801 | x: {}, 802 | y: [ 'test' ] 803 | } ], 804 | baz: 1701 805 | }; 806 | const z = { 807 | foo: [ 1, 42 ], 808 | bar: [ { 809 | x: {}, 810 | y: [ 'test' ] 811 | } ], 812 | baz: 1701 813 | }; 814 | 815 | assert.strictEqual( 816 | oo.compare( x, y, false ), 817 | false, 818 | 'A subset of B (asymmetrical: false)' 819 | ); 820 | 821 | assert.strictEqual( 822 | oo.compare( x, y, true ), 823 | true, 824 | 'A subset of B (asymmetrical: true)' 825 | ); 826 | 827 | assert.strictEqual( 828 | oo.compare( x, z, true ), 829 | false, 830 | 'A subset of B with differences (asymmetrical: true)' 831 | ); 832 | 833 | assert.strictEqual( 834 | oo.compare( [ undefined, 'val2' ], [ 'val1', 'val2', 'val3' ], true ), 835 | true, 836 | 'A subset of B with sparse array' 837 | ); 838 | 839 | x = null; 840 | y = null; 841 | const depth = 15; 842 | for ( let i = 0; i < depth; i++ ) { 843 | x = [ x, x ]; 844 | y = [ y, y ]; 845 | } 846 | const compare = oo.compare; 847 | try { 848 | oo.compare = function () { 849 | oo.compare.callCount += 1; 850 | return compare.apply( null, arguments ); 851 | }; 852 | oo.compare.callCount = 0; 853 | oo.compare( x, y ); 854 | assert.strictEqual( 855 | oo.compare.callCount, 856 | Math.pow( 2, depth + 1 ) - 2, 857 | 'Efficient depth recursion' 858 | ); 859 | } finally { 860 | oo.compare = compare; 861 | } 862 | } ); 863 | 864 | QUnit.test( 'copy( source )', ( assert ) => { 865 | const simpleObj = { foo: 'bar', baz: 3, quux: null, truth: true, falsehood: false }, 866 | simpleArray = [ 'foo', 3, true, false ], 867 | withObj = [ { bar: 'baz', quux: 3 }, 5, null ], 868 | withArray = [ [ 'a', 'b' ], [ 1, 3, 4 ] ], 869 | sparseArray = [ 'a', undefined, undefined, 'b' ], 870 | withSparseArray = [ [ 'a', undefined, undefined, 'b' ] ], 871 | withFunction = [ function () { 872 | return true; 873 | } ], 874 | nodeLike = { 875 | cloneNode: function () { 876 | return 'cloned node'; 877 | } 878 | }; 879 | 880 | function Cloneable( name ) { 881 | this.name = name; 882 | } 883 | Cloneable.prototype.clone = function () { 884 | return new Cloneable( this.name + '-clone' ); 885 | }; 886 | 887 | function Thing( id ) { 888 | this.id = id; 889 | 890 | // Create a trap here to make sure we explode if 891 | // oo.copy tries to copy non-plain objects. 892 | this.child = { 893 | parent: this 894 | }; 895 | } 896 | 897 | assert.deepEqual( 898 | oo.copy( simpleObj ), 899 | simpleObj, 900 | 'Simple object' 901 | ); 902 | 903 | assert.deepEqual( 904 | oo.copy( simpleArray ), 905 | simpleArray, 906 | 'Simple array' 907 | ); 908 | 909 | assert.deepEqual( 910 | oo.copy( withObj ), 911 | withObj, 912 | 'Nested object' 913 | ); 914 | 915 | assert.deepEqual( 916 | oo.copy( withArray ), 917 | withArray, 918 | 'Nested array' 919 | ); 920 | 921 | assert.deepEqual( 922 | oo.copy( sparseArray ), 923 | sparseArray, 924 | 'Sparse array' 925 | ); 926 | 927 | assert.deepEqual( 928 | oo.copy( withSparseArray ), 929 | withSparseArray, 930 | 'Nested sparse array' 931 | ); 932 | 933 | assert.deepEqual( 934 | oo.copy( withFunction ), 935 | withFunction, 936 | 'Nested function' 937 | ); 938 | 939 | assert.deepEqual( 940 | oo.copy( new Cloneable( 'bar' ) ), 941 | new Cloneable( 'bar-clone' ), 942 | 'Cloneable object' 943 | ); 944 | 945 | assert.deepEqual( 946 | oo.copy( { x: new Cloneable( 'bar' ) } ), 947 | { x: new Cloneable( 'bar-clone' ) }, 948 | 'Nested Cloneable object' 949 | ); 950 | 951 | assert.deepEqual( 952 | oo.copy( [ new Thing( 42 ) ] ), 953 | [ new Thing( 42 ) ] 954 | ); 955 | 956 | assert.deepEqual( 957 | oo.copy( null ), 958 | null, 959 | 'Copying null' 960 | ); 961 | 962 | assert.deepEqual( 963 | oo.copy( undefined ), 964 | undefined, 965 | 'Copying undefined' 966 | ); 967 | 968 | assert.deepEqual( 969 | oo.copy( { a: null, b: undefined } ), 970 | { a: null, b: undefined }, 971 | 'Copying objects with null and undefined fields' 972 | ); 973 | 974 | assert.deepEqual( 975 | oo.copy( [ null, undefined ] ), 976 | [ null, undefined ], 977 | 'Copying arrays with null and undefined elements' 978 | ); 979 | 980 | assert.deepEqual( 981 | oo.copy( nodeLike ), 982 | 'cloned node', 983 | 'Node-like object (using #cloneNode)' 984 | ); 985 | assert.deepEqual( 986 | // eslint-disable-next-line no-proto 987 | oo.copy( Object.create( null ) ).__proto__, 988 | undefined, 989 | 'Copying an object with a null prototype' 990 | ); 991 | } ); 992 | 993 | QUnit.test( 'copy( source, Function leafCallback )', ( assert ) => { 994 | const nodeLike = { 995 | cloneNode: function () { 996 | return 'cloned node'; 997 | } 998 | }; 999 | 1000 | function Cloneable( name ) { 1001 | this.name = name; 1002 | this.clone = function () { 1003 | return new Cloneable( this.name + '-clone' ); 1004 | }; 1005 | } 1006 | 1007 | assert.deepEqual( 1008 | oo.copy( 1009 | { foo: 'bar', baz: [ 1 ], bat: null, bar: undefined }, 1010 | ( val ) => 'mod-' + val 1011 | ), 1012 | { foo: 'mod-bar', baz: [ 'mod-1' ], bat: 'mod-null', bar: 'mod-undefined' }, 1013 | 'Callback on primitive values' 1014 | ); 1015 | 1016 | assert.deepEqual( 1017 | oo.copy( 1018 | new Cloneable( 'callback' ), 1019 | ( val ) => { 1020 | val.name += '-mod'; 1021 | return val; 1022 | } 1023 | ), 1024 | new Cloneable( 'callback-clone-mod' ), 1025 | 'Callback on cloneables (top-level)' 1026 | ); 1027 | 1028 | assert.deepEqual( 1029 | oo.copy( 1030 | [ new Cloneable( 'callback' ) ], 1031 | ( val ) => { 1032 | val.name += '-mod'; 1033 | return val; 1034 | } 1035 | ), 1036 | [ new Cloneable( 'callback-clone-mod' ) ], 1037 | 'Callback on cloneables (as array elements)' 1038 | ); 1039 | 1040 | assert.deepEqual( 1041 | oo.copy( 1042 | nodeLike, 1043 | ( val ) => { 1044 | val += ' leaf'; 1045 | return val; 1046 | } 1047 | ), 1048 | 'cloned node leaf', 1049 | 'Node-like object' 1050 | ); 1051 | } ); 1052 | 1053 | QUnit.test( 'copy( source, Function leafCallback, Function nodeCallback )', ( assert ) => { 1054 | function Cloneable( name ) { 1055 | this.name = name; 1056 | this.clone = function () { 1057 | return new Cloneable( this.name + '-clone' ); 1058 | }; 1059 | } 1060 | 1061 | assert.deepEqual( 1062 | oo.copy( 1063 | { foo: 'bar', baz: [ 1 ], bat: null, bar: undefined }, 1064 | ( val ) => 'mod-' + val, 1065 | ( val ) => { 1066 | if ( Array.isArray( val ) ) { 1067 | return [ 2 ]; 1068 | } 1069 | if ( val === undefined ) { 1070 | return '!'; 1071 | } 1072 | } 1073 | ), 1074 | { foo: 'mod-bar', baz: [ 2 ], bat: 'mod-null', bar: '!' }, 1075 | 'Callback to override array clone' 1076 | ); 1077 | 1078 | assert.deepEqual( 1079 | oo.copy( 1080 | [ 1081 | new Cloneable( 'callback' ), 1082 | new Cloneable( 'extension' ) 1083 | ], 1084 | ( val ) => { 1085 | val.name += '-mod'; 1086 | return val; 1087 | }, 1088 | ( val ) => { 1089 | if ( val && val.name === 'extension' ) { 1090 | return { type: 'extension' }; 1091 | } 1092 | } 1093 | ), 1094 | [ new Cloneable( 'callback-clone-mod' ), { type: 'extension' } ], 1095 | 'Extension callback overriding cloneables' 1096 | ); 1097 | } ); 1098 | 1099 | QUnit.test( 'getHash: Basic usage', ( assert ) => { 1100 | const cases = {}, 1101 | hash = '{"a":1,"b":1,"c":1}', 1102 | customHash = '{"first":1,"last":1}'; 1103 | 1104 | cases[ 'a-z literal' ] = { 1105 | object: { 1106 | a: 1, 1107 | b: 1, 1108 | c: 1 1109 | }, 1110 | hash: hash 1111 | }; 1112 | 1113 | cases[ 'z-a literal' ] = { 1114 | object: { 1115 | c: 1, 1116 | b: 1, 1117 | a: 1 1118 | }, 1119 | hash: hash 1120 | }; 1121 | 1122 | const tmp1 = {}; 1123 | cases[ 'a-z augmented' ] = { 1124 | object: tmp1, 1125 | hash: hash 1126 | }; 1127 | tmp1.a = 1; 1128 | tmp1.b = 1; 1129 | tmp1.c = 1; 1130 | 1131 | const tmp2 = {}; 1132 | cases[ 'z-a augmented' ] = { 1133 | object: tmp2, 1134 | hash: hash 1135 | }; 1136 | tmp2.c = 1; 1137 | tmp2.b = 1; 1138 | tmp2.a = 1; 1139 | 1140 | cases[ 'custom hash' ] = { 1141 | object: { 1142 | getHashObject: function () { 1143 | return { 1144 | first: 1, 1145 | last: 1 1146 | }; 1147 | } 1148 | }, 1149 | hash: customHash 1150 | }; 1151 | 1152 | cases[ 'custom hash reversed' ] = { 1153 | object: { 1154 | getHashObject: function () { 1155 | return { 1156 | last: 1, 1157 | first: 1 1158 | }; 1159 | } 1160 | }, 1161 | hash: customHash 1162 | }; 1163 | 1164 | for ( const key in cases ) { 1165 | assert.strictEqual( 1166 | oo.getHash( cases[ key ].object ), 1167 | cases[ key ].hash, 1168 | key + ': object has expected hash, regardless of "property order"' 1169 | ); 1170 | } 1171 | 1172 | // .. and that something completely different is in face different 1173 | // (just incase getHash is broken and always returns the same) 1174 | assert.notStrictEqual( 1175 | oo.getHash( { a: 2, b: 2 } ), 1176 | hash, 1177 | 'A different object has a different hash' 1178 | ); 1179 | } ); 1180 | 1181 | QUnit.test( 'getHash: Complex usage', ( assert ) => { 1182 | const obj = { 1183 | a: 1, 1184 | b: 1, 1185 | c: 1, 1186 | // Nested array 1187 | d: [ 'x', 'y', 'z' ], 1188 | e: { 1189 | a: 2, 1190 | b: 2, 1191 | c: 2 1192 | } 1193 | }; 1194 | 1195 | assert.strictEqual( 1196 | oo.getHash( obj ), 1197 | '{"a":1,"b":1,"c":1,"d":["x","y","z"],"e":{"a":2,"b":2,"c":2}}', 1198 | 'Object with nested array and nested object' 1199 | ); 1200 | 1201 | // Include a circular reference 1202 | /* 1203 | * PhantomJS hangs when calling JSON.stringify with an object containing a 1204 | * circular reference (https://github.com/ariya/phantomjs/issues/11206). 1205 | * We know latest Chrome/Firefox and IE 11 support this. So, for the sake of 1206 | * having qunit/phantomjs work, lets disable this for now. 1207 | obj.f = obj; 1208 | 1209 | assert.throws( function () { 1210 | oo.getHash( obj ); 1211 | }, 'Throw exceptions for objects with cirular references ' ); 1212 | */ 1213 | 1214 | function Foo() { 1215 | this.a = 1; 1216 | this.c = 3; 1217 | this.b = 2; 1218 | } 1219 | 1220 | const hash = '{"a":1,"b":2,"c":3}'; 1221 | 1222 | assert.strictEqual( 1223 | oo.getHash( new Foo() ), 1224 | hash, 1225 | // This was previously broken when we used .constructor === Object 1226 | // oo.getHash.keySortReplacer, because although instances of Foo 1227 | // do inherit from Object (( new Foo() ) instanceof Object === true), 1228 | // direct comparison would return false. 1229 | 'Treat objects constructed by a function as well' 1230 | ); 1231 | } ); 1232 | 1233 | if ( global.document ) { 1234 | QUnit.test( 'getHash( iframe Object )', ( assert ) => { 1235 | const IframeObject = QUnit.tmpIframe().contentWindow.Object; 1236 | const obj = new IframeObject(); 1237 | obj.c = 3; 1238 | obj.b = 2; 1239 | obj.a = 1; 1240 | 1241 | const hash = '{"a":1,"b":2,"c":3}'; 1242 | 1243 | assert.strictEqual( 1244 | oo.getHash( obj ), 1245 | hash, 1246 | // This was previously broken when we used comparison with "Object" in 1247 | // oo.getHash.keySortReplacer, because they are an instance of the other 1248 | // window's "Object". 1249 | 'Treat objects constructed by a another window as well' 1250 | ); 1251 | } ); 1252 | } 1253 | 1254 | QUnit.test( 'unique', ( assert ) => { 1255 | 1256 | assert.deepEqual( 1257 | oo.unique( [] ), 1258 | [], 1259 | 'Empty' 1260 | ); 1261 | 1262 | assert.deepEqual( 1263 | oo.unique( [ 'a', 'b', 'a' ] ), 1264 | [ 'a', 'b' ], 1265 | 'Simple string duplication' 1266 | ); 1267 | 1268 | assert.deepEqual( 1269 | oo.unique( [ 'o', 'o', 'j', 's' ] ), 1270 | [ 'o', 'j', 's' ], 1271 | 'Simple string duplication' 1272 | ); 1273 | 1274 | assert.deepEqual( 1275 | oo.unique( [ 3, 3, 2, 4, 3, 1, 2, 1, 1, 2 ] ), 1276 | [ 3, 2, 4, 1 ], 1277 | 'Simple number duplication' 1278 | ); 1279 | 1280 | assert.deepEqual( 1281 | oo.unique( [ 1, '1', 1, '1', { a: 1 }, { a: 1 } ] ), 1282 | [ 1, '1', { a: 1 }, { a: 1 } ], 1283 | 'Strict equality de-duplication only' 1284 | ); 1285 | 1286 | const obj = {}; 1287 | assert.deepEqual( 1288 | oo.unique( [ obj, obj ] ), 1289 | [ obj ], 1290 | 'Object identity de-duplication' 1291 | ); 1292 | 1293 | assert.deepEqual( 1294 | oo.unique( [ 1, 2, 3 ] ), 1295 | [ 1, 2, 3 ], 1296 | 'No duplication' 1297 | ); 1298 | 1299 | } ); 1300 | 1301 | QUnit.test( 'simpleArrayUnion', ( assert ) => { 1302 | 1303 | assert.deepEqual( 1304 | oo.simpleArrayUnion( [] ), 1305 | [], 1306 | 'Empty' 1307 | ); 1308 | 1309 | assert.deepEqual( 1310 | oo.simpleArrayUnion( [ 'a', 'b', 'a' ] ), 1311 | [ 'a', 'b' ], 1312 | 'Single array with dupes' 1313 | ); 1314 | 1315 | assert.deepEqual( 1316 | oo.simpleArrayUnion( [ 'a', 'b', 'a' ], [ 'c', 'd', 'c' ] ), 1317 | [ 'a', 'b', 'c', 'd' ], 1318 | 'Multiple arrays with their own dupes' 1319 | ); 1320 | 1321 | assert.deepEqual( 1322 | oo.simpleArrayUnion( [ 'a', 'b', 'a', 'c' ], [ 'c', 'd', 'c', 'a' ] ), 1323 | [ 'a', 'b', 'c', 'd' ], 1324 | 'Multiple arrays with mixed dupes' 1325 | ); 1326 | 1327 | assert.deepEqual( 1328 | oo.simpleArrayUnion( 1329 | [ 1, 2, 1, 2, true, { a: 1 } ], 1330 | [ 3, 3, 2, 1, false, { b: 2 } ] 1331 | ), 1332 | [ 1, 2, true, { a: 1 }, 3, false, { b: 2 } ], 1333 | 'Objects are supported' 1334 | ); 1335 | 1336 | } ); 1337 | 1338 | QUnit.test( 'simpleArrayIntersection', ( assert ) => { 1339 | 1340 | assert.deepEqual( 1341 | oo.simpleArrayIntersection( [], [] ), 1342 | [], 1343 | 'Empty' 1344 | ); 1345 | 1346 | assert.deepEqual( 1347 | oo.simpleArrayIntersection( 1348 | [ 'a', 'b', 'c', 'a' ], 1349 | [ 'b', 'c', 'd', 'c' ] 1350 | ), 1351 | [ 'b', 'c' ], 1352 | 'Simple' 1353 | ); 1354 | 1355 | } ); 1356 | 1357 | QUnit.test( 'simpleArrayDifference', ( assert ) => { 1358 | 1359 | assert.deepEqual( 1360 | oo.simpleArrayDifference( [], [] ), 1361 | [], 1362 | 'Empty' 1363 | ); 1364 | 1365 | assert.deepEqual( 1366 | oo.simpleArrayDifference( 1367 | [ 'a', 'b', 'c', 'a' ], 1368 | [ 'b', 'c', 'd', 'c' ] 1369 | ), 1370 | [ 'a', 'a' ], 1371 | 'Simple' 1372 | ); 1373 | 1374 | } ); 1375 | 1376 | }( OO, this ) ); 1377 | -------------------------------------------------------------------------------- /tests/unit/util.test.js: -------------------------------------------------------------------------------- 1 | ( function ( oo, global ) { 2 | 3 | QUnit.module( 'util' ); 4 | 5 | QUnit.test( 'isPlainObject', ( assert ) => { 6 | function Thing() {} 7 | 8 | // Plain objects 9 | assert.strictEqual( oo.isPlainObject( {} ), true, 'empty plain object' ); 10 | assert.strictEqual( oo.isPlainObject( { a: 1 } ), true, 'non-empty plain object' ); 11 | assert.strictEqual( oo.isPlainObject( Object.create( null ) ), true, 'empty object with no prototype, via Object.create( null )' ); 12 | let obj = Object.create( null ); 13 | obj.foo = true; 14 | assert.strictEqual( oo.isPlainObject( obj ), true, 'non-empty object with no prototype' ); 15 | 16 | // Non-plain objects (any inheritance other than Object.prototype is not plain) 17 | obj = Object.create( Object.create( null ) ); 18 | assert.strictEqual( oo.isPlainObject( obj ), false, 'empty object inheriting from object with no prototype' ); 19 | obj.foo = true; 20 | assert.strictEqual( oo.isPlainObject( obj ), false, 'non-empty object inheriting from object with no prototype' ); 21 | obj = Object.create( {} ); 22 | assert.strictEqual( oo.isPlainObject( obj ), false, 'object inheriting from plain object' ); 23 | 24 | // Primitives 25 | assert.strictEqual( oo.isPlainObject( undefined ), false, 'undefined' ); 26 | assert.strictEqual( oo.isPlainObject( null ), false, 'null' ); 27 | assert.strictEqual( oo.isPlainObject( false ), false, 'boolean false' ); 28 | assert.strictEqual( oo.isPlainObject( true ), false, 'boolean true' ); 29 | assert.strictEqual( oo.isPlainObject( 0 ), false, 'number 0' ); 30 | assert.strictEqual( oo.isPlainObject( 42 ), false, 'positive number' ); 31 | assert.strictEqual( oo.isPlainObject( -42 ), false, 'negative number' ); 32 | assert.strictEqual( oo.isPlainObject( '' ), false, 'empty string' ); 33 | assert.strictEqual( oo.isPlainObject( 'a' ), false, 'non-empty string' ); 34 | 35 | // Objects that inherit from Object but are not plain objects 36 | assert.strictEqual( oo.isPlainObject( [] ), false, 'instance of Array' ); 37 | assert.strictEqual( oo.isPlainObject( new Date() ), false, 'instance of Date' ); 38 | assert.strictEqual( oo.isPlainObject( Thing ), false, 'instance of Function' ); 39 | assert.strictEqual( oo.isPlainObject( new Thing() ), false, 'Instance of constructor function with empty prototype' ); 40 | 41 | // Add method to the prototype 42 | Thing.prototype.time = function () {}; 43 | 44 | assert.strictEqual( oo.isPlainObject( new Thing() ), false, 'Instance of constructor function with prototype' ); 45 | } ); 46 | 47 | if ( global.document ) { 48 | QUnit.test( 'isPlainObject - browser specific', ( assert ) => { 49 | assert.strictEqual( 50 | oo.isPlainObject( global.document.createElement( 'div' ) ), 51 | false, 52 | 'instance of HTMLElement' 53 | ); 54 | 55 | assert.strictEqual( 56 | oo.isPlainObject( global.document ), 57 | false, 58 | 'instance of Document' 59 | ); 60 | 61 | assert.strictEqual( 62 | oo.isPlainObject( global ), 63 | false, 64 | 'instance of Window' 65 | ); 66 | 67 | const IframeObject = QUnit.tmpIframe().contentWindow.Object; 68 | 69 | assert.strictEqual( 70 | typeof IframeObject, 71 | 'function', 72 | 'Object constructor found' 73 | ); 74 | 75 | assert.notStrictEqual( 76 | IframeObject, 77 | Object, 78 | 'Object constructor from other window is different' 79 | ); 80 | 81 | assert.strictEqual( 82 | oo.isPlainObject( new IframeObject() ), 83 | true, 84 | 'instance of iframeObject' 85 | ); 86 | 87 | // https://bugzilla.mozilla.org/814622 88 | let threw = false; 89 | try { 90 | oo.isPlainObject( global.location ); 91 | } catch ( e ) { 92 | threw = true; 93 | } 94 | assert.strictEqual( threw, false, 'native Location object' ); 95 | } ); 96 | } 97 | 98 | }( OO, this ) ); 99 | --------------------------------------------------------------------------------