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', color1: '#111111', color2: '#222222', color3: '#333333', blocks: [ { func: 'MAKE_A_VARIABLE', blockType: BlockType.BUTTON, text: 'this is a button' }, { opcode: 'reporter', blockType: BlockType.REPORTER, text: 'simple text', blockIconURI: 'invalid icon URI' // trigger the 'scratch_extension' path }, { opcode: 'inlineImage', blockType: BlockType.REPORTER, text: 'text and [IMAGE]', arguments: { IMAGE: { type: ArgumentType.IMAGE, dataURI: 'invalid image URI' } } }, '---', // separator between groups of blocks in an extension { opcode: 'command', blockType: BlockType.COMMAND, text: 'text with [ARG] [ARG_WITH_DEFAULT]', arguments: { ARG: { type: ArgumentType.STRING }, ARG_WITH_DEFAULT: { type: ArgumentType.STRING, defaultValue: 'default text' } } }, { 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 extensionInfoWithCustomFieldTypes = { id: 'test_custom_fieldType', name: 'fake test extension with customFieldTypes', color1: '#111111', color2: '#222222', color3: '#333333', blocks: [ { // Block that uses custom field types opcode: 'motorTurnFor', blockType: BlockType.COMMAND, text: '[PORT] run [DIRECTION] for [VALUE] [UNIT]', arguments: { PORT: { defaultValue: 'A', type: 'single-port-selector' }, DIRECTION: { defaultValue: 'clockwise', type: 'custom-direction' } } } ], customFieldTypes: { 'single-port-selector': { output: 'string', outputShape: 2, implementation: { fromJson: () => null } }, 'custom-direction': { output: 'string', outputShape: 3, implementation: { fromJson: () => null } } } }; const testCategoryInfo = function (t, block) { t.equal(block.json.category, 'fake test extension'); t.equal(block.json.colour, '#111111'); t.equal(block.json.colourSecondary, '#222222'); t.equal(block.json.colourTertiary, '#333333'); t.equal(block.json.inputsInline, true); }; const testButton = function (t, button) { t.same(button.json, null); // should be null or undefined t.equal(button.xml, ''); }; const testReporter = function (t, reporter) { t.equal(reporter.json.type, 'test_reporter'); testCategoryInfo(t, reporter); t.equal(reporter.json.checkboxInFlyout, true); 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.same(reporter.json.extensions, ['scratch_extension']); t.equal(reporter.json.message0, '%1 %2simple text'); // "%1 %2" from the block icon t.notOk(reporter.json.hasOwnProperty('message1')); t.same(reporter.json.args0, [ // %1 in message0: the block icon { type: 'field_image', src: 'invalid icon URI', width: 40, height: 40 }, // %2 in message0: separator between icon and text (only added when there's also an icon) { type: 'field_vertical_separator' } ]); t.notOk(reporter.json.hasOwnProperty('args1')); t.equal(reporter.xml, ''); }; const testInlineImage = function (t, inlineImage) { t.equal(inlineImage.json.type, 'test_inlineImage'); testCategoryInfo(t, inlineImage); t.equal(inlineImage.json.checkboxInFlyout, true); t.equal(inlineImage.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND); t.equal(inlineImage.json.output, 'String'); t.notOk(inlineImage.json.hasOwnProperty('previousStatement')); t.notOk(inlineImage.json.hasOwnProperty('nextStatement')); t.notOk(inlineImage.json.extensions && inlineImage.json.extensions.length); // OK if it's absent or empty t.equal(inlineImage.json.message0, 'text and %1'); // block text followed by inline image t.notOk(inlineImage.json.hasOwnProperty('message1')); t.same(inlineImage.json.args0, [ // %1 in message0: the block icon { type: 'field_image', src: 'invalid image URI', width: 24, height: 24, flip_rtl: false // False by default } ]); t.notOk(inlineImage.json.hasOwnProperty('args1')); t.equal(inlineImage.xml, ''); }; const testSeparator = function (t, separator) { t.same(separator.json, null); // should be null or undefined t.equal(separator.xml, ''); }; const testCommand = function (t, command) { t.equal(command.json.type, 'test_command'); testCategoryInfo(t, command); t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); t.assert(command.json.hasOwnProperty('previousStatement')); t.assert(command.json.hasOwnProperty('nextStatement')); t.notOk(command.json.extensions && command.json.extensions.length); // OK if it's absent or empty t.equal(command.json.message0, 'text with %1 %2'); 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, '' + '' + 'default text'); }; const testConditional = function (t, conditional) { t.equal(conditional.json.type, 'test_ifElse'); testCategoryInfo(t, conditional); t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); t.ok(conditional.json.hasOwnProperty('previousStatement')); t.ok(conditional.json.hasOwnProperty('nextStatement')); t.notOk(conditional.json.extensions && conditional.json.extensions.length); // OK if it's absent or empty 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, ''); }; const testLoop = function (t, loop) { t.equal(loop.json.type, 'test_loop'); testCategoryInfo(t, 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.notOk(loop.json.extensions && loop.json.extensions.length); // OK if it's absent or empty 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, ''); }; test('registerExtensionPrimitives', t => { const runtime = new Runtime(); runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => { const blocksInfo = categoryInfo.blocks; t.equal(blocksInfo.length, testExtensionInfo.blocks.length); blocksInfo.forEach(blockInfo => { // `true` here means "either an object or a non-empty string but definitely not null or undefined" t.true(blockInfo.info, 'Every block and pseudo-block must have a non-empty "info" field'); }); // Note that this also implicitly tests that block order is preserved const [button, reporter, inlineImage, separator, command, conditional, loop] = blocksInfo; testButton(t, button); testReporter(t, reporter); testInlineImage(t, inlineImage); testSeparator(t, separator); testCommand(t, command); testConditional(t, conditional); testLoop(t, loop); t.end(); }); runtime._registerExtensionPrimitives(testExtensionInfo); }); test('custom field types should be added to block and EXTENSION_FIELD_ADDED callback triggered', t => { const runtime = new Runtime(); runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => { const blockInfo = categoryInfo.blocks[0]; // We expect that for each argument there's a corresponding -tag in the block XML Object.values(blockInfo.info.arguments).forEach(argument => { const regex = new RegExp(``); t.true(regex.test(blockInfo.xml)); }); }); let fieldAddedCallbacks = 0; runtime.on(Runtime.EXTENSION_FIELD_ADDED, () => { fieldAddedCallbacks++; }); runtime._registerExtensionPrimitives(extensionInfoWithCustomFieldTypes); // Extension includes two custom field types t.equal(fieldAddedCallbacks, 2); t.end(); });