diff --git a/.travis.yml b/.travis.yml
index f836d9861..091fe53fe 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,6 +6,9 @@ sudo: false
cache:
directories:
- node_modules
+before_install:
+# Install the most up to date scratch-* dependencies
+- rm -rf node_modules/scratch-*
after_script:
- |
# RELEASE_BRANCHES and NPM_TOKEN defined in Travis settings panel
diff --git a/playground/index.html b/playground/index.html
index 173ab844e..804d31f8e 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -437,7 +437,7 @@
-
+
diff --git a/src/blocks/scratch3_motion.js b/src/blocks/scratch3_motion.js
index 10679750d..8b8e7356e 100644
--- a/src/blocks/scratch3_motion.js
+++ b/src/blocks/scratch3_motion.js
@@ -22,6 +22,7 @@ Scratch3MotionBlocks.prototype.getPrimitives = function() {
'motion_turnleft': this.turnLeft,
'motion_pointindirection': this.pointInDirection,
'motion_glidesecstoxy': this.glide,
+ 'motion_setrotationstyle': this.setRotationStyle,
'motion_changexby': this.changeX,
'motion_setx': this.setX,
'motion_changeyby': this.changeY,
@@ -96,6 +97,10 @@ Scratch3MotionBlocks.prototype.glide = function (args, util) {
}
};
+Scratch3MotionBlocks.prototype.setRotationStyle = function (args, util) {
+ util.target.setRotationStyle(args.STYLE);
+};
+
Scratch3MotionBlocks.prototype.changeX = function (args, util) {
var dx = Cast.toNumber(args.DX);
util.target.setXY(util.target.x + dx, util.target.y);
diff --git a/src/blocks/scratch3_procedures.js b/src/blocks/scratch3_procedures.js
new file mode 100644
index 000000000..8d43c4c92
--- /dev/null
+++ b/src/blocks/scratch3_procedures.js
@@ -0,0 +1,32 @@
+function Scratch3ProcedureBlocks(runtime) {
+ /**
+ * The runtime instantiating this block package.
+ * @type {Runtime}
+ */
+ this.runtime = runtime;
+}
+
+/**
+ * Retrieve the block primitives implemented by this package.
+ * @return {Object.} Mapping of opcode to Function.
+ */
+Scratch3ProcedureBlocks.prototype.getPrimitives = function() {
+ return {
+ 'procedures_defnoreturn': this.defNoReturn,
+ 'procedures_callnoreturn': this.callNoReturn
+ };
+};
+
+Scratch3ProcedureBlocks.prototype.defNoReturn = function () {
+ // No-op: execute the blocks.
+};
+
+Scratch3ProcedureBlocks.prototype.callNoReturn = function (args, util) {
+ if (!util.stackFrame.executed) {
+ var procedureName = args.mutation.name;
+ util.stackFrame.executed = true;
+ util.startProcedure(procedureName);
+ }
+};
+
+module.exports = Scratch3ProcedureBlocks;
diff --git a/src/engine/adapter.js b/src/engine/adapter.js
index 26f52be90..327aae5e3 100644
--- a/src/engine/adapter.js
+++ b/src/engine/adapter.js
@@ -1,3 +1,4 @@
+var mutationAdapter = require('./mutation-adapter');
var html = require('htmlparser2');
/**
@@ -138,6 +139,9 @@ function domToBlock (blockDOM, blocks, isTopBlock, parent) {
// Link next block to this block.
block.next = childBlockNode.attribs.id;
break;
+ case 'mutation':
+ block.mutation = mutationAdapter(xmlChild);
+ break;
}
}
}
diff --git a/src/engine/blocks.js b/src/engine/blocks.js
index 99f52477d..f96c916ad 100644
--- a/src/engine/blocks.js
+++ b/src/engine/blocks.js
@@ -1,4 +1,5 @@
var adapter = require('./adapter');
+var mutationAdapter = require('./mutation-adapter');
var xmlEscape = require('../util/xml-escape');
/**
@@ -116,6 +117,16 @@ Blocks.prototype.getInputs = function (id) {
return inputs;
};
+/**
+ * Get mutation data for a block.
+ * @param {?string} id ID of block to query.
+ * @return {!Object} Mutation for the block.
+ */
+Blocks.prototype.getMutation = function (id) {
+ if (typeof this._blocks[id] === 'undefined') return null;
+ return this._blocks[id].mutation;
+};
+
/**
* Get the top-level script for a given block.
* @param {?string} id ID of block to query.
@@ -130,6 +141,23 @@ Blocks.prototype.getTopLevelScript = function (id) {
return block.id;
};
+/**
+ * Get the procedure definition for a given name.
+ * @param {?string} name Name of procedure to query.
+ * @return {?string} ID of procedure definition.
+ */
+Blocks.prototype.getProcedureDefinition = function (name) {
+ for (var id in this._blocks) {
+ var block = this._blocks[id];
+ if ((block.opcode == 'procedures_defnoreturn' ||
+ block.opcode == 'procedures_defreturn') &&
+ block.fields['NAME'].value == name) {
+ return id;
+ }
+ }
+ return null;
+};
+
// ---------------------------------------------------------------------
/**
@@ -226,12 +254,16 @@ Blocks.prototype.createBlock = function (block, opt_isFlyoutBlock) {
*/
Blocks.prototype.changeBlock = function (args) {
// Validate
- if (args.element !== 'field') return;
+ if (args.element !== 'field' && args.element !== 'mutation') return;
if (typeof this._blocks[args.id] === 'undefined') return;
- if (typeof this._blocks[args.id].fields[args.name] === 'undefined') return;
- // Update block value
- this._blocks[args.id].fields[args.name].value = args.value;
+ if (args.element == 'field') {
+ // Update block value
+ if (!this._blocks[args.id].fields[args.name]) return;
+ this._blocks[args.id].fields[args.name].value = args.value;
+ } else if (args.element == 'mutation') {
+ this._blocks[args.id].mutation = mutationAdapter(args.value);
+ }
};
/**
@@ -355,6 +387,10 @@ Blocks.prototype.blockToXML = function (blockId) {
' type="' + block.opcode + '"' +
xy +
'>';
+ // Add any mutation. Must come before inputs.
+ if (block.mutation) {
+ xmlString += this.mutationToXML(block.mutation);
+ }
// Add any inputs on this block.
for (var input in block.inputs) {
var blockInput = block.inputs[input];
@@ -389,6 +425,25 @@ Blocks.prototype.blockToXML = function (blockId) {
return xmlString;
};
+/**
+ * Recursively encode a mutation object to XML.
+ * @param {!Object} mutation Object representing a mutation.
+ * @return {string} XML string representing a mutation.
+ */
+Blocks.prototype.mutationToXML = function (mutation) {
+ var mutationString = '<' + mutation.tagName;
+ for (var prop in mutation) {
+ if (prop == 'children' || prop == 'tagName') continue;
+ mutationString += ' ' + prop + '="' + mutation[prop] + '"';
+ }
+ mutationString += '>';
+ for (var i = 0; i < mutation.children.length; i++) {
+ mutationString += this.mutationToXML(mutation.children[i]);
+ }
+ mutationString += '' + mutation.tagName + '>';
+ return mutationString;
+};
+
// ---------------------------------------------------------------------
/**
diff --git a/src/engine/execute.js b/src/engine/execute.js
index 29116d5e5..59b4a3cbf 100644
--- a/src/engine/execute.js
+++ b/src/engine/execute.js
@@ -132,6 +132,12 @@ var execute = function (sequencer, thread) {
argValues[inputName] = currentStackFrame.reported[inputName];
}
+ // Add any mutation to args (e.g., for procedures).
+ var mutation = target.blocks.getMutation(currentBlockId);
+ if (mutation) {
+ argValues.mutation = mutation;
+ }
+
// If we've gotten this far, all of the input blocks are evaluated,
// and `argValues` is fully populated. So, execute the block primitive.
// First, clear `currentStackFrame.reported`, so any subsequent execution
@@ -155,6 +161,9 @@ var execute = function (sequencer, thread) {
startBranch: function (branchNum) {
sequencer.stepToBranch(thread, branchNum);
},
+ startProcedure: function (procedureName) {
+ sequencer.stepToProcedure(thread, procedureName);
+ },
startHats: function(requestedHat, opt_matchFields, opt_target) {
return (
runtime.startHats(requestedHat, opt_matchFields, opt_target)
diff --git a/src/engine/mutation-adapter.js b/src/engine/mutation-adapter.js
new file mode 100644
index 000000000..12dc123e1
--- /dev/null
+++ b/src/engine/mutation-adapter.js
@@ -0,0 +1,39 @@
+var html = require('htmlparser2');
+
+/**
+ * Adapter between mutator XML or DOM and block representation which can be
+ * used by the Scratch runtime.
+ * @param {(Object|string)} mutation Mutation XML string or DOM.
+ * @return {Object} Object representing the mutation.
+ */
+module.exports = function (mutation) {
+ var mutationParsed;
+ // Check if the mutation is already parsed; if not, parse it.
+ if (typeof mutation === 'object') {
+ mutationParsed = mutation;
+ } else {
+ mutationParsed = html.parseDOM(mutation)[0];
+ }
+ return mutatorTagToObject(mutationParsed);
+};
+
+/**
+ * Convert a part of a mutation DOM to a mutation VM object, recursively.
+ * @param {Object} dom DOM object for mutation tag.
+ * @return {Object} Object representing useful parts of this mutation.
+ */
+function mutatorTagToObject (dom) {
+ var obj = Object.create(null);
+ obj.tagName = dom.name;
+ obj.children = [];
+ for (var prop in dom.attribs) {
+ if (prop == 'xmlns') continue;
+ obj[prop] = dom.attribs[prop];
+ }
+ for (var i = 0; i < dom.children.length; i++) {
+ obj.children.push(
+ mutatorTagToObject(dom.children[i])
+ );
+ }
+ return obj;
+}
diff --git a/src/engine/runtime.js b/src/engine/runtime.js
index 2792dab2b..bad95bdf7 100644
--- a/src/engine/runtime.js
+++ b/src/engine/runtime.js
@@ -16,7 +16,8 @@ var defaultBlockPackages = {
'scratch3_operators': require('../blocks/scratch3_operators'),
'scratch3_sound': require('../blocks/scratch3_sound'),
'scratch3_sensing': require('../blocks/scratch3_sensing'),
- 'scratch3_data': require('../blocks/scratch3_data')
+ 'scratch3_data': require('../blocks/scratch3_data'),
+ 'scratch3_procedures': require('../blocks/scratch3_procedures')
};
/**
diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js
index 690ab61a7..85575c804 100644
--- a/src/engine/sequencer.js
+++ b/src/engine/sequencer.js
@@ -124,6 +124,16 @@ Sequencer.prototype.stepToBranch = function (thread, branchNum) {
}
};
+/**
+ * Step a procedure.
+ * @param {!Thread} thread Thread object to step to procedure.
+ * @param {!string} procedureName Name of procedure defined in this target.
+ */
+Sequencer.prototype.stepToProcedure = function (thread, procedureName) {
+ var definition = thread.target.blocks.getProcedureDefinition(procedureName);
+ thread.pushStack(definition);
+};
+
/**
* Step a thread into an input reporter, and manage its status appropriately.
* @param {!Thread} thread Thread object to step to reporter.
diff --git a/src/import/sb2import.js b/src/import/sb2import.js
index 5d383cc74..50911c83e 100644
--- a/src/import/sb2import.js
+++ b/src/import/sb2import.js
@@ -6,6 +6,7 @@
*/
var Blocks = require('../engine/blocks');
+var Clone = require('../sprites/clone');
var Sprite = require('../sprites/sprite');
var Color = require('../util/color.js');
var uid = require('../util/uid');
@@ -123,7 +124,16 @@ function parseScratchObject (object, runtime, topLevel) {
target.visible = object.visible;
}
if (object.hasOwnProperty('currentCostumeIndex')) {
- target.currentCostume = object.currentCostumeIndex;
+ target.currentCostume = Math.round(object.currentCostumeIndex);
+ }
+ if (object.hasOwnProperty('rotationStyle')) {
+ if (object.rotationStyle == 'none') {
+ target.rotationStyle = Clone.ROTATION_STYLE_NONE;
+ } else if (object.rotationStyle == 'leftRight') {
+ target.rotationStyle = Clone.ROTATION_STYLE_LEFT_RIGHT;
+ } else if (object.rotationStyle == 'normal') {
+ target.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND;
+ }
}
target.isStage = topLevel;
target.updateAllDrawableProperties();
@@ -332,6 +342,14 @@ function parseBlock (sb2block) {
};
}
}
+ // Special cases to generate mutations.
+ if (oldOpcode == 'call') {
+ activeBlock.mutation = {
+ tagName: 'mutation',
+ children: [],
+ name: sb2block[1]
+ };
+ }
return activeBlock;
}
diff --git a/src/import/sb2specmap.js b/src/import/sb2specmap.js
index 693d82f81..76e9683e1 100644
--- a/src/import/sb2specmap.js
+++ b/src/import/sb2specmap.js
@@ -1374,15 +1374,20 @@ var specMap = {
]
},
'procDef':{
- 'opcode':'proc_def',
- 'argMap':[]
+ 'opcode':'procedures_defnoreturn',
+ 'argMap':[
+ {
+ 'type':'field',
+ 'fieldName':'NAME'
+ }
+ ]
},
'getParam':{
'opcode':'proc_param',
'argMap':[]
},
'call':{
- 'opcode':'proc_call',
+ 'opcode':'procedures_callnoreturn',
'argMap':[]
}
};
diff --git a/src/sprites/clone.js b/src/sprites/clone.js
index 5b3d2def2..c1b3d42cf 100644
--- a/src/sprites/clone.js
+++ b/src/sprites/clone.js
@@ -29,6 +29,20 @@ function Clone(sprite, runtime) {
* @type {?Number}
*/
this.drawableID = null;
+
+ /**
+ * Map of current graphic effect values.
+ * @type {!Object.}
+ */
+ this.effects = {
+ 'color': 0,
+ 'fisheye': 0,
+ 'whirl': 0,
+ 'pixelate': 0,
+ 'mosaic': 0,
+ 'brightness': 0,
+ 'ghost': 0
+ };
}
util.inherits(Clone, Target);
@@ -38,7 +52,6 @@ util.inherits(Clone, Target);
Clone.prototype.initDrawable = function () {
if (this.renderer) {
this.drawableID = this.renderer.createDrawable();
- this.updateAllDrawableProperties();
}
// If we're a clone, start the hats.
if (!this.isOriginal) {
@@ -99,18 +112,29 @@ Clone.prototype.size = 100;
Clone.prototype.currentCostume = 0;
/**
- * Map of current graphic effect values.
- * @type {!Object.}
+ * Rotation style for "all around"/spinning.
+ * @enum
*/
-Clone.prototype.effects = {
- 'color': 0,
- 'fisheye': 0,
- 'whirl': 0,
- 'pixelate': 0,
- 'mosaic': 0,
- 'brightness': 0,
- 'ghost': 0
-};
+Clone.ROTATION_STYLE_ALL_AROUND = 'all around';
+
+/**
+ * Rotation style for "left-right"/flipping.
+ * @enum
+ */
+Clone.ROTATION_STYLE_LEFT_RIGHT = 'left-right';
+
+/**
+ * Rotation style for "no rotation."
+ * @enum
+ */
+Clone.ROTATION_STYLE_NONE = 'don\'t rotate';
+
+/**
+ * Current rotation style.
+ * @type {!string}
+ */
+Clone.prototype.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND;
+
// End clone-level properties.
/**
@@ -131,6 +155,26 @@ Clone.prototype.setXY = function (x, y) {
}
};
+/**
+ * Get the rendered direction and scale, after applying rotation style.
+ * @return {Object} Direction and scale to render.
+ */
+Clone.prototype._getRenderedDirectionAndScale = function () {
+ // Default: no changes to `this.direction` or `this.scale`.
+ var finalDirection = this.direction;
+ var finalScale = [this.size, this.size];
+ if (this.rotationStyle == Clone.ROTATION_STYLE_NONE) {
+ // Force rendered direction to be 90.
+ finalDirection = 90;
+ } else if (this.rotationStyle === Clone.ROTATION_STYLE_LEFT_RIGHT) {
+ // Force rendered direction to be 90, and flip drawable if needed.
+ finalDirection = 90;
+ var scaleFlip = (this.direction < 0) ? -1 : 1;
+ finalScale = [scaleFlip * this.size, this.size];
+ }
+ return {direction: finalDirection, scale: finalScale};
+};
+
/**
* Set the direction of a clone.
* @param {!number} direction New direction of clone.
@@ -142,8 +186,10 @@ Clone.prototype.setDirection = function (direction) {
// Keep direction between -179 and +180.
this.direction = MathUtil.wrapClamp(direction, -179, 180);
if (this.renderer) {
+ var renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
- direction: this.direction
+ direction: renderedDirectionScale.direction,
+ scale: renderedDirectionScale.scale
});
}
};
@@ -192,8 +238,10 @@ Clone.prototype.setSize = function (size) {
// Keep size between 5% and 535%.
this.size = MathUtil.clamp(size, 5, 535);
if (this.renderer) {
+ var renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
- scale: [this.size, this.size]
+ direction: renderedDirectionScale.direction,
+ scale: renderedDirectionScale.scale
});
}
};
@@ -231,6 +279,7 @@ Clone.prototype.clearEffects = function () {
*/
Clone.prototype.setCostume = function (index) {
// Keep the costume index within possible values.
+ index = Math.round(index);
this.currentCostume = MathUtil.wrapClamp(
index, 0, this.sprite.costumes.length - 1
);
@@ -241,6 +290,27 @@ Clone.prototype.setCostume = function (index) {
}
};
+/**
+ * Update the rotation style for this clone.
+ * @param {!string} rotationStyle New rotation style.
+ */
+Clone.prototype.setRotationStyle = function (rotationStyle) {
+ if (rotationStyle == Clone.ROTATION_STYLE_NONE) {
+ this.rotationStyle = Clone.ROTATION_STYLE_NONE;
+ } else if (rotationStyle == Clone.ROTATION_STYLE_ALL_AROUND) {
+ this.rotationStyle = Clone.ROTATION_STYLE_ALL_AROUND;
+ } else if (rotationStyle == Clone.ROTATION_STYLE_LEFT_RIGHT) {
+ this.rotationStyle = Clone.ROTATION_STYLE_LEFT_RIGHT;
+ }
+ if (this.renderer) {
+ var renderedDirectionScale = this._getRenderedDirectionAndScale();
+ this.renderer.updateDrawableProperties(this.drawableID, {
+ direction: renderedDirectionScale.direction,
+ scale: renderedDirectionScale.scale
+ });
+ }
+};
+
/**
* Get a costume index of this clone, by name of the costume.
* @param {?string} costumeName Name of a costume.
@@ -275,10 +345,11 @@ Clone.prototype.getSoundIndexByName = function (soundName) {
*/
Clone.prototype.updateAllDrawableProperties = function () {
if (this.renderer) {
+ var renderedDirectionScale = this._getRenderedDirectionAndScale();
this.renderer.updateDrawableProperties(this.drawableID, {
position: [this.x, this.y],
- direction: this.direction,
- scale: [this.size, this.size],
+ direction: renderedDirectionScale.direction,
+ scale: renderedDirectionScale.scale,
visible: this.visible,
skin: this.sprite.costumes[this.currentCostume].skin
});
@@ -340,6 +411,7 @@ Clone.prototype.makeClone = function () {
newClone.visible = this.visible;
newClone.size = this.size;
newClone.currentCostume = this.currentCostume;
+ newClone.rotationStyle = this.rotationStyle;
newClone.effects = JSON.parse(JSON.stringify(this.effects));
newClone.variables = JSON.parse(JSON.stringify(this.variables));
newClone.lists = JSON.parse(JSON.stringify(this.lists));
diff --git a/src/sprites/sprite.js b/src/sprites/sprite.js
index cfa8de90d..53f16e286 100644
--- a/src/sprites/sprite.js
+++ b/src/sprites/sprite.js
@@ -54,7 +54,6 @@ Sprite.prototype.createClone = function () {
this.clones.push(newClone);
if (newClone.isOriginal) {
newClone.initDrawable();
- newClone.updateAllDrawableProperties();
}
return newClone;
};
diff --git a/test/unit/adapter.js b/test/unit/engine_adapter.js
similarity index 100%
rename from test/unit/adapter.js
rename to test/unit/engine_adapter.js
diff --git a/test/unit/blocks.js b/test/unit/engine_blocks.js
similarity index 100%
rename from test/unit/blocks.js
rename to test/unit/engine_blocks.js
diff --git a/test/unit/runtime.js b/test/unit/engine_runtime.js
similarity index 100%
rename from test/unit/runtime.js
rename to test/unit/engine_runtime.js
diff --git a/test/unit/sequencer.js b/test/unit/engine_sequencer.js
similarity index 100%
rename from test/unit/sequencer.js
rename to test/unit/engine_sequencer.js
diff --git a/test/unit/thread.js b/test/unit/engine_thread.js
similarity index 100%
rename from test/unit/thread.js
rename to test/unit/engine_thread.js
diff --git a/test/unit/sprites_clone.js b/test/unit/sprites_clone.js
new file mode 100644
index 000000000..246e1b955
--- /dev/null
+++ b/test/unit/sprites_clone.js
@@ -0,0 +1,13 @@
+var test = require('tap').test;
+var Clone = require('../../src/sprites/clone');
+var Sprite = require('../../src/sprites/sprite');
+
+test('clone effects', function (t) {
+ // Create two clones and ensure they have different graphic effect objects.
+ // Regression test for Github issue #224
+ var spr = new Sprite();
+ var a = new Clone(spr, null);
+ var b = new Clone(spr, null);
+ t.ok(a.effects !== b.effects);
+ t.end();
+});
diff --git a/test/unit/util_cast.js b/test/unit/util_cast.js
new file mode 100644
index 000000000..1d372213e
--- /dev/null
+++ b/test/unit/util_cast.js
@@ -0,0 +1,179 @@
+var test = require('tap').test;
+var cast = require('../../src/util/cast');
+
+test('toNumber', function (t) {
+ // Numeric
+ t.strictEqual(cast.toNumber(0), 0);
+ t.strictEqual(cast.toNumber(1), 1);
+ t.strictEqual(cast.toNumber(3.14), 3.14);
+
+ // String
+ t.strictEqual(cast.toNumber('0'), 0);
+ t.strictEqual(cast.toNumber('1'), 1);
+ t.strictEqual(cast.toNumber('3.14'), 3.14);
+ t.strictEqual(cast.toNumber('0.1e10'), 1000000000);
+ t.strictEqual(cast.toNumber('foobar'), 0);
+
+ // Boolean
+ t.strictEqual(cast.toNumber(true), 1);
+ t.strictEqual(cast.toNumber(false), 0);
+ t.strictEqual(cast.toNumber('true'), 0);
+ t.strictEqual(cast.toNumber('false'), 0);
+
+ // Undefined & object
+ t.strictEqual(cast.toNumber(undefined), 0);
+ t.strictEqual(cast.toNumber({}), 0);
+ t.strictEqual(cast.toNumber(NaN), 0);
+ t.end();
+});
+
+test('toBoolean', function (t) {
+ // Numeric
+ t.strictEqual(cast.toBoolean(0), false);
+ t.strictEqual(cast.toBoolean(1), true);
+ t.strictEqual(cast.toBoolean(3.14), true);
+
+ // String
+ t.strictEqual(cast.toBoolean('0'), false);
+ t.strictEqual(cast.toBoolean('1'), true);
+ t.strictEqual(cast.toBoolean('3.14'), true);
+ t.strictEqual(cast.toBoolean('0.1e10'), true);
+ t.strictEqual(cast.toBoolean('foobar'), true);
+
+ // Boolean
+ t.strictEqual(cast.toBoolean(true), true);
+ t.strictEqual(cast.toBoolean(false), false);
+
+ // Undefined & object
+ t.strictEqual(cast.toBoolean(undefined), false);
+ t.strictEqual(cast.toBoolean({}), true);
+ t.end();
+});
+
+test('toString', function (t) {
+ // Numeric
+ t.strictEqual(cast.toString(0), '0');
+ t.strictEqual(cast.toString(1), '1');
+ t.strictEqual(cast.toString(3.14), '3.14');
+
+ // String
+ t.strictEqual(cast.toString('0'), '0');
+ t.strictEqual(cast.toString('1'), '1');
+ t.strictEqual(cast.toString('3.14'), '3.14');
+ t.strictEqual(cast.toString('0.1e10'), '0.1e10');
+ t.strictEqual(cast.toString('foobar'), 'foobar');
+
+ // Boolean
+ t.strictEqual(cast.toString(true), 'true');
+ t.strictEqual(cast.toString(false), 'false');
+
+ // Undefined & object
+ t.strictEqual(cast.toString(undefined), 'undefined');
+ t.strictEqual(cast.toString({}), '[object Object]');
+ t.end();
+});
+
+test('toRbgColorList', function (t) {
+ // Hex (minimal, see "color" util tests)
+ t.deepEqual(cast.toRgbColorList('#000'), [0,0,0]);
+ t.deepEqual(cast.toRgbColorList('#000000'), [0,0,0]);
+ t.deepEqual(cast.toRgbColorList('#fff'), [255,255,255]);
+ t.deepEqual(cast.toRgbColorList('#ffffff'), [255,255,255]);
+
+ // Decimal (minimal, see "color" util tests)
+ t.deepEqual(cast.toRgbColorList(0), [0,0,0]);
+ t.deepEqual(cast.toRgbColorList(1), [0,0,1]);
+ t.deepEqual(cast.toRgbColorList(16777215), [255,255,255]);
+
+ // Malformed
+ t.deepEqual(cast.toRgbColorList('ffffff'), [0,0,0]);
+ t.deepEqual(cast.toRgbColorList('foobar'), [0,0,0]);
+ t.end();
+});
+
+test('compare', function (t) {
+ // Numeric
+ t.strictEqual(cast.compare(0, 0), 0);
+ t.strictEqual(cast.compare(1, 0), 1);
+ t.strictEqual(cast.compare(0, 1), -1);
+ t.strictEqual(cast.compare(1, 1), 0);
+
+ // String
+ t.strictEqual(cast.compare('0', '0'), 0);
+ t.strictEqual(cast.compare('0.1e10', '1000000000'), 0);
+ t.strictEqual(cast.compare('foobar', 'FOOBAR'), 0);
+ t.ok(cast.compare('dog', 'cat') > 0);
+
+ // Boolean
+ t.strictEqual(cast.compare(true, true), 0);
+ t.strictEqual(cast.compare(true, false), 1);
+ t.strictEqual(cast.compare(false, true), -1);
+ t.strictEqual(cast.compare(true, true), 0);
+
+ // Undefined & object
+ t.strictEqual(cast.compare(undefined, undefined), 0);
+ t.strictEqual(cast.compare(undefined, 'undefined'), 0);
+ t.strictEqual(cast.compare({}, {}), 0);
+ t.strictEqual(cast.compare({}, '[object Object]'), 0);
+ t.end();
+});
+
+test('isInt', function (t) {
+ // Numeric
+ t.strictEqual(cast.isInt(0), true);
+ t.strictEqual(cast.isInt(1), true);
+ t.strictEqual(cast.isInt(0.0), true);
+ t.strictEqual(cast.isInt(3.14), false);
+ t.strictEqual(cast.isInt(NaN), true);
+
+ // String
+ t.strictEqual(cast.isInt('0'), true);
+ t.strictEqual(cast.isInt('1'), true);
+ t.strictEqual(cast.isInt('0.0'), false);
+ t.strictEqual(cast.isInt('0.1e10'), false);
+ t.strictEqual(cast.isInt('3.14'), false);
+
+ // Boolean
+ t.strictEqual(cast.isInt(true), true);
+ t.strictEqual(cast.isInt(false), true);
+
+ // Undefined & object
+ t.strictEqual(cast.isInt(undefined), false);
+ t.strictEqual(cast.isInt({}), false);
+ t.end();
+});
+
+test('toListIndex', function (t) {
+ var list = [0,1,2,3,4,5];
+ var empty = [];
+
+ // Valid
+ t.strictEqual(cast.toListIndex(1, list.length), 1);
+ t.strictEqual(cast.toListIndex(6, list.length), 6);
+
+ // Invalid
+ t.strictEqual(cast.toListIndex(-1, list.length), cast.LIST_INVALID);
+ t.strictEqual(cast.toListIndex(0.1, list.length), cast.LIST_INVALID);
+ t.strictEqual(cast.toListIndex(0, list.length), cast.LIST_INVALID);
+ t.strictEqual(cast.toListIndex(7, list.length), cast.LIST_INVALID);
+
+ // "all"
+ t.strictEqual(cast.toListIndex('all', list.length), cast.LIST_ALL);
+
+ // "last"
+ t.strictEqual(cast.toListIndex('last', list.length), list.length);
+ t.strictEqual(cast.toListIndex('last', empty.length), cast.LIST_INVALID);
+
+ // "random"
+ var random = cast.toListIndex('random', list.length);
+ t.ok(random <= list.length);
+ t.ok(random > 0);
+ t.strictEqual(cast.toListIndex('random', empty.length), cast.LIST_INVALID);
+
+ // "any" (alias for "random")
+ var any = cast.toListIndex('any', list.length);
+ t.ok(any <= list.length);
+ t.ok(any > 0);
+ t.strictEqual(cast.toListIndex('any', empty.length), cast.LIST_INVALID);
+ t.end();
+});
diff --git a/test/unit/util_color.js b/test/unit/util_color.js
new file mode 100644
index 000000000..ba7fa059c
--- /dev/null
+++ b/test/unit/util_color.js
@@ -0,0 +1,62 @@
+var test = require('tap').test;
+var color = require('../../src/util/color');
+
+test('decimalToHex', function (t) {
+ t.strictEqual(color.decimalToHex(0), '#000000');
+ t.strictEqual(color.decimalToHex(1), '#000001');
+ t.strictEqual(color.decimalToHex(16777215), '#ffffff');
+ t.strictEqual(color.decimalToHex(-16777215), '#000001');
+ t.strictEqual(color.decimalToHex(99999999), '#5f5e0ff');
+ t.end();
+});
+
+test('decimalToRgb', function (t) {
+ t.deepEqual(color.decimalToRgb(0), {r:0,g:0,b:0});
+ t.deepEqual(color.decimalToRgb(1), {r:0,g:0,b:1});
+ t.deepEqual(color.decimalToRgb(16777215), {r:255,g:255,b:255});
+ t.deepEqual(color.decimalToRgb(-16777215), {r:0,g:0,b:1});
+ t.deepEqual(color.decimalToRgb(99999999), {r:245,g:224,b:255});
+ t.end();
+});
+
+test('hexToRgb', function (t) {
+ t.deepEqual(color.hexToRgb('#000'), {r:0,g:0,b:0});
+ t.deepEqual(color.hexToRgb('#000000'), {r:0,g:0,b:0});
+ t.deepEqual(color.hexToRgb('#fff'), {r:255,g:255,b:255});
+ t.deepEqual(color.hexToRgb('#ffffff'), {r:255,g:255,b:255});
+ t.deepEqual(color.hexToRgb('#0fa'), {r:0,g:255,b:170});
+ t.deepEqual(color.hexToRgb('#00ffaa'), {r:0,g:255,b:170});
+
+ t.deepEqual(color.hexToRgb('000'), {r:0,g:0,b:0});
+ t.deepEqual(color.hexToRgb('fff'), {r:255,g:255,b:255});
+ t.deepEqual(color.hexToRgb('00ffaa'), {r:0,g:255,b:170});
+
+ t.deepEqual(color.hexToRgb('0'), null);
+ t.deepEqual(color.hexToRgb('hello world'), null);
+
+ t.end();
+});
+
+test('rgbToHex', function (t) {
+ t.strictEqual(color.rgbToHex({r:0,g:0,b:0}), '#000000');
+ t.strictEqual(color.rgbToHex({r:255,g:255,b:255}), '#ffffff');
+ t.strictEqual(color.rgbToHex({r:0,g:255,b:170}), '#00ffaa');
+ t.end();
+});
+
+test('rgbToDecimal', function (t) {
+ t.strictEqual(color.rgbToDecimal({r:0,g:0,b:0}), 0);
+ t.strictEqual(color.rgbToDecimal({r:255,g:255,b:255}), 16777215);
+ t.strictEqual(color.rgbToDecimal({r:0,g:255,b:170}), 65450);
+ t.end();
+});
+
+test('hexToDecimal', function (t) {
+ t.strictEqual(color.hexToDecimal('#000'), 0);
+ t.strictEqual(color.hexToDecimal('#000000'), 0);
+ t.strictEqual(color.hexToDecimal('#fff'), 16777215);
+ t.strictEqual(color.hexToDecimal('#ffffff'), 16777215);
+ t.strictEqual(color.hexToDecimal('#0fa'), 65450);
+ t.strictEqual(color.hexToDecimal('#00ffaa'), 65450);
+ t.end();
+});
diff --git a/test/unit/util_math.js b/test/unit/util_math.js
new file mode 100644
index 000000000..b282e90cc
--- /dev/null
+++ b/test/unit/util_math.js
@@ -0,0 +1,37 @@
+var test = require('tap').test;
+var math = require('../../src/util/math-util');
+
+test('degToRad', function (t) {
+ // @todo This is incorrect
+ t.strictEqual(math.degToRad(0), 1.5707963267948966);
+ t.strictEqual(math.degToRad(1), 1.5533430342749535);
+ t.strictEqual(math.degToRad(180), -1.5707963267948966);
+ t.strictEqual(math.degToRad(360), -4.71238898038469);
+ t.strictEqual(math.degToRad(720), -10.995574287564276);
+ t.end();
+});
+
+test('radToDeg', function (t) {
+ t.strictEqual(math.radToDeg(0), 0);
+ t.strictEqual(math.radToDeg(1), 57.29577951308232);
+ t.strictEqual(math.radToDeg(180), 10313.240312354817);
+ t.strictEqual(math.radToDeg(360), 20626.480624709635);
+ t.strictEqual(math.radToDeg(720), 41252.96124941927);
+ t.end();
+});
+
+test('clamp', function (t) {
+ t.strictEqual(math.clamp(0, 0, 10), 0);
+ t.strictEqual(math.clamp(1, 0, 10), 1);
+ t.strictEqual(math.clamp(-10, 0, 10), 0);
+ t.strictEqual(math.clamp(100, 0, 10), 10);
+ t.end();
+});
+
+test('wrapClamp', function (t) {
+ t.strictEqual(math.wrapClamp(0, 0, 10), 0);
+ t.strictEqual(math.wrapClamp(1, 0, 10), 1);
+ t.strictEqual(math.wrapClamp(-10, 0, 10), 1);
+ t.strictEqual(math.wrapClamp(100, 0, 10), 1);
+ t.end();
+});
diff --git a/test/unit/timer.js b/test/unit/util_timer.js
similarity index 100%
rename from test/unit/timer.js
rename to test/unit/util_timer.js
diff --git a/test/unit/util_xml.js b/test/unit/util_xml.js
new file mode 100644
index 000000000..1906a2ab6
--- /dev/null
+++ b/test/unit/util_xml.js
@@ -0,0 +1,9 @@
+var test = require('tap').test;
+var xml = require('../../src/util/xml-escape');
+
+test('escape', function (t) {
+ var input = '';
+ var output = '<foo bar="he & llo '"></foo>';
+ t.strictEqual(xml(input), output);
+ t.end();
+});