diff --git a/src/engine/monitor-record.js b/src/engine/monitor-record.js index 925b7c5f25d..daa6f9db124 100644 --- a/src/engine/monitor-record.js +++ b/src/engine/monitor-record.js @@ -12,8 +12,8 @@ const MonitorRecord = Record({ mode: 'default', sliderMin: 0, sliderMax: 100, - x: 0, - y: 0, + x: null, // (x: null, y: null) Indicates that the monitor should be auto-positioned + y: null, width: 0, height: 0, visible: true diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 942c0e61255..b635bbd1fea 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -659,6 +659,10 @@ class Runtime extends EventEmitter { } } + getMonitorState () { + return this._monitorState; + } + /** * Generate an extension-specific menu ID. * @param {string} menuName - the name of the menu. @@ -2098,9 +2102,9 @@ class Runtime extends EventEmitter { const block = categoryInfo.blocks.find(b => b.info.opcode === opcode); if (!block) return; - // TODO: should this use some other category? Also, we may want to format the label in a locale-specific way. + // TODO: we may want to format the label in a locale-specific way. return { - category: 'data', + category: 'extension', // This assumes that all extensions have the same monitor color. label: `${categoryInfo.name}: ${block.info.text}` }; } diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 668305bcf06..864252a775b 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -9,6 +9,7 @@ const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); const Comment = require('../engine/comment'); +const MonitorRecord = require('../engine/monitor-record'); const StageLayering = require('../engine/stage-layering'); const log = require('../util/log'); const uid = require('../util/uid'); @@ -480,6 +481,29 @@ const getSimplifiedLayerOrdering = function (targets) { return MathUtil.reducedSortOrdering(layerOrders); }; +const serializeMonitors = function (monitors) { + return monitors.valueSeq().map(monitorData => { + const serializedMonitor = { + id: monitorData.id, + mode: monitorData.mode, + opcode: monitorData.opcode, + params: monitorData.params, + spriteName: monitorData.spriteName, + value: monitorData.value, + width: monitorData.width, + height: monitorData.height, + x: monitorData.x, + y: monitorData.y, + visible: monitorData.visible + }; + if (monitorData.mode !== 'list') { + serializedMonitor.min = monitorData.sliderMin; + serializedMonitor.max = monitorData.sliderMax; + } + return serializedMonitor; + }); +}; + /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. @@ -516,8 +540,7 @@ const serialize = function (runtime, targetId) { obj.targets = serializedTargets; - - // TODO Serialize monitors + obj.monitors = serializeMonitors(runtime.getMonitorState()); // Assemble extension list obj.extensions = Array.from(extensions); @@ -1015,6 +1038,94 @@ const parseScratchObject = function (object, runtime, extensions, zip) { return Promise.all(costumePromises.concat(soundPromises)).then(() => target); }; +const deserializeMonitor = function (monitorData, runtime, targets, extensions) { + // If the serialized monitor has spriteName defined, look up the sprite + // by name in the given list of targets and update the monitor's targetId + // to match the sprite's id. + if (monitorData.spriteName) { + const filteredTargets = targets.filter(t => t.sprite.name === monitorData.spriteName); + if (filteredTargets && filteredTargets.length > 0) { + monitorData.targetId = filteredTargets[0].id; + } else { + log.warn(`Tried to deserialize sprite specific monitor ${ + monitorData.opcode} but could not find sprite ${monitorData.spriteName}.`); + } + } + + // Get information about this monitor, if it exists, given the monitor's opcode. + // This will be undefined for extension blocks + const monitorBlockInfo = runtime.monitorBlockInfo[monitorData.opcode]; + + // Convert the serialized monitorData params into the block fields structure + const fields = {}; + for (const paramKey in monitorData.params) { + const field = { + name: paramKey, + value: monitorData.params[paramKey] + }; + fields[paramKey] = field; + } + + // Variables, lists, and non-sprite-specific monitors, including any extension + // monitors should already have the correct monitor ID serialized in the monitorData, + // find the correct id for all other monitors. + if (monitorData.opcode !== 'data_variable' && monitorData.opcode !== 'data_listcontents' && + monitorBlockInfo && monitorBlockInfo.isSpriteSpecific) { + monitorData.id = monitorBlockInfo.getId( + monitorData.targetId, fields); + } + + // If the runtime already has a monitor block for this monitor's id, + // update the existing block with the relevant monitor information. + const existingMonitorBlock = runtime.monitorBlocks._blocks[monitorData.id]; + if (existingMonitorBlock) { + // A monitor block already exists if the toolbox has been loaded and + // the monitor block is not target specific (because the block gets recycled). + existingMonitorBlock.isMonitored = monitorData.visible; + existingMonitorBlock.targetId = monitorData.targetId; + } else { + // If a monitor block doesn't already exist for this monitor, + // construct a monitor block to add to the monitor blocks container + const monitorBlock = { + id: monitorData.id, + opcode: monitorData.opcode, + inputs: {}, // Assuming that monitor blocks don't have droppable fields + fields: fields, + topLevel: true, + next: null, + parent: null, + shadow: false, + x: 0, + y: 0, + isMonitored: monitorData.visible, + targetId: monitorData.targetId + }; + + // Variables and lists have additional properties + // stored in their fields, update this info in the + // monitor block fields + if (monitorData.opcode === 'data_variable') { + const field = monitorBlock.fields.VARIABLE; + field.id = monitorData.id; + field.variableType = Variable.SCALAR_TYPE; + } else if (monitorData.opcode === 'data_listcontents') { + const field = monitorBlock.fields.LIST; + field.id = monitorData.id; + field.variableType = Variable.LIST_TYPE; + } + + runtime.monitorBlocks.createBlock(monitorBlock); + + // If the block is from an extension, record it. + const extensionID = getExtensionIdForOpcode(monitorBlock.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + + runtime.requestAddMonitor(MonitorRecord(monitorData)); +}; + /** * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. * @param {object} json - JSON representation of a VM runtime. @@ -1037,6 +1148,8 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { .map((t, i) => Object.assign(t, {targetPaneOrder: i})) .sort((a, b) => a.layerOrder - b.layerOrder); + const monitorObjects = json.monitors || []; + return Promise.all( targetObjects.map(target => parseScratchObject(target, runtime, extensions, zip)) @@ -1050,6 +1163,10 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { delete t.layerOrder; return t; })) + .then(targets => { + monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); + return targets; + }) .then(targets => ({ targets, extensions diff --git a/test/fixtures/monitors.sb3 b/test/fixtures/monitors.sb3 new file mode 100644 index 00000000000..1343243ddd8 Binary files /dev/null and b/test/fixtures/monitors.sb3 differ diff --git a/test/integration/monitors.js b/test/integration/monitors_sb2.js similarity index 100% rename from test/integration/monitors.js rename to test/integration/monitors_sb2.js diff --git a/test/integration/monitors_sb3.js b/test/integration/monitors_sb3.js new file mode 100644 index 00000000000..60a280e98f5 --- /dev/null +++ b/test/integration/monitors_sb3.js @@ -0,0 +1,264 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; +const VirtualMachine = require('../../src/index'); +const Variable = require('../../src/engine/variable'); + +const projectUri = path.resolve(__dirname, '../fixtures/monitors.sb3'); +const project = readFileToBuffer(projectUri); + +test('importing sb2 project with monitors', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', e => { + const threads = JSON.parse(e.threads); + // All monitors should create threads that finish during the step and + // are revoved from runtime.threads. + t.equal(threads.length, 0); + t.equal(vm.runtime._lastStepDoneThreads.length, 17); + // There should be one additional hidden monitor that is in the monitorState but + // does not start a thread. + t.equal(vm.runtime._monitorState.size, 18); + + const stage = vm.runtime.targets[0]; + const shirtSprite = vm.runtime.targets[1]; + const heartSprite = vm.runtime.targets[2]; + + // Global variable named "my variable" exists + let variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'my variable')[0]; + let monitorRecord = vm.runtime._monitorState.get(variableId); + let monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId); + t.equal(monitorRecord.opcode, 'data_variable'); + t.equal(monitorRecord.mode, 'default'); + // The following few properties are imported for all monitors, just check once. + // sliderMin and sliderMax are currently not implemented, + // but should still get default values serialized and deserialized correctly + t.equal(monitorRecord.sliderMin, 0); + t.equal(monitorRecord.sliderMax, 100); + t.equal(monitorRecord.x, 10); + t.equal(monitorRecord.y, 62); + // Height and width are only used for list monitors and should default to 0 + // for all other monitors + t.equal(monitorRecord.width, 0); + t.equal(monitorRecord.height, 0); + t.equal(monitorRecord.visible, true); + t.type(monitorRecord.params, 'object'); + // The variable name should be stored in the monitor params + t.equal(monitorRecord.params.VARIABLE, 'my variable'); + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.VARIABLE.value, 'my variable'); + t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE'); + t.equal(monitorBlock.fields.VARIABLE.id, variableId); + t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE); + + // There is a global variable named 'secret_slide' which has a hidden monitor + variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'secret_slide')[0]; + monitorRecord = vm.runtime._monitorState.get(variableId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId); + t.equal(monitorRecord.opcode, 'data_variable'); + t.equal(monitorRecord.mode, 'slider'); + t.equal(monitorRecord.visible, false); + t.equal(monitorRecord.sliderMin, 0); + t.equal(monitorRecord.sliderMax, 100); + t.type(monitorRecord.params, 'object'); + t.equal(monitorRecord.params.VARIABLE, 'secret_slide'); + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.VARIABLE.value, 'secret_slide'); + t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE'); + t.equal(monitorBlock.fields.VARIABLE.id, variableId); + t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE); + + + // Shirt sprite has a local list named "fashion" + variableId = Object.keys(shirtSprite.variables).filter(k => shirtSprite.variables[k].name === 'fashion')[0]; + monitorRecord = vm.runtime._monitorState.get(variableId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId); + t.equal(monitorRecord.opcode, 'data_listcontents'); + t.equal(monitorRecord.mode, 'list'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.height, 122); + t.equal(monitorRecord.width, 104); + t.type(monitorRecord.params, 'object'); + t.equal(monitorRecord.params.LIST, 'fashion'); // The list name should be stored in the monitor params + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.LIST.value, 'fashion'); + t.equal(monitorBlock.fields.LIST.name, 'LIST'); + t.equal(monitorBlock.fields.LIST.id, variableId); + t.equal(monitorBlock.fields.LIST.variableType, Variable.LIST_TYPE); + + // Shirt sprite has a local variable named "tee" + variableId = Object.keys(shirtSprite.variables).filter(k => shirtSprite.variables[k].name === 'tee')[0]; + monitorRecord = vm.runtime._monitorState.get(variableId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId); + t.equal(monitorRecord.opcode, 'data_variable'); + t.equal(monitorRecord.mode, 'slider'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.sliderMin, 0); + t.equal(monitorRecord.sliderMax, 100); + t.type(monitorRecord.params, 'object'); + t.equal(monitorRecord.params.VARIABLE, 'tee'); + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.VARIABLE.value, 'tee'); + t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE'); + t.equal(monitorBlock.fields.VARIABLE.id, variableId); + t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE); + + // Heart sprite has a local list named "hearty" + variableId = Object.keys(heartSprite.variables).filter(k => heartSprite.variables[k].name === 'hearty')[0]; + monitorRecord = vm.runtime._monitorState.get(variableId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId); + t.equal(monitorRecord.opcode, 'data_variable'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.type(monitorRecord.params, 'object'); + t.equal(monitorRecord.params.VARIABLE, 'hearty'); // The variable name should be stored in the monitor params + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.VARIABLE.value, 'hearty'); + t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE'); + t.equal(monitorBlock.fields.VARIABLE.id, variableId); + t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE); + + // Backdrop name monitor is visible, not sprite specific + // should get imported with id that references the name parameter + // via '_name' at the end since the 3.0 block has a dropdown. + let monitorId = 'backdropnumbername_name'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'looks_backdropnumbername'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.NUMBER_NAME.value, 'name'); + + // Backdrop name monitor is visible, not sprite specific + // should get imported with id that references the name parameter + // via '_number' at the end since the 3.0 block has a dropdown. + monitorId = 'backdropnumbername_number'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'looks_backdropnumbername'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + // Test that the monitor block and its fields were constructed correctly + t.equal(monitorBlock.fields.NUMBER_NAME.value, 'number'); + + // x position monitor is in large mode, specific to shirt sprite + monitorId = `${shirtSprite.id}_xposition`; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'motion_xposition'); + t.equal(monitorRecord.mode, 'large'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, 'Shirt-T'); + t.equal(monitorRecord.targetId, shirtSprite.id); + + // y position monitor is in large mode, specific to shirt sprite + monitorId = `${shirtSprite.id}_yposition`; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'motion_yposition'); + t.equal(monitorRecord.mode, 'large'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, 'Shirt-T'); + t.equal(monitorRecord.targetId, shirtSprite.id); + + // direction monitor is in large mode, specific to shirt sprite + monitorId = `${shirtSprite.id}_direction`; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'motion_direction'); + t.equal(monitorRecord.mode, 'large'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, 'Shirt-T'); + t.equal(monitorRecord.targetId, shirtSprite.id); + + monitorId = `${shirtSprite.id}_size`; + monitorRecord = vm.runtime._monitorState.get(monitorId); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.opcode, 'looks_size'); + t.equal(monitorRecord.mode, 'large'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, 'Shirt-T'); + t.equal(monitorRecord.targetId, shirtSprite.id); + + // The monitor IDs for the sensing_current block should be unique + // to the parameter that is selected on the block being monitored. + // The paramater portion of the id should be lowercase even + // though the field value on the block is uppercase. + monitorId = 'current_date'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + t.equal(monitorRecord.opcode, 'sensing_current'); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorBlock.fields.CURRENTMENU.value, 'DATE'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + + monitorId = 'current_year'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + t.equal(monitorRecord.opcode, 'sensing_current'); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorBlock.fields.CURRENTMENU.value, 'YEAR'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + + monitorId = 'current_month'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + t.equal(monitorRecord.opcode, 'sensing_current'); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorBlock.fields.CURRENTMENU.value, 'MONTH'); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + + // Extension Monitors + monitorId = 'music_getTempo'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + t.equal(monitorRecord.opcode, 'music_getTempo'); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + t.equal(vm.extensionManager.isExtensionLoaded('music'), true); + + monitorId = 'ev3_getDistance'; + monitorRecord = vm.runtime._monitorState.get(monitorId); + t.equal(monitorRecord.opcode, 'ev3_getDistance'); + monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId); + t.equal(monitorRecord.mode, 'default'); + t.equal(monitorRecord.visible, true); + t.equal(monitorRecord.spriteName, null); + t.equal(monitorRecord.targetId, null); + t.equal(vm.extensionManager.isExtensionLoaded('ev3'), true); + + t.end(); + process.nextTick(process.exit); + }); + + // Start VM, load project, and run + t.doesNotThrow(() => { + vm.start(); + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + vm.loadProject(project).then(() => { + vm.greenFlag(); + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 100); + }); + }); +});