diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 93b865770..a767c75d6 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -198,7 +198,7 @@ class Blocks { // UI event: clicked scripts toggle in the runtime. if (e.element === 'stackclick') { if (optRuntime) { - optRuntime.toggleScript(e.blockId, {showVisualReport: true}); + optRuntime.toggleScript(e.blockId, {stackClick: true}); } return; } diff --git a/src/engine/execute.js b/src/engine/execute.js index 83530d380..1f5a25b35 100644 --- a/src/engine/execute.js +++ b/src/engine/execute.js @@ -74,14 +74,17 @@ const execute = function (sequencer, thread) { // Hat predicate was evaluated. if (runtime.getIsEdgeActivatedHat(opcode)) { // If this is an edge-activated hat, only proceed if - // the value is true and used to be false. - const oldEdgeValue = runtime.updateEdgeActivatedValue( - currentBlockId, - resolvedValue - ); - const edgeWasActivated = !oldEdgeValue && resolvedValue; - if (!edgeWasActivated) { - sequencer.retireThread(thread); + // the value is true and used to be false, or the stack was activated + // explicitly via stack click + if (!thread.stackClick) { + const oldEdgeValue = runtime.updateEdgeActivatedValue( + currentBlockId, + resolvedValue + ); + const edgeWasActivated = !oldEdgeValue && resolvedValue; + if (!edgeWasActivated) { + sequencer.retireThread(thread); + } } } else { // Not an edge-activated hat: retire the thread @@ -94,7 +97,7 @@ const execute = function (sequencer, thread) { // In a non-hat, report the value visually if necessary if // at the top of the thread stack. if (typeof resolvedValue !== 'undefined' && thread.atStackTop()) { - if (thread.showVisualReport) { + if (thread.stackClick) { runtime.visualReport(currentBlockId, resolvedValue); } if (thread.updateMonitor) { diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 7983a4565..b0a1b482a 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -399,19 +399,19 @@ class Runtime extends EventEmitter { * @param {!string} id ID of block that starts the stack. * @param {!Target} target Target to run thread on. * @param {?object} opts optional arguments - * @param {?boolean} opts.showVisualReport true if the script should show speech bubble for its value + * @param {?boolean} opts.stackClick true if the script was activated by clicking on the stack * @param {?boolean} opts.updateMonitor true if the script should update a monitor value * @return {!Thread} The newly created thread. */ _pushThread (id, target, opts) { opts = Object.assign({ - showVisualReport: false, + stackClick: false, updateMonitor: false }, opts); const thread = new Thread(id); thread.target = target; - thread.showVisualReport = opts.showVisualReport; + thread.stackClick = opts.stackClick; thread.updateMonitor = opts.updateMonitor; thread.pushStack(id); @@ -442,7 +442,7 @@ class Runtime extends EventEmitter { _restartThread (thread) { const newThread = new Thread(thread.topBlock); newThread.target = thread.target; - newThread.showVisualReport = thread.showVisualReport; + newThread.stackClick = thread.stackClick; newThread.updateMonitor = thread.updateMonitor; newThread.pushStack(thread.topBlock); const i = this.threads.indexOf(thread); @@ -467,18 +467,28 @@ 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.showVisualReport true if the speech bubble should pop up on the block, 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, - showVisualReport: false, - updateMonitor: false + updateMonitor: false, + stackClick: false }, opts); // Remove any existing thread. for (let i = 0; i < this.threads.length; i++) { - if (this.threads[i].topBlock === topBlockId) { + // Toggling a script that's already running turns it off + if (this.threads[i].topBlock === topBlockId && this.threads[i].status !== Thread.STATUS_DONE) { + const blockContainer = opts.target.blocks; + const opcode = blockContainer.getOpcode(blockContainer.getBlock(topBlockId)); + + if (this.getIsEdgeActivatedHat(opcode) && this.threads[i].stackClick !== opts.stackClick) { + // Allow edge activated hat thread stack click to coexist with + // edge activated hat thread that runs every frame + continue; + } this._removeThread(this.threads[i]); return; } @@ -577,6 +587,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; @@ -587,7 +598,9 @@ class Runtime extends EventEmitter { // give up if any threads with the top block are running. for (let j = 0; j < instance.threads.length; j++) { if (instance.threads[j].topBlock === topBlockId && - instance.threads[j].target === target) { + instance.threads[j].target === target && + !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 000000000..3dfe93186 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 000000000..8b735b73b 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 000000000..3f0fd2bf0 --- /dev/null +++ b/test/integration/hat-threads-run-every-frame.js @@ -0,0 +1,194 @@ +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 added again when another step is taken + 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); + 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 added again when another step is taken + vm.runtime._step(); + 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 added again when another step is taken + vm.runtime._step(); + 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/stack-click.js b/test/integration/stack-click.js new file mode 100644 index 000000000..1243eed6a --- /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); + }); + }); +});