diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/test/renders/solutions/graphics-rect.json b/test/renders/solutions/graphics-rect.json new file mode 100644 index 0000000..d51ebd2 --- /dev/null +++ b/test/renders/solutions/graphics-rect.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + } +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/test/renders/solutions/graphics-rect.json b/test/renders/solutions/graphics-rect.json new file mode 100644 index 0000000..d51ebd2 --- /dev/null +++ b/test/renders/solutions/graphics-rect.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + } +} \ No newline at end of file diff --git a/test/renders/solutions/sprite-new.json b/test/renders/solutions/sprite-new.json new file mode 100644 index 0000000..c5396a6 --- /dev/null +++ b/test/renders/solutions/sprite-new.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + } +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/test/renders/solutions/graphics-rect.json b/test/renders/solutions/graphics-rect.json new file mode 100644 index 0000000..d51ebd2 --- /dev/null +++ b/test/renders/solutions/graphics-rect.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + } +} \ No newline at end of file diff --git a/test/renders/solutions/sprite-new.json b/test/renders/solutions/sprite-new.json new file mode 100644 index 0000000..c5396a6 --- /dev/null +++ b/test/renders/solutions/sprite-new.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + } +} \ No newline at end of file diff --git a/test/renders/tests/assets/bitmap-1.png b/test/renders/tests/assets/bitmap-1.png new file mode 100644 index 0000000..1284fb8 --- /dev/null +++ b/test/renders/tests/assets/bitmap-1.png Binary files differ diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/test/renders/solutions/graphics-rect.json b/test/renders/solutions/graphics-rect.json new file mode 100644 index 0000000..d51ebd2 --- /dev/null +++ b/test/renders/solutions/graphics-rect.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + } +} \ No newline at end of file diff --git a/test/renders/solutions/sprite-new.json b/test/renders/solutions/sprite-new.json new file mode 100644 index 0000000..c5396a6 --- /dev/null +++ b/test/renders/solutions/sprite-new.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + } +} \ No newline at end of file diff --git a/test/renders/tests/assets/bitmap-1.png b/test/renders/tests/assets/bitmap-1.png new file mode 100644 index 0000000..1284fb8 --- /dev/null +++ b/test/renders/tests/assets/bitmap-1.png Binary files differ diff --git a/test/renders/tests/graphics-rect.js b/test/renders/tests/graphics-rect.js new file mode 100644 index 0000000..1143c96 --- /dev/null +++ b/test/renders/tests/graphics-rect.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = function () +{ + const graphic = new PIXI.Graphics() + .beginFill(0xFFCC00, 1) + .drawRect(8, 8, 16, 16); + + expect(graphic.width).to.equal(16); + expect(graphic.height).to.equal(16); + expect(graphic.x).to.equal(0); + expect(graphic.y).to.equal(0); + + const bounds = graphic.getBounds(); + + expect(bounds.x).to.equal(8); + expect(bounds.y).to.equal(8); + expect(bounds.width).to.equal(16); + expect(bounds.height).to.equal(16); + + this.stage.addChild(graphic); +}; diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +scripts/renders/node_modules/* \ No newline at end of file diff --git a/package.json b/package.json index 94140a4..8cc41a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "watch:lint": "watch \"eslint scripts src test || exit 0\" src", "test": "floss --path test/index.js", "test:debug": "npm test -- --debug", + "prerenders": "npm --prefix scripts/renders i scripts/renders", + "renders": "electron scripts/renders", "precoverage": "rimraf coverage && npm run build -- --noExternal", "coverage": "npm test -- -c bin/pixi.js -s -h", "lint": "eslint scripts src test --max-warnings 0", @@ -66,6 +68,7 @@ "floss": "^1.2.0", "gh-pages": "^0.11.0", "jaguarjs-jsdoc": "^1.0.1", + "js-md5": "^0.4.1", "jsdoc": "^3.4.2", "minimist": "^1.2.0", "mkdirp": "^0.5.1", diff --git a/scripts/renders/.eslintrc.json b/scripts/renders/.eslintrc.json new file mode 100644 index 0000000..5a103d2 --- /dev/null +++ b/scripts/renders/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "globals": { + "document": false, + "PIXI": false + } +} \ No newline at end of file diff --git a/scripts/renders/client.css b/scripts/renders/client.css new file mode 100755 index 0000000..b544283 --- /dev/null +++ b/scripts/renders/client.css @@ -0,0 +1,145 @@ +body { + background:#ccc; + font-family:Arial, sans-serif; + margin: 0 0 0 0; + padding: 20px; + width: 100%; + height: 100%; + position: absolute; + overflow: hidden; + box-sizing: border-box; + transition:background-color 0.3s; +} +body, body * { + user-select:none; + -webkit-user-select:none; + cursor:default; +} +body.dragging { + background-color:#ddd; +} +body.dragging #solution { + background-color: #fff; +} +#frame { + margin:0 auto; + max-width:640px; + width:100%; +} +.hide { + display:none; +} +#solution { + border:1px solid #bbb; + padding:10px; + border-radius:6px; + box-sizing:border-box; + transition:background-color 0.3s; + font-size:10px; + font-family: monospace; + background:#ddd; + color:#666; + height:240px; + width:403px; + margin-bottom:20px; + resize:none; + white-space:pre; + line-height: 1.4; + overflow-x:hidden; + overflow-y: auto; + user-select:initial; + -webkit-user-select:initial; +} +#solution:focus, textarea:focus { + outline:0; + border-color:#4993EF; +} +#solution.copied { + position:relative; + border-color:#4993EF; +} +#solution.copied:before { + display:block; + position:absolute; + top:0; + right:10px; + padding:8px 16px; + color:#fff; + text-transform: uppercase; + letter-spacing: 0.2em; + font-size:8px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + font-family: Arial, sans-serif; + content: 'Copied To Clipboard'; + background-color:#4993EF; +} +label { + display:inline-block; + padding:15px 0 10px; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.2em; + border-radius: 6px; + background-color: rgba(0,0,0,0.1); + padding: 4px 8px; + color: #fff; + margin-bottom:10px; +} +button { + padding:10px 20px; + border: 1px solid #3267A9; + background-color: #4993EF; + color: #fff; + font-size:14px; + border-radius:6px; + cursor:pointer; + display:block; + width:100%; + transition:background 0.3s; + box-shadow: inset 0px 1px 0px 0px rgba(255,255,255,0.5); + outline:0; +} +button:hover{ + background-color: #5CA5FF; +} +button:active { + background-color: #3267A9; + box-shadow: none; +} +button.disabled { + pointer-events:none; + background-color:#999; + border-color:#777; + box-shadow: none; + color:#ddd; +} +.cols { + display:flex; + margin-top:20px; +} +.cols > .preview { + width:100px; + margin-right:20px; +} +.cols > .output { + width:100%; +} +.full { + width:100%; +} +.center { + text-align:center; +} +.view { + image-rendering: optimizeSpeed; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + zoom:3; +} \ No newline at end of file diff --git a/scripts/renders/client.js b/scripts/renders/client.js new file mode 100755 index 0000000..16c9565 --- /dev/null +++ b/scripts/renders/client.js @@ -0,0 +1,135 @@ +'use strict'; + +require('../../bin/pixi'); +PIXI.utils.skipHello(); + +const fs = require('fs'); +const path = require('path'); +const electron = require('electron'); +const remote = electron.remote; +const clipboard = electron.clipboard; +const dialog = remote.dialog; +const Renderer = require('../../test/renders/lib/Renderer'); +const Droppable = require('./droppable'); +const $ = document.querySelector.bind(document); + +// Feature parity with floss: chai, sinon, sinon-chai +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +global.chai = chai; +global.sinon = sinon; +global.should = chai.should; +global.assert = chai.assert; +global.expect = chai.expect; +global.chai.use(sinonChai); + +// Create the renderer to convert the json into +// a JSON solution which can be used by testing +const renderer = new Renderer( + $('#view-webgl'), + $('#view-canvas') +); + +// Clicking on the solution copies it to the clipboard +const solution = $('#solution'); + +// Current filename +let currentName; +let currentPath; + +// drag-n-drop +new Droppable($('body'), (err, file) => // eslint-disable-line no-new +{ + if (err) + { + dialog.showErrorBox('Drop Error', err.message); + } + else if (!(/\.js$/).test(file)) + { + dialog.showErrorBox('Invalid Filetype', 'The specified file must be a ".js" file.'); + } + else + { + open(file); + } +}); + +// Click on the chooser button to browser for file +$('#choose').addEventListener('click', () => +{ + dialog.showOpenDialog({ + filters: [{ + name: 'JavaScript', + extensions: ['js'], + }], + }, + (fileNames) => + { + if (fileNames) + { + open(fileNames[0]); + } + }); +}); + +const save = $('#save'); + +save.addEventListener('click', () => +{ + dialog.showSaveDialog({ + title: 'Save Solution', + defaultPath: path.join(currentPath, `${currentName}.json`), + filters: [{ + name: 'JSON', + extensions: ['json'], + }], + }, (fileName) => + { + if (fileName) + { + fs.writeFileSync(fileName, solution.innerHTML); + } + }); +}); + +let copiedId = null; + +solution.addEventListener('click', () => +{ + if (solution.innerHTML) + { + clipboard.writeText(solution.innerHTML); + solution.className = 'copied'; + if (copiedId) + { + clearTimeout(copiedId); + copiedId = null; + } + copiedId = setTimeout(() => + { + solution.className = ''; + copiedId = null; + }, 3000); + } +}); + +function open(file) +{ + save.className = 'disabled'; + renderer.run(file, (err, result) => + { + if (err) + { + dialog.showErrorBox('Invalid Output Format', err.message); + } + else + { + solution.innerHTML = JSON.stringify(result, null, ' '); + save.className = ''; + currentName = path.basename(file, '.js'); + currentPath = path.dirname(file); + } + }); +} diff --git a/scripts/renders/droppable.js b/scripts/renders/droppable.js new file mode 100644 index 0000000..b5db16b --- /dev/null +++ b/scripts/renders/droppable.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Add drag-n-drop handling + * @class Droppable + */ +class Droppable +{ + /** + * @constructor + * @param {HTMLElement} node Element to listen on + * @param {Function} callback Callback when complete + */ + constructor(node, callback) + { + node.addEventListener('dragenter', (ev) => + { + ev.preventDefault(); + node.className = Droppable.CLASS_NAME; + }); + + node.addEventListener('dragover', (ev) => + { + ev.preventDefault(); + if (node.className !== Droppable.CLASS_NAME) + { + node.className = Droppable.CLASS_NAME; + } + }); + + node.addEventListener('dragleave', (ev) => + { + ev.preventDefault(); + node.className = ''; + }); + + node.addEventListener('drop', (ev) => + { + ev.preventDefault(); + node.className = ''; + const fileList = ev.dataTransfer.files; + + if (fileList.length > 1) + { + callback(new Error('Only one file at a time.')); + } + else + { + const file = fileList[0]; + + callback(null, file.path); + } + }); + } + + /** + * The name of the class to add to the HTML node + * @static + * @property {String} CLASS_NAME + */ + static get CLASS_NAME() + { + return 'dragging'; + } +} + +module.exports = Droppable; diff --git a/scripts/renders/index.html b/scripts/renders/index.html new file mode 100755 index 0000000..bc95d9a --- /dev/null +++ b/scripts/renders/index.html @@ -0,0 +1,35 @@ + + + + + + Pixi Tests Tool + + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/scripts/renders/main.js b/scripts/renders/main.js new file mode 100644 index 0000000..7ee1a1c --- /dev/null +++ b/scripts/renders/main.js @@ -0,0 +1,33 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; // Control application life +const BrowserWindow = electron.BrowserWindow; // Native browser window + +// Keep a global reference of the window object +let mainWindow = null; + +// Quit when all windows are closed. +app.on('window-all-closed', () => +{ + app.quit(); +}); + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +app.on('ready', () => +{ + mainWindow = new BrowserWindow({ + width: 675, + height: 400, + useContentSize: true, + resizable: false, + textAreasAreResizable: false, + }); + + mainWindow.loadURL(`file://${__dirname}/index.html`); + mainWindow.on('closed', () => + { + mainWindow = null; + }); +}); diff --git a/scripts/renders/package.json b/scripts/renders/package.json new file mode 100644 index 0000000..72753f3 --- /dev/null +++ b/scripts/renders/package.json @@ -0,0 +1,10 @@ +{ + "name": "renders-app", + "private": true, + "main": "main.js", + "dependencies": { + "chai": "^3.5.0", + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0" + } +} diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 09620cb..e03220d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -11,6 +11,7 @@ "globals": { "sinon": false, "expect": false, + "assert": false, "PIXI": false }, "rules": { diff --git a/test/index.js b/test/index.js index 1d245bc..7424430 100755 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,8 @@ /* eslint-disable global-require */ require('../bin/pixi'); +PIXI.utils.skipHello(); // hide banner + describe('PIXI', function () { it('should exist as a global object', function () @@ -11,4 +13,5 @@ }); require('./core'); require('./interaction'); + require('./renders'); }); diff --git a/test/renders/index.js b/test/renders/index.js new file mode 100644 index 0000000..cf9cbe2 --- /dev/null +++ b/test/renders/index.js @@ -0,0 +1,49 @@ +'use strict'; + +const Renderer = require('./lib/Renderer'); +const path = require('path'); + +describe('renders', function () +{ + before(function () + { + this.renderer = new Renderer(); + this.validate = function (id, done) + { + const code = path.join(__dirname, 'tests', `${id}.js`); + const solution = path.join(__dirname, 'solutions', `${id}.json`); + + this.renderer.compare(code, solution, (err, success) => + { + if (err) + { + assert(false, err.message); + } + assert(success, 'Render not successful'); + done(); + }); + }; + }); + + beforeEach(function () + { + this.renderer.clear(); + }); + + after(function () + { + this.renderer.destroy(); + this.renderer = null; + this.validate = null; + }); + + it('should draw a rectangle', function (done) + { + this.validate('graphics-rect', done); + }); + + it('should draw a sprite', function (done) + { + this.validate('sprite-new', done); + }); +}); diff --git a/test/renders/lib/ImageDiff.js b/test/renders/lib/ImageDiff.js new file mode 100644 index 0000000..6bf2b54 --- /dev/null +++ b/test/renders/lib/ImageDiff.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Compare images + * @class ImageDiff + */ +class ImageDiff +{ + /** + * @constructor + * @param {int} width Width of the canvas + * @param {int} height Height of the canvas + * @param {Number} tolerance Tolerance for difference + */ + constructor(width, height, tolerance) + { + this.width = width; + this.height = height; + this.tolerance = tolerance; + + const canvas = document.createElement('canvas'); + + canvas.width = width; + canvas.height = height; + this.context = canvas.getContext('2d', { + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * Compare two base64 images + * @method compare + * @param {string} src1 Canvas source + * @param {string} src2 Canvas source + * @return {Boolean} If images are within tolerance + */ + compare(src1, src2) + { + const a = this.getImageData(src1); + const b = this.getImageData(src2); + const len = a.length; + const tolerance = this.tolerance; + + const diff = a.filter(function (val, i) + { + return Math.abs(val - b[i]) / 255 > tolerance; + }); + + if (diff.length / len > tolerance) + { + return false; + } + + return true; + } + + /** + * Get an array of pixels + * @method getImageData + * @param {string} src Source of the image + * @return {Uint8ClampedArray} Data for image of pixels + */ + getImageData(src) + { + const image = new Image(); + + image.src = src; + this.context.clearRect(0, 0, this.width, this.height); + this.context.drawImage(image, 0, 0, this.width, this.height); + const imageData = this.context.getImageData(0, 0, this.width, this.height); + + return imageData.data; + } +} + +module.exports = ImageDiff; diff --git a/test/renders/lib/Renderer.js b/test/renders/lib/Renderer.js new file mode 100644 index 0000000..d60217f --- /dev/null +++ b/test/renders/lib/Renderer.js @@ -0,0 +1,302 @@ +'use strict'; + +const md5 = require('js-md5'); +const path = require('path'); +const ImageDiff = require('./ImageDiff'); + +/** + * Class to create and validate solutions. + * @class Renderer + */ +class Renderer +{ + /** + * @constructor + * @param {HTMLCanvasElement} [viewWebGL] Optional canvas element for webgl + * @param {HTMLCanvasElement} [viewContext2d] Optional canvas element for context2d + * @param {HTMLElement} [parentNode] container, defaults to `document.body` + */ + constructor(viewWebGL, viewContext2d, parentNode) + { + viewWebGL = viewWebGL || document.createElement('canvas'); + viewContext2d = viewContext2d || document.createElement('canvas'); + + /** + * The container node to add the canvas elements. + * @name parentNode + * @type {HTMLElement} + */ + this.parentNode = parentNode || document.body; + + if (!viewWebGL.parentNode) + { + this.parentNode.appendChild(viewWebGL); + } + if (!viewContext2d.parentNode) + { + this.parentNode.appendChild(viewContext2d); + } + + /** + * The container for the display objects. + * @name stage + * @type {PIXI.Container} + */ + this.stage = new PIXI.Container(); + + /** + * If the current browser supports WebGL. + * @name hasWebGL + * @type {Boolean} + */ + this.hasWebGL = PIXI.utils.isWebGLSupported(); + + if (this.hasWebGL) + { + /** + * The WebGL PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.webgl = new PIXI.WebGLRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewWebGL, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + }); + } + + /** + * The Canvas PIXI renderer. + * @name webgl + * @type {PIXI.WebGLRenderer} + */ + this.canvas = new PIXI.CanvasRenderer(Renderer.WIDTH, Renderer.HEIGHT, { + view: viewContext2d, + backgroundColor: 0xffffff, + antialias: false, + preserveDrawingBuffer: true, + roundPixels: true, + }); + this.canvas.smoothProperty = null; + this.render(); + + /** + * Library for comparing images diffs. + * @name imagediff + * @type {ImageDiff} + */ + this.imagediff = new ImageDiff( + Renderer.WIDTH, + Renderer.HEIGHT, + Renderer.TOLERANCE + ); + } + + /** + * Rerender the stage + * @method render + */ + render() + { + if (this.hasWebGL) + { + this.webgl.render(this.stage); + } + this.canvas.render(this.stage); + } + + /** + * Clear the stage + * @method clear + */ + clear() + { + this.stage.children.forEach(function (child) + { + child.destroy(true); + }); + this.render(); + } + + /** + * Run the solution renderer + * @method run + * @param {String} filePath Fully resolved path to javascript. + * @param {Function} callback Takes error and result as arguments. + */ + run(filePath, callback) + { + delete require.cache[path.resolve(filePath)]; + let data; + const code = require(filePath); // eslint-disable-line global-require + + if (typeof code !== 'function' && !code.async) + { + callback(new Error('Invalid JS format, make sure file is ' + + 'CommonJS compatible, e.g. "module.exports = function(){};"')); + } + else + { + this.clear(); + const done = () => + { + if (!this.stage.children.length) + { + callback(new Error('Stage has no children, make sure to ' + + 'add children in your test, e.g. "module.exports = function(){};"')); + } + else + { + // Generate the result + const result = { + webgl: {}, + canvas: {}, + }; + + this.render(); + if (this.hasWebGL) + { + data = this.webgl.view.toDataURL(); + result.webgl.image = data; + result.webgl.hash = md5(data); + } + data = this.canvas.view.toDataURL(); + result.canvas.image = data; + result.canvas.hash = md5(data); + + callback(null, result); + } + }; + + // Support for asynchronous tests + if (code.async) + { + code.async.call(this, done); + } + else + { + // Just run the test synchronously + code.call(this); + done(); + } + } + } + + /** + * Compare a file with a solution. + * @method compare + * @param {String} filePath The file to load. + * @param {String} solutionPath The path to the solution file. + * @param {Array} solution.webgl Solution for webgl + * @param {Array} solution.canvas Solution for canvas + * @param {Function} callback Complete callback, takes err as an error and success boolean as args. + */ + compare(filePath, solutionPath, callback) + { + this.run(filePath, (err, result) => + { + const solution = require(solutionPath); // eslint-disable-line global-require + + if (!solution.webgl || !solution.canvas) + { + callback(new Error('Invalid solution')); + } + else if (err) + { + callback(err); + } + else if (this.hasWebGL && !this.compareResult(solution.webgl, result.webgl)) + { + callback(new Error('WebGL results do not match.')); + } + else if (!this.compareResult(solution.canvas, result.canvas)) + { + callback(new Error('Canvas results do not match.')); + } + else + { + callback(null, true); + } + }); + } + + /** + * Compare two arrays of frames + * @method compareResult + * @private + * @param {Array} a First result to compare + * @param {Array} b Second result to compare + * @return {Boolean} If we're equal + */ + compareResult(a, b) + { + if (a === b) + { + return true; + } + if (a === null || b === null) + { + return false; + } + if (a.hash !== b.hash) + { + if (!this.imagediff.compare(a.image, b.image)) + { + return false; + } + } + + return true; + } + + /** + * Destroy and don't use after this. + * @method destroy + */ + destroy() + { + this.clear(); + this.stage.destroy(true); + this.parentNode = null; + this.canvas.destroy(true); + this.canvas = null; + + if (this.hasWebGL) + { + this.webgl.destroy(true); + this.webgl = null; + } + } +} + +/** + * The width of the render. + * @static + * @type {int} + * @name WIDTH + * @default 32 + */ +Renderer.WIDTH = 32; + +/** + * The height of the render. + * @static + * @type {int} + * @name HEIGHT + * @default 32 + */ +Renderer.HEIGHT = 32; + +/** + * The tolerance when comparing image solutions. + * for instance 0.01 would mean any difference greater + * than 1% would be consider not the same. + * @static + * @type {Number} + * @name TOLERANCE + * @default 0.01 + */ +Renderer.TOLERANCE = 0.01; + +module.exports = Renderer; diff --git a/test/renders/solutions/graphics-rect.json b/test/renders/solutions/graphics-rect.json new file mode 100644 index 0000000..d51ebd2 --- /dev/null +++ b/test/renders/solutions/graphics-rect.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYR+3WsREAIAxCUZnJ/UdwJtxACopY/NTG417BRba9BkcEQACB/wWOupbY75rJPUAABBBAAAEEEBgX6K6BuJ3vgfhF94AACCBwAThbn6FVGGGJAAAAAElFTkSuQmCC", + "hash": "243969710ac251e9d5080cd55dedc517" + } +} \ No newline at end of file diff --git a/test/renders/solutions/sprite-new.json b/test/renders/solutions/sprite-new.json new file mode 100644 index 0000000..c5396a6 --- /dev/null +++ b/test/renders/solutions/sprite-new.json @@ -0,0 +1,10 @@ +{ + "webgl": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + }, + "canvas": { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASUlEQVRYR+3XsQ0AMAwCwTBtZvK0pI6S1sLFewALnWiQba/giQDjBLTV2gjXXbmnAwRAAAEEEEAAAQQQQACBuEDrKPg8ZxvGBQ6XssehFyMftwAAAABJRU5ErkJggg==", + "hash": "bd5ca4fe3ec53c421044c78d86592b86" + } +} \ No newline at end of file diff --git a/test/renders/tests/assets/bitmap-1.png b/test/renders/tests/assets/bitmap-1.png new file mode 100644 index 0000000..1284fb8 --- /dev/null +++ b/test/renders/tests/assets/bitmap-1.png Binary files differ diff --git a/test/renders/tests/graphics-rect.js b/test/renders/tests/graphics-rect.js new file mode 100644 index 0000000..1143c96 --- /dev/null +++ b/test/renders/tests/graphics-rect.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = function () +{ + const graphic = new PIXI.Graphics() + .beginFill(0xFFCC00, 1) + .drawRect(8, 8, 16, 16); + + expect(graphic.width).to.equal(16); + expect(graphic.height).to.equal(16); + expect(graphic.x).to.equal(0); + expect(graphic.y).to.equal(0); + + const bounds = graphic.getBounds(); + + expect(bounds.x).to.equal(8); + expect(bounds.y).to.equal(8); + expect(bounds.width).to.equal(16); + expect(bounds.height).to.equal(16); + + this.stage.addChild(graphic); +}; diff --git a/test/renders/tests/sprite-new.js b/test/renders/tests/sprite-new.js new file mode 100644 index 0000000..b25ae83 --- /dev/null +++ b/test/renders/tests/sprite-new.js @@ -0,0 +1,30 @@ +'use strict'; + +module.exports.async = function (done) +{ + const url = `file://${__dirname}/assets/bitmap-1.png`; + const loader = new PIXI.loaders.Loader(); + + loader.add('bitmap', url); + loader.once('complete', (loader, resources) => + { + expect(resources.bitmap).to.be.ok; + expect(resources.bitmap.texture).to.be.ok; + expect(resources.bitmap.url).to.equal(url); + + const sprite = new PIXI.Sprite(resources.bitmap.texture); + + expect(sprite.width).to.equal(24); + expect(sprite.height).to.equal(24); + + sprite.x = (32 - sprite.width) / 2; + sprite.y = (32 - sprite.height) / 2; + + expect(sprite.x).to.equal(4); + expect(sprite.y).to.equal(4); + + this.stage.addChild(sprite); + done(); + }); + loader.load(); +};