From b7c938a9639c71457836e12f71a0ac222895f188 Mon Sep 17 00:00:00 2001 From: liabru Date: Mon, 30 Sep 2019 23:28:17 +0100 Subject: [PATCH] Added build comparison tools and tests --- .gitignore | 3 +- CONTRIBUTING.md | 2 + demo/index.html | 21 ++++ demo/js/Compare.js | 179 +++++++++++++++++++++++++++++ demo/js/Demo.js | 13 ++- package-lock.json | 12 +- package.json | 6 +- test/Common.js | 119 ------------------- test/Examples.spec.js | 137 +++++++++------------- test/TestTools.js | 257 ++++++++++++++++++++++++++++++++++++++++++ webpack.config.js | 3 +- 11 files changed, 543 insertions(+), 209 deletions(-) create mode 100644 demo/js/Compare.js delete mode 100644 test/Common.js create mode 100644 test/TestTools.js diff --git a/.gitignore b/.gitignore index 3ea218b..baa4e68 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ test/browser/diffs test/browser/refs test/node/diffs test/node/refs -__snapshots__ \ No newline at end of file +__snapshots__ +__compare__ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2e6233..242a4bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,5 +34,7 @@ creates a release build runs the linter - **npm run test** runs the tests +- **npm run compare** +compares the output of examples for current source against release build - **npm run doc** builds the documentation diff --git a/demo/index.html b/demo/index.html index 56ec2d5..b6898cf 100644 --- a/demo/index.html +++ b/demo/index.html @@ -82,6 +82,27 @@ margin: 0; padding: 0; } + + .matter-js-compare-build { + position: absolute; + background: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; + z-index: 1; + pointer-events: none; + } + + .matter-js-compare-build .matter-header-outer { + display: none; + } + + .matter-js-compare-build canvas { + opacity: 0.5; + background: transparent !important; + } diff --git a/demo/js/Compare.js b/demo/js/Compare.js new file mode 100644 index 0000000..a38619e --- /dev/null +++ b/demo/js/Compare.js @@ -0,0 +1,179 @@ +/** +* A Matter.js build comparison testbed. +* +* Tool for Matter.js maintainers to compare results of +* the current source build with the release build in the browser. +* +* USAGE: open http://localhost:8000/?compare=120#mixed +* +* NOTE: For the actual example code, refer to the source files in `/examples/`. +* +* @class Compare +*/ + +(function() { + // maintain reference to dev version of Matter already loaded + var MatterDev = window.Matter; + + // load the build version of Matter + var matterBuildScript = document.createElement('script'); + matterBuildScript.src = '../build/matter.min.js'; + + // wait for load + matterBuildScript.addEventListener('load', function() { + var examples = window.MatterDemo.examples; + + // maintain reference of build version and set dev version as main + var MatterBuild = window.Matter; + window.Matter = MatterDev; + + var demo = MatterTools.Demo.create({ + toolbar: { + title: 'matter-js', + url: 'https://github.com/liabru/matter-js', + reset: true, + source: true, + inspector: true, + tools: true, + fullscreen: true, + exampleSelect: true + }, + tools: { + inspector: true, + gui: true + }, + inline: false, + preventZoom: true, + resetOnOrientation: true, + routing: true, + startExample: 'mixed', + examples: examples + }); + + var demoBuild = MatterTools.Demo.create({ + toolbar: { + title: 'matter-js-compare-build', + reset: false, + source: false, + inspector: false, + tools: false, + fullscreen: false, + exampleSelect: false + }, + tools: { + inspector: false, + gui: false + }, + inline: false, + preventZoom: true, + resetOnOrientation: true, + routing: false, + startExample: 'mixed', + examples: examples.map(function(example) { + return Matter.Common.extend({}, example); + }) + }); + + /** + * NOTE: For the actual example code, refer to the source files in `/examples/`. + * The code below is tooling for Matter.js maintainers to compare versions of Matter. + */ + + // build version should not run itself + MatterBuild.Runner.run = function() {}; + MatterBuild.Render.run = function() {}; + + // maintain original references to patched methods + MatterDev.Runner._tick = MatterDev.Runner.tick; + MatterDev.Render._world = MatterDev.Render.world; + MatterBuild.Mouse._setElement = MatterBuild.Mouse.setElement; + + // patch MatterTools to control both demo versions simultaneously + MatterTools.Demo._setExample = MatterTools.Demo.setExample; + MatterTools.Demo.setExample = function(_demo, example) { + MatterBuild.Common._nextId = MatterBuild.Common._seed = 0; + MatterDev.Common._nextId = MatterDev.Common._seed = 0; + + MatterBuild.Plugin._registry = MatterDev.Plugin._registry; + MatterBuild.use.apply(null, MatterDev.used); + + window.Matter = MatterDev; + MatterTools.Demo._setExample( + demo, demo.examples.find(function(e) { return e.name === example.name; }) + ); + + var maxTicks = parseFloat(window.location.search.split('=')[1]); + var ticks = 0; + + MatterDev.Runner.tick = function(runner, engine, time) { + if (ticks === -1) { + return; + } + + if (ticks >= maxTicks) { + console.info( + 'Demo.Compare: ran ' + ticks + ' ticks, timestamp is now ' + + engine.timing.timestamp.toFixed(2) + ); + + ticks = -1; + + return; + } + + ticks += 1; + + var demoBuildInstance = demoBuild.example.instance; + runner.isFixed = demoBuildInstance.runner.isFixed = true; + runner.delta = demoBuildInstance.runner.delta = 1000 / 60; + + window.Matter = MatterBuild; + MatterBuild.Runner.tick(demoBuildInstance.runner, demoBuildInstance.engine, time); + window.Matter = MatterDev; + return MatterDev.Runner._tick(runner, engine, time); + }; + + MatterDev.Render.world = function(render) { + window.Matter = MatterBuild; + MatterBuild.Render.world(demoBuild.example.instance.render); + window.Matter = MatterDev; + return MatterDev.Render._world(render); + }; + + MatterBuild.Mouse.setElement = function(mouse) { + return MatterBuild.Mouse._setElement(mouse, demo.example.instance.render.canvas); + }; + + window.Matter = MatterBuild; + MatterTools.Demo._setExample( + demoBuild, demoBuild.examples.find(function(e) { return e.name === example.name; }) + ); + + window.Matter = MatterDev; + }; + + // reset both engine versions simultaneously + MatterTools.Demo._reset = MatterTools.Demo.reset; + MatterTools.Demo.reset = function(_demo) { + MatterBuild.Common._nextId = MatterBuild.Common._seed = 0; + MatterDev.Common._nextId = MatterDev.Common._seed = 0; + + window.Matter = MatterBuild; + MatterTools.Demo._reset(demoBuild); + + window.Matter = MatterDev; + MatterTools.Demo._reset(demo); + }; + + document.body.appendChild(demo.dom.root); + document.body.appendChild(demoBuild.dom.root); + + MatterTools.Demo.start(demo); + + console.info( + 'Demo.Compare: comparing matter-js@' + MatterDev.version + ' with matter-js@' + MatterBuild.version + ); + }); + + document.body.append(matterBuildScript); +})(); diff --git a/demo/js/Demo.js b/demo/js/Demo.js index aa910f8..c99888c 100644 --- a/demo/js/Demo.js +++ b/demo/js/Demo.js @@ -1,5 +1,8 @@ /** -* The Matter.js demo page controller and example runner. +* The Matter.js development demo and testing tool. +* +* This demo uses MatterTools, you can see the wiki for a simple example instead: +* https://github.com/liabru/matter-js/wiki/Getting-started * * NOTE: For the actual example code, refer to the source files in `/examples/`. * @@ -65,6 +68,14 @@ } } + if (window.location.search.indexOf('compare') >= 0) { + var compareScript = document.createElement('script'); + compareScript.src = '../js/Compare.js'; + window.MatterDemo = { examples: examples }; + document.body.append(compareScript); + return; + } + var demo = MatterTools.Demo.create({ toolbar: { title: 'matter-js', diff --git a/package-lock.json b/package-lock.json index 9359b8c..f804b71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5562,9 +5562,9 @@ "dev": true }, "handlebars": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.2.0.tgz", - "integrity": "sha512-Kb4xn5Qh1cxAKvQnzNWZ512DhABzyFNmsaJf3OAkWNa4NkaqWcNI8Tao8Tasi0/F4JD9oyG0YxuFyvyR57d+Gw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.3.4.tgz", + "integrity": "sha512-vvpo6mpK4ScNC1DbGRZ2d5BznS6ht0r1hi20RivsibMc6jNvFAeZQ6qk5VNspo6SOwVOJQbjHyBCpuS7BzA1pw==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -7224,6 +7224,12 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, + "json-stringify-pretty-compact": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz", + "integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==", + "dev": true + }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", diff --git a/package.json b/package.json index d895fa9..9729f10 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "gulp-tag-version": "^1.3.0", "gulp-util": "^3.0.8", "jest": "^24.9.0", + "json-stringify-pretty-compact": "^2.0.0", "run-sequence": "^1.1.4", "webpack": "^4.39.3", "webpack-cli": "^3.3.8", @@ -37,11 +38,10 @@ "build": "webpack --mode=production & webpack --mode=production --env.MINIMIZE", "build-alpha": "webpack --mode=production --env.EDGE & webpack --mode=production --env.MINIMIZE --env.EDGE", "build-examples": "webpack --config webpack.examples.config.js --mode=production --env.EDGE & webpack --config webpack.examples.config.js --mode=production --env.MINIMIZE --env.EDGE", - "lint": "eslint 'src/**/*.js' 'demo/js/Demo.js' 'examples/*.js' 'test/*.spec.js' 'webpack.*.js' 'Gulpfile.js'", + "lint": "eslint 'src/**/*.js' 'demo/js/Demo.js' 'demo/js/Compare.js' 'examples/*.js' 'test/*.spec.js' 'webpack.*.js' 'Gulpfile.js'", "doc": "gulp doc", "test": "jest", - "test-snapshot": "TEST_SNAPSHOTS=true jest --ci", - "test-snapshot-update": "TEST_SNAPSHOTS=true jest -u" + "compare": "COMPARE=true jest" }, "dependencies": {}, "files": [ diff --git a/test/Common.js b/test/Common.js deleted file mode 100644 index 84fe962..0000000 --- a/test/Common.js +++ /dev/null @@ -1,119 +0,0 @@ -const { Composite, Constraint, Vertices } = require('../src/module/main'); - -const includeKeys = [ - // Common - 'id', 'label', - - // Constraint - 'angularStiffness', 'bodyA', 'bodyB', 'pointA', 'pointB', 'damping', 'length', 'stiffness', - - // Body - 'angle', 'anglePrev', 'area', 'axes', 'bounds', 'min', 'max', 'x', 'y', 'collisionFilter', 'category', 'mask', - 'group', 'density', 'friction', 'frictionAir', 'frictionStatic', 'inertia', 'inverseInertia', 'inverseMass', 'isSensor', - 'isSleeping', 'isStatic', 'mass', 'parent', 'parts', 'position', 'positionPrev', 'restitution', 'sleepThreshold', 'slop', - 'timeScale', 'vertices' -]; - -const limit = (val, precision=3) => { - if (typeof val === 'number') { - return parseFloat(val.toPrecision(precision)); - } - - return val; -}; - -const engineSnapshot = (engine, extended=false) => { - const { - positionIterations, velocityIterations, - constraintIterations, timing, world - } = engine; - - const bodies = Composite.allBodies(world); - const constraints = Composite.allConstraints(world); - const composites = Composite.allComposites(world); - - return { - positionIterations, - velocityIterations, - constraintIterations, - timing, - bodyCount: bodies.length, - constraintCount: constraints.length, - compositeCount: composites.length, - averageBodyPosition: Vertices.mean(bodies.map(body => body.position)), - averageBodyPositionPrev: Vertices.mean(bodies.map(body => body.positionPrev)), - averageBodyAngle: bodies.reduce((angle, body) => angle + body.angle, 0) / bodies.length, - averageBodyAnglePrev: bodies.reduce((angle, body) => angle + body.anglePrev, 0) / bodies.length, - averageConstraintPosition: Vertices.mean( - constraints.reduce((positions, constraint) => { - positions.push( - Constraint.pointAWorld(constraint), - Constraint.pointBWorld(constraint) - ); - return positions; - }, []).concat({ x: 0, y: 0 }) - ), - world: extended ? worldSnapshotExtended(engine.world) : worldSnapshot(engine.world) - }; -}; - -const worldSnapshot = world => ({ - ...Composite.allBodies(world).reduce((bodies, body) => { - bodies[`${body.id} ${body.label}`] = - `${limit(body.position.x)} ${limit(body.position.y)} ${limit(body.angle)}` - + ` ${limit(body.position.x - body.positionPrev.x)} ${limit(body.position.y - body.positionPrev.y)}` - + ` ${limit(body.angle - body.anglePrev)}`; - return bodies; - }, {}), - ...Composite.allConstraints(world).reduce((constraints, constraint) => { - const positionA = Constraint.pointAWorld(constraint); - const positionB = Constraint.pointBWorld(constraint); - - constraints[`${constraint.id} ${constraint.label}`] = - `${limit(positionA.x)} ${limit(positionA.y)} ${limit(positionB.x)} ${limit(positionB.y)}` - + ` ${constraint.bodyA ? constraint.bodyA.id : null} ${constraint.bodyB ? constraint.bodyB.id : null}`; - - return constraints; - }, {}) -}); - -const worldSnapshotExtended = world => worldSnapshotExtendedBase({ - ...Composite.allBodies(world).reduce((bodies, body) => { - bodies[body.id] = body; - return bodies; - }, {}), - ...Composite.allConstraints(world).reduce((constraints, constraint) => { - constraints[constraint.id] = constraint; - return constraints; - }, {}) -}); - -const worldSnapshotExtendedBase = (obj, depth=0) => { - if (typeof obj === 'number') { - return limit(obj); - } - - if (Array.isArray(obj)) { - return obj.map(item => worldSnapshotExtendedBase(item, depth + 1)); - } - - if (typeof obj !== 'object') { - return obj; - } - - return Object.entries(obj) - .filter(([key]) => depth === 0 || includeKeys.includes(key)) - .reduce((cleaned, [key, val]) => { - if (val && val.id && String(val.id) !== key) { - val = val.id; - } - - if (Array.isArray(val)) { - val = `[${val.length}]`; - } - - return { ...cleaned, [key]: worldSnapshotExtendedBase(val, depth + 1) }; - }, {}); -}; - -module.exports = { engineSnapshot }; \ No newline at end of file diff --git a/test/Examples.spec.js b/test/Examples.spec.js index 7d1f791..1cef0fe 100644 --- a/test/Examples.spec.js +++ b/test/Examples.spec.js @@ -2,97 +2,72 @@ /* eslint no-global-assign: 0 */ "use strict"; -const Common = require('./Common'); -const fs = require('fs'); -const execSync = require('child_process').execSync; -const useSnapshots = process.env.TEST_SNAPSHOTS === 'true'; -const useBuild = process.env.TEST_BUILD === 'true'; +const { + stubBrowserFeatures, engineSnapshot, toMatchExtrinsics, toMatchIntrinsics +} = require('./TestTools'); -console.info(`Testing Matter from ${useBuild ? `build '../build/matter'.` : `source '../src/module/main'.`}`); +const totalUpdates = 120; +const isCompare = process.env.COMPARE === 'true'; +const excludeExamples = ['stress', 'stress2', 'svg', 'terrain']; -// mock modules -if (useBuild) { - jest.mock('matter-js', () => require('../build/matter'), { virtual: true }); -} else { - jest.mock('matter-js', () => require('../src/module/main'), { virtual: true }); -} +const MatterBuild = require('../build/matter'); +const MatterDev = require('../src/module/main'); jest.mock('matter-wrap', () => require('../demo/lib/matter-wrap'), { virtual: true }); jest.mock('poly-decomp', () => require('../demo/lib/decomp'), { virtual: true }); -// import mocked Matter and plugins -const Matter = global.Matter = require('matter-js'); -Matter.Plugin.register(require('matter-wrap')); +const runExamples = (matter) => { + let snapshots = {}; + matter = stubBrowserFeatures(matter); + global.Matter = matter; + matter.Plugin.register(require('matter-wrap')); -// import Examples after Matter -const Example = require('../examples/index'); + const Example = require('../examples/index'); + const examples = Object.keys(Example).filter(key => !excludeExamples.includes(key)); -// stub out browser-only functions -const noop = () => ({ collisionFilter: {}, mouse: {} }); -Matter.Render.create = () => ({ options: {}, bounds: { min: { x: 0, y: 0 }, max: { x: 800, y: 600 }}}); -Matter.Render.run = Matter.Render.lookAt = noop; -Matter.Runner.create = Matter.Runner.run = noop; -Matter.MouseConstraint.create = Matter.Mouse.create = noop; -Matter.Common.log = Matter.Common.info = Matter.Common.warn = noop; + const consoleOriginal = global.console; + global.console = { log: () => {} }; -// check initial snapshots if enabled (experimental) -if (useSnapshots && !fs.existsSync('./test/__snapshots__')) { - const gitState = execSync('git log -n 1 --pretty=%d HEAD').toString().trim(); - const gitIsClean = execSync('git status --porcelain').toString().trim().length === 0; - const gitIsMaster = gitState.startsWith('(HEAD -> master, origin/master'); + for (name of examples) { + matter.Common._nextId = matter.Common._seed = 0; - if (!gitIsMaster || !gitIsClean) { - throw `Snapshots are experimental and are not currently committed due to size. - Stash changes and switch to HEAD on origin/master. - Use 'npm run test-snapshot-update' to generate initial snapshots. - Then run 'npm run test-snapshot' to test against these snapshots. - Currently on ${gitState}. - `; - } -} + const example = Example[name](); + const engine = example.engine; -// prevent examples from logging -const consoleOriginal = console; -beforeEach(() => { global.console = { log: noop }; }); -afterEach(() => { global.console = consoleOriginal; }); - -// list the examples to test -const examplesExtended = ['constraints']; -const examples = [ - 'airFriction', 'ballPool', 'bridge', 'broadphase', 'car', 'catapult', 'chains', 'circleStack', - 'cloth', 'collisionFiltering', 'compositeManipulation', 'compound', 'compoundStack', 'concave', - 'constraints', 'doublePendulum', 'events', 'friction', 'gravity', 'gyro', 'manipulation', 'mixed', - 'newtonsCradle', 'ragdoll', 'pyramid', 'raycasting', 'restitution', 'rounded', 'sensors', 'sleeping', - 'slingshot', 'softBody', 'sprites', 'stack', 'staticFriction', 'timescale', 'views', 'wreckingBall' -]; - -// perform integration tests using listed examples -const testName = `Example.%s simulates without throwing${useSnapshots ? ' and matches snapshot' : ''}`; -test.each(examples.map(key => [key]))(testName, exampleName => { - let engine, startSnapshot, endSnapshot; - - const simulate = () => { - const example = Example[exampleName](); - const extended = examplesExtended.includes(exampleName); - engine = example.engine; - startSnapshot = Common.engineSnapshot(engine, extended); - - for (let i = 0; i < 100; i += 1) { - Matter.Engine.update(engine, 1000 / 60); - } - - endSnapshot = Common.engineSnapshot(engine, extended); - }; - - // simulate and assert nothing is thrown - expect(simulate).not.toThrow(); - - // assert there has been some change to the world - expect(startSnapshot.world).not.toEqual(endSnapshot.world); - - // compare to stored snapshot (experimental) - if (useSnapshots) { - expect(endSnapshot).toMatchSnapshot(); + for (let i = 0; i < totalUpdates; i += 1) { + matter.Engine.update(engine, 1000 / 60); } + + snapshots[name] = isCompare ? engineSnapshot(engine) : {}; } -); \ No newline at end of file + + global.console = consoleOriginal; + global.Matter = undefined; + return snapshots; +}; + +const snapshotsDev = runExamples(MatterDev); +const snapshotsBuild = runExamples(MatterBuild); +const examples = Object.keys(snapshotsDev); + +describe(`Integration tests (${examples.length})`, () => { + test(`Examples run without throwing`, () => { + expect(Object.keys(snapshotsDev)).toEqual(examples); + expect(Object.keys(snapshotsBuild)).toEqual(examples); + }); +}); + +if (isCompare) { + describe(`Regression tests (${examples.length})`, () => { + expect.extend(toMatchExtrinsics); + expect.extend(toMatchIntrinsics); + + test(`Examples match properties with release build`, () => { + expect(snapshotsDev).toMatchIntrinsics(snapshotsBuild, totalUpdates); + }); + + test(`Examples match positions and velocities with release build`, () => { + expect(snapshotsDev).toMatchExtrinsics(snapshotsBuild, totalUpdates); + }); + }); +} \ No newline at end of file diff --git a/test/TestTools.js b/test/TestTools.js new file mode 100644 index 0000000..e19acec --- /dev/null +++ b/test/TestTools.js @@ -0,0 +1,257 @@ +/* eslint-env es6 */ +"use strict"; + +const fs = require('fs'); +const compactStringify = require('json-stringify-pretty-compact'); +const { Composite, Constraint } = require('../src/module/main'); + +const comparePath = './test/__compare__'; +const compareCommand = 'open http://localhost:8000/?compare' +const diffCommand = 'code -n -d test/__compare__/examples-dev.json test/__compare__/examples-build.json'; + +const intrinsicProps = [ + // Common + 'id', 'label', + + // Constraint + 'angularStiffness', 'bodyA', 'bodyB', 'damping', 'length', 'stiffness', + + // Body + 'area', 'axes', 'collisionFilter', 'category', 'mask', + 'group', 'density', 'friction', 'frictionAir', 'frictionStatic', 'inertia', 'inverseInertia', 'inverseMass', 'isSensor', + 'isSleeping', 'isStatic', 'mass', 'parent', 'parts', 'restitution', 'sleepThreshold', 'slop', + 'timeScale', 'vertices', + + // Composite + 'bodies', 'constraints', 'composites' +]; + +const stubBrowserFeatures = M => { + const noop = () => ({ collisionFilter: {}, mouse: {} }); + M.Render.create = () => ({ options: {}, bounds: { min: { x: 0, y: 0 }, max: { x: 800, y: 600 }}}); + M.Render.run = M.Render.lookAt = noop; + M.Runner.create = M.Runner.run = noop; + M.MouseConstraint.create = M.Mouse.create = noop; + M.Common.log = M.Common.info = M.Common.warn = noop; + return M; +}; + +const colors = { White: 37, BrightWhite: 90, BrightCyan: 36 }; +const color = (text, number) => `\x1b[${number}m${text}\x1b[0m`; +const limit = (val, precision=3) => parseFloat(val.toPrecision(precision)); + +const engineSnapshot = (engine) => ({ + timestamp: limit(engine.timing.timestamp), + world: worldSnapshotExtrinsic(engine.world), + worldIntrinsic: worldSnapshotIntrinsic(engine.world) +}); + +const worldSnapshotExtrinsic = world => ({ + bodies: Composite.allBodies(world).reduce((bodies, body) => { + bodies[body.id] = [ + body.position.x, + body.position.y, + body.positionPrev.x, + body.positionPrev.y, + body.angle, + body.anglePrev, + ...body.vertices.reduce((flat, vertex) => (flat.push(vertex.x, vertex.y), flat), []) + ]; + + return bodies; + }, {}), + constraints: Composite.allConstraints(world).reduce((constraints, constraint) => { + const positionA = Constraint.pointAWorld(constraint); + const positionB = Constraint.pointBWorld(constraint); + + constraints[constraint.id] = [ + positionA.x, + positionA.y, + positionB.x, + positionB.y + ]; + + return constraints; + }, {}) +}); + +const worldSnapshotIntrinsic = world => worldSnapshotIntrinsicBase({ + bodies: Composite.allBodies(world).reduce((bodies, body) => { + bodies[body.id] = body; + return bodies; + }, {}), + constraints: Composite.allConstraints(world).reduce((constraints, constraint) => { + constraints[constraint.id] = constraint; + return constraints; + }, {}), + composites: Composite.allComposites(world).reduce((composites, composite) => { + composites[composite.id] = { + bodies: Composite.allBodies(composite).map(body => body.id), + constraints: Composite.allConstraints(composite).map(constraint => constraint.id), + composites: Composite.allComposites(composite).map(composite => composite.id) + }; + return composites; + }, {}) +}); + +const worldSnapshotIntrinsicBase = (obj, depth=0) => { + if (obj === Infinity) { + return 'Infinity'; + } else if (typeof obj === 'number') { + return limit(obj); + } else if (Array.isArray(obj)) { + return obj.map(item => worldSnapshotIntrinsicBase(item, depth + 1)); + } else if (typeof obj !== 'object') { + return obj; + } + + const result = Object.entries(obj) + .filter(([key]) => depth <= 1 || intrinsicProps.includes(key)) + .reduce((cleaned, [key, val]) => { + if (val && val.id && String(val.id) !== key) { + val = val.id; + } + + if (Array.isArray(val) && !['composites', 'constraints', 'bodies'].includes(key)) { + val = `[${val.length}]`; + } + + cleaned[key] = worldSnapshotIntrinsicBase(val, depth + 1); + return cleaned; + }, {}); + + return Object.keys(result).sort() + .reduce((sorted, key) => (sorted[key] = result[key], sorted), {}); +}; + +const similarity = (a, b) => { + const distance = Math.sqrt(a.reduce((sum, _val, i) => sum + Math.pow(a[i] - b[i], 2), 0)); + return 1 / (1 + (distance / a.length)); +}; + +const snapshotSimilarityExtrinsic = (currentSnapshots, referenceSnapshots) => { + const result = {}; + + Object.entries(currentSnapshots).forEach(([name, current]) => { + const reference = referenceSnapshots[name]; + const worldVector = []; + const worldVectorRef = []; + + Object.keys(current.world).forEach(objectType => { + Object.keys(current.world[objectType]).forEach(objectId => { + worldVector.push(...current.world[objectType][objectId]); + worldVectorRef.push(...reference.world[objectType][objectId]); + }); + }); + + result[name] = similarity(worldVector, worldVectorRef); + }); + + return result; +}; + +const writeSnapshots = (name, obj) => { + try { + fs.mkdirSync(comparePath, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + fs.writeFileSync(`${comparePath}/${name}.json`, compactStringify(obj, { maxLength: 100 }), 'utf8'); +}; + +const toMatchExtrinsics = { + toMatchExtrinsics(received, value, ticks) { + const changed = []; + const borderline = []; + const equal = []; + const similaritys = snapshotSimilarityExtrinsic(received, value); + const entries = Object.entries(similaritys); + + entries.sort(([_nameA, similarityA], [_nameB, similarityB]) => similarityA - similarityB); + + entries.forEach(([name, similarity], i) => { + const percentSimilar = similarity * 100; + + if (percentSimilar < 99.99) { + const col = i < 5 ? colors.White : colors.BrightWhite; + changed.push(color(`◇ ${name}`, col) + ` ${percentSimilar.toFixed(2)}%`); + } else if (percentSimilar !== 100) { + borderline.push(`~ ${name}`); + } else { + equal.push(`✓ ${name}`); + } + }); + + const pass = equal.length === similaritys.length && changed.length === 0 && borderline.length === 0; + + return { + message: () => `Expected positions and velocities to match between builds. + +${color('▶', colors.White)} Debug using ${color(compareCommand + '=' + ticks + '#' + entries[0][0], colors.BrightCyan)} + +(${changed.length}) Changed + + ${changed.join(' ')} + +(${borderline.length}) Borderline (> 99.99%) + + ${borderline.join(' ').slice(0, 80)}... + +(${equal.length}) Equal + + ${equal.join(' ').slice(0, 80)}...`, + pass + }; + } +}; + +const toMatchIntrinsics = { + toMatchIntrinsics(currentSnapshots, referenceSnapshots) { + const changed = []; + const equal = []; + const currentChanged = {}; + const referenceChanged = {}; + const entries = Object.entries(currentSnapshots); + + entries.forEach(([name, current]) => { + const reference = referenceSnapshots[name]; + const endWorld = current.worldIntrinsic; + const endWorldRef = reference.worldIntrinsic; + + if (this.equals(endWorld, endWorldRef)) { + equal.push(`✓ ${name}`); + } else { + changed.push(color(`◇ ${name}`, changed.length < 5 ? colors.White : colors.BrightWhite)); + + if (changed.length < 2) { + currentChanged[name] = endWorld; + referenceChanged[name] = endWorldRef; + } + } + }); + + const pass = equal.length === entries.length && changed.length === 0; + + writeSnapshots('examples-dev', currentChanged); + writeSnapshots('examples-build', referenceChanged); + + return { + message: () => `Expected intrinsic properties to match between builds. + +(${changed.length}) Changed + +${changed.join(' ')} + +(${equal.length}) Equal + +${equal.join(' ').slice(0, 80)}... + +${color('▶', colors.White)} Inspect using ${color(diffCommand, colors.BrightCyan)}`, + pass + }; + } +}; + +module.exports = { + stubBrowserFeatures, engineSnapshot, toMatchExtrinsics, toMatchIntrinsics +}; \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 1a582f8..cd4f5d9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -53,7 +53,8 @@ module.exports = (env = {}) => { devServer: { contentBase: [ path.resolve(__dirname, './demo'), - path.resolve(__dirname, './examples') + path.resolve(__dirname, './examples'), + path.resolve(__dirname, './build') ], open: true, openPage: '',