diff --git a/README.md b/README.md index 0d43b2616..a14c91d8d 100644 --- a/README.md +++ b/README.md @@ -52,35 +52,33 @@ The Virtual Machine constructs and maintains the state of an [Abstract Syntax Tr #### Anatomy of a Block ```json { - "id": "^1r~63Gdl7;Dh?I*OP3_", - "opcode": "wedo_motorclockwise", - "next": null, - "fields": { + "7AJZR#NA;m*b}R]pdq63": { + "id": "7AJZR#NA;m*b}R]pdq63", + "opcode": "control_wait", + "inputs": { "DURATION": { - "name": "DURATION", - "value": null, - "blocks": { - "1?P=eV(OiDY3vMk!24Ip": { - "id": "1?P=eV(OiDY3vMk!24Ip", - "opcode": "math_number", - "next": null, - "fields": { - "NUM": { - "name": "NUM", - "value": "10", - "blocks": null - } - } - } - } - }, - "SUBSTACK": { - "name": "SUBSTACK", - "value": "@1ln(HsUO4!]*2*%BrE|", - "blocks": null + "name": "DURATION", + "block": ",xA8/S!Z6+kR,9dph.rO" } + }, + "fields": {}, + "next": null, + "topLevel": true + }, + ",xA8/S!Z6+kR,9dph.rO": { + "id": ",xA8/S!Z6+kR,9dph.rO", + "opcode": "math_number", + "inputs": {}, + "fields": { + "NUM": { + "name": "NUM", + "value": "1" + } + }, + "next": null, + "topLevel": false } -} + } ``` ## Testing diff --git a/src/engine/adapter.js b/src/engine/adapter.js index 222273c5c..48613ec5d 100644 --- a/src/engine/adapter.js +++ b/src/engine/adapter.js @@ -9,79 +9,123 @@ var parseDOM = memoize(html.parseDOM, { /** * Adapter between block creation events and block representation which can be * used by the Scratch runtime. - * - * @param {Object} `Blockly.events.create` - * - * @return {Object} + * @param {Object} e `Blockly.events.create` + * @return {Array.<Object>} List of blocks from this CREATE event. */ module.exports = function (e) { // Validate input if (typeof e !== 'object') return; - if (typeof e.blockId !== 'string') return; if (typeof e.xml !== 'object') return; - // Storage object - var obj = { - id: e.blockId, - opcode: null, - next: null, - fields: {} - }; - - // Set opcode - if (typeof e.xml.attributes === 'object') { - obj.opcode = e.xml.attributes.type.value; - } - - // Extract fields from event's `innerHTML` - if (typeof e.xml.innerHTML !== 'string') return obj; - if (e.xml.innerHTML === '') return obj; - obj.fields = extract(parseDOM(e.xml.innerHTML)); - - return obj; + return domToBlocks(parseDOM(e.xml.outerHTML)); }; /** - * Extracts fields from a block's innerHTML. - * @todo Extend this to support vertical grammar / nested blocks. - * - * @param {Object} DOM representation of block's innerHTML - * - * @return {Object} + * Convert outer blocks DOM from a Blockly CREATE event + * to a usable form for the Scratch runtime. + * This structure is based on Blockly xml.js:`domToWorkspace` and `domToBlock`. + * @param {Element} blocksDOM DOM tree for this event. + * @return {Array.<Object>} Usable list of blocks from this CREATE event. */ -function extract (dom) { - // Storage object - var fields = {}; - - // Field - var field = dom[0]; - var fieldName = field.attribs.name; - fields[fieldName] = { - name: fieldName, - value: null, - blocks: {} - }; - - // Shadow block - var shadow = field.children[0]; - var shadowId = shadow.attribs.id; - var shadowOpcode = shadow.attribs.type; - fields[fieldName].blocks[shadowId] = { - id: shadowId, - opcode: shadowOpcode, - next: null, - fields: {} - }; - - // Primitive - var primitive = shadow.children[0]; - var primitiveName = primitive.attribs.name; - var primitiveValue = primitive.children[0].data; - fields[fieldName].blocks[shadowId].fields[primitiveName] = { - name: primitiveName, - value: primitiveValue, - blocks: null - }; - - return fields; +function domToBlocks (blocksDOM) { + // At this level, there could be multiple blocks adjacent in the DOM tree. + var blocks = {}; + for (var i = 0; i < blocksDOM.length; i++) { + var block = blocksDOM[i]; + if (!block.name || !block.attribs) { + continue; + } + var tagName = block.name.toLowerCase(); + if (tagName == 'block' || tagName == 'shadow') { + domToBlock(block, blocks, true); + } + } + // Flatten blocks object into a list. + var blocksList = []; + for (var b in blocks) { + blocksList.push(blocks[b]); + } + return blocksList; +} + +/** + * Convert and an individual block DOM to the representation tree. + * Based on Blockly's `domToBlockHeadless_`. + * @param {Element} blockDOM DOM tree for an individual block. + * @param {Boolean} isTopBlock Whether blocks at this level are "top blocks." + * @param {Object} blocks Collection of blocks to add to. + */ +function domToBlock (blockDOM, blocks, isTopBlock) { + // Block skeleton. + var block = { + id: blockDOM.attribs.id, // Block ID + opcode: blockDOM.attribs.type, // For execution, "event_whengreenflag". + inputs: {}, // Inputs to this block and the blocks they point to. + fields: {}, // Fields on this block and their values. + next: null, // Next block in the stack, if one exists. + topLevel: isTopBlock // If this block starts a stack. + }; + + // Add the block to the representation tree. + blocks[block.id] = block; + + // Process XML children and find enclosed blocks, fields, etc. + for (var i = 0; i < blockDOM.children.length; i++) { + var xmlChild = blockDOM.children[i]; + // Enclosed blocks and shadows + var childBlockNode = null; + var childShadowNode = null; + for (var j = 0; j < xmlChild.children.length; j++) { + var grandChildNode = xmlChild.children[j]; + if (!grandChildNode.name) { + // Non-XML tag node. + continue; + } + var grandChildNodeName = grandChildNode.name.toLowerCase(); + if (grandChildNodeName == 'block') { + childBlockNode = grandChildNode; + } else if (grandChildNodeName == 'shadow') { + childShadowNode = grandChildNode; + } + } + + // Use shadow block only if there's no real block node. + if (!childBlockNode && childShadowNode) { + childBlockNode = childShadowNode; + } + + // Not all Blockly-type blocks are handled here, + // as we won't be using all of them for Scratch. + switch (xmlChild.name.toLowerCase()) { + case 'field': + // Add the field to this block. + var fieldName = xmlChild.attribs.name; + block.fields[fieldName] = { + name: fieldName, + value: xmlChild.children[0].data + }; + break; + case 'value': + case 'statement': + // Recursively generate block structure for input block. + domToBlock(childBlockNode, blocks, false); + // Link this block's input to the child block. + var inputName = xmlChild.attribs.name; + block.inputs[inputName] = { + name: inputName, + block: childBlockNode.attribs.id + }; + break; + case 'next': + if (!childBlockNode || !childBlockNode.attribs) { + // Invalid child block. + continue; + } + // Recursively generate block structure for next block. + domToBlock(childBlockNode, blocks, false); + // Link next block to this block. + block.next = childBlockNode.attribs.id; + break; + } + } } diff --git a/src/engine/blocks.js b/src/engine/blocks.js new file mode 100644 index 000000000..896e9c8b4 --- /dev/null +++ b/src/engine/blocks.js @@ -0,0 +1,207 @@ +/** +* @fileoverview + * Store and mutate the VM block representation, + * and handle updates from Scratch Blocks events. + */ + + function Blocks () { + /** + * All blocks in the workspace. + * Keys are block IDs, values are metadata about the block. + * @type {Object.<string, Object>} + */ + this._blocks = {}; + + /** + * All stacks in the workspace. + * A list of block IDs that represent stacks (first block in stack). + * @type {Array.<String>} + */ + this._stacks = []; + } + +/** + * Provide an object with metadata for the requested block ID. + * @param {!string} blockId ID of block we have stored. + * @return {?Object} Metadata about the block, if it exists. + */ + Blocks.prototype.getBlock = function (blockId) { + return this._blocks[blockId]; + }; + +/** + * Get all known top-level blocks that start stacks. + * @return {Array.<string>} List of block IDs. + */ + Blocks.prototype.getStacks = function () { + return this._stacks; + }; + + /** + * Get the next block for a particular block + * @param {?string} id ID of block to get the next block for + * @return {?string} ID of next block in the sequence + */ + Blocks.prototype.getNextBlock = function (id) { + if (typeof this._blocks[id] === 'undefined') return null; + return this._blocks[id].next; + }; + + /** + * Get the substack for a particular C-shaped block + * @param {?string} id ID for block to get the substack for + * @param {?number} substackNum Which substack to select (e.g. for if-else) + * @return {?string} ID of block in the substack + */ + Blocks.prototype.getSubstack = function (id, substackNum) { + var block = this._blocks[id]; + if (typeof block === 'undefined') return null; + if (!substackNum) substackNum = 1; + + var inputName = 'SUBSTACK'; + if (substackNum > 1) { + inputName += substackNum; + } + + // Empty C-block? + if (!(inputName in block.inputs)) return null; + return block.inputs[inputName].block; + }; + + /** + * Get the opcode for a particular block + * @param {?string} id ID of block to query + * @return {?string} the opcode corresponding to that block + */ + Blocks.prototype.getOpcode = function (id) { + if (typeof this._blocks[id] === 'undefined') return null; + return this._blocks[id].opcode; + }; + + // --------------------------------------------------------------------- + + /** + * Block management: create blocks and stacks from a `create` event + * @param {!Object} block Blockly create event to be processed + */ + Blocks.prototype.createBlock = function (block, opt_isFlyoutBlock) { + // Create new block + this._blocks[block.id] = block; + + // Push block id to stacks array. + // Blocks are added as a top-level stack if they are marked as a top-block + // (if they were top-level XML in the event) and if they are not + // flyout blocks. + if (!opt_isFlyoutBlock && block.topLevel) { + this._addStack(block.id); + } + }; + + /** + * Block management: change block field values + * @param {!Object} args Blockly change event to be processed + */ + Blocks.prototype.changeBlock = function (args) { + // Validate + if (args.element !== 'field') 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; + }; + + /** + * Block management: move blocks from parent to parent + * @param {!Object} e Blockly move event to be processed + */ + Blocks.prototype.moveBlock = function (e) { + // Remove from any old parent. + if (e.oldParent !== undefined) { + var oldParent = this._blocks[e.oldParent]; + if (e.oldInput !== undefined && + oldParent.inputs[e.oldInput].block === e.id) { + // This block was connected to the old parent's input. + oldParent.inputs[e.oldInput].block = null; + } else if (oldParent.next === e.id) { + // This block was connected to the old parent's next connection. + oldParent.next = null; + } + } + + // Has the block become a top-level block? + if (e.newParent === undefined) { + this._addStack(e.id); + } else { + // Remove stack, if one exists. + this._deleteStack(e.id); + // Otherwise, try to connect it in its new place. + if (e.newInput !== undefined) { + // Moved to the new parent's input. + this._blocks[e.newParent].inputs[e.newInput] = { + name: e.newInput, + block: e.id + }; + } else { + // Moved to the new parent's next connection. + this._blocks[e.newParent].next = e.id; + } + } + }; + + /** + * Block management: delete blocks and their associated stacks + * @param {!Object} e Blockly delete event to be processed + */ + Blocks.prototype.deleteBlock = function (e) { + // @todo In runtime, stop threads running on this stack + + // Get block + var block = this._blocks[e.id]; + + // Delete children + if (block.next !== null) { + this.deleteBlock({id: block.next}); + } + + // Delete inputs (including substacks) + for (var input in block.inputs) { + // If it's null, the block in this input moved away. + if (block.inputs[input].block !== null) { + this.deleteBlock({id: block.inputs[input].block}); + } + } + + // Delete stack + this._deleteStack(e.id); + + // Delete block + delete this._blocks[e.id]; + }; + + // --------------------------------------------------------------------- + + /** + * Helper to add a stack to `this._stacks` + * @param {?string} id ID of block that starts the stack + */ + Blocks.prototype._addStack = function (id) { + var i = this._stacks.indexOf(id); + if (i > -1) return; // Already in stacks. + this._stacks.push(id); + // Update `topLevel` property on the top block. + this._blocks[id].topLevel = true; + }; + + /** + * Helper to remove a stack from `this._stacks` + * @param {?string} id ID of block that starts the stack + */ + Blocks.prototype._deleteStack = function (id) { + var i = this._stacks.indexOf(id); + if (i > -1) this._stacks.splice(i, 1); + // Update `topLevel` property on the top block. + if (this._blocks[id]) this._blocks[id].topLevel = false; + }; + + module.exports = Blocks; diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 0507b96b5..9531eadc7 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -10,25 +10,18 @@ var defaultBlockPackages = { /** * Manages blocks, stacks, and the sequencer. + * @param blocks Blocks instance for this runtime. */ -function Runtime () { +function Runtime (blocks) { // Bind event emitter EventEmitter.call(this); // State for the runtime - /** - * All blocks in the workspace. - * Keys are block IDs, values are metadata about the block. - * @type {Object.<string, Object>} - */ - this.blocks = {}; /** - * All stacks in the workspace. - * A list of block IDs that represent stacks (first block in stack). - * @type {Array.<String>} + * Block management and storage */ - this.stacks = []; + this.blocks = blocks; /** * A list of threads that are currently running in the VM. @@ -83,118 +76,6 @@ util.inherits(Runtime, EventEmitter); */ Runtime.THREAD_STEP_INTERVAL = 1000 / 30; -/** - * Block management: create blocks and stacks from a `create` event - * @param {!Object} block Blockly create event to be processed - */ -Runtime.prototype.createBlock = function (block, opt_isFlyoutBlock) { - // Create new block - this.blocks[block.id] = block; - - // Walk each field and add any shadow blocks - // @todo Expand this to cover vertical / nested blocks - for (var i in block.fields) { - var shadows = block.fields[i].blocks; - for (var y in shadows) { - var shadow = shadows[y]; - this.blocks[shadow.id] = shadow; - } - } - - // Push block id to stacks array. New blocks are always a stack even if only - // momentary. If the new block is added to an existing stack this stack will - // be removed by the `moveBlock` method below. - if (!opt_isFlyoutBlock) { - this.stacks.push(block.id); - } -}; - -/** - * Block management: change block field values - * @param {!Object} args Blockly change event to be processed - */ -Runtime.prototype.changeBlock = function (args) { - // Validate - if (args.element !== 'field') 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; -}; - -/** - * Block management: move blocks from parent to parent - * @param {!Object} e Blockly move event to be processed - */ -Runtime.prototype.moveBlock = function (e) { - var _this = this; - - // Block was removed from parent - if (e.newParent === undefined && e.oldParent !== undefined) { - // Add stack - _this.stacks.push(e.id); - - // Update old parent - if (e.oldField === undefined) { - _this.blocks[e.oldParent].next = null; - } else { - delete _this.blocks[e.oldParent].fields[e.oldField]; - } - } else if (e.newParent !== undefined) { - // Block was moved to a new parent - // Either happens because it was previously parentless - // (e.oldParent === undefined) - // or because a block was moved in front of it. - - // Remove stack - _this._deleteStack(e.id); - - // Update new parent - if (e.newField === undefined) { - _this.blocks[e.newParent].next = e.id; - } else { - _this.blocks[e.newParent].fields[e.newField] = { - name: e.newField, - value: e.id, - blocks: {} - }; - } - } -}; - -/** - * Block management: delete blocks and their associated stacks - * @param {!Object} e Blockly delete event to be processed - */ -Runtime.prototype.deleteBlock = function (e) { - // @todo Stop threads running on this stack - - // Get block - var block = this.blocks[e.id]; - - // Delete children - if (block.next !== null) { - this.deleteBlock({id: block.next}); - } - - // Delete substacks and fields - for (var field in block.fields) { - if (field === 'SUBSTACK') { - this.deleteBlock({id: block.fields[field].value}); - } else { - for (var shadow in block.fields[field].blocks) { - this.deleteBlock({id: shadow}); - } - } - } - - // Delete stack - this._deleteStack(e.id); - - // Delete block - delete this.blocks[e.id]; -}; // ----------------------------------------------------------------------------- // ----------------------------------------------------------------------------- @@ -280,10 +161,11 @@ Runtime.prototype.greenFlag = function () { this._removeThread(this.threads[i]); } // Add all top stacks with green flag - for (var j = 0; j < this.stacks.length; j++) { - var topBlock = this.stacks[j]; - if (this.blocks[topBlock].opcode === 'event_whenflagclicked') { - this._pushThread(this.stacks[j]); + var stacks = this.blocks.getStacks(); + for (var j = 0; j < stacks.length; j++) { + var topBlock = stacks[j]; + if (this.blocks.getBlock(topBlock).opcode === 'event_whenflagclicked') { + this._pushThread(stacks[j]); } } }; @@ -293,9 +175,11 @@ Runtime.prototype.greenFlag = function () { */ Runtime.prototype.startDistanceSensors = function () { // Add all top stacks with distance sensor - for (var j = 0; j < this.stacks.length; j++) { - var topBlock = this.stacks[j]; - if (this.blocks[topBlock].opcode === 'wedo_whendistanceclose') { + var stacks = this.blocks.getStacks(); + for (var j = 0; j < stacks.length; j++) { + var topBlock = stacks[j]; + if (this.blocks.getBlock(topBlock).opcode === + 'wedo_whendistanceclose') { var alreadyRunning = false; for (var k = 0; k < this.threads.length; k++) { if (this.threads[k].topBlock === topBlock) { @@ -303,7 +187,7 @@ Runtime.prototype.startDistanceSensors = function () { } } if (!alreadyRunning) { - this._pushThread(this.stacks[j]); + this._pushThread(stacks[j]); } } } @@ -357,46 +241,4 @@ Runtime.prototype.start = function () { }.bind(this), Runtime.THREAD_STEP_INTERVAL); }; -// ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- - -/** - * Helper to remove a stack from `this.stacks` - * @param {?string} id ID of block that starts the stack - */ -Runtime.prototype._deleteStack = function (id) { - var i = this.stacks.indexOf(id); - if (i > -1) this.stacks.splice(i, 1); -}; - -/** - * Helper to get the next block for a particular block - * @param {?string} id ID of block to get the next block for - * @return {?string} ID of next block in the sequence - */ -Runtime.prototype._getNextBlock = function (id) { - if (typeof this.blocks[id] === 'undefined') return null; - return this.blocks[id].next; -}; - -/** - * Helper to get the substack for a particular C-shaped block - * @param {?string} id ID for block to get the substack for - * @return {?string} ID of block in the substack - */ -Runtime.prototype._getSubstack = function (id) { - if (typeof this.blocks[id] === 'undefined') return null; - return this.blocks[id].fields['SUBSTACK']; -}; - -/** - * Helper to get the opcode for a particular block - * @param {?string} id ID of block to query - * @return {?string} the opcode corresponding to that block - */ -Runtime.prototype._getOpcode = function (id) { - if (typeof this.blocks[id] === 'undefined') return null; - return this.blocks[id].opcode; -}; - module.exports = Runtime; diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js index f495264c8..95a28f7bf 100644 --- a/src/engine/sequencer.js +++ b/src/engine/sequencer.js @@ -97,13 +97,13 @@ Sequencer.prototype.stepThread = function (thread) { // If the primitive would like to do control flow, // it can overwrite nextBlock. var currentBlock = thread.nextBlock; - if (!currentBlock || !this.runtime.blocks[currentBlock]) { + if (!currentBlock || !this.runtime.blocks.getBlock(currentBlock)) { thread.status = Thread.STATUS_DONE; return; } - thread.nextBlock = this.runtime._getNextBlock(currentBlock); + thread.nextBlock = this.runtime.blocks.getNextBlock(currentBlock); - var opcode = this.runtime._getOpcode(currentBlock); + var opcode = this.runtime.blocks.getOpcode(currentBlock); // Push the current block to the stack thread.stack.push(currentBlock); @@ -130,7 +130,7 @@ Sequencer.prototype.stepThread = function (thread) { var threadDoneCallback = function () { thread.status = Thread.STATUS_DONE; // Refresh nextBlock in case it has changed during a yield. - thread.nextBlock = instance.runtime._getNextBlock(currentBlock); + thread.nextBlock = instance.runtime.blocks.getNextBlock(currentBlock); // Pop the stack and stack frame thread.stack.pop(); thread.stackFrames.pop(); @@ -141,9 +141,10 @@ Sequencer.prototype.stepThread = function (thread) { * @todo very hacked... */ var startHats = function(callback) { - for (var i = 0; i < instance.runtime.stacks.length; i++) { - var stack = instance.runtime.stacks[i]; - var stackBlock = instance.runtime.blocks[stack]; + var stacks = instance.runtime.blocks.getStacks(); + for (var i = 0; i < stacks.length; i++) { + var stack = stacks[i]; + var stackBlock = instance.runtime.blocks.getBlock(stack); var result = callback(stackBlock); if (result) { // Check if the stack is already running @@ -174,7 +175,7 @@ Sequencer.prototype.stepThread = function (thread) { */ var threadStartSubstack = function () { // Set nextBlock to the start of the substack - var substack = instance.runtime._getSubstack(currentBlock); + var substack = instance.runtime.blocks.getSubstack(currentBlock); if (substack && substack.value) { thread.nextBlock = substack.value; } else { @@ -185,7 +186,7 @@ Sequencer.prototype.stepThread = function (thread) { // @todo extreme hack to get the single argument value for prototype var argValues = []; - var blockInputs = this.runtime.blocks[currentBlock].fields; + var blockInputs = this.runtime.blocks.getBlock(currentBlock).fields; for (var bi in blockInputs) { var outer = blockInputs[bi]; for (var b in outer.blocks) { diff --git a/src/index.js b/src/index.js index ba759f0bd..9f3917a54 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ var EventEmitter = require('events'); var util = require('util'); +var Blocks = require('./engine/blocks'); var Runtime = require('./engine/runtime'); var adapter = require('./engine/adapter'); @@ -15,7 +16,8 @@ function VirtualMachine () { // Bind event emitter and runtime to VM instance // @todo Post message (Web Worker) polyfill EventEmitter.call(instance); - instance.runtime = new Runtime(); + instance.blocks = new Blocks(); + instance.runtime = new Runtime(instance.blocks); /** * Event listener for blocks. Handles validation and serves as a generic @@ -37,10 +39,14 @@ function VirtualMachine () { // Block create/update/destroy switch (e.type) { case 'create': - instance.runtime.createBlock(adapter(e), false); + var newBlocks = adapter(e); + // A create event can create many blocks. Add them all. + for (var i = 0; i < newBlocks.length; i++) { + instance.blocks.createBlock(newBlocks[i], false); + } break; case 'change': - instance.runtime.changeBlock({ + instance.blocks.changeBlock({ id: e.blockId, element: e.element, name: e.name, @@ -48,16 +54,16 @@ function VirtualMachine () { }); break; case 'move': - instance.runtime.moveBlock({ + instance.blocks.moveBlock({ id: e.blockId, oldParent: e.oldParentId, - oldField: e.oldInputName, + oldInput: e.oldInputName, newParent: e.newParentId, - newField: e.newInputName + newInput: e.newInputName }); break; case 'delete': - instance.runtime.deleteBlock({ + instance.blocks.deleteBlock({ id: e.blockId }); break; @@ -67,10 +73,14 @@ function VirtualMachine () { instance.flyoutBlockListener = function (e) { switch (e.type) { case 'create': - instance.runtime.createBlock(adapter(e), true); + var newBlocks = adapter(e); + // A create event can create many blocks. Add them all. + for (var i = 0; i < newBlocks.length; i++) { + instance.blocks.createBlock(newBlocks[i], true); + } break; case 'change': - instance.runtime.changeBlock({ + instance.blocks.changeBlock({ id: e.blockId, element: e.element, name: e.name, @@ -78,7 +88,7 @@ function VirtualMachine () { }); break; case 'delete': - instance.runtime.deleteBlock({ + instance.blocks.deleteBlock({ id: e.blockId }); break; diff --git a/test/fixtures/events.json b/test/fixtures/events.json index fca8693ff..6d94fc62a 100644 --- a/test/fixtures/events.json +++ b/test/fixtures/events.json @@ -1,20 +1,57 @@ { "create": { - "blockId": "z!+#Nqr,_(V=xz0y7a@d", "workspaceId": "7Luws3lyb*Z98~Kk+IG|", "group": ";OswyM#@%`%,xOrhOXC=", "recordUndo": true, + "name": "block", "xml": { - "attributes": { - "type": { - "value": "wedo_motorclockwise" - } - }, - "innerHTML": "<value name=\"DURATION\"><shadow type=\"math_number\" id=\"!6Ahqg4f}Ljl}X5Hws?Z\"><field name=\"NUM\">10</field></shadow></value>" + "outerHTML": "<block type=\"wedo_motorclockwise\" id=\"z!+#Nqr,_(V=xz0y7a@d\"><value name=\"DURATION\"><shadow type=\"math_number\" id=\"!6Ahqg4f}Ljl}X5Hws?Z\"><field name=\"NUM\">10</field></shadow></value></block>" }, "ids": [ "z!+#Nqr,_(V=xz0y7a@d", "!6Ahqg4f}Ljl}X5Hws?Z" ] + }, + "createsubstack": { + "name": "block", + "xml": { + "outerHTML": "<block type=\"control_forever\" id=\"r9`RpL74T6*SXPKv7}Dq\" x=\"61\" y=\"90\"><statement name=\"SUBSTACK\"><block type=\"control_wait\" id=\"{Rwt[LFtD1-JPAi-qf:.\"><value name=\"DURATION\"><shadow type=\"math_number\" id=\"VMDxt_9SYe5{*eNRe5dZ\"><field name=\"NUM\">1</field></shadow></value></block></statement></block>" + } + }, + "createtwosubstacks": { + "name": "block", + "xml": { + "outerHTML": "<block type=\"control_if_else\" id=\"8W?lmIY!Tgnh)~0!G#9-\" x=\"87\" y=\"159\"><statement name=\"SUBSTACK\"><block type=\"event_broadcast\" id=\"lgU2GGtwlREuasCB02Vr\"></block></statement><statement name=\"SUBSTACK2\"><block type=\"event_broadcast\" id=\"Gb]N,2P;|J%F?pxSwz(2\"></block></statement></block>" + } + }, + "createtoplevelshadow": { + "name": "shadow", + "xml": { + "outerHTML": "<shadow type=\"math_number\" id=\"z9d57=IUI5se;DBbyug)\"><field name=\"NUM\">4</field></shadow>" + } + }, + "createwithnext": { + "name": "block", + "xml": { + "outerHTML": "<block type=\"wedo_setcolor\" id=\"*CT)7+UKjQIEtUw.OGT6\" x=\"89\" y=\"48\"><next><block type=\"wedo_motorspeed\" id=\"Er*:^o7yYL#dX+5)R^xq\"></block></next></block>" + } + }, + "createinvalid": { + "name": "whatever", + "xml": { + "outerHTML": "<xml></xml>" + } + }, + "createinvalidgrandchild": { + "name": "block", + "xml": { + "outerHTML": "<block type=\"control_forever\" id=\"r9`RpL74T6*SXPKv7}Dq\" x=\"61\" y=\"90\"><next><invalidgrandchild>xxx</invalidgrandchild></next></block>" + } + }, + "createbadxml": { + "name": "whatever", + "xml": { + "outerHTML": "></xml>" + } } } diff --git a/test/unit/adapter.js b/test/unit/adapter.js index 547e0b9b0..838815626 100644 --- a/test/unit/adapter.js +++ b/test/unit/adapter.js @@ -7,14 +7,161 @@ test('spec', function (t) { t.end(); }); +test('invalid inputs', function(t) { + var nothing = adapter('not an object'); + t.type(nothing, 'undefined'); + nothing = adapter({noxmlproperty:true}); + t.type(nothing, 'undefined'); + t.end(); +}); + test('create event', function (t) { var result = adapter(events.create); - t.type(result, 'object'); - t.type(result.id, 'string'); - t.type(result.opcode, 'string'); - t.type(result.fields, 'object'); - t.type(result.fields['DURATION'], 'object'); + t.ok(Array.isArray(result)); + t.equal(result.length, 2); + + // Outer block + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].inputs, 'object'); + t.type(result[0].inputs['DURATION'], 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + + // Enclosed shadow block + t.type(result[1].id, 'string'); + t.type(result[1].opcode, 'string'); + t.type(result[1].fields, 'object'); + t.type(result[1].inputs, 'object'); + t.type(result[1].fields['NUM'], 'object'); + t.type(result[1].fields['NUM'].value, '10'); + t.type(result[1].topLevel, 'boolean'); + t.equal(result[1].topLevel, false); t.end(); }); + +test('create with substack', function (t) { + var result = adapter(events.createsubstack); + // Outer block + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].inputs, 'object'); + t.type(result[0].inputs['SUBSTACK'], 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + // In substack + var substackBlockId = result[0].inputs['SUBSTACK']['block']; + t.type(substackBlockId, 'string'); + // Find actual substack block + var substackBlock = null; + for (var i = 0; i < result.length; i++) { + if (result[i].id == substackBlockId) { + substackBlock = result[i]; + } + } + t.type(substackBlock, 'object'); + t.end(); +}); + +test('create with two substacks', function (t) { + var result = adapter(events.createtwosubstacks); + // Outer block + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].inputs, 'object'); + t.type(result[0].inputs['SUBSTACK'], 'object'); + t.type(result[0].inputs['SUBSTACK2'], 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + // In substacks + var firstSubstackBlockId = result[0].inputs['SUBSTACK']['block']; + var secondSubstackBlockId = result[0].inputs['SUBSTACK2']['block']; + t.type(firstSubstackBlockId, 'string'); + t.type(secondSubstackBlockId, 'string'); + // Find actual substack blocks + var firstSubstackBlock = null; + var secondSubstackBlock = null; + for (var i = 0; i < result.length; i++) { + if (result[i].id == firstSubstackBlockId) { + firstSubstackBlock = result[i]; + } + if (result[i].id == secondSubstackBlockId) { + secondSubstackBlock = result[i]; + } + } + t.type(firstSubstackBlock, 'object'); + t.type(secondSubstackBlock, 'object'); + t.end(); +}); + +test('create with top-level shadow', function (t) { + var result = adapter(events.createtoplevelshadow); + t.ok(Array.isArray(result)); + t.equal(result.length, 1); + + // Outer block + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].inputs, 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + t.end(); +}); + +test('create with next connection', function (t) { + var result = adapter(events.createwithnext); + + t.ok(Array.isArray(result)); + t.equal(result.length, 2); + + // First block + t.type(result[0].id, 'string'); + t.type(result[0].opcode, 'string'); + t.type(result[0].fields, 'object'); + t.type(result[0].inputs, 'object'); + t.type(result[0].topLevel, 'boolean'); + t.equal(result[0].topLevel, true); + t.type(result[0].next, 'string'); + t.equal(result[0].next, result[1].id); + + // Second block + t.type(result[1].id, 'string'); + t.type(result[1].opcode, 'string'); + t.type(result[1].fields, 'object'); + t.type(result[1].inputs, 'object'); + t.type(result[1].topLevel, 'boolean'); + t.equal(result[1].topLevel, false); + t.equal(result[1].next, null); + + t.end(); +}); + +test('create with invalid block xml', function (t) { + // Entirely invalid block XML + var result = adapter(events.createinvalid); + t.ok(Array.isArray(result)); + t.equal(result.length, 0); + + // Invalid grandchild tag + var result2 = adapter(events.createinvalidgrandchild); + t.ok(Array.isArray(result2)); + t.equal(result2.length, 1); + t.type(result2[0].id, 'string'); + t.equal(Object.keys(result2[0].inputs).length, 0); + t.equal(Object.keys(result2[0].fields).length, 0); + + t.end(); +}); + +test('create with invalid xml', function (t) { + var result = adapter(events.createbadxml); + t.ok(Array.isArray(result)); + t.equal(result.length, 0); + t.end(); +}); diff --git a/test/unit/blocks.js b/test/unit/blocks.js new file mode 100644 index 000000000..518317f59 --- /dev/null +++ b/test/unit/blocks.js @@ -0,0 +1,468 @@ +var test = require('tap').test; +var Blocks = require('../../src/engine/blocks'); + +test('spec', function (t) { + var b = new Blocks(); + + t.type(Blocks, 'function'); + t.type(b, 'object'); + t.ok(b instanceof Blocks); + + t.type(b._blocks, 'object'); + t.type(b._stacks, 'object'); + t.ok(Array.isArray(b._stacks)); + + t.type(b.createBlock, 'function'); + t.type(b.moveBlock, 'function'); + t.type(b.changeBlock, 'function'); + t.type(b.deleteBlock, 'function'); + t.type(b.getBlock, 'function'); + t.type(b.getStacks, 'function'); + t.type(b.getNextBlock, 'function'); + t.type(b.getSubstack, 'function'); + t.type(b.getOpcode, 'function'); + + + t.end(); +}); + +// Getter tests +test('getBlock', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + var block = b.getBlock('foo'); + t.type(block, 'object'); + var notBlock = b.getBlock('?'); + t.type(notBlock, 'undefined'); + t.end(); +}); + +test('getStacks', function (t) { + var b = new Blocks(); + var stacks = b.getStacks(); + t.type(stacks, 'object'); + t.equals(stacks.length, 0); + // Create two top-level blocks and one not. + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + b.createBlock({ + id: 'foo3', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + + stacks = b.getStacks(); + t.type(stacks, 'object'); + t.equals(stacks.length, 2); + t.ok(stacks.indexOf('foo') > -1); + t.ok(stacks.indexOf('foo2') > -1); + t.equals(stacks.indexOf('foo3'), -1); + t.end(); + +}); + +test('getNextBlock', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + + var next = b.getNextBlock('foo'); + t.equals(next, null); + + // Add a block with "foo" as its next. + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: 'foo', + fields: {}, + inputs: {}, + topLevel: true + }); + + next = b.getNextBlock('foo2'); + t.equals(next, 'foo'); + + // Block that doesn't exist. + var noBlock = b.getNextBlock('?'); + t.equals(noBlock, null); + + t.end(); +}); + +test('getSubstack', function (t) { + var b = new Blocks(); + // Single substack + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: { + SUBSTACK: { + name: 'SUBSTACK', + block: 'foo2' + } + }, + topLevel: true + }); + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + + var substack = b.getSubstack('foo'); + t.equals(substack, 'foo2'); + + var notSubstack = b.getSubstack('?'); + t.equals(notSubstack, null); + + t.end(); +}); + +test('getSubstack2', function (t) { + var b = new Blocks(); + // Second substack + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: { + SUBSTACK: { + name: 'SUBSTACK', + block: 'foo2' + }, + SUBSTACK2: { + name: 'SUBSTACK2', + block: 'foo3' + } + }, + topLevel: true + }); + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + b.createBlock({ + id: 'foo3', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + + var substack1 = b.getSubstack('foo', 1); + var substack2 = b.getSubstack('foo', 2); + t.equals(substack1, 'foo2'); + t.equals(substack2, 'foo3'); + + t.end(); +}); + +test('getSubstack with none', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + var noSubstack = b.getSubstack('foo'); + t.equals(noSubstack, null); + t.end(); +}); + +test('getOpcode', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + var opcode = b.getOpcode('foo'); + t.equals(opcode, 'TEST_BLOCK'); + var notOpcode = b.getOpcode('?'); + t.equals(notOpcode, null); + t.end(); +}); + +// Block events tests +test('create', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + + t.type(b._blocks['foo'], 'object'); + t.equal(b._blocks['foo'].opcode, 'TEST_BLOCK'); + t.notEqual(b._stacks.indexOf('foo'), -1); + t.end(); +}); + +test('move', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + b.createBlock({ + id: 'bar', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + + // Attach 'bar' to the end of 'foo' + b.moveBlock({ + id: 'bar', + newParent: 'foo' + }); + t.equal(b._stacks.length, 1); + t.equal(Object.keys(b._blocks).length, 2); + t.equal(b._blocks['foo'].next, 'bar'); + + // Detach 'bar' from 'foo' + b.moveBlock({ + id: 'bar', + oldParent: 'foo' + }); + t.equal(b._stacks.length, 2); + t.equal(Object.keys(b._blocks).length, 2); + t.equal(b._blocks['foo'].next, null); + + t.end(); +}); + +test('change', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: { + someField: { + name: 'someField', + value: 'initial-value' + } + }, + inputs: {}, + topLevel: true + }); + + // Test that the field is updated + t.equal(b._blocks['foo'].fields.someField.value, 'initial-value'); + + b.changeBlock({ + element: 'field', + id: 'foo', + name: 'someField', + value: 'final-value' + }); + + t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + + // Invalid cases + // No `element` + b.changeBlock({ + id: 'foo', + name: 'someField', + value: 'invalid-value' + }); + t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + + // No block ID + b.changeBlock({ + element: 'field', + name: 'someField', + value: 'invalid-value' + }); + t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + + // No such field + b.changeBlock({ + element: 'field', + id: 'foo', + name: 'someWrongField', + value: 'final-value' + }); + t.equal(b._blocks['foo'].fields.someField.value, 'final-value'); + + t.end(); +}); + +test('delete', function (t) { + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: true + }); + b.deleteBlock({ + id: 'foo' + }); + + t.type(b._blocks['foo'], 'undefined'); + t.equal(b._stacks.indexOf('foo'), -1); + t.end(); +}); + +test('delete chain', function (t) { + // Create a chain of connected blocks and delete the top one. + // All of them should be deleted. + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: 'foo2', + fields: {}, + inputs: {}, + topLevel: true + }); + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: 'foo3', + fields: {}, + inputs: {}, + topLevel: false + }); + b.createBlock({ + id: 'foo3', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + b.deleteBlock({ + id: 'foo' + }); + t.type(b._blocks['foo'], 'undefined'); + t.type(b._blocks['foo2'], 'undefined'); + t.type(b._blocks['foo3'], 'undefined'); + t.equal(b._stacks.indexOf('foo'), -1); + t.equal(Object.keys(b._blocks).length, 0); + t.equal(b._stacks.length, 0); + t.end(); +}); + +test('delete inputs', function (t) { + // Create a block with two inputs, one of which has its own input. + // Delete the block - all of them should be deleted. + var b = new Blocks(); + b.createBlock({ + id: 'foo', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: { + input1: { + name: 'input1', + block: 'foo2' + }, + SUBSTACK: { + name: 'SUBSTACK', + block: 'foo3' + } + }, + topLevel: true + }); + b.createBlock({ + id: 'foo2', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + b.createBlock({ + id: 'foo3', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: { + subinput: { + name: 'subinput', + block: 'foo4' + } + }, + topLevel: false + }); + b.createBlock({ + id: 'foo4', + opcode: 'TEST_BLOCK', + next: null, + fields: {}, + inputs: {}, + topLevel: false + }); + b.deleteBlock({ + id: 'foo' + }); + t.type(b._blocks['foo'], 'undefined'); + t.type(b._blocks['foo2'], 'undefined'); + t.type(b._blocks['foo3'], 'undefined'); + t.type(b._blocks['foo4'], 'undefined'); + t.equal(b._stacks.indexOf('foo'), -1); + t.equal(Object.keys(b._blocks).length, 0); + t.equal(b._stacks.length, 0); + t.end(); +}); diff --git a/test/unit/runtime.js b/test/unit/runtime.js index 0e41ed5be..40757c054 100644 --- a/test/unit/runtime.js +++ b/test/unit/runtime.js @@ -8,82 +8,5 @@ test('spec', function (t) { t.type(r, 'object'); t.ok(r instanceof Runtime); - t.type(r.blocks, 'object'); - t.type(r.stacks, 'object'); - t.ok(Array.isArray(r.stacks)); - - t.type(r.createBlock, 'function'); - t.type(r.moveBlock, 'function'); - t.type(r.changeBlock, 'function'); - t.type(r.deleteBlock, 'function'); - - t.end(); -}); - -test('create', function (t) { - var r = new Runtime(); - r.createBlock({ - id: 'foo', - opcode: 'TEST_BLOCK', - next: null, - fields: {} - }); - - t.type(r.blocks['foo'], 'object'); - t.equal(r.blocks['foo'].opcode, 'TEST_BLOCK'); - t.notEqual(r.stacks.indexOf('foo'), -1); - t.end(); -}); - -test('move', function (t) { - var r = new Runtime(); - r.createBlock({ - id: 'foo', - opcode: 'TEST_BLOCK', - next: null, - fields: {} - }); - r.createBlock({ - id: 'bar', - opcode: 'TEST_BLOCK', - next: null, - fields: {} - }); - - // Attach 'bar' to the end of 'foo' - r.moveBlock({ - id: 'bar', - newParent: 'foo' - }); - t.equal(r.stacks.length, 1); - t.equal(Object.keys(r.blocks).length, 2); - t.equal(r.blocks['foo'].next, 'bar'); - - // Detach 'bar' from 'foo' - r.moveBlock({ - id: 'bar', - oldParent: 'foo' - }); - t.equal(r.stacks.length, 2); - t.equal(Object.keys(r.blocks).length, 2); - t.equal(r.blocks['foo'].next, null); - - t.end(); -}); - -test('delete', function (t) { - var r = new Runtime(); - r.createBlock({ - id: 'foo', - opcode: 'TEST_BLOCK', - next: null, - fields: {} - }); - r.deleteBlock({ - id: 'foo' - }); - - t.type(r.blocks['foo'], 'undefined'); - t.equal(r.stacks.indexOf('foo'), -1); t.end(); });