mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2025-03-13 17:04:39 -04:00
Merge pull request #1021 from cwillisf/features-for-control-extension
Features for control extension
This commit is contained in:
commit
631d7fb4de
12 changed files with 647 additions and 193 deletions
|
@ -5,9 +5,13 @@ const escapeHtml = require('escape-html');
|
|||
const ArgumentType = require('../extension-support/argument-type');
|
||||
const Blocks = require('./blocks');
|
||||
const BlockType = require('../extension-support/block-type');
|
||||
const Sequencer = require('./sequencer');
|
||||
const Thread = require('./thread');
|
||||
const Profiler = require('./profiler');
|
||||
const Sequencer = require('./sequencer');
|
||||
const ScratchBlocksConstants = require('./scratch-blocks-constants');
|
||||
const TargetType = require('../extension-support/target-type');
|
||||
const Thread = require('./thread');
|
||||
const log = require('../util/log');
|
||||
const maybeFormatMessage = require('../util/maybe-format-message');
|
||||
|
||||
// Virtual I/O devices.
|
||||
const Clock = require('../io/clock');
|
||||
|
@ -56,29 +60,13 @@ const ArgumentTypeMap = (() => {
|
|||
})();
|
||||
|
||||
/**
|
||||
* These constants are copied from scratch-blocks/core/constants.js
|
||||
* @TODO find a way to require() these... maybe make a scratch-blocks/dist/constants.js or something like that?
|
||||
* @readonly
|
||||
* @enum {int}
|
||||
* Predefined "Converted block info" for a separator between blocks in a block category
|
||||
* @type {ConvertedBlockInfo}
|
||||
*/
|
||||
const ScratchBlocksConstants = {
|
||||
/**
|
||||
* ENUM for output shape: hexagonal (booleans/predicates).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_HEXAGONAL: 1,
|
||||
|
||||
/**
|
||||
* ENUM for output shape: rounded (numbers).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_ROUND: 2,
|
||||
|
||||
/**
|
||||
* ENUM for output shape: squared (any/all values; strings).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_SQUARE: 3
|
||||
const ConvertedSeparator = {
|
||||
info: {},
|
||||
json: null,
|
||||
xml: '<sep gap="36"/>'
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -484,15 +472,28 @@ class Runtime extends EventEmitter {
|
|||
return `${extensionId}.menu.${escapeHtml(menuName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context ("args") object for use with `formatMessage` on messages which might be target-specific.
|
||||
* @param {Target} [target] - the target to use as context. If a target is not provided, default to the current
|
||||
* editing target or the stage.
|
||||
*/
|
||||
makeMessageContextForTarget (target) {
|
||||
const context = {};
|
||||
target = target || this.getEditingTarget() || this.getTargetForStage();
|
||||
if (target) {
|
||||
context.targetType = (target.isStage ? TargetType.STAGE : TargetType.SPRITE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the primitives provided by an extension.
|
||||
* @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.)
|
||||
* @param {ExtensionMetadata} extensionInfo - information about the extension (id, blocks, etc.)
|
||||
* @private
|
||||
*/
|
||||
_registerExtensionPrimitives (extensionInfo) {
|
||||
const categoryInfo = {
|
||||
id: extensionInfo.id,
|
||||
name: extensionInfo.name,
|
||||
name: maybeFormatMessage(extensionInfo.name),
|
||||
blockIconURI: extensionInfo.blockIconURI,
|
||||
menuIconURI: extensionInfo.menuIconURI,
|
||||
color1: '#FF6680',
|
||||
|
@ -504,30 +505,14 @@ class Runtime extends EventEmitter {
|
|||
|
||||
this._blockInfo.push(categoryInfo);
|
||||
|
||||
for (const menuName in extensionInfo.menus) {
|
||||
if (extensionInfo.menus.hasOwnProperty(menuName)) {
|
||||
const menuItems = extensionInfo.menus[menuName];
|
||||
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
|
||||
categoryInfo.menus.push(convertedMenu);
|
||||
}
|
||||
}
|
||||
for (const blockInfo of extensionInfo.blocks) {
|
||||
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
|
||||
const opcode = convertedBlock.json.type;
|
||||
categoryInfo.blocks.push(convertedBlock);
|
||||
this._primitives[opcode] = convertedBlock.info.func;
|
||||
if (blockInfo.blockType === BlockType.HAT) {
|
||||
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
|
||||
}
|
||||
}
|
||||
this._fillExtensionCategory(categoryInfo, extensionInfo);
|
||||
|
||||
this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reregister the primitives for an extension
|
||||
* @param {ExtensionInfo} extensionInfo - new info (results of running getInfo)
|
||||
* for an extension
|
||||
* @param {ExtensionMetadata} extensionInfo - new info (results of running getInfo) for an extension
|
||||
* @private
|
||||
*/
|
||||
_refreshExtensionPrimitives (extensionInfo) {
|
||||
|
@ -536,22 +521,7 @@ class Runtime extends EventEmitter {
|
|||
if (extensionInfo.id === categoryInfo.id) {
|
||||
categoryInfo.blocks = [];
|
||||
categoryInfo.menus = [];
|
||||
for (const menuName in extensionInfo.menus) {
|
||||
if (extensionInfo.menus.hasOwnProperty(menuName)) {
|
||||
const menuItems = extensionInfo.menus[menuName];
|
||||
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
|
||||
categoryInfo.menus.push(convertedMenu);
|
||||
}
|
||||
}
|
||||
for (const blockInfo of extensionInfo.blocks) {
|
||||
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
|
||||
const opcode = convertedBlock.json.type;
|
||||
categoryInfo.blocks.push(convertedBlock);
|
||||
this._primitives[opcode] = convertedBlock.info.func;
|
||||
if (blockInfo.blockType === BlockType.HAT) {
|
||||
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
|
||||
}
|
||||
}
|
||||
this._fillExtensionCategory(categoryInfo, extensionInfo);
|
||||
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
|
||||
}
|
||||
}
|
||||
|
@ -559,6 +529,44 @@ class Runtime extends EventEmitter {
|
|||
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read extension information, convert menus and blocks, and store the results in the provided category object.
|
||||
* @param {CategoryInfo} categoryInfo - the category to be filled
|
||||
* @param {ExtensionMetadata} extensionInfo - the extension metadata to read
|
||||
* @private
|
||||
*/
|
||||
_fillExtensionCategory (categoryInfo, extensionInfo) {
|
||||
for (const menuName in extensionInfo.menus) {
|
||||
if (extensionInfo.menus.hasOwnProperty(menuName)) {
|
||||
const menuItems = extensionInfo.menus[menuName];
|
||||
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
|
||||
categoryInfo.menus.push(convertedMenu);
|
||||
}
|
||||
}
|
||||
for (const blockInfo of extensionInfo.blocks) {
|
||||
if (blockInfo === '---') {
|
||||
categoryInfo.blocks.push(ConvertedSeparator);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
|
||||
const opcode = convertedBlock.json.type;
|
||||
categoryInfo.blocks.push(convertedBlock);
|
||||
if (blockInfo.blockType !== BlockType.EVENT) {
|
||||
this._primitives[opcode] = convertedBlock.info.func;
|
||||
}
|
||||
if (blockInfo.blockType === BlockType.EVENT || blockInfo.blockType === BlockType.HAT) {
|
||||
this._hats[opcode] = {
|
||||
edgeActivated: blockInfo.isEdgeActivated,
|
||||
restartExistingThreads: blockInfo.shouldRestartExistingThreads
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Error parsing block: ', {block: blockInfo, error: e});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block.
|
||||
* @param {string} menuName - the name of the menu
|
||||
|
@ -573,14 +581,16 @@ class Runtime extends EventEmitter {
|
|||
if (typeof menuItems === 'function') {
|
||||
options = menuItems;
|
||||
} else {
|
||||
const extensionMessageContext = this.makeMessageContextForTarget();
|
||||
options = menuItems.map(item => {
|
||||
switch (typeof item) {
|
||||
const formattedItem = maybeFormatMessage(item, extensionMessageContext);
|
||||
switch (typeof formattedItem) {
|
||||
case 'string':
|
||||
return [item, item];
|
||||
return [formattedItem, formattedItem];
|
||||
case 'object':
|
||||
return [item.text, item.value];
|
||||
return [maybeFormatMessage(item.text, extensionMessageContext), item.value];
|
||||
default:
|
||||
throw new Error(`Can't interpret menu item: ${item}`);
|
||||
throw new Error(`Can't interpret menu item: ${JSON.stringify(item)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -606,14 +616,15 @@ class Runtime extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function.
|
||||
* @param {BlockInfo} blockInfo - the block to convert
|
||||
* Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function.
|
||||
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
|
||||
* @param {CategoryInfo} categoryInfo - the category for this block
|
||||
* @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information
|
||||
* @returns {ConvertedBlockInfo} - the converted & original block information
|
||||
* @private
|
||||
*/
|
||||
_convertForScratchBlocks (blockInfo, categoryInfo) {
|
||||
const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`;
|
||||
|
||||
const blockJSON = {
|
||||
type: extendedOpcode,
|
||||
inputsInline: true,
|
||||
|
@ -621,19 +632,19 @@ class Runtime extends EventEmitter {
|
|||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
args0: [],
|
||||
extensions: ['scratch_extension']
|
||||
};
|
||||
|
||||
const inputList = [];
|
||||
|
||||
// TODO: store this somewhere so that we can map args appropriately after translation.
|
||||
// This maps an arg name to its relative position in the original (usually English) block text.
|
||||
// When displaying a block in another language we'll need to run a `replace` action similar to the one below,
|
||||
// but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`.
|
||||
const argsMap = {};
|
||||
|
||||
blockJSON.message0 = '';
|
||||
const context = {
|
||||
// TODO: store this somewhere so that we can map args appropriately after translation.
|
||||
// This maps an arg name to its relative position in the original (usually English) block text.
|
||||
// When displaying a block in another language we'll need to run a `replace` action similar to the one
|
||||
// below, but each `[ARG]` will need to be replaced with the number in this map.
|
||||
argsMap: {},
|
||||
blockJSON,
|
||||
categoryInfo,
|
||||
blockInfo,
|
||||
inputList: []
|
||||
};
|
||||
|
||||
// If an icon for the extension exists, prepend it to each block, with a vertical separator.
|
||||
if (categoryInfo.blockIconURI) {
|
||||
|
@ -647,60 +658,12 @@ class Runtime extends EventEmitter {
|
|||
const separatorJSON = {
|
||||
type: 'field_vertical_separator'
|
||||
};
|
||||
blockJSON.args0.push(iconJSON);
|
||||
blockJSON.args0.push(separatorJSON);
|
||||
blockJSON.args0 = [
|
||||
iconJSON,
|
||||
separatorJSON
|
||||
];
|
||||
}
|
||||
|
||||
blockJSON.message0 += blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => {
|
||||
// Sanitize the placeholder to ensure valid XML
|
||||
placeholder = placeholder.replace(/[<"&]/, '_');
|
||||
|
||||
const argJSON = {
|
||||
type: 'input_value',
|
||||
name: placeholder
|
||||
};
|
||||
|
||||
const argInfo = blockInfo.arguments[placeholder] || {};
|
||||
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
|
||||
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
|
||||
'' :
|
||||
escapeHtml(argInfo.defaultValue.toString()));
|
||||
|
||||
if (argTypeInfo.check) {
|
||||
argJSON.check = argTypeInfo.check;
|
||||
}
|
||||
|
||||
const shadowType = (argInfo.menu ?
|
||||
this._makeExtensionMenuId(argInfo.menu, categoryInfo.id) :
|
||||
argTypeInfo.shadowType);
|
||||
const fieldType = argInfo.menu || argTypeInfo.fieldType;
|
||||
|
||||
// <value> is the ScratchBlocks name for a block input.
|
||||
inputList.push(`<value name="${placeholder}">`);
|
||||
|
||||
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
|
||||
// Boolean inputs don't need to specify a shadow in the XML.
|
||||
if (shadowType) {
|
||||
inputList.push(`<shadow type="${shadowType}">`);
|
||||
|
||||
// <field> is a text field that the user can type into. Some shadows, like the color picker, don't allow
|
||||
// text input and therefore don't need a field element.
|
||||
if (fieldType) {
|
||||
inputList.push(`<field name="${fieldType}">${defaultValue}</field>`);
|
||||
}
|
||||
|
||||
inputList.push('</shadow>');
|
||||
}
|
||||
|
||||
inputList.push('</value>');
|
||||
|
||||
// scratch-blocks uses 1-based argument indexing
|
||||
blockJSON.args0.push(argJSON);
|
||||
const argNum = blockJSON.args0.length;
|
||||
argsMap[placeholder] = argNum;
|
||||
return `%${argNum}`;
|
||||
});
|
||||
|
||||
switch (blockInfo.blockType) {
|
||||
case BlockType.COMMAND:
|
||||
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
||||
|
@ -718,37 +681,141 @@ class Runtime extends EventEmitter {
|
|||
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL;
|
||||
break;
|
||||
case BlockType.HAT:
|
||||
case BlockType.EVENT:
|
||||
if (!blockInfo.hasOwnProperty('isEdgeActivated')) {
|
||||
// if absent, this property defaults to true
|
||||
blockInfo.isEdgeActivated = true;
|
||||
}
|
||||
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
||||
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
||||
break;
|
||||
case BlockType.CONDITIONAL:
|
||||
// Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ...
|
||||
for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) {
|
||||
blockJSON[`message${branchNum}`] = '%1';
|
||||
blockJSON[`args${branchNum}`] = [{
|
||||
type: 'input_statement',
|
||||
name: `SUBSTACK${branchNum > 1 ? branchNum : ''}`
|
||||
}];
|
||||
}
|
||||
case BlockType.LOOP:
|
||||
blockInfo.branchCount = blockInfo.branchCount || 1;
|
||||
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
|
||||
blockJSON.previousStatement = null; // null = available connection; undefined = hat
|
||||
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
||||
if (!blockInfo.isTerminal) {
|
||||
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockInfo.isTerminal) {
|
||||
delete blockJSON.nextStatement;
|
||||
const blockText = Array.isArray(blockInfo.text) ? blockInfo.text : [blockInfo.text];
|
||||
let inTextNum = 0; // text for the next block "arm" is blockText[inTextNum]
|
||||
let inBranchNum = 0; // how many branches have we placed into the JSON so far?
|
||||
let outLineNum = 0; // used for scratch-blocks `message${outLineNum}` and `args${outLineNum}`
|
||||
const convertPlaceholders = this._convertPlaceholders.bind(this, context);
|
||||
const extensionMessageContext = this.makeMessageContextForTarget();
|
||||
|
||||
// alternate between a block "arm" with text on it and an open slot for a substack
|
||||
while (inTextNum < blockText.length || inBranchNum < blockInfo.branchCount) {
|
||||
if (inTextNum < blockText.length) {
|
||||
context.outLineNum = outLineNum;
|
||||
const lineText = maybeFormatMessage(blockText[inTextNum], extensionMessageContext);
|
||||
const convertedText = lineText.replace(/\[(.+?)]/g, convertPlaceholders);
|
||||
if (blockJSON[`message${outLineNum}`]) {
|
||||
blockJSON[`message${outLineNum}`] += convertedText;
|
||||
} else {
|
||||
blockJSON[`message${outLineNum}`] = convertedText;
|
||||
}
|
||||
++inTextNum;
|
||||
++outLineNum;
|
||||
}
|
||||
if (inBranchNum < blockInfo.branchCount) {
|
||||
blockJSON[`message${outLineNum}`] = '%1';
|
||||
blockJSON[`args${outLineNum}`] = [{
|
||||
type: 'input_statement',
|
||||
name: `SUBSTACK${inBranchNum > 0 ? inBranchNum + 1 : ''}`
|
||||
}];
|
||||
++inBranchNum;
|
||||
++outLineNum;
|
||||
}
|
||||
}
|
||||
|
||||
const blockXML = `<block type="${extendedOpcode}">${inputList.join('')}</block>`;
|
||||
// Add icon to the bottom right of a loop block
|
||||
if (blockInfo.blockType === BlockType.LOOP) {
|
||||
blockJSON[`lastDummyAlign${outLineNum}`] = 'RIGHT';
|
||||
blockJSON[`message${outLineNum}`] = '%1';
|
||||
blockJSON[`args${outLineNum}`] = [{
|
||||
type: 'field_image',
|
||||
src: './static/blocks-media/repeat.svg', // TODO: use a constant or make this configurable?
|
||||
width: 24,
|
||||
height: 24,
|
||||
alt: '*',
|
||||
flip_rtl: true
|
||||
}];
|
||||
++outLineNum;
|
||||
}
|
||||
|
||||
const blockXML = `<block type="${extendedOpcode}">${context.inputList.join('')}</block>`;
|
||||
|
||||
return {
|
||||
info: blockInfo,
|
||||
json: blockJSON,
|
||||
info: context.blockInfo,
|
||||
json: context.blockJSON,
|
||||
xml: blockXML
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for _convertForScratchBlocks which handles linearization of argument placeholders. Called as a callback
|
||||
* from string#replace. In addition to the return value the JSON and XML items in the context will be filled.
|
||||
* @param {object} context - information shared with _convertForScratchBlocks about the block, etc.
|
||||
* @param {string} match - the overall string matched by the placeholder regex, including brackets: '[FOO]'.
|
||||
* @param {string} placeholder - the name of the placeholder being matched: 'FOO'.
|
||||
* @return {string} scratch-blocks placeholder for the argument: '%1'.
|
||||
* @private
|
||||
*/
|
||||
_convertPlaceholders (context, match, placeholder) {
|
||||
// Sanitize the placeholder to ensure valid XML
|
||||
placeholder = placeholder.replace(/[<"&]/, '_');
|
||||
|
||||
const argJSON = {
|
||||
type: 'input_value',
|
||||
name: placeholder
|
||||
};
|
||||
|
||||
const argInfo = context.blockInfo.arguments[placeholder] || {};
|
||||
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
|
||||
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
|
||||
'' :
|
||||
escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString()));
|
||||
|
||||
if (argTypeInfo.check) {
|
||||
argJSON.check = argTypeInfo.check;
|
||||
}
|
||||
|
||||
const shadowType = (argInfo.menu ?
|
||||
this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id) :
|
||||
argTypeInfo.shadowType);
|
||||
const fieldType = argInfo.menu || argTypeInfo.fieldType;
|
||||
|
||||
// <value> is the ScratchBlocks name for a block input.
|
||||
context.inputList.push(`<value name="${placeholder}">`);
|
||||
|
||||
// The <shadow> is a placeholder for a reporter and is visible when there's no reporter in this input.
|
||||
// Boolean inputs don't need to specify a shadow in the XML.
|
||||
if (shadowType) {
|
||||
context.inputList.push(`<shadow type="${shadowType}">`);
|
||||
|
||||
// <field> is a text field that the user can type into. Some shadows, like the color picker, don't allow
|
||||
// text input and therefore don't need a field element.
|
||||
if (fieldType) {
|
||||
context.inputList.push(`<field name="${fieldType}">${defaultValue}</field>`);
|
||||
}
|
||||
|
||||
context.inputList.push('</shadow>');
|
||||
}
|
||||
|
||||
context.inputList.push('</value>');
|
||||
|
||||
const argsName = `args${context.outLineNum}`;
|
||||
const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []);
|
||||
blockArgs.push(argJSON);
|
||||
const argNum = blockArgs.length;
|
||||
context.argsMap[placeholder] = argNum;
|
||||
return `%${argNum}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
|
||||
*/
|
||||
|
|
27
src/engine/scratch-blocks-constants.js
Normal file
27
src/engine/scratch-blocks-constants.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* These constants are copied from scratch-blocks/core/constants.js
|
||||
* @TODO find a way to require() these straight from scratch-blocks... maybe make a scratch-blocks/dist/constants.js?
|
||||
* @readonly
|
||||
* @enum {int}
|
||||
*/
|
||||
const ScratchBlocksConstants = {
|
||||
/**
|
||||
* ENUM for output shape: hexagonal (booleans/predicates).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_HEXAGONAL: 1,
|
||||
|
||||
/**
|
||||
* ENUM for output shape: rounded (numbers).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_ROUND: 2,
|
||||
|
||||
/**
|
||||
* ENUM for output shape: squared (any/all values; strings).
|
||||
* @const
|
||||
*/
|
||||
OUTPUT_SHAPE_SQUARE: 3
|
||||
};
|
||||
|
||||
module.exports = ScratchBlocksConstants;
|
|
@ -15,14 +15,27 @@ const BlockType = {
|
|||
|
||||
/**
|
||||
* Specialized command block which may or may not run a child branch
|
||||
* The thread continues with the next block whether or not a child branch ran.
|
||||
*/
|
||||
CONDITIONAL: 'conditional',
|
||||
|
||||
/**
|
||||
* Specialized hat block with no implementation function
|
||||
* This stack only runs if the corresponding event is emitted by other code.
|
||||
*/
|
||||
EVENT: 'event',
|
||||
|
||||
/**
|
||||
* Hat block which conditionally starts a block stack
|
||||
*/
|
||||
HAT: 'hat',
|
||||
|
||||
/**
|
||||
* Specialized command block which may or may not run a child branch
|
||||
* If a child branch runs, the thread evaluates the loop block again.
|
||||
*/
|
||||
LOOP: 'loop',
|
||||
|
||||
/**
|
||||
* General reporter with numeric or string value
|
||||
*/
|
||||
|
|
18
src/extension-support/define-messages.js
Normal file
18
src/extension-support/define-messages.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* @typedef {object} MessageDescriptor
|
||||
* @property {string} id - the translator-friendly unique ID of this message.
|
||||
* @property {string} default - the message text in the default language (English).
|
||||
* @property {string} [description] - a description of this message to help translators understand the context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is a hook for extracting messages from extension source files.
|
||||
* This function simply returns the message descriptor map object that's passed in.
|
||||
* @param {object.<MessageDescriptor>} messages - the messages to be defined
|
||||
* @return {object.<MessageDescriptor>} - the input, unprocessed
|
||||
*/
|
||||
const defineMessages = function (messages) {
|
||||
return messages;
|
||||
};
|
||||
|
||||
module.exports = defineMessages;
|
|
@ -1,5 +1,6 @@
|
|||
const dispatch = require('../dispatch/central-dispatch');
|
||||
const log = require('../util/log');
|
||||
const maybeFormatMessage = require('../util/maybe-format-message');
|
||||
|
||||
const BlockType = require('./block-type');
|
||||
|
||||
|
@ -25,26 +26,22 @@ const builtinExtensions = {
|
|||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} BlockInfo - Information about an extension block
|
||||
* @property {string} opcode - the block opcode
|
||||
* @property {string|object} text - the human-readable text on this block
|
||||
* @property {BlockType|undefined} blockType - the type of block (default: BlockType.COMMAND)
|
||||
* @property {int|undefined} branchCount - the number of branches this block controls, if conditional (default: 0)
|
||||
* @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false)
|
||||
* @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false)
|
||||
* @property {object.<string,ArgumentInfo>|undefined} arguments - information about this block's arguments, if any
|
||||
* @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode)
|
||||
* @property {Array.<string>|undefined} filter - the list of targets for which this block should appear (default: all)
|
||||
* @property {Boolean|undefined} hideFromPalette - true if should not be appear in the palette. (default false)
|
||||
* @typedef {object} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks
|
||||
* @property {ExtensionBlockMetadata} info - the raw block info
|
||||
* @property {object} json - the scratch-blocks JSON definition for this block
|
||||
* @property {string} xml - the scratch-blocks XML definition for this block
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CategoryInfo - Information about a block category
|
||||
* @property {string} id - the unique ID of this category
|
||||
* @property {string} name - the human-readable name of this category
|
||||
* @property {string|undefined} blockIconURI - optional URI for the block icon image
|
||||
* @property {string} color1 - the primary color for this category, in '#rrggbb' format
|
||||
* @property {string} color2 - the secondary color for this category, in '#rrggbb' format
|
||||
* @property {string} color3 - the tertiary color for this category, in '#rrggbb' format
|
||||
* @property {Array.<BlockInfo>} block - the blocks in this category
|
||||
* @property {Array.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category
|
||||
* @property {Array.<object>} menus - the menus provided by this category
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -208,7 +205,7 @@ class ExtensionManager {
|
|||
_registerExtensionInfo (serviceName, extensionInfo) {
|
||||
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo);
|
||||
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => {
|
||||
log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`);
|
||||
log.error(`Failed to register primitives for extension on service ${serviceName}:`, e);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -236,14 +233,23 @@ class ExtensionManager {
|
|||
extensionInfo.name = extensionInfo.name || extensionInfo.id;
|
||||
extensionInfo.blocks = extensionInfo.blocks || [];
|
||||
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
|
||||
extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => {
|
||||
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => {
|
||||
try {
|
||||
result.push(this._prepareBlockInfo(serviceName, blockInfo));
|
||||
let result;
|
||||
switch (blockInfo) {
|
||||
case '---': // separator
|
||||
result = '---';
|
||||
break;
|
||||
default: // an ExtensionBlockMetadata object
|
||||
result = this._prepareBlockInfo(serviceName, blockInfo);
|
||||
break;
|
||||
}
|
||||
results.push(result);
|
||||
} catch (e) {
|
||||
// TODO: more meaningful error reporting
|
||||
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
|
||||
}
|
||||
return result;
|
||||
return results;
|
||||
}, []);
|
||||
extensionInfo.menus = extensionInfo.menus || [];
|
||||
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
|
||||
|
@ -284,12 +290,23 @@ class ExtensionManager {
|
|||
_getExtensionMenuItems (extensionObject, menuName) {
|
||||
// Fetch the items appropriate for the target currently being edited. This assumes that menus only
|
||||
// collect items when opened by the user while editing a particular target.
|
||||
const editingTarget = this.runtime.getEditingTarget();
|
||||
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
|
||||
const editingTargetID = editingTarget ? editingTarget.id : null;
|
||||
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
|
||||
|
||||
// TODO: Fix this to use dispatch.call when extensions are running in workers.
|
||||
const menuFunc = extensionObject[menuName];
|
||||
const menuItems = menuFunc.call(extensionObject, editingTargetID);
|
||||
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
|
||||
item => {
|
||||
item = maybeFormatMessage(item, extensionMessageContext);
|
||||
if (typeof item === 'object') {
|
||||
return [
|
||||
maybeFormatMessage(item.text, extensionMessageContext),
|
||||
item.value
|
||||
];
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
if (!menuItems || menuItems.length < 1) {
|
||||
throw new Error(`Extension menu returned no items: ${menuName}`);
|
||||
|
@ -300,8 +317,8 @@ class ExtensionManager {
|
|||
/**
|
||||
* Apply defaults for optional block fields.
|
||||
* @param {string} serviceName - the name of the service hosting this extension block
|
||||
* @param {BlockInfo} blockInfo - the block info from the extension
|
||||
* @returns {BlockInfo} - a new block info object which has values for all relevant optional fields.
|
||||
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension
|
||||
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields.
|
||||
* @private
|
||||
*/
|
||||
_prepareBlockInfo (serviceName, blockInfo) {
|
||||
|
@ -312,24 +329,30 @@ class ExtensionManager {
|
|||
arguments: {}
|
||||
}, blockInfo);
|
||||
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
|
||||
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
|
||||
blockInfo.text = blockInfo.text || blockInfo.opcode;
|
||||
|
||||
/**
|
||||
* This is only here because the VM performs poorly when blocks return promises.
|
||||
* @TODO make it possible for the VM to resolve a promise and continue during the same frame.
|
||||
*/
|
||||
if (dispatch._isRemoteService(serviceName)) {
|
||||
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
|
||||
} else {
|
||||
const serviceObject = dispatch.services[serviceName];
|
||||
const func = serviceObject[blockInfo.func];
|
||||
if (func) {
|
||||
blockInfo.func = func.bind(serviceObject);
|
||||
if (blockInfo.blockType !== BlockType.EVENT) {
|
||||
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
|
||||
|
||||
/**
|
||||
* This is only here because the VM performs poorly when blocks return promises.
|
||||
* @TODO make it possible for the VM to resolve a promise and continue during the same Scratch "tick"
|
||||
*/
|
||||
if (dispatch._isRemoteService(serviceName)) {
|
||||
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
|
||||
} else {
|
||||
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
|
||||
const serviceObject = dispatch.services[serviceName];
|
||||
const func = serviceObject[blockInfo.func];
|
||||
if (func) {
|
||||
blockInfo.func = func.bind(serviceObject);
|
||||
} else if (blockInfo.blockType !== BlockType.EVENT) {
|
||||
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
|
||||
}
|
||||
}
|
||||
} else if (blockInfo.func) {
|
||||
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
|
||||
}
|
||||
|
||||
return blockInfo;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,33 +2,36 @@
|
|||
* @typedef {object} ExtensionMetadata
|
||||
* All the metadata needed to register an extension.
|
||||
* @property {string} id - a unique alphanumeric identifier for this extension. No special characters allowed.
|
||||
* @property {string} name - the human-readable name of this extension.
|
||||
* @property {string} blockIconURI - URI for an image to be placed on each block in this extension. Data URI ok.
|
||||
* @property {string} menuIconURI - URI for an image to be placed on this extension's category menu entry. Data URI ok.
|
||||
* @property {string} docsURI - link to documentation content for this extension.
|
||||
* @property {Array.<ExtensionBlockMetadata|string>} - the blocks provided by this extension, with optional separators.
|
||||
* @property {Object.<ExtensionMenuMetadata>} - map of menu name to metadata about each of this extension's menus.
|
||||
* @property {string} [name] - the human-readable name of this extension.
|
||||
* @property {string} [blockIconURI] - URI for an image to be placed on each block in this extension. Data URI ok.
|
||||
* @property {string} [menuIconURI] - URI for an image to be placed on this extension's category menu item. Data URI ok.
|
||||
* @property {string} [docsURI] - link to documentation content for this extension.
|
||||
* @property {Array.<ExtensionBlockMetadata|string>} blocks - the blocks provided by this extension, plus separators.
|
||||
* @property {Object.<ExtensionMenuMetadata>} [menus] - map of menu name to metadata for each of this extension's menus.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionBlockMetadata
|
||||
* All the metadata needed to register an extension block.
|
||||
* @property {string} opcode - a unique alphanumeric identifier for this block. No special characters allowed.
|
||||
* @property {string} [func] - the name of the function implementing this block. Can be shared by other blocks/opcodes.
|
||||
* @property {BlockType} blockType - the type of block (command, reporter, etc.) being described.
|
||||
* @property {string} func - the name of the function implementing this block. Can be shared with other blocks/opcodes.
|
||||
* @property {Boolean} hideFromPalette - true if this block should not appear in the block palette.
|
||||
* @property {ReporterScope} reporterScope - if this block is a reporter, this is the scope/context for its value.
|
||||
* @property {Boolean} terminal - true if the block ends a stack - no blocks can be connected after it.
|
||||
* @property {string} text - the text on the block, with [PLACEHOLDERS] for arguments.
|
||||
* @property {Object.<ExtensionArgumentMetadata>} arguments - map of argument placeholder to metadata about each arg.
|
||||
* @property {Boolean} [hideFromPalette] - true if this block should not appear in the block palette.
|
||||
* @property {Boolean} [isTerminal] - true if the block ends a stack - no blocks can be connected after it.
|
||||
* @property {ReporterScope} [reporterScope] - if this block is a reporter, this is the scope/context for its value.
|
||||
* @property {Boolean} [isEdgeActivated] - sets whether a hat block is edge-activated.
|
||||
* @property {Boolean} [shouldRestartExistingThreads] - sets whether a hat/event block should restart existing threads.
|
||||
* @property {int} [branchCount] - for flow control blocks, the number of branches/substacks for this block.
|
||||
* @property {Object.<ExtensionArgumentMetadata>} [arguments] - map of argument placeholder to metadata about each arg.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionArgumentMetadata
|
||||
* All the metadata needed to register an argument for an extension block.
|
||||
* @property {ArgumentType} type - the type of the argument (number, string, etc.)
|
||||
* @property {*} defaultValue - the default value of this argument.
|
||||
* @property {string} menu - the name of the menu to use for this argument, if any.
|
||||
* @property {*} [defaultValue] - the default value of this argument.
|
||||
* @property {string} [menu] - the name of the menu to use for this argument, if any.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
const ArgumentType = require('../extension-support/argument-type');
|
||||
const BlockType = require('../extension-support/block-type');
|
||||
const dispatch = require('../dispatch/worker-dispatch');
|
||||
const TargetType = require('../extension-support/target-type');
|
||||
|
||||
class ExtensionWorker {
|
||||
constructor () {
|
||||
|
@ -47,6 +48,7 @@ class ExtensionWorker {
|
|||
global.Scratch = global.Scratch || {};
|
||||
global.Scratch.ArgumentType = ArgumentType;
|
||||
global.Scratch.BlockType = BlockType;
|
||||
global.Scratch.TargetType = TargetType;
|
||||
|
||||
/**
|
||||
* Expose only specific parts of the worker to extensions.
|
||||
|
|
17
src/extension-support/target-type.js
Normal file
17
src/extension-support/target-type.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Default types of Target supported by the VM
|
||||
* @enum {string}
|
||||
*/
|
||||
const TargetType = {
|
||||
/**
|
||||
* Rendered target which can move, change costumes, etc.
|
||||
*/
|
||||
SPRITE: 'sprite',
|
||||
|
||||
/**
|
||||
* Rendered target which cannot move but can change backdrops
|
||||
*/
|
||||
STAGE: 'stage'
|
||||
};
|
||||
|
||||
module.exports = TargetType;
|
18
src/util/maybe-format-message.js
Normal file
18
src/util/maybe-format-message.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
const formatMessage = require('format-message');
|
||||
|
||||
/**
|
||||
* Check if `maybeMessage` looks like a message object, and if so pass it to `formatMessage`.
|
||||
* Otherwise, return `maybeMessage` as-is.
|
||||
* @param {*} maybeMessage - something that might be a message descriptor object.
|
||||
* @param {object} [args] - the arguments to pass to `formatMessage` if it gets called.
|
||||
* @param {string} [locale] - the locale to pass to `formatMessage` if it gets called.
|
||||
* @return {string|*} - the formatted message OR the original `maybeMessage` input.
|
||||
*/
|
||||
const maybeFormatMessage = function (maybeMessage, args, locale) {
|
||||
if (maybeMessage && maybeMessage.id && maybeMessage.default) {
|
||||
return formatMessage(maybeMessage, args, locale);
|
||||
}
|
||||
return maybeMessage;
|
||||
};
|
||||
|
||||
module.exports = maybeFormatMessage;
|
|
@ -20,6 +20,18 @@ const {serializeSounds, serializeCostumes} = require('./serialization/serialize-
|
|||
|
||||
const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_'];
|
||||
|
||||
const CORE_EXTENSIONS = [
|
||||
// 'motion',
|
||||
// 'looks',
|
||||
// 'sound',
|
||||
// 'events',
|
||||
// 'control',
|
||||
// 'sensing',
|
||||
// 'operators',
|
||||
// 'variables',
|
||||
// 'myBlocks'
|
||||
];
|
||||
|
||||
/**
|
||||
* Handles connections between blocks, stage, and extensions.
|
||||
* @constructor
|
||||
|
@ -314,6 +326,17 @@ class VirtualMachine extends EventEmitter {
|
|||
*/
|
||||
installTargets (targets, extensions, wholeProject) {
|
||||
const extensionPromises = [];
|
||||
|
||||
if (wholeProject) {
|
||||
this.clear();
|
||||
|
||||
CORE_EXTENSIONS.forEach(extensionID => {
|
||||
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
|
||||
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionID));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
extensions.extensionIDs.forEach(extensionID => {
|
||||
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
|
||||
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;
|
||||
|
@ -324,9 +347,6 @@ class VirtualMachine extends EventEmitter {
|
|||
targets = targets.filter(target => !!target);
|
||||
|
||||
return Promise.all(extensionPromises).then(() => {
|
||||
if (wholeProject) {
|
||||
this.clear();
|
||||
}
|
||||
targets.forEach(target => {
|
||||
this.runtime.targets.push(target);
|
||||
(/** @type RenderedTarget */ target).updateAllDrawableProperties();
|
||||
|
|
169
test/unit/extension_conversion.js
Normal file
169
test/unit/extension_conversion.js
Normal file
|
@ -0,0 +1,169 @@
|
|||
const test = require('tap').test;
|
||||
|
||||
const ArgumentType = require('../../src/extension-support/argument-type');
|
||||
const BlockType = require('../../src/extension-support/block-type');
|
||||
const Runtime = require('../../src/engine/runtime');
|
||||
const ScratchBlocksConstants = require('../../src/engine/scratch-blocks-constants');
|
||||
|
||||
/**
|
||||
* @type {ExtensionMetadata}
|
||||
*/
|
||||
const testExtensionInfo = {
|
||||
id: 'test',
|
||||
name: 'fake test extension',
|
||||
blocks: [
|
||||
{
|
||||
opcode: 'reporter',
|
||||
blockType: BlockType.REPORTER,
|
||||
text: 'simple text'
|
||||
},
|
||||
'---', // separator between groups of blocks in an extension
|
||||
{
|
||||
opcode: 'command',
|
||||
blockType: BlockType.COMMAND,
|
||||
text: 'text with [ARG]',
|
||||
arguments: {
|
||||
ARG: {
|
||||
type: ArgumentType.STRING
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'ifElse',
|
||||
blockType: BlockType.CONDITIONAL,
|
||||
branchCount: 2,
|
||||
text: [
|
||||
'test if [THING] is spiffy and if so then',
|
||||
'or elsewise'
|
||||
],
|
||||
arguments: {
|
||||
THING: {
|
||||
type: ArgumentType.BOOLEAN
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
opcode: 'loop',
|
||||
blockType: BlockType.LOOP, // implied branchCount of 1 unless otherwise stated
|
||||
isTerminal: true,
|
||||
text: [
|
||||
'loopty [MANY] loops'
|
||||
],
|
||||
arguments: {
|
||||
MANY: {
|
||||
type: ArgumentType.NUMBER
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const testReporter = function (t, reporter) {
|
||||
t.equal(reporter.json.type, 'test.reporter');
|
||||
t.equal(reporter.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND);
|
||||
t.equal(reporter.json.output, 'String');
|
||||
t.notOk(reporter.json.hasOwnProperty('previousStatement'));
|
||||
t.notOk(reporter.json.hasOwnProperty('nextStatement'));
|
||||
t.equal(reporter.json.message0, 'simple text');
|
||||
t.notOk(reporter.json.hasOwnProperty('message1'));
|
||||
t.notOk(reporter.json.hasOwnProperty('args0'));
|
||||
t.notOk(reporter.json.hasOwnProperty('args1'));
|
||||
t.equal(reporter.xml, '<block type="test.reporter"></block>');
|
||||
};
|
||||
|
||||
const testSeparator = function (t, separator) {
|
||||
t.equal(separator.json, null);
|
||||
t.equal(separator.xml, '<sep gap="36"/>');
|
||||
};
|
||||
|
||||
const testCommand = function (t, command) {
|
||||
t.equal(command.json.type, 'test.command');
|
||||
t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
|
||||
t.assert(command.json.hasOwnProperty('previousStatement'));
|
||||
t.assert(command.json.hasOwnProperty('nextStatement'));
|
||||
t.equal(command.json.message0, 'text with %1');
|
||||
t.notOk(command.json.hasOwnProperty('message1'));
|
||||
t.strictSame(command.json.args0[0], {
|
||||
type: 'input_value',
|
||||
name: 'ARG'
|
||||
});
|
||||
t.notOk(command.json.hasOwnProperty('args1'));
|
||||
t.equal(command.xml,
|
||||
'<block type="test.command"><value name="ARG"><shadow type="text"><field name="TEXT">' +
|
||||
'</field></shadow></value></block>');
|
||||
};
|
||||
|
||||
const testConditional = function (t, conditional) {
|
||||
t.equal(conditional.json.type, 'test.ifElse');
|
||||
t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
|
||||
t.ok(conditional.json.hasOwnProperty('previousStatement'));
|
||||
t.ok(conditional.json.hasOwnProperty('nextStatement'));
|
||||
t.equal(conditional.json.message0, 'test if %1 is spiffy and if so then');
|
||||
t.equal(conditional.json.message1, '%1'); // placeholder for substack #1
|
||||
t.equal(conditional.json.message2, 'or elsewise');
|
||||
t.equal(conditional.json.message3, '%1'); // placeholder for substack #2
|
||||
t.notOk(conditional.json.hasOwnProperty('message4'));
|
||||
t.strictSame(conditional.json.args0[0], {
|
||||
type: 'input_value',
|
||||
name: 'THING',
|
||||
check: 'Boolean'
|
||||
});
|
||||
t.strictSame(conditional.json.args1[0], {
|
||||
type: 'input_statement',
|
||||
name: 'SUBSTACK'
|
||||
});
|
||||
t.notOk(conditional.json.hasOwnProperty(conditional.json.args2));
|
||||
t.strictSame(conditional.json.args3[0], {
|
||||
type: 'input_statement',
|
||||
name: 'SUBSTACK2'
|
||||
});
|
||||
t.notOk(conditional.json.hasOwnProperty('args4'));
|
||||
t.equal(conditional.xml, '<block type="test.ifElse"><value name="THING"></value></block>');
|
||||
};
|
||||
|
||||
const testLoop = function (t, loop) {
|
||||
t.equal(loop.json.type, 'test.loop');
|
||||
t.equal(loop.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
|
||||
t.ok(loop.json.hasOwnProperty('previousStatement'));
|
||||
t.notOk(loop.json.hasOwnProperty('nextStatement')); // isTerminal is set on this block
|
||||
t.equal(loop.json.message0, 'loopty %1 loops');
|
||||
t.equal(loop.json.message1, '%1'); // placeholder for substack
|
||||
t.equal(loop.json.message2, '%1'); // placeholder for loop arrow
|
||||
t.notOk(loop.json.hasOwnProperty('message3'));
|
||||
t.strictSame(loop.json.args0[0], {
|
||||
type: 'input_value',
|
||||
name: 'MANY'
|
||||
});
|
||||
t.strictSame(loop.json.args1[0], {
|
||||
type: 'input_statement',
|
||||
name: 'SUBSTACK'
|
||||
});
|
||||
t.equal(loop.json.lastDummyAlign2, 'RIGHT'); // move loop arrow to right side
|
||||
t.equal(loop.json.args2[0].type, 'field_image');
|
||||
t.equal(loop.json.args2[0].flip_rtl, true);
|
||||
t.notOk(loop.json.hasOwnProperty('args3'));
|
||||
t.equal(loop.xml,
|
||||
'<block type="test.loop"><value name="MANY"><shadow type="math_number"><field name="NUM">' +
|
||||
'</field></shadow></value></block>');
|
||||
};
|
||||
|
||||
test('registerExtensionPrimitives', t => {
|
||||
const runtime = new Runtime();
|
||||
|
||||
runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
|
||||
t.equal(blocksInfo.length, testExtensionInfo.blocks.length);
|
||||
|
||||
// Note that this also implicitly tests that block order is preserved
|
||||
const [reporter, separator, command, conditional, loop] = blocksInfo;
|
||||
|
||||
testReporter(t, reporter);
|
||||
testSeparator(t, separator);
|
||||
testCommand(t, command);
|
||||
testConditional(t, conditional);
|
||||
testLoop(t, loop);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
runtime._registerExtensionPrimitives(testExtensionInfo);
|
||||
});
|
77
test/unit/maybe_format_message.js
Normal file
77
test/unit/maybe_format_message.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
const test = require('tap').test;
|
||||
const maybeFormatMessage = require('../../src/util/maybe-format-message');
|
||||
|
||||
const nonMessages = [
|
||||
'hi',
|
||||
42,
|
||||
true,
|
||||
function () {
|
||||
return 'unused';
|
||||
},
|
||||
{
|
||||
a: 1,
|
||||
b: 2
|
||||
},
|
||||
{
|
||||
id: 'almost a message',
|
||||
notDefault: 'but missing the "default" property'
|
||||
},
|
||||
{
|
||||
notId: 'this one is missing the "id" property',
|
||||
default: 'but has "default"'
|
||||
}
|
||||
];
|
||||
|
||||
const argsQuick = {
|
||||
speed: 'quick'
|
||||
};
|
||||
|
||||
const argsOther = {
|
||||
speed: 'slow'
|
||||
};
|
||||
|
||||
const argsEmpty = {};
|
||||
|
||||
const simpleMessage = {
|
||||
id: 'test.simpleMessage',
|
||||
default: 'The quick brown fox jumped over the lazy dog.'
|
||||
};
|
||||
|
||||
const complexMessage = {
|
||||
id: 'test.complexMessage',
|
||||
default: '{speed, select, quick {The quick brown fox jumped over the lazy dog.} other {Too slow, Gobo!}}'
|
||||
};
|
||||
|
||||
const quickExpectedResult = 'The quick brown fox jumped over the lazy dog.';
|
||||
const otherExpectedResult = 'Too slow, Gobo!';
|
||||
|
||||
test('preserve non-messages', t => {
|
||||
t.plan(nonMessages.length);
|
||||
|
||||
for (const x of nonMessages) {
|
||||
const result = maybeFormatMessage(x);
|
||||
t.strictSame(x, result);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('format messages', t => {
|
||||
const quickResult1 = maybeFormatMessage(simpleMessage);
|
||||
t.strictNotSame(quickResult1, simpleMessage);
|
||||
t.same(quickResult1, quickExpectedResult);
|
||||
|
||||
const quickResult2 = maybeFormatMessage(complexMessage, argsQuick);
|
||||
t.strictNotSame(quickResult2, complexMessage);
|
||||
t.same(quickResult2, quickExpectedResult);
|
||||
|
||||
const otherResult1 = maybeFormatMessage(complexMessage, argsOther);
|
||||
t.strictNotSame(otherResult1, complexMessage);
|
||||
t.same(otherResult1, otherExpectedResult);
|
||||
|
||||
const otherResult2 = maybeFormatMessage(complexMessage, argsEmpty);
|
||||
t.strictNotSame(otherResult2, complexMessage);
|
||||
t.same(otherResult2, otherExpectedResult);
|
||||
|
||||
t.end();
|
||||
});
|
Loading…
Reference in a new issue