diff --git a/.travis.yml b/.travis.yml index 76950271a..50ae66370 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,8 +28,8 @@ jobs: env: NPM_SCRIPT=build before_deploy: - npm --no-git-tag-version version $($(npm bin)/json -f package.json version)-prerelease.$(date +%s) - - git config --global user.email $(git log --pretty=format:"%ae" -n1) - - git config --global user.name $(git log --pretty=format:"%an" -n1) + - git config --global user.email "$(git log --pretty=format:"%ae" -n1)" + - git config --global user.name "$(git log --pretty=format:"%an" -n1)" deploy: - provider: npm on: diff --git a/src/blocks/scratch3_sound.js b/src/blocks/scratch3_sound.js index a599b314c..16b041bf8 100644 --- a/src/blocks/scratch3_sound.js +++ b/src/blocks/scratch3_sound.js @@ -88,6 +88,7 @@ class Scratch3SoundBlocks { if (!soundState) { soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE); target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState); + target.soundEffects = soundState.effects; } return soundState; } @@ -139,20 +140,19 @@ class Scratch3SoundBlocks { } playSound (args, util) { - const index = this._getSoundIndex(args.SOUND_MENU, util); - if (index >= 0) { - const soundId = util.target.sprite.sounds[index].soundId; - if (util.target.audioPlayer === null) return; - util.target.audioPlayer.playSound(soundId); - } + // Don't return the promise, it's the only difference for AndWait + this.playSoundAndWait(args, util); } playSoundAndWait (args, util) { const index = this._getSoundIndex(args.SOUND_MENU, util); if (index >= 0) { - const soundId = util.target.sprite.sounds[index].soundId; - if (util.target.audioPlayer === null) return; - return util.target.audioPlayer.playSound(soundId); + const {target} = util; + const {sprite} = target; + const {soundId} = sprite.sounds[index]; + if (sprite.soundBank) { + return sprite.soundBank.playSound(target, soundId); + } } } @@ -199,8 +199,9 @@ class Scratch3SoundBlocks { } _stopAllSoundsForTarget (target) { - if (target.audioPlayer === null) return; - target.audioPlayer.stopAllSounds(); + if (target.sprite.soundBank) { + target.sprite.soundBank.stopAllSounds(target); + } } setEffect (args, util) { @@ -224,23 +225,19 @@ class Scratch3SoundBlocks { soundState.effects[effect] = value; } - const effectRange = Scratch3SoundBlocks.EFFECT_RANGE[effect]; - soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], effectRange.min, effectRange.max); - - if (util.target.audioPlayer === null) return; - util.target.audioPlayer.setEffect(effect, soundState.effects[effect]); + const {min, max} = Scratch3SoundBlocks.EFFECT_RANGE[effect]; + soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max); + this._syncEffectsForTarget(util.target); // Yield until the next tick. return Promise.resolve(); } _syncEffectsForTarget (target) { - if (!target || !target.audioPlayer) return; - const soundState = this._getSoundState(target); - for (const effect in soundState.effects) { - if (!soundState.effects.hasOwnProperty(effect)) continue; - target.audioPlayer.setEffect(effect, soundState.effects[effect]); - } + if (!target || !target.sprite.soundBank) return; + target.soundEffects = this._getSoundState(target).effects; + + target.sprite.soundBank.setEffects(target); } clearEffects (args, util) { @@ -253,8 +250,7 @@ class Scratch3SoundBlocks { if (!soundState.effects.hasOwnProperty(effect)) continue; soundState.effects[effect] = 0; } - if (target.audioPlayer === null) return; - target.audioPlayer.clearEffects(); + this._syncEffectsForTarget(target); } _clearEffectsForAllTargets () { @@ -278,8 +274,7 @@ class Scratch3SoundBlocks { _updateVolume (volume, util) { volume = MathUtil.clamp(volume, 0, 100); util.target.volume = volume; - if (util.target.audioPlayer === null) return; - util.target.audioPlayer.setVolume(util.target.volume); + this._syncEffectsForTarget(util.target); // Yield until the next tick. return Promise.resolve(); diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 13122c1f1..0a6750224 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -6,6 +6,7 @@ const Clone = require('../util/clone'); const {Map} = require('immutable'); const BlocksExecuteCache = require('./blocks-execute-cache'); const log = require('../util/log'); +const Variable = require('./variable'); /** * @fileoverview @@ -670,6 +671,44 @@ class Blocks { this.resetCache(); } + /** + * Returns a map of all references to variables or lists from blocks + * in this block container. + * @return {object} A map of variable ID to a list of all variable references + * for that ID. A variable reference contains the field referencing that variable + * and also the type of the variable being referenced. + */ + getAllVariableAndListReferences () { + const blocks = this._blocks; + const allReferences = Object.create(null); + for (const blockId in blocks) { + let varOrListField = null; + let varType = null; + if (blocks[blockId].fields.VARIABLE) { + varOrListField = blocks[blockId].fields.VARIABLE; + varType = Variable.SCALAR_TYPE; + } else if (blocks[blockId].fields.LIST) { + varOrListField = blocks[blockId].fields.LIST; + varType = Variable.LIST_TYPE; + } + if (varOrListField) { + const currVarId = varOrListField.id; + if (allReferences[currVarId]) { + allReferences[currVarId].push({ + referencingField: varOrListField, + type: varType + }); + } else { + allReferences[currVarId] = [{ + referencingField: varOrListField, + type: varType + }]; + } + } + } + return allReferences; + } + /** * Keep blocks up to date after a variable gets renamed. * @param {string} varId The id of the variable that was renamed diff --git a/src/engine/target.js b/src/engine/target.js index a8c6828ee..69620c91b 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -6,6 +6,7 @@ const Comment = require('../engine/comment'); const uid = require('../util/uid'); const {Map} = require('immutable'); const log = require('../util/log'); +const StringUtil = require('../util/string-util'); /** * @fileoverview @@ -80,14 +81,40 @@ class Target extends EventEmitter { } /** - * Look up a variable object, and create it if one doesn't exist. + * Get the names of all the variables of the given type that are in scope for this target. + * For targets that are not the stage, this includes any target-specific + * variables as well as any stage variables. + * For the stage, this is all stage variables. + * @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE + * @return {Array} A list of variable names + */ + getAllVariableNamesInScopeByType (type) { + if (typeof type !== 'string') type = Variable.SCALAR_TYPE; + const targetVariables = Object.values(this.variables) + .filter(v => v.type === type) + .map(variable => variable.name); + if (this.isStage || !this.runtime) { + return targetVariables; + } + const stage = this.runtime.getTargetForStage(); + const stageVariables = stage.getAllVariableNamesInScopeByType(type); + return targetVariables.concat(stageVariables); + } + + /** + * Look up a variable object, first by id, and then by name if the id is not found. + * Create a new variable if both lookups fail. * @param {string} id Id of the variable. * @param {string} name Name of the variable. * @return {!Variable} Variable object. */ lookupOrCreateVariable (id, name) { - const variable = this.lookupVariableById(id); + let variable = this.lookupVariableById(id); if (variable) return variable; + + variable = this.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE); + if (variable) return variable; + // No variable with this name exists - create it locally. const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false); this.variables[id] = newVariable; @@ -161,6 +188,40 @@ class Target extends EventEmitter { } } + /** + * Look up a variable object by its name and variable type. + * Search begins with local variables; then global variables if a local one + * was not found. + * @param {string} name Name of the variable. + * @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE. + * @return {?Variable} Variable object if found, or null if not. + */ + lookupVariableByNameAndType (name, type) { + if (typeof name !== 'string') return; + if (typeof type !== 'string') type = Variable.SCALAR_TYPE; + + for (const varId in this.variables) { + const currVar = this.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return currVar; + } + } + + if (this.runtime && !this.isStage) { + const stage = this.runtime.getTargetForStage(); + if (stage) { + for (const varId in stage.variables) { + const currVar = stage.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return currVar; + } + } + } + } + + return null; + } + /** * Look up a list object for this target, and create it if one doesn't exist. * Search begins for local lists; then look for globals. @@ -169,8 +230,12 @@ class Target extends EventEmitter { * @return {!Varible} Variable object representing the found/created list. */ lookupOrCreateList (id, name) { - const list = this.lookupVariableById(id); + let list = this.lookupVariableById(id); if (list) return list; + + list = this.lookupVariableByNameAndType(name, Variable.LIST_TYPE); + if (list) return list; + // No variable with this name exists - create it locally. const newList = new Variable(id, name, Variable.LIST_TYPE, false); this.variables[id] = newList; @@ -240,10 +305,13 @@ class Target extends EventEmitter { name: 'VARIABLE', value: id }, this.runtime); - this.runtime.requestUpdateMonitor(Map({ - id: id, - params: blocks._getBlockParams(blocks.getBlock(variable.id)) - })); + const monitorBlock = blocks.getBlock(variable.id); + if (monitorBlock) { + this.runtime.requestUpdateMonitor(Map({ + id: id, + params: blocks._getBlockParams(monitorBlock) + })); + } } } @@ -264,6 +332,101 @@ class Target extends EventEmitter { } } + /** + * Fixes up variable references in this target avoiding conflicts with + * pre-existing variables in the same scope. + * This is used when uploading this target as a new sprite into an existing + * project, where the new sprite may contain references + * to variable names that already exist as global variables in the project + * (and thus are in scope for variable references in the given sprite). + * + * If the given target has a block that references an existing global variable and that + * variable *does not* exist in the target itself (e.g. it was a global variable in the + * project the sprite was originally exported from), fix the variable references in this sprite + * to reference the id of the pre-existing global variable. + * If the given target has a block that references an existing global variable and that + * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), + * then the variable is renamed to distinguish itself from the pre-existing variable. + * All blocks that reference the local variable will be updated to use the new name. + */ + fixUpVariableReferences () { + if (!this.runtime) return; // There's no runtime context to conflict with + if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded) + const stage = this.runtime.getTargetForStage(); + if (!stage || !stage.variables) return; + + const renameConflictingLocalVar = (id, name, type) => { + const conflict = stage.lookupVariableByNameAndType(name, type); + if (conflict) { + const newName = StringUtil.unusedName( + `${this.getName()}: ${name}`, + this.getAllVariableNamesInScopeByType(type)); + this.renameVariable(id, newName); + return newName; + } + return null; + }; + + const allReferences = this.blocks.getAllVariableAndListReferences(); + const unreferencedLocalVarIds = []; + if (Object.keys(this.variables).length > 0) { + for (const localVarId in this.variables) { + if (!this.variables.hasOwnProperty(localVarId)) continue; + if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId); + } + } + const conflictIdsToReplace = Object.create(null); + for (const varId in allReferences) { + // We don't care about which var ref we get, they should all have the same var info + const varRef = allReferences[varId][0]; + const varName = varRef.referencingField.value; + const varType = varRef.type; + if (this.lookupVariableById(varId)) { + // Found a variable with the id in either the target or the stage, + // figure out which one. + if (this.variables.hasOwnProperty(varId)) { + // If the target has the variable, then check whether the stage + // has one with the same name and type. If it does, then rename + // this target specific variable so that there is a distinction. + const newVarName = renameConflictingLocalVar(varId, varName, varType); + + if (newVarName) { + // We are not calling this.blocks.updateBlocksAfterVarRename + // here because it will search through all the blocks. We already + // have access to all the references for this var id. + allReferences[varId].map(ref => { + ref.referencingField.value = newVarName; + return ref; + }); + } + } + } else { + const existingVar = this.lookupVariableByNameAndType(varName, varType); + if (existingVar && !conflictIdsToReplace[varId]) { + conflictIdsToReplace[varId] = existingVar.id; + } + } + } + // Rename any local variables that were missed above because they aren't + // referenced by any blocks + for (const id in unreferencedLocalVarIds) { + const varId = unreferencedLocalVarIds[id]; + const name = this.variables[varId].name; + const type = this.variables[varId].type; + renameConflictingLocalVar(varId, name, type); + } + // Finally, handle global var conflicts (e.g. a sprite is uploaded, and has + // blocks referencing some variable that the sprite does not own, and this + // variable conflicts with a global var) + for (const conflictId in conflictIdsToReplace) { + const existingId = conflictIdsToReplace[conflictId]; + allReferences[conflictId].map(varRef => { + varRef.referencingField.id = existingId; + return varRef; + }); + } + } + /** * Post/edit sprite info. * @param {object} data An object with sprite info data to set. diff --git a/src/extensions/scratch3_music/index.js b/src/extensions/scratch3_music/index.js index 4450a69e8..1ef4d6942 100644 --- a/src/extensions/scratch3_music/index.js +++ b/src/extensions/scratch3_music/index.js @@ -52,18 +52,25 @@ class Scratch3MusicBlocks { this._concurrencyCounter = 0; /** - * An array of audio buffers, one for each drum sound. + * An array of sound players, one for each drum sound. * @type {Array} * @private */ - this._drumBuffers = []; + this._drumPlayers = []; /** - * An array of arrays of audio buffers. Each instrument has one or more audio buffers. + * An array of arrays of sound players. Each instrument has one or more audio players. * @type {Array[]} * @private */ - this._instrumentBufferArrays = []; + this._instrumentPlayerArrays = []; + + /** + * An array of arrays of sound players. Each instrument mya have an audio player for each playable note. + * @type {Array[]} + * @private + */ + this._instrumentPlayerNoteArrays = []; /** * An array of audio bufferSourceNodes. Each time you play an instrument or drum sound, @@ -87,14 +94,15 @@ class Scratch3MusicBlocks { const loadingPromises = []; this.DRUM_INFO.forEach((drumInfo, index) => { const filePath = `drums/${drumInfo.fileName}`; - const promise = this._storeSound(filePath, index, this._drumBuffers); + const promise = this._storeSound(filePath, index, this._drumPlayers); loadingPromises.push(promise); }); this.INSTRUMENT_INFO.forEach((instrumentInfo, instrumentIndex) => { - this._instrumentBufferArrays[instrumentIndex] = []; + this._instrumentPlayerArrays[instrumentIndex] = []; + this._instrumentPlayerNoteArrays[instrumentIndex] = []; instrumentInfo.samples.forEach((sample, noteIndex) => { const filePath = `instruments/${instrumentInfo.dirName}/${sample}`; - const promise = this._storeSound(filePath, noteIndex, this._instrumentBufferArrays[instrumentIndex]); + const promise = this._storeSound(filePath, noteIndex, this._instrumentPlayerArrays[instrumentIndex]); loadingPromises.push(promise); }); }); @@ -104,22 +112,22 @@ class Scratch3MusicBlocks { } /** - * Decode a sound and store the buffer in an array. + * Decode a sound and store the player in an array. * @param {string} filePath - the audio file name. - * @param {number} index - the index at which to store the audio buffer. - * @param {array} bufferArray - the array of buffers in which to store it. + * @param {number} index - the index at which to store the audio player. + * @param {array} playerArray - the array of players in which to store it. * @return {Promise} - a promise which will resolve once the sound has been stored. */ - _storeSound (filePath, index, bufferArray) { + _storeSound (filePath, index, playerArray) { const fullPath = `${filePath}.mp3`; if (!assetData[fullPath]) return; - // The sound buffer has already been downloaded via the manifest file required above. + // The sound player has already been downloaded via the manifest file required above. const soundBuffer = assetData[fullPath]; - return this._decodeSound(soundBuffer).then(buffer => { - bufferArray[index] = buffer; + return this._decodeSound(soundBuffer).then(player => { + playerArray[index] = player; }); } @@ -129,24 +137,14 @@ class Scratch3MusicBlocks { * @return {Promise} - a promise which will resolve once the sound has decoded. */ _decodeSound (soundBuffer) { - const context = this.runtime.audioEngine && this.runtime.audioEngine.audioContext; + const engine = this.runtime.audioEngine; - if (!context) { + if (!engine) { return Promise.reject(new Error('No Audio Context Detected')); } // Check for newer promise-based API - if (context.decodeAudioData.length === 1) { - return context.decodeAudioData(soundBuffer); - } else { // eslint-disable-line no-else-return - // Fall back to callback API - return new Promise((resolve, reject) => - context.decodeAudioData(soundBuffer, - buffer => resolve(buffer), - error => reject(error) - ) - ); - } + return engine.decodeSoundPlayer({data: {buffer: soundBuffer}}); } /** @@ -623,7 +621,11 @@ class Scratch3MusicBlocks { getInfo () { return { id: 'music', - name: 'Music', + name: formatMessage({ + id: 'music.categoryName', + default: 'Music', + description: 'Label for the Music extension category' + }), menuIconURI: menuIconURI, blockIconURI: blockIconURI, blocks: [ @@ -774,26 +776,34 @@ class Scratch3MusicBlocks { */ _playDrumNum (util, drumNum) { if (util.runtime.audioEngine === null) return; - if (util.target.audioPlayer === null) return; + if (util.target.sprite.soundBank === null) return; // If we're playing too many sounds, do not play the drum sound. if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { return; } - const outputNode = util.target.audioPlayer.getInputNode(); - const context = util.runtime.audioEngine.audioContext; - const bufferSource = context.createBufferSource(); - bufferSource.buffer = this._drumBuffers[drumNum]; - bufferSource.connect(outputNode); - bufferSource.start(); - const bufferSourceIndex = this._bufferSources.length; - this._bufferSources.push(bufferSource); + const player = this._drumPlayers[drumNum]; + + if (typeof player === 'undefined') return; + + if (player.isPlaying && !player.isStarting) { + // Take the internal player state and create a new player with it. + // `.play` does this internally but then instructs the sound to + // stop. + player.take(); + } + + const engine = util.runtime.audioEngine; + const chain = engine.createEffectChain(); + chain.setEffectsFromTarget(util.target); + player.connect(chain); this._concurrencyCounter++; - bufferSource.onended = () => { + player.once('stop', () => { this._concurrencyCounter--; - delete this._bufferSources[bufferSourceIndex]; - }; + }); + + player.play(); } /** @@ -852,7 +862,7 @@ class Scratch3MusicBlocks { */ _playNote (util, note, durationSec) { if (util.runtime.audioEngine === null) return; - if (util.target.audioPlayer === null) return; + if (util.target.sprite.soundBank === null) return; // If we're playing too many sounds, do not play the note. if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { @@ -867,28 +877,37 @@ class Scratch3MusicBlocks { const sampleIndex = this._selectSampleIndexForNote(note, sampleArray); // If the audio sample has not loaded yet, bail out - if (typeof this._instrumentBufferArrays[inst] === 'undefined') return; - if (typeof this._instrumentBufferArrays[inst][sampleIndex] === 'undefined') return; + if (typeof this._instrumentPlayerArrays[inst] === 'undefined') return; + if (typeof this._instrumentPlayerArrays[inst][sampleIndex] === 'undefined') return; - // Create the audio buffer to play the note, and set its pitch - const context = util.runtime.audioEngine.audioContext; - const bufferSource = context.createBufferSource(); + // Fetch the sound player to play the note. + const engine = util.runtime.audioEngine; - const bufferSourceIndex = this._bufferSources.length; - this._bufferSources.push(bufferSource); + if (!this._instrumentPlayerNoteArrays[inst][note]) { + this._instrumentPlayerNoteArrays[inst][note] = this._instrumentPlayerArrays[inst][sampleIndex].take(); + } - bufferSource.buffer = this._instrumentBufferArrays[inst][sampleIndex]; + const player = this._instrumentPlayerNoteArrays[inst][note]; + + if (player.isPlaying && !player.isStarting) { + // Take the internal player state and create a new player with it. + // `.play` does this internally but then instructs the sound to + // stop. + player.take(); + } + + const chain = engine.createEffectChain(); + chain.setEffectsFromTarget(util.target); + + // Set its pitch. const sampleNote = sampleArray[sampleIndex]; - bufferSource.playbackRate.value = this._ratioForPitchInterval(note - sampleNote); + const notePitchInterval = this._ratioForPitchInterval(note - sampleNote); - // Create a gain node for this note, and connect it to the sprite's audioPlayer. - const gainNode = context.createGain(); - bufferSource.connect(gainNode); - const outputNode = util.target.audioPlayer.getInputNode(); - gainNode.connect(outputNode); - - // Start playing the note - bufferSource.start(); + // Create a gain node for this note, and connect it to the sprite's + // simulated effectChain. + const context = engine.audioContext; + const releaseGain = context.createGain(); + releaseGain.connect(chain.getInputNode()); // Schedule the release of the note, ramping its gain down to zero, // and then stopping the sound. @@ -898,16 +917,24 @@ class Scratch3MusicBlocks { } const releaseStart = context.currentTime + durationSec; const releaseEnd = releaseStart + releaseDuration; - gainNode.gain.setValueAtTime(1, releaseStart); - gainNode.gain.linearRampToValueAtTime(0.0001, releaseEnd); - bufferSource.stop(releaseEnd); + releaseGain.gain.setValueAtTime(1, releaseStart); + releaseGain.gain.linearRampToValueAtTime(0.0001, releaseEnd); - // Update the concurrency counter this._concurrencyCounter++; - bufferSource.onended = () => { + player.once('stop', () => { this._concurrencyCounter--; - delete this._bufferSources[bufferSourceIndex]; - }; + }); + + // Start playing the note + player.play(); + // Connect the player to the gain node. + player.connect({getInputNode () { + return releaseGain; + }}); + // Set playback now after play creates the outputNode. + player.outputNode.playbackRate.value = notePitchInterval; + // Schedule playback to stop. + player.outputNode.stop(releaseEnd); } /** diff --git a/src/extensions/scratch3_pen/index.js b/src/extensions/scratch3_pen/index.js index e4211b328..4c46ce370 100644 --- a/src/extensions/scratch3_pen/index.js +++ b/src/extensions/scratch3_pen/index.js @@ -282,7 +282,11 @@ class Scratch3PenBlocks { getInfo () { return { id: 'pen', - name: 'Pen', + name: formatMessage({ + id: 'pen.categoryName', + default: 'Pen', + description: 'Label for the pen extension category' + }), blockIconURI: blockIconURI, blocks: [ { diff --git a/src/extensions/scratch3_translate/index.js b/src/extensions/scratch3_translate/index.js index 1d0c2ec05..787b8545f 100644 --- a/src/extensions/scratch3_translate/index.js +++ b/src/extensions/scratch3_translate/index.js @@ -86,7 +86,11 @@ class Scratch3TranslateBlocks { getInfo () { return { id: 'translate', - name: 'Translate', + name: formatMessage({ + id: 'translate.categoryName', + default: 'Translate', + description: 'Label for the translate extension category' + }), menuIconURI: '', // TODO: Add the final icons. blockIconURI: '', blocks: [ diff --git a/src/extensions/scratch3_video_sensing/index.js b/src/extensions/scratch3_video_sensing/index.js index c6e29f85b..afe8f44a1 100644 --- a/src/extensions/scratch3_video_sensing/index.js +++ b/src/extensions/scratch3_video_sensing/index.js @@ -372,7 +372,11 @@ class Scratch3VideoSensingBlocks { // Return extension definition return { id: 'videoSensing', - name: 'Video Motion', + name: formatMessage({ + id: 'videoSensing.categoryName', + default: 'Video Motion', + description: 'Label for the video motion extension category' + }), blocks: [ { // @todo this hat needs to be set itself to restart existing diff --git a/src/import/load-sound.js b/src/import/load-sound.js index e13db14d2..8f4f7a1a6 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -8,27 +8,32 @@ const log = require('../util/log'); * @property {Buffer} data - sound data will be written here once loaded. * @param {!Asset} soundAsset - the asset loaded from storage. * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @param {Sprite} sprite - Scratch sprite to add sounds to. * @returns {!Promise} - a promise which will resolve to the sound when ready. */ -const loadSoundFromAsset = function (sound, soundAsset, runtime) { +const loadSoundFromAsset = function (sound, soundAsset, runtime, sprite) { sound.assetId = soundAsset.assetId; if (!runtime.audioEngine) { log.error('No audio engine present; cannot load sound asset: ', sound.md5); return Promise.resolve(sound); } - return runtime.audioEngine.decodeSound(Object.assign( + return runtime.audioEngine.decodeSoundPlayer(Object.assign( {}, sound, {data: soundAsset.data} - )).then(soundId => { - sound.soundId = soundId; + )).then(soundPlayer => { + sound.soundId = soundPlayer.id; // Set the sound sample rate and sample count based on the // the audio buffer from the audio engine since the sound // gets resampled by the audio engine - const soundBuffer = runtime.audioEngine.getSoundBuffer(soundId); + const soundBuffer = soundPlayer.buffer; sound.rate = soundBuffer.sampleRate; sound.sampleCount = soundBuffer.length; + if (sprite.soundBank !== null) { + sprite.soundBank.addSoundPlayer(soundPlayer); + } + return sound; }); }; @@ -39,9 +44,10 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime) { * @property {string} md5 - the MD5 and extension of the sound to be loaded. * @property {Buffer} data - sound data will be written here once loaded. * @param {!Runtime} runtime - Scratch runtime, used to access the storage module. + * @param {Sprite} sprite - Scratch sprite to add sounds to. * @returns {!Promise} - a promise which will resolve to the sound when ready. */ -const loadSound = function (sound, runtime) { +const loadSound = function (sound, runtime, sprite) { if (!runtime.storage) { log.error('No storage module present; cannot load sound asset: ', sound.md5); return Promise.resolve(sound); @@ -52,7 +58,7 @@ const loadSound = function (sound, runtime) { return runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) .then(soundAsset => { sound.dataFormat = ext; - return loadSoundFromAsset(sound, soundAsset, runtime); + return loadSoundFromAsset(sound, soundAsset, runtime, sprite); }); }; diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 9ca378d90..ffde8220a 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -420,7 +420,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip) // followed by the file ext const assetFileName = `${soundSource.soundID}.${ext}`; soundPromises.push(deserializeSound(sound, runtime, zip, assetFileName) - .then(() => loadSound(sound, runtime))); + .then(() => loadSound(sound, runtime, sprite))); } } diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 99c6ec226..16db421fe 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -460,17 +460,27 @@ const serializeTarget = function (target, extensions) { /** * Serializes the specified VM runtime. - * @param {!Runtime} runtime VM runtime instance to be serialized. + * @param {!Runtime} runtime VM runtime instance to be serialized. + * @param {string=} targetId Optional target id if serializing only a single target * @return {object} Serialized runtime instance. */ -const serialize = function (runtime) { +const serialize = function (runtime, targetId) { // Fetch targets const obj = Object.create(null); // Create extension set to hold extension ids found while serializing targets const extensions = new Set(); - const flattenedOriginalTargets = JSON.parse(JSON.stringify( + const flattenedOriginalTargets = JSON.parse(JSON.stringify(targetId ? + [runtime.getTargetById(targetId)] : runtime.targets.filter(target => target.isOriginal))); - obj.targets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions)); + + const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions)); + + if (targetId) { + return serializedTargets[0]; + } + + obj.targets = serializedTargets; + // TODO Serialize monitors @@ -819,7 +829,7 @@ const parseScratchObject = function (object, runtime, extensions, zip) { // any translation that needs to happen will happen in the process // of building up the costume object into an sb3 format return deserializeSound(sound, runtime, zip) - .then(() => loadSound(sound, runtime)); + .then(() => loadSound(sound, runtime, sprite)); // Only attempt to load the sound after the deserialization // process has been completed. }); @@ -945,10 +955,11 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { return Promise.all( ((isSingleSprite ? [json] : json.targets) || []).map(target => parseScratchObject(target, runtime, extensions, zip)) - ).then(targets => ({ - targets, - extensions - })); + ) + .then(targets => ({ + targets, + extensions + })); }; module.exports = { diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index 9dc7bf0d9..ac444f11a 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -5,10 +5,11 @@ * to be written and the contents of the file, the serialized asset. * @param {Runtime} runtime The runtime with the assets to be serialized * @param {string} assetType The type of assets to be serialized: 'sounds' | 'costumes' + * @param {string=} optTargetId Optional target id to serialize assets for * @returns {Array} An array of file descriptors for each asset */ -const serializeAssets = function (runtime, assetType) { - const targets = runtime.targets; +const serializeAssets = function (runtime, assetType, optTargetId) { + const targets = optTargetId ? [runtime.getTargetById(optTargetId)] : runtime.targets; const assetDescs = []; for (let i = 0; i < targets.length; i++) { const currTarget = targets[i]; @@ -27,14 +28,16 @@ const serializeAssets = function (runtime, assetType) { }; /** - * Serialize all the sounds in the provided runtime into an array of file - * descriptors. A file descriptor is an object containing the name of the file + * Serialize all the sounds in the provided runtime or, if a target id is provided, + * in the specified target into an array of file descriptors. + * A file descriptor is an object containing the name of the file * to be written and the contents of the file, the serialized sound. * @param {Runtime} runtime The runtime with the sounds to be serialized + * @param {string=} optTargetId Optional targetid for serializing sounds of a single target * @returns {Array} An array of file descriptors for each sound */ -const serializeSounds = function (runtime) { - return serializeAssets(runtime, 'sounds'); +const serializeSounds = function (runtime, optTargetId) { + return serializeAssets(runtime, 'sounds', optTargetId); }; /** @@ -42,10 +45,11 @@ const serializeSounds = function (runtime) { * descriptors. A file descriptor is an object containing the name of the file * to be written and the contents of the file, the serialized costume. * @param {Runtime} runtime The runtime with the costumes to be serialized + * @param {string} optTargetId Optional targetid for serializing costumes of a single target * @returns {Array} An array of file descriptors for each costume */ -const serializeCostumes = function (runtime) { - return serializeAssets(runtime, 'costumes'); +const serializeCostumes = function (runtime, optTargetId) { + return serializeAssets(runtime, 'costumes', optTargetId); }; module.exports = { diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index 632374384..eb996786b 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -170,21 +170,30 @@ class RenderedTarget extends Target { } } + get audioPlayer () { + /* eslint-disable no-console */ + console.warn('get audioPlayer deprecated, please update to use .sprite.soundBank methods'); + console.warn(new Error('stack for debug').stack); + /* eslint-enable no-console */ + const bank = this.sprite.soundBank; + const audioPlayerProxy = { + playSound: soundId => bank.play(this, soundId) + }; + + Object.defineProperty(this, 'audioPlayer', { + configurable: false, + enumerable: true, + writable: false, + value: audioPlayerProxy + }); + + return audioPlayerProxy; + } + /** * Initialize the audio player for this sprite or clone. */ initAudio () { - this.audioPlayer = null; - if (this.runtime && this.runtime.audioEngine) { - this.audioPlayer = this.runtime.audioEngine.createPlayer(); - // If this is a clone, it gets a reference to its parent's activeSoundPlayers object. - if (!this.isOriginal) { - const parent = this.sprite.clones[0]; - if (parent && parent.audioPlayer) { - this.audioPlayer.activeSoundPlayers = parent.audioPlayer.activeSoundPlayers; - } - } - } } /** @@ -1004,8 +1013,8 @@ class RenderedTarget extends Target { const newTarget = newSprite.createClone(); // Copy all properties. // @todo refactor with clone methods - newTarget.x = Math.random() * 400 / 2; - newTarget.y = Math.random() * 300 / 2; + newTarget.x = (Math.random() - 0.5) * 400 / 2; + newTarget.y = (Math.random() - 0.5) * 300 / 2; newTarget.direction = this.direction; newTarget.draggable = this.draggable; newTarget.visible = this.visible; @@ -1034,9 +1043,8 @@ class RenderedTarget extends Target { */ onStopAll () { this.clearEffects(); - if (this.audioPlayer) { - this.audioPlayer.stopAllSounds(); - this.audioPlayer.clearEffects(); + if (this.sprite.soundBank) { + this.sprite.soundBank.stopAllSounds(); } } @@ -1132,10 +1140,6 @@ class RenderedTarget extends Target { this.runtime.requestRedraw(); } } - if (this.audioPlayer) { - this.audioPlayer.stopAllSounds(); - this.audioPlayer.dispose(); - } } } diff --git a/src/sprites/sprite.js b/src/sprites/sprite.js index de735ecc3..bc61960a0 100644 --- a/src/sprites/sprite.js +++ b/src/sprites/sprite.js @@ -8,7 +8,8 @@ const StageLayering = require('../engine/stage-layering'); class Sprite { /** * Sprite to be used on the Scratch stage. - * All clones of a sprite have shared blocks, shared costumes, shared variables. + * All clones of a sprite have shared blocks, shared costumes, shared variables, + * shared sounds, etc. * @param {?Blocks} blocks Shared blocks object for all clones of sprite. * @param {Runtime} runtime Reference to the runtime. * @constructor @@ -47,6 +48,11 @@ class Sprite { * @type {Array.} */ this.clones = []; + + this.soundBank = null; + if (this.runtime && this.runtime.audioEngine) { + this.soundBank = this.runtime.audioEngine.createBank(); + } } /** @@ -149,12 +155,18 @@ class Sprite { newSprite.sounds = this.sounds.map(sound => { const newSound = Object.assign({}, sound); const soundAsset = this.runtime.storage.get(sound.assetId); - assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime)); + assetPromises.push(loadSoundFromAsset(newSound, soundAsset, this.runtime, this)); return newSound; }); return Promise.all(assetPromises).then(() => newSprite); } + + dispose () { + if (this.soundBank) { + this.soundBank.dispose(); + } + } } module.exports = Sprite; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 767641eec..940991e49 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -263,14 +263,7 @@ class VirtualMachine extends EventEmitter { // Put everything in a zip file zip.file('project.json', projectJson); - for (let i = 0; i < soundDescs.length; i++) { - const currSound = soundDescs[i]; - zip.file(currSound.fileName, currSound.fileContent); - } - for (let i = 0; i < costumeDescs.length; i++) { - const currCostume = costumeDescs[i]; - zip.file(currCostume.fileName, currCostume.fileContent); - } + this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip); return zip.generateAsync({ type: 'blob', @@ -281,6 +274,43 @@ class VirtualMachine extends EventEmitter { }); } + _addFileDescsToZip (fileDescs, zip) { + for (let i = 0; i < fileDescs.length; i++) { + const currFileDesc = fileDescs[i]; + zip.file(currFileDesc.fileName, currFileDesc.fileContent); + } + } + + /** + * Exports a sprite in the sprite3 format. + * @param {string} targetId ID of the target to export + * @param {string=} optZipType Optional type that the resulting + * zip should be outputted in. Options are: base64, binarystring, + * array, uint8array, arraybuffer, blob, or nodebuffer. Defaults to + * blob if argument not provided. + * See https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html#type-option + * for more information about these options. + * @return {object} A generated zip of the sprite and its assets in the format + * specified by optZipType or blob by default. + */ + exportSprite (targetId, optZipType) { + const soundDescs = serializeSounds(this.runtime, targetId); + const costumeDescs = serializeCostumes(this.runtime, targetId); + const spriteJson = JSON.stringify(sb3.serialize(this.runtime, targetId)); + + const zip = new JSZip(); + zip.file('sprite.json', spriteJson); + this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip); + + return zip.generateAsync({ + type: typeof optZipType === 'string' ? optZipType : 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 6 + } + }); + } + /** * Export project as a Scratch 3.0 JSON representation. * @return {string} Serialized state of the runtime. @@ -368,6 +398,10 @@ class VirtualMachine extends EventEmitter { this.editingTarget = targets[0]; } + if (!wholeProject) { + this.editingTarget.fixUpVariableReferences(); + } + // Update the VM user's knowledge of targets and blocks on the workspace. this.emitTargetsUpdate(); this.emitWorkspaceUpdate(); @@ -495,7 +529,7 @@ class VirtualMachine extends EventEmitter { duplicateSound (soundIndex) { const originalSound = this.editingTarget.getSounds()[soundIndex]; const clone = Object.assign({}, originalSound); - return loadSound(clone, this.runtime).then(() => { + return loadSound(clone, this.runtime, this.editingTarget.sprite).then(() => { this.editingTarget.addSound(clone, soundIndex + 1); this.emitTargetsUpdate(); }); @@ -525,7 +559,7 @@ class VirtualMachine extends EventEmitter { * @returns {?Promise} - a promise that resolves when the sound has been decoded and added */ addSound (soundObject) { - return loadSound(soundObject, this.runtime).then(() => { + return loadSound(soundObject, this.runtime, this.editingTarget.sprite).then(() => { this.editingTarget.addSound(soundObject); this.emitTargetsUpdate(); }); @@ -549,7 +583,7 @@ class VirtualMachine extends EventEmitter { getSoundBuffer (soundIndex) { const id = this.editingTarget.sprite.sounds[soundIndex].soundId; if (id && this.runtime && this.runtime.audioEngine) { - return this.runtime.audioEngine.getSoundBuffer(id); + return this.editingTarget.sprite.soundBank.getSoundPlayer(id).buffer; } return null; } @@ -564,7 +598,7 @@ class VirtualMachine extends EventEmitter { const sound = this.editingTarget.sprite.sounds[soundIndex]; const id = sound ? sound.soundId : null; if (id && this.runtime && this.runtime.audioEngine) { - this.runtime.audioEngine.updateSoundBuffer(id, newBuffer); + this.editingTarget.sprite.soundBank.getSoundPlayer(id).buffer = newBuffer; } // Update sound in runtime if (soundEncoding) { @@ -966,8 +1000,8 @@ class VirtualMachine extends EventEmitter { shareSoundToTarget (soundIndex, targetId) { const originalSound = this.editingTarget.getSounds()[soundIndex]; const clone = Object.assign({}, originalSound); - return loadSound(clone, this.runtime).then(() => { - const target = this.runtime.getTargetById(targetId); + const target = this.runtime.getTargetById(targetId); + return loadSound(clone, this.runtime, target.sprite).then(() => { if (target) { target.addSound(clone); this.emitTargetsUpdate(); diff --git a/test/fixtures/events.json b/test/fixtures/events.json index a5a126f07..dbddd2777 100644 --- a/test/fixtures/events.json +++ b/test/fixtures/events.json @@ -90,5 +90,17 @@ "type": "comment_create", "commentId": "a comment", "xy": {"x": 10, "y": 20} + }, + "mockVariableBlock": { + "name": "block", + "xml": { + "outerHTML": "a mock variable" + } + }, + "mockListBlock": { + "name": "block", + "xml": { + "outerHTML": "a mock list" + } } } diff --git a/test/unit/blocks_sounds.js b/test/unit/blocks_sounds.js index 9a44e7f9f..53acc8aff 100644 --- a/test/unit/blocks_sounds.js +++ b/test/unit/blocks_sounds.js @@ -11,10 +11,10 @@ const util = { {name: 'second name', soundId: 'second soundId'}, {name: 'third name', soundId: 'third soundId'}, {name: '6', soundId: 'fourth soundId'} - ] - }, - audioPlayer: { - playSound: soundId => (playedSound = soundId) + ], + soundBank: { + playSound: (target, soundId) => (playedSound = soundId) + } } } }; diff --git a/test/unit/engine_blocks.js b/test/unit/engine_blocks.js index 08b0b414e..5659b6878 100644 --- a/test/unit/engine_blocks.js +++ b/test/unit/engine_blocks.js @@ -1,5 +1,8 @@ const test = require('tap').test; const Blocks = require('../../src/engine/blocks'); +const Variable = require('../../src/engine/variable'); +const adapter = require('../../src/engine/adapter'); +const events = require('../fixtures/events.json'); test('spec', t => { const b = new Blocks(); @@ -776,3 +779,32 @@ test('updateTargetSpecificBlocks changes sprite clicked hat to stage clicked for t.end(); }); + +test('getAllVariableAndListReferences returns an empty map references when variable blocks do not exist', t => { + const b = new Blocks(); + t.equal(Object.keys(b.getAllVariableAndListReferences()).length, 0); + t.end(); +}); + +test('getAllVariableAndListReferences returns references when variable blocks exist', t => { + const b = new Blocks(); + + let varListRefs = b.getAllVariableAndListReferences(); + t.equal(Object.keys(varListRefs).length, 0); + + b.createBlock(adapter(events.mockVariableBlock)[0]); + b.createBlock(adapter(events.mockListBlock)[0]); + + varListRefs = b.getAllVariableAndListReferences(); + t.equal(Object.keys(varListRefs).length, 2); + t.equal(Array.isArray(varListRefs['mock var id']), true); + t.equal(varListRefs['mock var id'].length, 1); + t.equal(varListRefs['mock var id'][0].type, Variable.SCALAR_TYPE); + t.equal(varListRefs['mock var id'][0].referencingField.value, 'a mock variable'); + t.equal(Array.isArray(varListRefs['mock list id']), true); + t.equal(varListRefs['mock list id'].length, 1); + t.equal(varListRefs['mock list id'][0].type, Variable.LIST_TYPE); + t.equal(varListRefs['mock list id'][0].referencingField.value, 'a mock list'); + + t.end(); +}); diff --git a/test/unit/engine_target.js b/test/unit/engine_target.js index a92dd5e61..098908e1f 100644 --- a/test/unit/engine_target.js +++ b/test/unit/engine_target.js @@ -1,6 +1,9 @@ const test = require('tap').test; const Target = require('../../src/engine/target'); const Variable = require('../../src/engine/variable'); +const adapter = require('../../src/engine/adapter'); +const Runtime = require('../../src/engine/runtime'); +const events = require('../fixtures/events.json'); test('spec', t => { const target = new Target(); @@ -145,7 +148,7 @@ test('deleteVariable2', t => { t.end(); }); -test('lookupOrCreateList creates a list if var with given id does not exist', t => { +test('lookupOrCreateList creates a list if var with given id or var with given name does not exist', t => { const target = new Target(); const variables = target.variables; @@ -174,6 +177,22 @@ test('lookupOrCreateList returns list if one with given id exists', t => { t.end(); }); +test('lookupOrCreateList succeeds in finding list if id is incorrect but name matches', t => { + const target = new Target(); + const variables = target.variables; + + t.equal(Object.keys(variables).length, 0); + target.createVariable('foo', 'bar', Variable.LIST_TYPE); + t.equal(Object.keys(variables).length, 1); + + const listVar = target.lookupOrCreateList('not foo', 'bar'); + t.equal(Object.keys(variables).length, 1); + t.equal(listVar.id, 'foo'); + t.equal(listVar.name, 'bar'); + + t.end(); +}); + test('lookupBroadcastMsg returns the var with given id if exists', t => { const target = new Target(); const variables = target.variables; @@ -263,3 +282,147 @@ test('creating a comment with a blockId also updates the comment property on the t.end(); }); + +test('fixUpVariableReferences fixes sprite global var conflicting with project global var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 0); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + + target.fixUpVariableReferences(); + + t.equal(Object.keys(target.variables).length, 0); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'pre-existing global var id'); + + t.end(); +}); + +test('fixUpVariableReferences fixes sprite local var conflicting with project global var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'Target: a mock variable'); + + t.end(); +}); + +test('fixUpVariableReferences fixes conflicting sprite local var without blocks referencing var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.equal(target.variables['mock var id'].name, 'Target: a mock variable'); + + t.end(); +}); + +test('fixUpVariableReferences does not change variable name if there is no variable conflict', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a variable', Variable.SCALAR_TYPE); + stage.createVariable('pre-existing global list id', 'a mock variable', Variable.LIST_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 2); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 2); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + t.end(); +});