Initial functioning parser

This commit is contained in:
Andrew Sliwinski 2016-03-18 19:51:40 -04:00
parent 4642c66e08
commit a0a03ef679
36 changed files with 113034 additions and 102 deletions

View file

@ -5,6 +5,9 @@ TAP=./node_modules/.bin/tap
# ------------------------------------------------------------------------------
lint:
$(ESLINT) ./*.js
$(ESLINT) ./bin/*.js
$(ESLINT) ./lib/*.js
$(ESLINT) ./test/**/*.js
test:

View file

@ -1,39 +1,68 @@
## scratch-parser
#### Streaming parser for Scratch projects
# scratch-parser
#### Parser for Scratch projects
## Installation
[![Build Status](https://travis-ci.com/LLK/scratch-parser.svg?token=xzzHj4ct3SyBTpeqxnx1&branch=master)](https://travis-ci.com/LLK/scratch-parser)
[![Dependency Status](https://david-dm.org/llk/scratch-parser.svg)](https://david-dm.org/llk/scratch-parser)
[![devDependency Status](https://david-dm.org/llk/scratch-parser/dev-status.svg)](https://david-dm.org/llk/scratch-parser#info=devDependencies)
## Overview
The Scratch Parser is a [Node.js](https://nodejs.org) module that parses and validates [Scratch](https://scratch.mit.edu) projects. Internally, this utility is used for validation of Scratch projects as well as for extracting metadata from projects for research and search purposes.
## API
#### Installation
```bash
npm install scratch-parser
```
## Interface
#### HTTP Stream
```js
var parser = require('scratch-parser');
parser({
method: 'GET',
url: 'https://scratch.mit.edu/path/to/project.json'
}, function (err, callback) {
if (err) // handle the error
// do something interesting
});
```
#### File Stream
#### Basic Use
```js
var fs = require('fs');
var parser = require('scratch-parser');
var scratchp = require('scratch-parser');
var stream = fs.createReadStream('/path/to/project.json');
parser(stream, function (err, callback) {
var buffer = fs.readFileSync('/path/to/project.sb2');
scratchp(buffer, function (err, project) {
if (err) // handle the error
// do something interesting
});
```
## Metadata
| 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` |
#### "Info"
In addition to the `_meta` data described above, Scratch projects include an attribute called `info` that *may* include the following:
| Key | Description |
| ----------------- | -------------------------------------------------------- |
| `flashVersion` | Installed version of Adobe Flash |
| `swfVersion` | Version of the Scratch editor used to create the project |
| `userAgent` | User agent used to create the project |
| `savedExtensions` | Array of Scratch Extensions used in the project |
## Testing
#### Running the Test Suite
```bash
npm test
```
#### Code Coverage Report
```bash
make coverage
```
#### Performance Benchmarks / Stress Testing
```bash
make benchmark
```

View file

@ -1,13 +1,25 @@
var validate = require('./lib/validate');
var async = require('async');
var unpack = require('./lib/unpack');
var parse = require('./lib/parse');
var validate = require('./lib/validate');
var analyze = require('./lib/analyze');
/**
* Unpacks, parses, validates, and analyzes Scratch projects. If successful,
* will return a valid Scratch project object with appended metadata.
*
* @param {Buffer} Input buffer
*
* @return {Object}
*/
module.exports = function (input, callback) {
parse(input, function (err, project) {
if (err) return callback(err);
validate(project, function (err) {
if (err) return callback(err);
callback(null, project);
});
});
async.waterfall([
function (cb) {
unpack(input, cb);
},
parse,
validate,
analyze
], callback);
};

190
lib/analyze.js Normal file
View file

@ -0,0 +1,190 @@
/**
* Returns an array of items matching the specified attribute.
*
* @param {Object} Project
* @param {String} Attribute to extract and flatten
*
* @return {Array}
*/
function flatten (project, attribute) {
// Storage object
var 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 (var i in project.children) {
var 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
* @param {String} Attribute key
* @param {String, Optional} "id" key
* @param {String, Optional} "hash" key
*
* @return {Object}
*/
function extract (project, attribute, id, hash) {
// Create storage objects and flatten project
var idList = null;
var hashList = null;
var 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;
}
/**
* Tallys term frequency from an array of strings.
*
* @param {Array} Array of strings
*
* @return {Object}
*/
function frequency (input) {
var result = Object.create(null);
for (var i in input) {
var term = input[i];
if (typeof result[term] === 'undefined') result[term] = 0;
result[term]++;
}
return result;
}
/**
* Extract blocks and generate frequency count.
*
* @param {Object} Project
*
* @return {Object}
*/
function blocks (project) {
// Storage objects
var blocks = [];
// Walk scripts array(s) and build block list
function walk (stack) {
for (var 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
blocks.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][0].slice(1));
}
}
walk(flatten(project, 'scripts'));
// Generate frequency count
var freq = frequency(blocks);
// Build result and return
return {
count: blocks.length,
unique: Object.keys(freq).length,
id: blocks,
frequency: freq
};
}
/**
* Extract extensions list.
*
* @param {Object} Project
*
* @return {Object}
*/
function extensions (project) {
var result = {count: 0, id: []};
// Check to ensure project includes any extensions
if (typeof project.info.savedExtensions === 'undefined') return result;
// Iterate over extensions and build list
var ext = project.info.savedExtensions;
for (var i in ext) {
result.id.push(ext[i].extensionName);
}
// Count and return
result.count = result.id.length;
return result;
}
/**
* Analyzes project and appends metadata to the project object.
*
* @param {Object} Project
*
* @return {Object}
*/
module.exports = function (project, callback) {
// Create metadata object
var meta = {
sprites: extract(project, 'children'),
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')
};
// Blocks
meta.blocks = blocks(project);
// Extensions
meta.extensions = extensions(project);
// Bind metadata to project and return
project._meta = meta;
callback(null, project);
};

View file

@ -1,63 +1,17 @@
var oboe = require('oboe');
var once = require('once');
/**
* Converts string from unpack method into a project object. Note: this method
* will be expanded greatly in the future in order to support the Scratch 1.4
* file format. For now, this is nothing but an (awkward) async wrapper around
* the `JSON.parse` function.
*
* @param {String} Input
*
* @return {Object}
*/
module.exports = function (input, callback) {
// Ensure callback is only called once
callback = once(callback);
// Metadata storage object
var _meta = {
assets: [],
blocks: [],
variables: []
};
// Node handlers
function _infoHandler (obj) {
return obj;
try {
callback(null, JSON.parse(input));
} catch (e) {
return callback(e.toString());
}
function _costumeHandler (obj) {
return obj;
}
function _listHandler (obj) {
return obj;
}
function _soundHandler (obj) {
return obj;
}
function _variableHandler (obj) {
return obj;
}
// Start parser
oboe(input)
// Meta
.node('info', _infoHandler)
// Stage
.node('costumes.*', _costumeHandler)
.node('lists.*', _listHandler)
.node('sounds.*', _soundHandler)
.node('variables.*', _variableHandler)
// Sprites
.node('children.*.costumes.*', _costumeHandler)
.node('children.*.lists.*', _listHandler)
.node('children.*.sounds.*', _soundHandler)
.node('children.*.variables.*', _variableHandler)
// Error handler
.fail(function (err) {
callback(err);
})
// Return
.done(function () {
var result = this.root();
callback(null, result);
});
};

View file

@ -6,11 +6,121 @@
"objName": {
"type": "string"
},
"sounds": {
"variables": {
"type": "array",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
},
"isPersistent": {
"type": "boolean"
}
},
"required": [
"name",
"value"
]
},
"lists": {
"type": "array",
"properties": {
"listName": {
"type": "string"
},
"contents": {
"type": "array"
},
"isPersistent": {
"type": "boolean"
},
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"width": {
"type": "number"
},
"height": {
"type": "number"
},
"visible": {
"type": "boolean"
}
},
"required": [
"listName",
"contents"
]
},
"scripts": {
"type": "array"
},
"sounds": {
"type": "array",
"properties": {
"soundName": {
"type": "string"
},
"soundID": {
"type": "number"
},
"md5": {
"type": "string"
},
"sampleCount": {
"type": "number"
},
"rate": {
"type": "number"
},
"format": {
"type": "string"
}
},
"required": [
"soundName",
"soundID",
"md5",
"sampleCount",
"rate",
"format"
]
},
"costumes": {
"type": "array"
"type": "array",
"properties": {
"costumeName": {
"type": "string"
},
"baseLayerID": {
"type": "number"
},
"baseLayerMD5": {
"type": "string"
},
"bitmapResolution": {
"type": "number"
},
"rotationCenterX": {
"type": "number"
},
"rotationCenterY": {
"type": "number"
}
},
"required": [
"costumeName",
"baseLayerID",
"baseLayerMD5",
"bitmapResolution",
"rotationCenterX",
"rotationCenterY"
]
},
"currentCostumeIndex": {
"type": "number",
@ -28,13 +138,62 @@
},
"videoAlpha": {
"type": "number",
"minimum": 0
"minimum": 0,
"maximum": 1
},
"children": {
"type": "array"
"type": "array",
"properties": {},
"required": [
"objName",
"sounds",
"costumes",
"currentCostumeIndex"
]
},
"info": {
"type": "object"
"type": "object",
"properties": {
"flashVersion": {
"type": "string"
},
"swfVersion": {
"type": "string"
},
"userAgent": {
"type": "string"
},
"videoOn": {
"type": "boolean"
},
"savedExtensions": {
"type": "array",
"properties": {
"extensionName": {
"type": "string"
},
"blockSpecs": {
"type": "array"
},
"menus": {
"type": "object"
},
"javascriptURL": {
"type": "string"
}
},
"required": [
"extensionName",
"blockSpecs",
"menus",
"javascriptURL"
]
}
},
"required": [
"flashVersion",
"swfVersion"
]
}
},
"required": [
@ -43,7 +202,6 @@
"costumes",
"currentCostumeIndex",
"penLayerMD5",
"penLayerID",
"tempoBPM",
"videoAlpha",
"children",

39
lib/unpack.js Normal file
View file

@ -0,0 +1,39 @@
var Adm = require('adm-zip');
/**
* Transforms buffer into a UTF-8 string. If input is encoded in ZIP format,
* the input will be extracted and decoded.
*
* @param {Buffer} Input
*
* @return {String}
*/
module.exports = function (input, callback) {
// Validate input type
var typeError = 'Input must be a Buffer.';
if (!Buffer.isBuffer(input)) return callback(typeError);
// Determine format
// We don't use the file suffix as this is unreliable and mine-type
// information is unavailable from Scratch's project CDN. Instead, we look
// at the first few bytes from the provided buffer (byte signature).
// https://en.wikipedia.org/wiki/List_of_file_signatures
var signature = input.slice(0, 3).join(' ');
var isLegacy = false;
var isZip = false;
if (signature.indexOf('83 99 114') === 0) isLegacy = true;
if (signature.indexOf('80 75') === 0) isZip = true;
// If not legacy or zip, convert buffer to UTF-8 string and return
if (!isZip && !isLegacy) return callback(null, input.toString('utf-8'));
// Return error if legacy encoding detected
if (isLegacy) return callback('Parser only supports Scratch 2.X');
// Handle zip
// @todo Handle error
var zip = new Adm(input);
var project = zip.readAsText('project.json', 'utf8');
callback(null, project);
};

View file

@ -6,5 +6,5 @@ module.exports = function (input, callback) {
var valid = validate(input);
if (!valid) return callback(validate.errors);
callback();
callback(null, input);
};

View file

@ -1,7 +1,7 @@
{
"name": "scratch-parser",
"version": "1.0.0",
"description": "Streaming parser for Scratch projects",
"description": "Parser for Scratch projects",
"author": "MIT Media Lab",
"license": "BSD-3-Clause",
"homepage": "https://github.com/LLK/scratch-parser#readme",
@ -14,12 +14,12 @@
"test": "make test"
},
"dependencies": {
"adm-zip": "0.4.7",
"ajv": "3.4.0",
"oboe": "2.1.2",
"once": "1.3.3"
"async": "1.5.2"
},
"devDependencies": {
"benchmark": "2.0.0",
"benchmark": "^2.0.0",
"eslint": "^1.10.3",
"glob": "^6.0.4",
"tap": "^5.1.1"

View file

@ -0,0 +1,52 @@
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite();
var data = require('../fixtures/data');
var parser = require('../../index');
// Run suite
suite
.add({
name: 'JSON - Empty',
defer: true,
fn: function (deferred) {
parser(data.empty.json, function () {
deferred.resolve();
});
}
})
.add({
name: 'SB2 - Empty',
defer: true,
fn: function (deferred) {
parser(data.empty.sb2, function () {
deferred.resolve();
});
}
})
.add({
name: 'JSON - Example',
defer: true,
fn: function (deferred) {
parser(data.example.json, function () {
deferred.resolve();
});
}
})
.add({
name: 'SB2 - Example',
defer: true,
fn: function (deferred) {
parser(data.example.sb2, function () {
deferred.resolve();
});
}
})
.on('cycle', function (event) {
process.stdout.write(String(event.target) + '\n');
})
.run({
async: false,
minSamples: 100,
delay: 2
});

30
test/fixtures/data.js vendored Normal file
View file

@ -0,0 +1,30 @@
var fs = require('fs');
var glob = require('glob');
var path = require('path');
// Build file listings
var sb = glob.sync(path.resolve(__dirname, './data/*.sb'));
var sb2 = glob.sync(path.resolve(__dirname, './data/*.sb2'));
var json = glob.sync(path.resolve(__dirname, './data/*.json'));
// Read files and convert to buffers
for (var x in sb) sb[x] = fs.readFileSync(sb[x]);
for (var y in sb2) sb2[y] = fs.readFileSync(sb2[y]);
for (var z in json) json[z] = fs.readFileSync(json[z]);
// Return listings
module.exports = {
empty: {
sb: fs.readFileSync(path.resolve(__dirname, './data/_empty.sb')),
sb2: fs.readFileSync(path.resolve(__dirname, './data/_empty.sb2')),
json: fs.readFileSync(path.resolve(__dirname, './data/_empty.json'))
},
example: {
sb: fs.readFileSync(path.resolve(__dirname, './data/_example.sb')),
sb2: fs.readFileSync(path.resolve(__dirname, './data/_example.sb2')),
json: fs.readFileSync(path.resolve(__dirname, './data/_example.json'))
},
sb: sb,
sb2: sb2,
json: json
};

BIN
test/fixtures/data/11168978.sb vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/14634522.sb vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/21235386.sb2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/22002488.sb2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/22690261.sb vendored Normal file

Binary file not shown.

108889
test/fixtures/data/53355028.json vendored Normal file

File diff suppressed because it is too large Load diff

BIN
test/fixtures/data/68971934.sb2 vendored Normal file

Binary file not shown.

2735
test/fixtures/data/89993699.json vendored Normal file

File diff suppressed because it is too large Load diff

36
test/fixtures/data/92887076.json vendored Normal file
View file

@ -0,0 +1,36 @@
{
"objName": "Stage",
"scripts": [[10, 10, [["whenGreenFlag"], ["doPlaySoundAndWait", "bensound-betterdays.mp3"]]]],
"sounds": [{
"soundName": "bensound-betterdays.mp3",
"soundID": -1,
"md5": "807e47a2f44fac0e26d47b0ea9d79cab.wav",
"sampleCount": 3392064,
"rate": 22050,
"format": "adpcm"
}],
"costumes": [{
"costumeName": "backdrop1",
"baseLayerID": -1,
"baseLayerMD5": "59f422e5dac2cf43026eb8a47d4d9d5f.png",
"bitmapResolution": 2,
"rotationCenterX": 480,
"rotationCenterY": 360
}],
"currentCostumeIndex": 0,
"penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png",
"penLayerID": -1,
"tempoBPM": 60,
"videoAlpha": 0.5,
"children": [],
"info": {
"scriptCount": 1,
"videoOn": false,
"swfVersion": "v442",
"userAgent": "Mozilla\/5.0 (Windows NT 6.3; WOW64; Trident\/7.0; .NET4.0E; .NET4.0C; .NET CLR 3.5.30729; .NET CLR 2.0.50727; .NET CLR 3.0.30729; Tablet PC 2.0; InfoPath.2; GWX:DOWNLOADED; rv:11.0) like Gecko",
"flashVersion": "WIN 20,0,0,267",
"hasCloudData": false,
"projectID": "92887076",
"spriteCount": 0
}
}

70
test/fixtures/data/_empty.json vendored Executable file
View file

@ -0,0 +1,70 @@
{
"objName": "Stage",
"sounds": [{
"soundName": "pop",
"soundID": 1,
"md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav",
"sampleCount": 258,
"rate": 11025,
"format": ""
}],
"costumes": [{
"costumeName": "backdrop1",
"baseLayerID": 3,
"baseLayerMD5": "739b5e2a2435f6e1ec2993791b423146.png",
"bitmapResolution": 1,
"rotationCenterX": 240,
"rotationCenterY": 180
}],
"currentCostumeIndex": 0,
"penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png",
"penLayerID": 0,
"tempoBPM": 60,
"videoAlpha": 0.5,
"children": [{
"objName": "Sprite1",
"sounds": [{
"soundName": "meow",
"soundID": 0,
"md5": "83c36d806dc92327b9e7049a565c6bff.wav",
"sampleCount": 18688,
"rate": 22050,
"format": ""
}],
"costumes": [{
"costumeName": "costume1",
"baseLayerID": 1,
"baseLayerMD5": "f9a1c175dbe2e5dee472858dd30d16bb.svg",
"bitmapResolution": 1,
"rotationCenterX": 47,
"rotationCenterY": 55
},
{
"costumeName": "costume2",
"baseLayerID": 2,
"baseLayerMD5": "6e8bd9ae68fdb02b7e1e3df656a75635.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": {
"spriteCount": 1,
"flashVersion": "MAC 20,0,0,267",
"userAgent": "Scratch 2.0 Offline Editor",
"swfVersion": "v442",
"scriptCount": 0,
"videoOn": false
}
}

BIN
test/fixtures/data/_empty.sb vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/_empty.sb2 vendored Normal file

Binary file not shown.

309
test/fixtures/data/_example.json vendored Executable file
View file

@ -0,0 +1,309 @@
{
"objName": "Stage",
"variables": [{
"name": "foo",
"value": "500",
"isPersistent": false
}],
"lists": [{
"listName": "baz",
"contents": ["asdf", "asdf", "1", "2", "3", "4", ""],
"isPersistent": false,
"x": 5,
"y": 59,
"width": 102,
"height": 202,
"visible": true
}],
"scripts": [[32, 32, [["whenGreenFlag"], ["playSound:", "pop"], ["call", "foo %n bar", 1]]],
[29, 150, [["PicoBoard.whenSensorConnected", "button pressed"], ["playSound:", "pop"]]],
[29.8,
259.6,
[["procDef", "foo %n bar", ["number1"], [1], false],
["startScene", "backdrop1"],
["changeGraphicEffect:by:", "color", 25]]]],
"sounds": [{
"soundName": "pop",
"soundID": 3,
"md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav",
"sampleCount": 258,
"rate": 11025,
"format": ""
}],
"costumes": [{
"costumeName": "backdrop1",
"baseLayerID": 16,
"baseLayerMD5": "739b5e2a2435f6e1ec2993791b423146.png",
"bitmapResolution": 1,
"rotationCenterX": 240,
"rotationCenterY": 180
}],
"currentCostumeIndex": 0,
"penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png",
"penLayerID": 0,
"tempoBPM": 60,
"videoAlpha": 0.5,
"children": [{
"objName": "Sprite1",
"variables": [{
"name": "bar",
"value": "25",
"isPersistent": false
}],
"lists": [{
"listName": "baq",
"contents": ["1", "2", "5", "5", "5", "5"],
"isPersistent": false,
"x": 111,
"y": 58,
"width": 102,
"height": 202,
"visible": true
}],
"scripts": [[23, 42, [["whenGreenFlag"], ["playSound:", "recording1"], ["say:duration:elapsed:from:", "Hello!", 2]]]],
"scriptComments": [[23, 154, 151, 201, true, -1, "Hello world"]],
"sounds": [{
"soundName": "meow",
"soundID": 0,
"md5": "83c36d806dc92327b9e7049a565c6bff.wav",
"sampleCount": 18688,
"rate": 22050,
"format": ""
},
{
"soundName": "recording1",
"soundID": 1,
"md5": "b586745b98e94d7574f7f7b48d831e20.wav",
"sampleCount": 1,
"rate": 22050,
"format": ""
}],
"costumes": [{
"costumeName": "costume1",
"baseLayerID": 1,
"baseLayerMD5": "f9a1c175dbe2e5dee472858dd30d16bb.svg",
"bitmapResolution": 1,
"rotationCenterX": 47,
"rotationCenterY": 55
},
{
"costumeName": "costume2",
"baseLayerID": 2,
"baseLayerMD5": "6e8bd9ae68fdb02b7e1e3df656a75635.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": {
}
},
{
"objName": "1080 Hip-Hop",
"scripts": [[27,
26,
[["whenKeyPressed", "space"],
["doRepeat", 10, [["playSound:", "dance celebrate"]]],
["doForever", [["changeGraphicEffect:by:", "color", 25]]]]]],
"sounds": [{
"soundName": "dance celebrate",
"soundID": 2,
"md5": "0edb8fb88af19e6e17d0f8cf64c1d136.wav",
"sampleCount": 176401,
"rate": 22050,
"format": "adpcm"
}],
"costumes": [{
"costumeName": "1080 stance",
"baseLayerID": 3,
"baseLayerMD5": "c013c7ae0cdb245e7cbcd1f0ef803d5c.png",
"bitmapResolution": 2,
"rotationCenterX": 70,
"rotationCenterY": 278
},
{
"costumeName": "1080 top stand",
"baseLayerID": 4,
"baseLayerMD5": "2beeccef2956cf00f3fe04a70941a304.png",
"bitmapResolution": 2,
"rotationCenterX": 74,
"rotationCenterY": 274
},
{
"costumeName": "1080 top R step",
"baseLayerID": 5,
"baseLayerMD5": "877253c7955364d01d3cdf6c00ad8f26.png",
"bitmapResolution": 2,
"rotationCenterX": 200,
"rotationCenterY": 270
},
{
"costumeName": "1080 top L step",
"baseLayerID": 6,
"baseLayerMD5": "0182ebabc5f0e2ba2f8c5a88543c841a.png",
"bitmapResolution": 2,
"rotationCenterX": 144,
"rotationCenterY": 266
},
{
"costumeName": "1080 top freeze",
"baseLayerID": 7,
"baseLayerMD5": "eeb7ec45ab14f11c59faa86d68aa6711.png",
"bitmapResolution": 2,
"rotationCenterX": 54,
"rotationCenterY": 258
},
{
"costumeName": "1080 top R cross",
"baseLayerID": 8,
"baseLayerMD5": "69eae69a7bb4951d2bf44031d709e9b7.png",
"bitmapResolution": 2,
"rotationCenterX": 206,
"rotationCenterY": 252
},
{
"costumeName": "1080 pop front",
"baseLayerID": 9,
"baseLayerMD5": "dd96cd3037b3e48814529bbf1d615bea.png",
"bitmapResolution": 2,
"rotationCenterX": 72,
"rotationCenterY": 266
},
{
"costumeName": "1080 pop down",
"baseLayerID": 10,
"baseLayerMD5": "0a4fe68e96f9194f3b5a039af8598c05.png",
"bitmapResolution": 2,
"rotationCenterX": 74,
"rotationCenterY": 188
},
{
"costumeName": "1080 pop left",
"baseLayerID": 11,
"baseLayerMD5": "097b4b61b323e4041315992f41196ec9.png",
"bitmapResolution": 2,
"rotationCenterX": 184,
"rotationCenterY": 266
},
{
"costumeName": "1080 pop right",
"baseLayerID": 12,
"baseLayerMD5": "9ab30ea9351f88b0bfcc7c1eff219ec4.png",
"bitmapResolution": 2,
"rotationCenterX": 78,
"rotationCenterY": 276
},
{
"costumeName": "1080 pop L arm",
"baseLayerID": 13,
"baseLayerMD5": "532ca24749bd0968b329c0970209805b.png",
"bitmapResolution": 2,
"rotationCenterX": 100,
"rotationCenterY": 280
},
{
"costumeName": "1080 pop stand",
"baseLayerID": 14,
"baseLayerMD5": "b140b94daf02503e0abfc1ec284c6ccd.png",
"bitmapResolution": 2,
"rotationCenterX": 92,
"rotationCenterY": 280
},
{
"costumeName": "1080 pop R arm",
"baseLayerID": 15,
"baseLayerMD5": "c69c7b764530b8a464f2ca4e5e85c303.png",
"bitmapResolution": 2,
"rotationCenterX": 74,
"rotationCenterY": 278
}],
"currentCostumeIndex": 0,
"scratchX": 96,
"scratchY": 12,
"scale": 1,
"direction": 90,
"rotationStyle": "normal",
"isDraggable": false,
"indexInLibrary": 2,
"visible": true,
"spriteInfo": {
}
},
{
"target": "Stage",
"cmd": "getVar:",
"param": "foo",
"color": 15629590,
"label": "foo",
"mode": 1,
"sliderMin": 0,
"sliderMax": 100,
"isDiscrete": true,
"x": 5,
"y": 5,
"visible": true
},
{
"target": "Sprite1",
"cmd": "getVar:",
"param": "bar",
"color": 15629590,
"label": "Sprite1: bar",
"mode": 1,
"sliderMin": 0,
"sliderMax": 100,
"isDiscrete": true,
"x": 5,
"y": 32,
"visible": true
},
{
"listName": "baz",
"contents": ["asdf", "asdf", "1", "2", "3", "4", ""],
"isPersistent": false,
"x": 5,
"y": 59,
"width": 102,
"height": 202,
"visible": true
},
{
"listName": "baq",
"contents": ["1", "2", "5", "5", "5", "5"],
"isPersistent": false,
"x": 111,
"y": 58,
"width": 102,
"height": 202,
"visible": true
}],
"info": {
"spriteCount": 2,
"flashVersion": "MAC 20,0,0,267",
"savedExtensions": [{
"extensionName": "PicoBoard",
"blockSpecs": [["h", "when %m.booleanSensor", "whenSensorConnected", "button pressed"],
["h", "when %m.sensor %m.lessMore %n", "whenSensorPass", "slider", ">", 50],
["b", "sensor %m.booleanSensor?", "sensorPressed", "button pressed"],
["rR", "%m.sensor sensor value", "sensor", "slider"]],
"menus": {
"booleanSensor": ["button pressed", "A connected", "B connected", "C connected", "D connected"],
"sensor": ["slider", "light", "sound", "resistance-A", "resistance-B", "resistance-C", "resistance-D"],
"lessMore": [">", "<"]
},
"javascriptURL": "file:\/\/\/\/Users\/asliwinski\/Library\/Application Support\/edu.media.mit.Scratch2Editor\/Local Store\/static\/js\/scratch_extensions\/picoExtension.js"
}],
"swfVersion": "v442",
"scriptCount": 5,
"videoOn": false,
"userAgent": "Scratch 2.0 Offline Editor"
}
}

BIN
test/fixtures/data/_example.sb vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/_example.sb2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/data/_large.sb2 vendored Normal file

Binary file not shown.

150
test/fixtures/meta.json vendored Normal file
View file

@ -0,0 +1,150 @@
{
"id": "http://json-schema.org/draft-04/schema#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Core schema meta-schema",
"definitions": {
"schemaArray": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#" }
},
"positiveInteger": {
"type": "integer",
"minimum": 0
},
"positiveIntegerDefault0": {
"allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
},
"simpleTypes": {
"enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ]
},
"stringArray": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
}
},
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uri"
},
"$schema": {
"type": "string",
"format": "uri"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"default": {},
"multipleOf": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": true
},
"maximum": {
"type": "number"
},
"exclusiveMaximum": {
"type": "boolean",
"default": false
},
"minimum": {
"type": "number"
},
"exclusiveMinimum": {
"type": "boolean",
"default": false
},
"maxLength": { "$ref": "#/definitions/positiveInteger" },
"minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
"pattern": {
"type": "string",
"format": "regex"
},
"additionalItems": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"items": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/schemaArray" }
],
"default": {}
},
"maxItems": { "$ref": "#/definitions/positiveInteger" },
"minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
"uniqueItems": {
"type": "boolean",
"default": false
},
"maxProperties": { "$ref": "#/definitions/positiveInteger" },
"minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
"required": { "$ref": "#/definitions/stringArray" },
"additionalProperties": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"definitions": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"properties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"patternProperties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"dependencies": {
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/stringArray" }
]
}
},
"enum": {
"type": "array",
"minItems": 1,
"uniqueItems": true
},
"type": {
"anyOf": [
{ "$ref": "#/definitions/simpleTypes" },
{
"type": "array",
"items": { "$ref": "#/definitions/simpleTypes" },
"minItems": 1,
"uniqueItems": true
}
]
},
"allOf": { "$ref": "#/definitions/schemaArray" },
"anyOf": { "$ref": "#/definitions/schemaArray" },
"oneOf": { "$ref": "#/definitions/schemaArray" },
"not": { "$ref": "#" }
},
"dependencies": {
"exclusiveMaximum": [ "maximum" ],
"exclusiveMinimum": [ "minimum" ]
},
"default": {}
}

31
test/integration/empty.js Normal file
View file

@ -0,0 +1,31 @@
var test = require('tap').test;
var data = require('../fixtures/data');
var parser = require('../../index');
test('sb', function (t) {
parser(data.empty.sb, function (err, res) {
t.type(err, 'string');
t.type(res, 'undefined');
t.end();
});
});
test('sb2', function (t) {
parser(data.empty.sb2, function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
t.end();
});
});
test('json', function (t) {
parser(data.empty.json, function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
t.end();
});
});

View file

@ -0,0 +1,31 @@
var test = require('tap').test;
var data = require('../fixtures/data');
var parser = require('../../index');
test('sb', function (t) {
parser(data.example.sb, function (err, res) {
t.type(err, 'string');
t.type(res, 'undefined');
t.end();
});
});
test('sb2', function (t) {
parser(data.example.sb2, function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
t.end();
});
});
test('json', function (t) {
parser(data.example.json, function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
t.end();
});
});

View file

@ -0,0 +1,40 @@
var test = require('tap').test;
var data = require('../fixtures/data');
var parser = require('../../index');
test('sb', function (t) {
var set = data.sb;
t.plan(set.length * 2);
for (var i in data.sb) {
parser(data.sb[i], function (err, res) {
t.type(err, 'string');
t.type(res, 'undefined');
});
}
});
test('sb2', function (t) {
var set = data.sb2;
t.plan(set.length * 4);
for (var i in data.sb2) {
parser(data.sb2[i], function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
});
}
});
test('json', function (t) {
var set = data.json;
t.plan(set.length * 4);
for (var i in data.json) {
parser(data.json[i], function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.type(res._meta, 'object');
t.type(res.info, 'object');
});
}
});

22
test/unit/analyze.js Normal file
View file

@ -0,0 +1,22 @@
var test = require('tap').test;
var data = require('../fixtures/data');
var analyze = require('../../lib/analyze');
test('spec', function (t) {
t.type(analyze, 'function');
t.end();
});
test('empty project', function (t) {
t.end();
});
test('example project', function (t) {
analyze(JSON.parse(data.example.json.toString()), function (err, project) {
t.equal(err, null);
t.type(project, 'object');
t.type(project._meta, 'object');
t.end();
});
});

26
test/unit/parser.js Normal file
View file

@ -0,0 +1,26 @@
var test = require('tap').test;
var parse = require('../../lib/parse');
var data = require('../fixtures/data');
test('spec', function (t) {
t.type(parse, 'function');
t.end();
});
test('valid', function (t) {
for (var i = 0; i < data.json.length; i++) {
parse(data.json[i].toString(), function (err, res) {
t.equal(err, null);
t.type(res, 'object');
});
}
t.end();
});
test('invalid', function (t) {
parse('&%@', function (err, res) {
t.type(err, 'string');
t.equal(res, undefined);
t.end();
});
});

19
test/unit/schema.js Normal file
View file

@ -0,0 +1,19 @@
var ajv = require('ajv')();
var test = require('tap').test;
var meta = require('../fixtures/meta.json');
var schema = require('../../lib/schema.json');
test('spec', function (t) {
t.type(schema, 'object');
t.end();
});
test('is valid', function (t) {
// Validate schema against the JSON meta schema:
// http://json-schema.org/draft-04/schema#
var validate = ajv.compile(meta);
var valid = validate(schema);
t.equal(valid, true);
t.end();
});

76
test/unit/unpack.js Normal file
View file

@ -0,0 +1,76 @@
var fs = require('fs');
var path = require('path');
var test = require('tap').test;
var unpack = require('../../lib/unpack');
var fixtures = {
sb: path.resolve(__dirname, '../fixtures/data/_example.sb'),
sb2: path.resolve(__dirname, '../fixtures/data/_example.sb2'),
json: path.resolve(__dirname, '../fixtures/data/_example.json')
};
for (var i in fixtures) {
fixtures[i] = fs.readFileSync(fixtures[i]);
}
test('spec', function (t) {
t.type(unpack, 'function');
t.end();
});
test('sb', function (t) {
var buffer = new Buffer(fixtures.sb);
unpack(buffer, function (err, res) {
t.type(err, 'string');
t.type(res, 'undefined');
t.end();
});
});
test('sb2', function (t) {
var buffer = new Buffer(fixtures.sb2);
unpack(buffer, function (err, res) {
t.equal(err, null);
t.type(res, 'string');
t.doesNotThrow(function () {
JSON.parse(res);
});
t.end();
});
});
test('json', function (t) {
var buffer = new Buffer(fixtures.json);
unpack(buffer, function (err, res) {
t.equal(err, null);
t.type(res, 'string');
t.doesNotThrow(function () {
JSON.parse(res);
});
t.end();
});
});
test('undefined', function (t) {
unpack(undefined, function (err, res) {
t.type(err, 'string');
t.equal(res, undefined);
t.end();
});
});
test('null', function (t) {
unpack(null, function (err, obj) {
t.type(err, 'string');
t.equal(obj, undefined);
t.end();
});
});
test('object', function (t) {
unpack({}, function (err, obj) {
t.type(err, 'string');
t.equal(obj, undefined);
t.end();
});
});

31
test/unit/validate.js Normal file
View file

@ -0,0 +1,31 @@
var test = require('tap').test;
var data = require('../fixtures/data');
var validate = require('../../lib/validate');
test('spec', function (t) {
t.type(validate, 'function');
t.end();
});
test('valid', function (t) {
validate(JSON.parse(data.example.json), function (err, res) {
t.equal(err, null);
t.type(res, 'object');
t.end();
});
});
test('invalid', function (t) {
validate({foo:1}, function (err, res) {
t.equal(Array.isArray(err), true);
t.equal(res, undefined);
t.type(err[0], 'object');
t.type(err[0].keyword, 'string');
t.type(err[0].dataPath, 'string');
t.type(err[0].schemaPath, 'string');
t.type(err[0].message, 'string');
t.type(err[0].params, 'object');
t.type(err[0].params.missingProperty, 'string');
t.end();
});
});