diff --git a/src/blocks/scratch3_core_example.js b/src/blocks/scratch3_core_example.js new file mode 100644 index 000000000..501e569c4 --- /dev/null +++ b/src/blocks/scratch3_core_example.js @@ -0,0 +1,50 @@ +const BlockType = require('../extension-support/block-type'); + +/** + * An example core block implemented using the extension spec. + * This is not loaded as part of the core blocks in the VM but it is provided + * and used as part of tests. + */ +class Scratch3CoreExample { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: 'coreExample', + name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example. + blocks: [ + { + func: 'MAKE_A_VARIABLE', + blockType: BlockType.BUTTON, + text: 'make a variable (CoreEx)' + }, + { + opcode: 'exampleOpcode', + blockType: BlockType.REPORTER, + text: 'example block' + } + ] + }; + } + + /** + * Example opcode just returns the name of the stage target. + * @returns {string} The name of the first target in the project. + */ + exampleOpcode () { + const stage = this.runtime.getTargetForStage(); + return stage ? stage.getName() : 'no stage yet'; + } + +} + +module.exports = Scratch3CoreExample; diff --git a/src/dispatch/central-dispatch.js b/src/dispatch/central-dispatch.js index a53e50d79..fe4987a6e 100644 --- a/src/dispatch/central-dispatch.js +++ b/src/dispatch/central-dispatch.js @@ -35,6 +35,39 @@ class CentralDispatch extends SharedDispatch { this.workers = []; } + /** + * Synchronously call a particular method on a particular service provided locally. + * Calling this function on a remote service will fail. + * @param {string} service - the name of the service. + * @param {string} method - the name of the method. + * @param {*} [args] - the arguments to be copied to the method, if any. + * @returns {*} - the return value of the service method. + */ + callSync (service, method, ...args) { + const {provider, isRemote} = this._getServiceProvider(service); + if (provider) { + if (isRemote) { + throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`); + } + + return provider[method].apply(provider, args); + } + throw new Error(`Provider not found for service: ${service}`); + } + + /** + * Synchronously set a local object as the global provider of the specified service. + * WARNING: Any method on the provider can be called from any worker within the dispatch system. + * @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'. + * @param {object} provider - a local object which provides this service. + */ + setServiceSync (service, provider) { + if (this.services.hasOwnProperty(service)) { + log.warn(`Central dispatch replacing existing service provider for ${service}`); + } + this.services[service] = provider; + } + /** * Set a local object as the global provider of the specified service. * WARNING: Any method on the provider can be called from any worker within the dispatch system. @@ -45,10 +78,7 @@ class CentralDispatch extends SharedDispatch { setService (service, provider) { /** Return a promise for consistency with {@link WorkerDispatch#setService} */ try { - if (this.services.hasOwnProperty(service)) { - log.warn(`Central dispatch replacing existing service provider for ${service}`); - } - this.services[service] = provider; + this.setServiceSync(service, provider); return Promise.resolve(); } catch (e) { return Promise.reject(e); diff --git a/src/engine/block-utility.js b/src/engine/block-utility.js index b163e4d5d..cfe18a227 100644 --- a/src/engine/block-utility.js +++ b/src/engine/block-utility.js @@ -205,9 +205,18 @@ class BlockUtility { * @return {Array.} List of threads started by this function. */ startHats (requestedHat, optMatchFields, optTarget) { - return ( - this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget) - ); + // Store thread and sequencer to ensure we can return to the calling block's context. + // startHats may execute further blocks and dirty the BlockUtility's execution context + // and confuse the calling block when we return to it. + const callerThread = this.thread; + const callerSequencer = this.sequencer; + const result = this.sequencer.runtime.startHats(requestedHat, optMatchFields, optTarget); + + // Restore thread and sequencer to prior values before we return to the calling block. + this.thread = callerThread; + this.sequencer = callerSequencer; + + return result; } /** diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 2cbdeb54c..204cd4633 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -7,6 +7,7 @@ const Blocks = require('./blocks'); const BlockType = require('../extension-support/block-type'); const Profiler = require('./profiler'); const Sequencer = require('./sequencer'); +const execute = require('./execute.js'); const ScratchBlocksConstants = require('./scratch-blocks-constants'); const TargetType = require('../extension-support/target-type'); const Thread = require('./thread'); @@ -122,16 +123,6 @@ const cloudDataManager = () => { }; }; -/** - * Predefined "Converted block info" for a separator between blocks in a block category - * @type {ConvertedBlockInfo} - */ -const ConvertedSeparator = { - info: {}, - json: null, - xml: '' -}; - /** * Numeric ID for Runtime._step in Profiler instances. * @type {number} @@ -316,7 +307,7 @@ class Runtime extends EventEmitter { // I/O related data. /** @type {Object.} */ this.ioDevices = { - clock: new Clock(), + clock: new Clock(this), cloud: new Cloud(this), keyboard: new Keyboard(this), mouse: new Mouse(this), @@ -865,22 +856,20 @@ class Runtime extends EventEmitter { } 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 - }; + if (convertedBlock.json) { + const opcode = convertedBlock.json.type; + 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}); @@ -985,6 +974,25 @@ class Runtime extends EventEmitter { }; } + /** + * Convert ExtensionBlockMetadata into data ready for scratch-blocks. + * @param {ExtensionBlockMetadata} blockInfo - the block info to convert + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {ConvertedBlockInfo} - the converted & original block information + * @private + */ + _convertForScratchBlocks (blockInfo, categoryInfo) { + if (blockInfo === '---') { + return this._convertSeparatorForScratchBlocks(blockInfo); + } + + if (blockInfo.blockType === BlockType.BUTTON) { + return this._convertButtonForScratchBlocks(blockInfo); + } + + return this._convertBlockForScratchBlocks(blockInfo, categoryInfo); + } + /** * Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function. * @param {ExtensionBlockMetadata} blockInfo - the block to convert @@ -992,7 +1000,7 @@ class Runtime extends EventEmitter { * @returns {ConvertedBlockInfo} - the converted & original block information * @private */ - _convertForScratchBlocks (blockInfo, categoryInfo) { + _convertBlockForScratchBlocks (blockInfo, categoryInfo) { const extendedOpcode = `${categoryInfo.id}_${blockInfo.opcode}`; const blockJSON = { @@ -1134,6 +1142,43 @@ class Runtime extends EventEmitter { }; } + /** + * Generate a separator between blocks categories or sub-categories. + * @param {ExtensionBlockMetadata} blockInfo - the block to convert + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {ConvertedBlockInfo} - the converted & original block information + * @private + */ + _convertSeparatorForScratchBlocks (blockInfo) { + return { + info: blockInfo, + xml: '' + }; + } + + /** + * Convert a button for scratch-blocks. A button has no opcode but specifies a callback name in the `func` field. + * @param {ExtensionBlockMetadata} buttonInfo - the button to convert + * @property {string} func - the callback name + * @param {CategoryInfo} categoryInfo - the category for this button + * @returns {ConvertedBlockInfo} - the converted & original button information + * @private + */ + _convertButtonForScratchBlocks (buttonInfo) { + // for now we only support these pre-defined callbacks handled in scratch-blocks + const supportedCallbackKeys = ['MAKE_A_LIST', 'MAKE_A_PROCEDURE', 'MAKE_A_VARIABLE']; + if (supportedCallbackKeys.indexOf(buttonInfo.func) < 0) { + log.error(`Custom button callbacks not supported yet: ${buttonInfo.func}`); + } + + const extensionMessageContext = this.makeMessageContextForTarget(); + const buttonText = maybeFormatMessage(buttonInfo.text, extensionMessageContext); + return { + info: buttonInfo, + xml: `` + }; + } + /** * 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. @@ -1631,6 +1676,12 @@ class Runtime extends EventEmitter { // Start the thread with this top block. newThreads.push(instance._pushThread(topBlockId, target)); }, optTarget); + // For compatibility with Scratch 2, edge triggered hats need to be processed before + // threads are stepped. See ScratchRuntime.as for original implementation + newThreads.forEach(thread => { + execute(this.sequencer, thread); + thread.goToNextBlock(); + }); return newThreads; } diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js index d5d71fd50..464cd7489 100644 --- a/src/engine/sequencer.js +++ b/src/engine/sequencer.js @@ -138,7 +138,6 @@ class Sequencer { activeThread.status === Thread.STATUS_DONE) { // Finished with this thread. stoppedThread = true; - this.runtime.updateCurrentMSecs(); } } // We successfully ticked once. Prevents running STATUS_YIELD_TICK diff --git a/src/extension-support/block-type.js b/src/extension-support/block-type.js index 3b38c0511..e7f0b18d1 100644 --- a/src/extension-support/block-type.js +++ b/src/extension-support/block-type.js @@ -8,6 +8,11 @@ const BlockType = { */ BOOLEAN: 'Boolean', + /** + * A button (not an actual block) for some special action, like making a variable + */ + BUTTON: 'button', + /** * Command block */ diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index d7ff1ee91..bf666a28c 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -9,6 +9,10 @@ const BlockType = require('./block-type'); // TODO: change extension spec so that library info, including extension ID, can be collected through static methods const builtinExtensions = { + // This is an example that isn't loaded with the other core blocks, + // but serves as a reference for loading core blocks as extensions. + coreExample: () => require('../blocks/scratch3_core_example'), + // These are the non-core built-in extensions. pen: () => require('../extensions/scratch3_pen'), wedo2: () => require('../extensions/scratch3_wedo2'), music: () => require('../extensions/scratch3_music'), @@ -107,6 +111,30 @@ class ExtensionManager { return this._loadedExtensions.has(extensionID); } + /** + * Synchronously load an internal extension (core or non-core) by ID. This call will + * fail if the provided id is not does not match an internal extension. + * @param {string} extensionId - the ID of an internal extension + */ + loadExtensionIdSync (extensionId) { + if (!builtinExtensions.hasOwnProperty(extensionId)) { + log.warn(`Could not find extension ${extensionId} in the built in extensions.`); + return; + } + + /** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ + if (this.isExtensionLoaded(extensionId)) { + const message = `Rejecting attempt to load a second extension with ID ${extensionId}`; + log.warn(message); + return; + } + + const extension = builtinExtensions[extensionId](); + const extensionInstance = new extension(this.runtime); + const serviceName = this._registerInternalExtension(extensionInstance); + this._loadedExtensions.set(extensionId, serviceName); + } + /** * Load an extension by URL or internal extension ID * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension @@ -118,14 +146,14 @@ class ExtensionManager { if (this.isExtensionLoaded(extensionURL)) { const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`; log.warn(message); - return Promise.reject(new Error(message)); + return Promise.resolve(); } const extension = builtinExtensions[extensionURL](); const extensionInstance = new extension(this.runtime); - return this._registerInternalExtension(extensionInstance).then(serviceName => { - this._loadedExtensions.set(extensionURL, serviceName); - }); + const serviceName = this._registerInternalExtension(extensionInstance); + this._loadedExtensions.set(extensionURL, serviceName); + return Promise.resolve(); } return new Promise((resolve, reject) => { @@ -149,7 +177,7 @@ class ExtensionManager { dispatch.call('runtime', '_refreshExtensionPrimitives', info); }) .catch(e => { - log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`); + log.error(`Failed to refresh built-in extension primitives: ${JSON.stringify(e)}`); }) ); return Promise.all(allPromises); @@ -162,6 +190,15 @@ class ExtensionManager { return [id, workerInfo.extensionURL]; } + /** + * Synchronously collect extension metadata from the specified service and begin the extension registration process. + * @param {string} serviceName - the name of the service hosting the extension. + */ + registerExtensionServiceSync (serviceName) { + const info = dispatch.callSync(serviceName, 'getInfo'); + this._registerExtensionInfo(serviceName, info); + } + /** * Collect extension metadata from the specified service and begin the extension registration process. * @param {string} serviceName - the name of the service hosting the extension. @@ -190,17 +227,15 @@ class ExtensionManager { /** * Register an internal (non-Worker) extension object * @param {object} extensionObject - the extension object to register - * @returns {Promise} resolved once the extension is fully registered or rejected on failure + * @returns {string} The name of the registered extension service */ _registerInternalExtension (extensionObject) { const extensionInfo = extensionObject.getInfo(); const fakeWorkerId = this.nextExtensionWorker++; const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`; - return dispatch.setService(serviceName, extensionObject) - .then(() => { - dispatch.call('extensions', 'registerExtensionService', serviceName); - return serviceName; - }); + dispatch.setServiceSync(serviceName, extensionObject); + dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName); + return serviceName; } /** @@ -341,16 +376,28 @@ class ExtensionManager { blockAllThreads: false, arguments: {} }, blockInfo); - blockInfo.opcode = this._sanitizeID(blockInfo.opcode); + blockInfo.opcode = blockInfo.opcode && this._sanitizeID(blockInfo.opcode); blockInfo.text = blockInfo.text || blockInfo.opcode; - if (blockInfo.blockType !== BlockType.EVENT) { + switch (blockInfo.blockType) { + case BlockType.EVENT: + if (blockInfo.func) { + log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`); + } + break; + case BlockType.BUTTON: + if (blockInfo.opcode) { + log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`); + } + break; + default: + if (!blockInfo.opcode) { + throw new Error('Missing opcode for block'); + } + 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" - */ + // Avoid promise overhead if possible if (dispatch._isRemoteService(serviceName)) { blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func); } else { @@ -358,12 +405,11 @@ class ExtensionManager { const func = serviceObject[blockInfo.func]; if (func) { blockInfo.func = func.bind(serviceObject); - } else if (blockInfo.blockType !== BlockType.EVENT) { + } else { 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}`); + break; } return blockInfo; diff --git a/src/extensions/scratch3_boost/index.js b/src/extensions/scratch3_boost/index.js index debc9e860..fe841244f 100644 --- a/src/extensions/scratch3_boost/index.js +++ b/src/extensions/scratch3_boost/index.js @@ -948,8 +948,9 @@ class Boost { const motor = this.motor(portID); if (motor) { motor._status = feedback; - if (feedback === (BoostPortFeedback.COMPLETED ^ BoostPortFeedback.IDLE) && - motor.pendingPromiseFunction) { + // Makes sure that commands resolve both when they actually complete and when they fail + const commandCompleted = feedback & (BoostPortFeedback.COMPLETED ^ BoostPortFeedback.DISCARDED); + if (commandCompleted && motor.pendingPromiseFunction) { motor.pendingPromiseFunction(); } } diff --git a/src/io/clock.js b/src/io/clock.js index cbb9f2428..cb3d0dd14 100644 --- a/src/io/clock.js +++ b/src/io/clock.js @@ -2,7 +2,7 @@ const Timer = require('../util/timer'); class Clock { constructor (runtime) { - this._projectTimer = new Timer(); + this._projectTimer = new Timer({now: () => runtime.currentMSecs}); this._projectTimer.start(); this._pausedTime = null; this._paused = false; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index daf9df544..82375c429 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -151,6 +151,11 @@ class VirtualMachine extends EventEmitter { this.extensionManager = new ExtensionManager(this.runtime); + // Load core extensions + for (const id of CORE_EXTENSIONS) { + this.extensionManager.loadExtensionIdSync(id); + } + this.blockListener = this.blockListener.bind(this); this.flyoutBlockListener = this.flyoutBlockListener.bind(this); this.monitorBlockListener = this.monitorBlockListener.bind(this); @@ -493,14 +498,6 @@ class VirtualMachine extends EventEmitter { installTargets (targets, extensions, wholeProject) { const extensionPromises = []; - if (wholeProject) { - 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; diff --git a/test/fixtures/edge-triggered-hat.sb3 b/test/fixtures/edge-triggered-hat.sb3 index 762ec1af4..cd9464378 100644 Binary files a/test/fixtures/edge-triggered-hat.sb3 and b/test/fixtures/edge-triggered-hat.sb3 differ diff --git a/test/fixtures/execute/hat-thread-execution.sb2 b/test/fixtures/execute/hat-thread-execution.sb2 new file mode 100644 index 000000000..6338f33a5 Binary files /dev/null and b/test/fixtures/execute/hat-thread-execution.sb2 differ diff --git a/test/fixtures/loudness-hat-block.sb2 b/test/fixtures/timer-greater-than-hat.sb2 similarity index 73% rename from test/fixtures/loudness-hat-block.sb2 rename to test/fixtures/timer-greater-than-hat.sb2 index 3dfe93186..b3f8c0f01 100644 Binary files a/test/fixtures/loudness-hat-block.sb2 and b/test/fixtures/timer-greater-than-hat.sb2 differ diff --git a/test/integration/hat-threads-run-every-frame.js b/test/integration/hat-threads-run-every-frame.js index 32b282ff8..f7a519b34 100644 --- a/test/integration/hat-threads-run-every-frame.js +++ b/test/integration/hat-threads-run-every-frame.js @@ -7,7 +7,7 @@ const Thread = require('../../src/engine/thread'); const Runtime = require('../../src/engine/runtime'); const execute = require('../../src/engine/execute.js'); -const projectUri = path.resolve(__dirname, '../fixtures/loudness-hat-block.sb2'); +const projectUri = path.resolve(__dirname, '../fixtures/timer-greater-than-hat.sb2'); const project = readFileToBuffer(projectUri); const checkIsHatThread = (t, vm, hatThread) => { @@ -24,8 +24,8 @@ const checkIsStackClickThread = (t, vm, stackClickThread) => { }; /** - * loudness-hat-block.sb2 contains a single stack - * when loudness > 10 + * timer-greater-than-hat.sb2 contains a single stack + * when timer > -1 * change color effect by 25 * The intention is to make sure that the hat block condition is evaluated * on each frame. @@ -111,7 +111,7 @@ test('edge activated hat thread not added twice', t => { */ test('edge activated hat should trigger for both sprites when sprite is duplicated', t => { - // Project that is similar to loudness-hat-block.sb2, but has code on the sprite so that + // Project that is similar to timer-greater-than-hat.sb2, but has code on the sprite so that // the sprite can be duplicated const projectWithSpriteUri = path.resolve(__dirname, '../fixtures/edge-triggered-hat.sb3'); const projectWithSprite = readFileToBuffer(projectWithSpriteUri); @@ -134,9 +134,6 @@ test('edge activated hat should trigger for both sprites when sprite is duplicat t.equal(vm.runtime.threads.length, 1); checkIsHatThread(t, vm, vm.runtime.threads[0]); t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); - // Run execute on the thread to populate the runtime's - // _edgeActivatedHatValues object - execute(vm.runtime.sequencer, vm.runtime.threads[0]); let numTargetEdgeHats = vm.runtime.targets.reduce((val, target) => val + Object.keys(target._edgeActivatedHatValues).length, 0); t.equal(numTargetEdgeHats, 1); @@ -145,7 +142,6 @@ test('edge activated hat should trigger for both sprites when sprite is duplicat vm.runtime._step(); // Check that the runtime's _edgeActivatedHatValues object has two separate keys // after execute is run on each thread - vm.runtime.threads.forEach(thread => execute(vm.runtime.sequencer, thread)); numTargetEdgeHats = vm.runtime.targets.reduce((val, target) => val + Object.keys(target._edgeActivatedHatValues).length, 0); t.equal(numTargetEdgeHats, 2); diff --git a/test/integration/internal-extension.js b/test/integration/internal-extension.js index 570fce3cb..9ad399402 100644 --- a/test/integration/internal-extension.js +++ b/test/integration/internal-extension.js @@ -4,6 +4,9 @@ const Worker = require('tiny-worker'); const dispatch = require('../../src/dispatch/central-dispatch'); const VirtualMachine = require('../../src/virtual-machine'); +const Sprite = require('../../src/sprites/sprite'); +const RenderedTarget = require('../../src/sprites/rendered-target'); + // By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. dispatch.workerClass = Worker; @@ -52,23 +55,54 @@ test('internal extension', t => { t.ok(extension.status.constructorCalled); t.notOk(extension.status.getInfoCalled); - return vm.extensionManager._registerInternalExtension(extension).then(() => { - t.ok(extension.status.getInfoCalled); + vm.extensionManager._registerInternalExtension(extension); + t.ok(extension.status.getInfoCalled); - const func = vm.runtime.getOpcodeFunction('testInternalExtension_go'); - t.type(func, 'function'); + const func = vm.runtime.getOpcodeFunction('testInternalExtension_go'); + t.type(func, 'function'); - t.notOk(extension.status.goCalled); - func(); - t.ok(extension.status.goCalled); + t.notOk(extension.status.goCalled); + func(); + t.ok(extension.status.goCalled); - // There should be 2 menus - one is an array, one is the function to call. - t.equal(vm.runtime._blockInfo[0].menus.length, 2); - // First menu has 3 items. - t.equal( - vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3); - // Second menu is a dynamic menu and therefore should be a function. - t.type( - vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function'); - }); + // There should be 2 menus - one is an array, one is the function to call. + t.equal(vm.runtime._blockInfo[0].menus.length, 2); + // First menu has 3 items. + t.equal( + vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3); + // Second menu is a dynamic menu and therefore should be a function. + t.type( + vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function'); + + t.end(); +}); + +test('load sync', t => { + const vm = new VirtualMachine(); + vm.extensionManager.loadExtensionIdSync('coreExample'); + t.ok(vm.extensionManager.isExtensionLoaded('coreExample')); + + t.equal(vm.runtime._blockInfo.length, 1); + + // blocks should be an array of two items: a button pseudo-block and a reporter block. + t.equal(vm.runtime._blockInfo[0].blocks.length, 2); + t.type(vm.runtime._blockInfo[0].blocks[0].info, 'object'); + t.type(vm.runtime._blockInfo[0].blocks[0].info.func, 'MAKE_A_VARIABLE'); + t.equal(vm.runtime._blockInfo[0].blocks[0].info.blockType, 'button'); + t.type(vm.runtime._blockInfo[0].blocks[1].info, 'object'); + t.equal(vm.runtime._blockInfo[0].blocks[1].info.opcode, 'exampleOpcode'); + t.equal(vm.runtime._blockInfo[0].blocks[1].info.blockType, 'reporter'); + + // Test the opcode function + t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'no stage yet'); + + const sprite = new Sprite(null, vm.runtime); + sprite.name = 'Stage'; + const stage = new RenderedTarget(sprite, vm.runtime); + stage.isStage = true; + vm.runtime.targets = [stage]; + + t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'Stage'); + + t.end(); }); diff --git a/test/unit/dispatch.js b/test/unit/dispatch.js index 92ed58f0d..dfd00155e 100644 --- a/test/unit/dispatch.js +++ b/test/unit/dispatch.js @@ -62,3 +62,21 @@ test('remote', t => { .then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e)) .then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e)); }); + +test('local, sync', t => { + dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService()); + + const a = dispatch.callSync('SyncDispatchTest', 'returnFortyTwo'); + t.equal(a, 42); + + const b = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9); + t.equal(b, 18); + + const c = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123); + t.equal(c, 246); + + t.throws(() => dispatch.callSync('SyncDispatchTest', 'throwException'), + new Error('This is a test exception thrown by DispatchTest')); + + t.end(); +}); diff --git a/test/unit/extension_conversion.js b/test/unit/extension_conversion.js index 07f599230..3a59c0a01 100644 --- a/test/unit/extension_conversion.js +++ b/test/unit/extension_conversion.js @@ -12,6 +12,11 @@ const testExtensionInfo = { id: 'test', name: 'fake test extension', blocks: [ + { + func: 'MAKE_A_VARIABLE', + blockType: BlockType.BUTTON, + text: 'this is a button' + }, { opcode: 'reporter', blockType: BlockType.REPORTER, @@ -58,6 +63,11 @@ const testExtensionInfo = { ] }; +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'); t.equal(reporter.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND); @@ -72,7 +82,7 @@ const testReporter = function (t, reporter) { }; const testSeparator = function (t, separator) { - t.equal(separator.json, null); + t.same(separator.json, null); // should be null or undefined t.equal(separator.xml, ''); }; @@ -153,9 +163,15 @@ test('registerExtensionPrimitives', t => { 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; + 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, separator, command, conditional, loop] = blocksInfo; + + testButton(t, button); testReporter(t, reporter); testSeparator(t, separator); testCommand(t, command); diff --git a/test/unit/io_clock.js b/test/unit/io_clock.js index 1978911e6..03ee48fb6 100644 --- a/test/unit/io_clock.js +++ b/test/unit/io_clock.js @@ -23,12 +23,15 @@ test('cycle', t => { setTimeout(() => { c.resetProjectTimer(); setTimeout(() => { - t.ok(c.projectTimer() > 0); + // The timer shouldn't advance until all threads have been stepped + t.ok(c.projectTimer() === 0); c.pause(); - t.ok(c.projectTimer() > 0); + t.ok(c.projectTimer() === 0); c.resume(); - t.ok(c.projectTimer() > 0); + t.ok(c.projectTimer() === 0); t.end(); }, 100); }, 100); + rt._step(); + t.ok(c.projectTimer() > 0); });