diff --git a/src/engine/blocks.js b/src/engine/blocks.js index a767c75d..85aeabfb 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -393,7 +393,7 @@ class Blocks { Object.keys(this._blocks).forEach(blockId => { if (this.getBlock(blockId).isMonitored) { // @todo handle specific targets (e.g. apple x position) - runtime.toggleScript(blockId, {updateMonitor: true}); + runtime.addMonitorScript(blockId); } }); } diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 53b31eb8..122750fb 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -467,14 +467,13 @@ class Runtime extends EventEmitter { * @param {!string} topBlockId ID of block that starts the script. * @param {?object} opts optional arguments to toggle script * @param {?string} opts.target target ID for target to run script on. If not supplied, uses editing target. - * @param {?boolean} opts.stackClick true if the user activated the stack by clicking, false if not. - * @param {?boolean} opts.updateMonitor true if the monitor for this block should get updated. + * @param {?boolean} opts.stackClick true if the user activated the stack by clicking, false if not. This + * determines whether we show a visual report when turning on the script. */ toggleScript (topBlockId, opts) { opts = Object.assign({ target: this._editingTarget, - stackClick: false, - updateMonitor: false + stackClick: false }, opts); // Remove any existing thread. for (let i = 0; i < this.threads.length; i++) { @@ -496,6 +495,23 @@ class Runtime extends EventEmitter { this._pushThread(topBlockId, opts.target, opts); } + /** + * Enqueue a script that when finished will update the monitor for the block. + * @param {!string} topBlockId ID of block that starts the script. + * @param {?string} optTarget target ID for target to run script on. If not supplied, uses editing target. + */ + addMonitorScript (topBlockId, optTarget) { + if (!optTarget) optTarget = this._editingTarget; + for (let i = 0; i < this.threads.length; i++) { + // Don't re-add the script if it's already running + if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE && + this.threads[i].updateMonitor) { + return; + } + } + // Otherwise add it. + this._pushThread(topBlockId, optTarget, {updateMonitor: true}); + } /** * Run a function `f` for all scripts in a workspace. @@ -587,6 +603,7 @@ class Runtime extends EventEmitter { // any existing threads starting with the top block. for (let i = 0; i < instance.threads.length; i++) { if (instance.threads[i].topBlock === topBlockId && + !instance.threads[i].stackClick && // stack click threads and hat threads can coexist instance.threads[i].target === target) { instance._restartThread(instance.threads[i]); return; @@ -598,7 +615,8 @@ class Runtime extends EventEmitter { for (let j = 0; j < instance.threads.length; j++) { if (instance.threads[j].topBlock === topBlockId && instance.threads[j].target === target && - !instance.threads[j].status === Thread.STATUS_DONE) { + !instance.threads[j].stackClick && // stack click threads and hat threads can coexist + instance.threads[j].status !== Thread.STATUS_DONE) { // Some thread is already running. return; } diff --git a/test/fixtures/loudness-hat-block.sb2 b/test/fixtures/loudness-hat-block.sb2 new file mode 100644 index 00000000..3dfe9318 Binary files /dev/null and b/test/fixtures/loudness-hat-block.sb2 differ diff --git a/test/fixtures/stack-click.sb2 b/test/fixtures/stack-click.sb2 new file mode 100644 index 00000000..8b735b73 Binary files /dev/null and b/test/fixtures/stack-click.sb2 differ diff --git a/test/integration/hat-threads-run-every-frame.js b/test/integration/hat-threads-run-every-frame.js new file mode 100644 index 00000000..4f858c45 --- /dev/null +++ b/test/integration/hat-threads-run-every-frame.js @@ -0,0 +1,197 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); +const Thread = require('../../src/engine/thread'); +const Runtime = require('../../src/engine/runtime'); + +const projectUri = path.resolve(__dirname, '../fixtures/loudness-hat-block.sb2'); +const project = extract(projectUri); + +const checkIsHatThread = (t, vm, hatThread) => { + t.equal(hatThread.stackClick, false); + t.equal(hatThread.updateMonitor, false); + const blockContainer = hatThread.target.blocks; + const opcode = blockContainer.getOpcode(blockContainer.getBlock(hatThread.topBlock)); + t.assert(vm.runtime.getIsEdgeActivatedHat(opcode)); +}; + +const checkIsStackClickThread = (t, vm, stackClickThread) => { + t.equal(stackClickThread.stackClick, true); + t.equal(stackClickThread.updateMonitor, false); +}; + +/** + * loudness-hat-block.sb2 contains a single stack + * when loudness > 10 + * change color effect by 25 + * The intention is to make sure that the hat block condition is evaluated + * on each frame. + */ +test('edge activated hat thread runs once every frame', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Check that the hat thread is again when another step is taken + vm.runtime._step(); + // There should now be one done hat thread and one new hat thread to run + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * If the hat doesn't finish evaluating within one frame, it shouldn't be added again + * on the next frame. (We skip execution by setting the step time to 0) + */ +test('edge activated hat thread not added twice', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + const prevThread = vm.runtime.threads[0]; + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + + // Check that no new threads are added when another step is taken + vm.runtime._step(); + // There should now be one done hat thread and one new hat thread to run + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0] === prevThread); + t.end(); + }); + }); +}); + +/** + * When adding a stack click thread first, make sure that the edge activated hat thread and + * the stack click thread are both pushed and run (despite having the same top block) + */ +test('edge activated hat thread does not interrupt stack click thread', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Add stack click thread on this hat + vm.runtime.toggleScript(vm.runtime.threads[0].topBlock, {stackClick: true}); + + // Check that the hat thread is again when another step is taken + vm.runtime._step(); + // There should now be one done hat thread and one new hat thread to run + t.equal(vm.runtime.threads.length, 2); + let hatThread; + let stackClickThread; + if (vm.runtime.threads[0].stackClick) { + stackClickThread = vm.runtime.threads[0]; + hatThread = vm.runtime.threads[1]; + } else { + stackClickThread = vm.runtime.threads[1]; + hatThread = vm.runtime.threads[0]; + } + checkIsHatThread(t, vm, hatThread); + checkIsStackClickThread(t, vm, stackClickThread); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.assert(vm.runtime.threads[1].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * When adding the hat thread first, make sure that the edge activated hat thread and + * the stack click thread are both pushed and run (despite having the same top block) + */ +test('edge activated hat thread does not interrupt stack click thread', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + t.equal(vm.runtime.threads.length, 1); + checkIsHatThread(t, vm, vm.runtime.threads[0]); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + + // Add stack click thread on this hat + vm.runtime.toggleScript(vm.runtime.threads[0].topBlock, {stackClick: true}); + + // Check that the hat thread is again when another step is taken + vm.runtime._step(); + // There should now be one done hat thread and one new hat thread to run + t.equal(vm.runtime.threads.length, 2); + let hatThread; + let stackClickThread; + if (vm.runtime.threads[0].stackClick) { + stackClickThread = vm.runtime.threads[0]; + hatThread = vm.runtime.threads[1]; + } else { + stackClickThread = vm.runtime.threads[1]; + hatThread = vm.runtime.threads[0]; + } + checkIsHatThread(t, vm, hatThread); + checkIsStackClickThread(t, vm, stackClickThread); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.assert(vm.runtime.threads[1].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); diff --git a/test/integration/monitor-threads-run-every-frame.js b/test/integration/monitor-threads-run-every-frame.js new file mode 100644 index 00000000..7a148359 --- /dev/null +++ b/test/integration/monitor-threads-run-every-frame.js @@ -0,0 +1,118 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); +const Thread = require('../../src/engine/thread'); +const Runtime = require('../../src/engine/runtime'); + +const projectUri = path.resolve(__dirname, '../fixtures/default.sb2'); +const project = extract(projectUri); + +const checkMonitorThreadPresent = (t, threads) => { + t.equal(threads.length, 1); + const monitorThread = threads[0]; + t.equal(monitorThread.stackClick, false); + t.equal(monitorThread.updateMonitor, true); + t.equal(monitorThread.topBlock.toString(), 'sensing_timer'); +}; + +/** + * Creates a monitor and then checks if it gets run every frame. + */ +/* TODO: when loadProject loads monitors, we can create a project with a monitor and will + * not have to do the create monitor step manually. + */ +test('monitor thread runs every frame', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL; + + // Manually populate the monitor block and set its isMonitored to true. + vm.runtime.monitorBlocks.createBlock({ + id: 'sensing_timer', + opcode: 'sensing_timer', + inputs: {}, + fields: {}, + next: null, + topLevel: true, + parent: null, + shadow: false, + isMonitored: true, + x: '0', + y: '0' + }); + + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + + // Check that both are added again when another step is taken + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_DONE); + t.end(); + }); + }); +}); + +/** + * If the monitor doesn't finish evaluating within one frame, it shouldn't be added again + * on the next frame. (We skip execution by setting the step time to 0) + */ +test('monitor thread not added twice', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Start VM, load project, and run + t.doesNotThrow(() => { + // Note: don't run vm.start(), we handle calling _step() manually in this test + vm.runtime.currentStepTime = 0; + + // Manually populate the monitor block and set its isMonitored to true. + vm.runtime.monitorBlocks.createBlock({ + id: 'sensing_timer', + opcode: 'sensing_timer', + inputs: {}, + fields: {}, + next: null, + topLevel: true, + parent: null, + shadow: false, + isMonitored: true, + x: '0', + y: '0' + }); + + vm.clear(); + vm.setCompatibilityMode(false); + vm.setTurboMode(false); + + vm.loadProject(project).then(() => { + t.equal(vm.runtime.threads.length, 0); + + debugger; + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING); + const prevThread = vm.runtime.threads[0]; + + // Check that both are added again when another step is taken + vm.runtime._step(); + checkMonitorThreadPresent(t, vm.runtime.threads); + t.equal(vm.runtime.threads[0], prevThread); + t.end(); + }); + }); +}); diff --git a/test/integration/stack-click.js b/test/integration/stack-click.js new file mode 100644 index 00000000..1243eed6 --- /dev/null +++ b/test/integration/stack-click.js @@ -0,0 +1,59 @@ +const path = require('path'); +const test = require('tap').test; +const makeTestStorage = require('../fixtures/make-test-storage'); +const extract = require('../fixtures/extract'); +const VirtualMachine = require('../../src/index'); + +const projectUri = path.resolve(__dirname, '../fixtures/stack-click.sb2'); +const project = extract(projectUri); + +/** + * stack-click.sb2 contains a sprite at (0, 0) with a single stack + * when timer > 100000000 + * move 100 steps + * The intention is to make sure that the stack can be activated by a stack click + * even when the hat predicate is false. + */ +test('stack click activates the stack', t => { + const vm = new VirtualMachine(); + vm.attachStorage(makeTestStorage()); + + // Evaluate playground data and exit + vm.on('playgroundData', () => { + // The sprite should have moved 100 to the right + t.equal(vm.editingTarget.x, 100); + 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(() => { + const blockContainer = vm.runtime.targets[1].blocks; + const allBlocks = blockContainer._blocks; + + // Confirm the editing target is initially at 0 + t.equal(vm.editingTarget.x, 0); + + // Find hat for greater than and click it + for (const blockId in allBlocks) { + if (allBlocks[blockId].opcode === 'event_whengreaterthan') { + blockContainer.blocklyListen({ + blockId: blockId, + element: 'stackclick' + }, vm.runtime); + } + } + + // After two seconds, get playground data and stop + setTimeout(() => { + vm.getPlaygroundData(); + vm.stopAll(); + }, 2000); + }); + }); +});