diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..58a4708 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +/node_modules +/.nyc_output +/coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c893eab --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["scratch", "scratch/node"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf35a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +## OSX +.DS_Store + +## NPM +/node_modules +npm-* +package-lock.json + +## Code Coverage +.nyc_output/ +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..afb20c5 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +## scratch-analysis +#### Analysis tool for summarizing the structure, composition, and complexity of [Scratch](https://scratch.mit.edu) programs. + +## Getting Started +```bash +npm install scratch-analysis +``` + +```js +const analysis = require('scratch-analysis'); +analysis(buffer, function (err, result) { + // handle any validation errors and ... + // do something interesting with the results! +}); +``` + +## Analysis Modules +### General +The `scratch-analysis` module will return an object containing high-level summary information about the project: + +| Key | Attributes | +| ----------------- | -------------------------------------------------------- | +| `scripts` | `count` | +| `blocks` | `count`, `unique`, `list`, `frequency` | +| `sprites` | `count` | +| `variables` | `count` | +| `lists` | `count` | +| `costumes` | `count`, `list`, `hash` | +| `sounds` | `count`, `list`, `hash` | +| `extensions` | `count`, `list` | +| `comments` | `count` | + +### Concepts +**Coming Soon** + +### Complexity +**Coming Soon** + +### Classification +**Coming Soon** + +## References +### New Frameworks for Studying and Assessing the Development of Computational Thinking +Author(s): Karen Brennan, Mitchel Resnick +PDF: [Download](https://web.media.mit.edu/~kbrennan/files/Brennan_Resnick_AERA2012_CT.pdf) diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..8a26121 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,23 @@ +const parser = require('scratch-parser'); + +const sb2 = require('./sb2'); +const sb3 = require('./sb3'); + +module.exports = function (buffer, callback) { + parser(buffer, false, (err, result) => { + if (err) return callback(err); + + // Extract only the project object from the parser results + const project = result[0]; + + // Push project object to the appropriate analysis handler + switch (project.projectVersion) { + case 2: + sb2(project, callback); + break; + case 3: + sb3(project, callback); + break; + } + }); +}; diff --git a/lib/sb2.js b/lib/sb2.js new file mode 100644 index 0000000..4831957 --- /dev/null +++ b/lib/sb2.js @@ -0,0 +1,185 @@ +const utility = require('./utility'); + +/** + * Returns an array of items matching the specified attribute. + * @param {object} project Project object (SB2 format) + * @param {string} attribute Attribute to extract and flatten + * @return {array} Array of specified attribute + */ +const flatten = function (project, attribute) { + // Storage object + let result = []; + + // If attribute exists at the top level of the project, append it + if (typeof project[attribute] !== 'undefined') { + result = project[attribute]; + } + + // Iterate over child elements and append to result array + for (let i in project.children) { + const child = project.children[i]; + if (typeof child[attribute] !== 'undefined') { + result = result.concat(child[attribute]); + } + } + + return result; +}; + +/** + * Extract summary information from a specific project attribute. Will attempt + * to concatinate all children when generating summary. + * @param {object} project Project object (SB2 format) + * @param {string} attribute Attribute key + * @param {string} id "id" key + * @param {string} hash "hash" key + * @return {object} Summary information + */ +const extract = function (project, attribute, id, hash) { + // Create storage objects and flatten project + let idList = null; + let hashList = null; + let elements = flatten(project, attribute); + + // Extract ids if specified + if (typeof id !== 'undefined') { + idList = []; + for (var x in elements) { + idList.push(elements[x][id]); + } + } + + // Extract hashes if specified + if (typeof hash !== 'undefined') { + hashList = []; + for (var y in elements) { + hashList.push(elements[y][hash]); + } + } + + // Build result and return + var result = { + count: elements.length + }; + if (idList !== null) result.id = idList; + if (hashList !== null) result.hash = hashList; + + return result; +}; + +/** + * Extract number of sprites from a project object. Will attempt to ignore + * "children" which are not sprites. + * @param {object} input Project object (SB2 format) + * @return {object} Sprite information + */ +const sprites = function (input) { + let result = 0; + + for (let i in input.children) { + if (input.children[i].hasOwnProperty('spriteInfo')) result++; + } + + return {count: result}; +}; + +/** + * Extracts all blocks and generates a frequency count. + * @param {object} project Project object (SB2 format) + * @return {object} Block information + */ +const blocks = function (project) { + // Storage objects + const result = []; + + /** + * Walk scripts array(s) and build block list. + * @param {array} stack Stack of blocks + * @return {void} + */ + const walk = function (stack) { + for (let i in stack) { + // Skip if item is not array + if (!Array.isArray(stack[i])) continue; + + // Recurse if first item is not a string + if (typeof stack[i][0] !== 'string') { + walk(stack[i]); + continue; + } + + // Add to block list + result.push(stack[i][0]); + + // Don't pull in params from procedures + if (stack[i][0] === 'procDef') continue; + + // Move to next item and walk + walk(stack[i].slice(1)); + } + }; + walk(flatten(project, 'scripts')); + + // Generate frequency count + const freq = utility.frequency(result); + + // Build result and return + return { + count: result.length, + unique: Object.keys(freq).length, + id: result, + frequency: freq + }; +}; + +/** + * Extracts extension information. + * @param {object} project Project object (SB2 format) + * @return {object} Extension information + */ +const extensions = function (project) { + const result = {count: 0, id: []}; + const ext = project.info.savedExtensions; + + // Check to ensure project includes any extensions + if (typeof ext === 'undefined') return result; + + // Iterate over extensions and build list + for (let i in ext) { + result.id.push(ext[i].extensionName); + } + + // Count and return + result.count = result.id.length; + return result; +}; + +/** + * Analyzes a project and returns summary information about the project. + * @param {object} project Project object (SB2 format) + * @param {Function} callback Callback function + * @return {void} + */ +module.exports = function (project, callback) { + // Create metadata object + const meta = { + scripts: extract(project, 'scripts'), + variables: extract(project, 'variables', 'name'), + lists: extract(project, 'lists', 'listName'), + comments: extract(project, 'scriptComments'), + sounds: extract(project, 'sounds', 'soundName', 'md5'), + costumes: extract(project, 'costumes', 'costumeName', 'baseLayerMD5') + }; + + // Sprites + meta.sprites = sprites(project); + + // Blocks + meta.blocks = blocks(project); + + // Extensions + meta.extensions = extensions(project); + + // Return all metadata + return callback(null, meta); +}; diff --git a/lib/sb3.js b/lib/sb3.js new file mode 100644 index 0000000..b9c910c --- /dev/null +++ b/lib/sb3.js @@ -0,0 +1,110 @@ +const utility = require('./utility'); + +const scripts = function (targets) { + // Storage objects + let occurrences = 0; + + // Iterate over all blocks in each target, and look for "top level" blocks + for (let t in targets) { + for (let b in targets[t].blocks) { + if (targets[t].blocks[b].topLevel) occurrences++; + } + } + + return { + count: occurrences + }; +}; + +const variables = function (targets, attribute) { + // Storage objects + let occurrences = 0; + let idList = []; + + for (let t in targets) { + for (let a in targets[t][attribute]) { + const variable = targets[t][attribute][a]; + occurrences++; + idList.push(variable[0]); + } + } + + return { + count: occurrences, + id: idList + }; +}; + +// Iterate over targets, extract attribute, and aggregate results +const extract = function (targets, attribute, id, hash) { + // Storage objects + let occurrences = 0; + let idList = []; + let hashList = []; + + for (let t in targets) { + for (let a in targets[t][attribute]) { + const asset = targets[t][attribute][a]; + occurrences++; + if (typeof id !== 'undefined') idList.push(asset[id]); + if (typeof hash !== 'undefined') hashList.push(asset[hash]); + } + } + + const result = {count: occurrences}; + if (typeof id !== 'undefined') result.id = idList; + if (typeof hash !== 'undefined') result.hash = hashList; + return result; +}; + +const sprites = function (targets) { + return { + count: targets.length - 1 + }; +}; + +const blocks = function (targets) { + // Storage object + let result = []; + + for (let t in targets) { + for (let a in targets[t].blocks) { + const block = targets[t].blocks[a]; + if (!block.shadow) result.push(block.opcode); + } + } + + // Calculate block frequency + const freq = utility.frequency(result); + + // Return summary + return { + count: result.length, + unique: Object.keys(freq).length, + id: result, + frequency: freq + }; +}; + +const extensions = function (list) { + return { + count: list.length, + id: list + }; +}; + +module.exports = function (project, callback) { + const meta = { + scripts: scripts(project.targets), + variables: variables(project.targets, 'variables'), + lists: variables(project.targets, 'lists'), + comments: extract(project.targets, 'comments'), + sounds: extract(project.targets, 'sounds', 'name', 'md5ext'), + costumes: extract(project.targets, 'costumes', 'name', 'md5ext'), + sprites: sprites(project.targets), + blocks: blocks(project.targets), + extensions: extensions(project.extensions) + }; + + callback(null, meta); +}; diff --git a/lib/utility.js b/lib/utility.js new file mode 100644 index 0000000..cb83509 --- /dev/null +++ b/lib/utility.js @@ -0,0 +1,23 @@ +/** + * Utility methods for costructing Scratch project summaries. + */ +class Utility { + /** + * Tallies term frequency from an array of strings. + * @param {array} input Array of strings + * @return {object} Frequency information + */ + static frequency (input) { + const result = Object.create(null); + + for (let i in input) { + var term = input[i]; + if (typeof result[term] === 'undefined') result[term] = 0; + result[term]++; + } + + return result; + } +} + +module.exports = Utility; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5308d51 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "scratch-analysis", + "version": "1.0.0", + "description": "Analysis tool for summarizing the structure, composition, and complexity of Scratch programs.", + "main": "lib/index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "test": "npm run test:lint && npm run test:unit && npm run test:integration", + "test:lint": "eslint .", + "test:unit": "tap test/unit/*.js", + "test:integration": "tap test/integration/*.js", + "test:coverage": "tap test/{unit,integration}/*.js --coverage --coverage-report=lcov" + }, + "author": "Scratch Foundation", + "license": "BSD-3-Clause", + "dependencies": { + "scratch-parser": "4.3.2" + }, + "devDependencies": { + "babel-eslint": "^10.0.1", + "eslint": "^5.10.0", + "eslint-config-scratch": "^5.0.0", + "tap": "^12.1.1" + } +} diff --git a/test/fixtures/invalid/garbage.jpg b/test/fixtures/invalid/garbage.jpg new file mode 100644 index 0000000..0cde843 Binary files /dev/null and b/test/fixtures/invalid/garbage.jpg differ diff --git a/test/fixtures/sb2/complex.sb2 b/test/fixtures/sb2/complex.sb2 new file mode 100644 index 0000000..43d16c3 Binary files /dev/null and b/test/fixtures/sb2/complex.sb2 differ diff --git a/test/fixtures/sb2/default.json b/test/fixtures/sb2/default.json new file mode 100644 index 0000000..af478a4 --- /dev/null +++ b/test/fixtures/sb2/default.json @@ -0,0 +1,71 @@ +{ + "objName": "Stage", + "sounds": [{ + "soundName": "pop", + "soundID": -1, + "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", + "sampleCount": 258, + "rate": 11025, + "format": "" + }], + "costumes": [{ + "costumeName": "backdrop1", + "baseLayerID": -1, + "baseLayerMD5": "739b5e2a2435f6e1ec2993791b423146.png", + "bitmapResolution": 1, + "rotationCenterX": 240, + "rotationCenterY": 180 + }], + "currentCostumeIndex": 0, + "penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png", + "penLayerID": -1, + "tempoBPM": 60, + "videoAlpha": 0.5, + "children": [{ + "objName": "Sprite1", + "sounds": [{ + "soundName": "meow", + "soundID": -1, + "md5": "83c36d806dc92327b9e7049a565c6bff.wav", + "sampleCount": 18688, + "rate": 22050, + "format": "" + }], + "costumes": [{ + "costumeName": "costume1", + "baseLayerID": -1, + "baseLayerMD5": "09dc888b0b7df19f70d81588ae73420e.svg", + "bitmapResolution": 1, + "rotationCenterX": 47, + "rotationCenterY": 55 + }, + { + "costumeName": "costume2", + "baseLayerID": -1, + "baseLayerMD5": "3696356a03a8d938318876a593572843.svg", + "bitmapResolution": 1, + "rotationCenterX": 47, + "rotationCenterY": 55 + }], + "currentCostumeIndex": 0, + "scratchX": 0, + "scratchY": 0, + "scale": 1, + "direction": 90, + "rotationStyle": "normal", + "isDraggable": false, + "indexInLibrary": 1, + "visible": true, + "spriteInfo": { + } + }], + "info": { + "videoOn": false, + "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/50.0.2661.102 Safari\/537.36", + "swfVersion": "v446", + "scriptCount": 0, + "spriteCount": 1, + "hasCloudData": false, + "flashVersion": "MAC 21,0,0,242" + } +} diff --git a/test/fixtures/sb2/default.sb2 b/test/fixtures/sb2/default.sb2 new file mode 100644 index 0000000..fe7426f Binary files /dev/null and b/test/fixtures/sb2/default.sb2 differ diff --git a/test/fixtures/sb3/complex.sb3 b/test/fixtures/sb3/complex.sb3 new file mode 100644 index 0000000..b4a300b Binary files /dev/null and b/test/fixtures/sb3/complex.sb3 differ diff --git a/test/fixtures/sb3/default.json b/test/fixtures/sb3/default.json new file mode 100644 index 0000000..3235973 --- /dev/null +++ b/test/fixtures/sb3/default.json @@ -0,0 +1,103 @@ +{ + "targets": [ + { + "isStage": true, + "name": "Stage", + "variables": { + "`jEk@4|i[#Fk?(8x)AV.-my variable": [ + "my variable", + 0 + ] + }, + "lists": {}, + "broadcasts": {}, + "blocks": {}, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "assetId": "cd21514d0531fdffb22204e0ec5ed84a", + "name": "backdrop1", + "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", + "dataFormat": "svg", + "rotationCenterX": 240, + "rotationCenterY": 180 + } + ], + "sounds": [ + { + "assetId": "83a9787d4cb6f3b7632b4ddfebf74367", + "name": "pop", + "dataFormat": "wav", + "format": "", + "rate": 44100, + "sampleCount": 1032, + "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav" + } + ], + "volume": 100, + "layerOrder": 0, + "tempo": 60, + "videoTransparency": 50, + "videoState": "on", + "textToSpeechLanguage": null + }, + { + "isStage": false, + "name": "Sprite1", + "variables": {}, + "lists": {}, + "broadcasts": {}, + "blocks": {}, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "assetId": "b7853f557e4426412e64bb3da6531a99", + "name": "costume1", + "bitmapResolution": 1, + "md5ext": "b7853f557e4426412e64bb3da6531a99.svg", + "dataFormat": "svg", + "rotationCenterX": 48, + "rotationCenterY": 50 + }, + { + "assetId": "e6ddc55a6ddd9cc9d84fe0b4c21e016f", + "name": "costume2", + "bitmapResolution": 1, + "md5ext": "e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg", + "dataFormat": "svg", + "rotationCenterX": 46, + "rotationCenterY": 53 + } + ], + "sounds": [ + { + "assetId": "83c36d806dc92327b9e7049a565c6bff", + "name": "Meow", + "dataFormat": "wav", + "format": "", + "rate": 44100, + "sampleCount": 37376, + "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav" + } + ], + "volume": 100, + "layerOrder": 1, + "visible": true, + "x": 0, + "y": 0, + "size": 100, + "direction": 90, + "draggable": false, + "rotationStyle": "all around" + } + ], + "monitors": [], + "extensions": [], + "meta": { + "semver": "3.0.0", + "vm": "0.2.0-prerelease.20181217191056", + "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36" + } +} diff --git a/test/fixtures/sb3/default.sb3 b/test/fixtures/sb3/default.sb3 new file mode 100644 index 0000000..455acd3 Binary files /dev/null and b/test/fixtures/sb3/default.sb3 differ diff --git a/test/unit/error.js b/test/unit/error.js new file mode 100644 index 0000000..32a6ec7 --- /dev/null +++ b/test/unit/error.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const analysis = require('../../lib/index'); + +const invalidBinary = fs.readFileSync( + path.resolve(__dirname, '../fixtures/invalid/garbage.jpg') +); + +test('invalid object', t => { + analysis('{}', (err, result) => { + t.type(err, 'object'); + t.type(result, 'undefined'); + t.end(); + }); +}); + +test('invalid binary', t => { + analysis(invalidBinary, (err, result) => { + t.type(err, 'string'); + t.type(result, 'undefined'); + t.end(); + }); +}); diff --git a/test/unit/sb2.js b/test/unit/sb2.js new file mode 100644 index 0000000..261211c --- /dev/null +++ b/test/unit/sb2.js @@ -0,0 +1,256 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const analysis = require('../../lib/index'); + +const defaultObject = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/default.json') +); +const defaultBinary = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/default.sb2') +); +const complexBinary = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/complex.sb2') +); + +test('defalt (object)', t => { + analysis(defaultObject, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 0); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 0); + t.deepEqual(result.variables.id, []); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 0); + t.deepEqual(result.lists.id, []); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + '739b5e2a2435f6e1ec2993791b423146.png', + '09dc888b0b7df19f70d81588ae73420e.svg', + '3696356a03a8d938318876a593572843.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 0); + t.equal(result.blocks.unique, 0); + t.deepEqual(result.blocks.id, []); + t.deepEqual(result.blocks.frequency, {}); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 0); + t.deepEqual(result.extensions.id, []); + + t.end(); + }); +}); + +test('defalt (binary)', t => { + analysis(defaultBinary, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 0); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 0); + t.deepEqual(result.variables.id, []); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 0); + t.deepEqual(result.lists.id, []); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + '739b5e2a2435f6e1ec2993791b423146.png', + 'f9a1c175dbe2e5dee472858dd30d16bb.svg', + '6e8bd9ae68fdb02b7e1e3df656a75635.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 0); + t.equal(result.blocks.unique, 0); + t.deepEqual(result.blocks.id, []); + t.deepEqual(result.blocks.frequency, {}); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 0); + t.deepEqual(result.extensions.id, []); + + t.end(); + }); +}); + +test('complex (binary)', t => { + analysis(complexBinary, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 6); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 2); + t.deepEqual(result.variables.id, [ + 'global', + 'local' + ]); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 2); + t.deepEqual(result.lists.id, [ + 'globallist', + 'locallist' + ]); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + '5b465b3b07d39019109d8dc6d6ee6593.svg', + 'f9a1c175dbe2e5dee472858dd30d16bb.svg', + '6e8bd9ae68fdb02b7e1e3df656a75635.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 34); + t.equal(result.blocks.unique, 18); + t.deepEqual(result.blocks.id, [ + 'whenGreenFlag', + 'doForever', + 'changeGraphicEffect:by:', + 'whenGreenFlag', + 'deleteLine:ofList:', + 'deleteLine:ofList:', + 'doForever', + 'forward:', + 'turnRight:', + 'randomFrom:to:', + 'bounceOffEdge', + 'whenGreenFlag', + 'doForever', + 'setGraphicEffect:to:', + 'xpos', + 'whenGreenFlag', + 'doForever', + 'call', + 'randomFrom:to:', + 'heading', + 'randomFrom:to:', + 'heading', + 'procDef', + 'setVar:to:', + 'getParam', + 'setVar:to:', + 'getParam', + 'append:toList:', + 'getParam', + 'append:toList:', + 'getParam', + 'LEGO WeDo 2.0\u001FwhenTilted', + 'LEGO WeDo 2.0\u001FsetLED', + 'randomFrom:to:' + ]); + t.deepEqual(result.blocks.frequency, { + 'LEGO WeDo 2.0\u001FsetLED': 1, + 'LEGO WeDo 2.0\u001FwhenTilted': 1, + 'bounceOffEdge': 1, + 'call': 1, + 'changeGraphicEffect:by:': 1, + 'doForever': 4, + 'deleteLine:ofList:': 2, + 'forward:': 1, + 'getParam': 4, + 'heading': 2, + 'procDef': 1, + 'append:toList:': 2, + 'randomFrom:to:': 4, + 'setGraphicEffect:to:': 1, + 'setVar:to:': 2, + 'turnRight:': 1, + 'whenGreenFlag': 4, + 'xpos': 1 + }); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 1); + t.deepEqual(result.extensions.id, [ + 'LEGO WeDo 2.0' + ]); + + t.end(); + }); +}); diff --git a/test/unit/sb3.js b/test/unit/sb3.js new file mode 100644 index 0000000..8edf126 --- /dev/null +++ b/test/unit/sb3.js @@ -0,0 +1,260 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const analysis = require('../../lib/index'); + +const defaultObject = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/default.json') +); +const defaultBinary = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/default.sb3') +); +const complexBinary = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/complex.sb3') +); + +test('defalt (object)', t => { + analysis(defaultObject, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 0); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 1); + t.deepEqual(result.variables.id, [ + 'my variable' + ]); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 0); + t.deepEqual(result.lists.id, []); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'Meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + 'cd21514d0531fdffb22204e0ec5ed84a.svg', + 'b7853f557e4426412e64bb3da6531a99.svg', + 'e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 0); + t.equal(result.blocks.unique, 0); + t.deepEqual(result.blocks.id, []); + t.deepEqual(result.blocks.frequency, {}); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 0); + t.deepEqual(result.extensions.id, []); + + t.end(); + }); +}); + +test('defalt (binary)', t => { + analysis(defaultBinary, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 0); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 1); + t.deepEqual(result.variables.id, [ + 'my variable' + ]); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 0); + t.deepEqual(result.lists.id, []); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'Meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + 'cd21514d0531fdffb22204e0ec5ed84a.svg', + 'b7853f557e4426412e64bb3da6531a99.svg', + 'e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 0); + t.equal(result.blocks.unique, 0); + t.deepEqual(result.blocks.id, []); + t.deepEqual(result.blocks.frequency, {}); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 0); + t.deepEqual(result.extensions.id, []); + + t.end(); + }); +}); + +test('complex (binary)', t => { + analysis(complexBinary, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + + t.type(result.scripts, 'object'); + t.equal(result.scripts.count, 6); + + t.type(result.variables, 'object'); + t.equal(result.variables.count, 2); + t.deepEqual(result.variables.id, [ + 'global', + 'local' + ]); + + t.type(result.lists, 'object'); + t.equal(result.lists.count, 2); + t.deepEqual(result.lists.id, [ + 'globallist', + 'locallist' + ]); + + t.type(result.comments, 'object'); + t.equal(result.comments.count, 0); + + t.type(result.sounds, 'object'); + t.equal(result.sounds.count, 2); + t.deepEqual(result.sounds.id, [ + 'pop', + 'meow' + ]); + t.deepEqual(result.sounds.hash, [ + '83a9787d4cb6f3b7632b4ddfebf74367.wav', + '83c36d806dc92327b9e7049a565c6bff.wav' + ]); + + t.type(result.costumes, 'object'); + t.equal(result.costumes.count, 3); + t.deepEqual(result.costumes.id, [ + 'backdrop1', + 'costume1', + 'costume2' + ]); + t.deepEqual(result.costumes.hash, [ + '7633d36de03d1df75808f581bbccc742.svg', + 'e6bcb4046c157f60c9f5c3bb5f299fce.svg', + '64208764c777be25d34d813dc0b743c7.svg' + ]); + + t.type(result.sprites, 'object'); + t.equal(result.sprites.count, 1); + + t.type(result.blocks, 'object'); + t.equal(result.blocks.count, 34); + t.equal(result.blocks.unique, 18); + t.deepEqual(result.blocks.id, [ + 'event_whenflagclicked', + 'control_forever', + 'looks_changeeffectby', + 'event_whenflagclicked', + 'data_deleteoflist', + 'data_deleteoflist', + 'control_forever', + 'motion_movesteps', + 'motion_turnright', + 'operator_random', + 'motion_ifonedgebounce', + 'event_whenflagclicked', + 'control_forever', + 'looks_seteffectto', + 'motion_xposition', + 'event_whenflagclicked', + 'control_forever', + 'procedures_call', + 'operator_random', + 'motion_direction', + 'operator_random', + 'motion_direction', + 'procedures_definition', + 'data_setvariableto', + 'argument_reporter_string_number', + 'data_setvariableto', + 'argument_reporter_string_number', + 'data_addtolist', + 'argument_reporter_string_number', + 'data_addtolist', + 'argument_reporter_string_number', + 'wedo2_whenTilted', + 'wedo2_setLightHue', + 'operator_random' + ]); + t.deepEqual(result.blocks.frequency, { + argument_reporter_string_number: 4, + control_forever: 4, + data_addtolist: 2, + data_deleteoflist: 2, + data_setvariableto: 2, + event_whenflagclicked: 4, + looks_changeeffectby: 1, + looks_seteffectto: 1, + motion_direction: 2, + motion_ifonedgebounce: 1, + motion_movesteps: 1, + motion_turnright: 1, + motion_xposition: 1, + operator_random: 4, + procedures_call: 1, + procedures_definition: 1, + wedo2_setLightHue: 1, + wedo2_whenTilted: 1 + }); + + t.type(result.extensions, 'object'); + t.equal(result.extensions.count, 1); + t.deepEqual(result.extensions.id, [ + 'wedo2' + ]); + + t.end(); + }); +}); diff --git a/test/unit/spec.js b/test/unit/spec.js new file mode 100644 index 0000000..d9e1d0a --- /dev/null +++ b/test/unit/spec.js @@ -0,0 +1,7 @@ +const test = require('tap').test; +const analysis = require('../../lib/index'); + +test('spec', t => { + t.type(analysis, 'function'); + t.end(); +}); diff --git a/test/unit/utility.js b/test/unit/utility.js new file mode 100644 index 0000000..ee220bd --- /dev/null +++ b/test/unit/utility.js @@ -0,0 +1,19 @@ +const test = require('tap').test; +const utility = require('../../lib/utility'); + +test('spec', t => { + t.type(utility, 'function'); + t.type(utility.frequency, 'function'); + t.end(); +}); + +test('frequency', t => { + const input = ['foo', 'foo', 'foo', 'bar', 'bar', 'baz']; + const result = utility.frequency(input); + t.deepEqual(result, { + foo: 3, + bar: 2, + baz: 1 + }); + t.end(); +});