Initial commit of 001code-html Scratch frontend project.

Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
const test = require('tap').test;
const Control = require('../../src/blocks/scratch3_control');
const Runtime = require('../../src/engine/runtime');
const BlockUtility = require('../../src/engine/block-utility');
test('getPrimitives', t => {
const rt = new Runtime();
const c = new Control(rt);
t.type(c.getPrimitives(), 'object');
t.end();
});
test('repeat', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let i = 0;
const repeat = 10;
const util = {
stackFrame: Object.create(null),
startBranch: function () {
i++;
c.repeat({TIMES: repeat}, util);
}
};
// Execute test
c.repeat({TIMES: 10}, util);
t.strictEqual(util.stackFrame.loopCounter, -1);
t.strictEqual(i, repeat);
t.end();
});
test('repeat rounds with round()', t => {
const rt = new Runtime();
const c = new Control(rt);
const roundingTest = (inputForRepeat, expectedTimes) => {
// Test harness (mocks `util`)
let i = 0;
const util = {
stackFrame: Object.create(null),
startBranch: function () {
i++;
c.repeat({TIMES: inputForRepeat}, util);
}
};
// Execute test
c.repeat({TIMES: inputForRepeat}, util);
t.strictEqual(i, expectedTimes);
};
// Execute tests
roundingTest(3.2, 3);
roundingTest(3.7, 4);
roundingTest(3.5, 4);
t.end();
});
test('repeatUntil', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let i = 0;
const repeat = 10;
const util = {
stackFrame: Object.create(null),
startBranch: function () {
i++;
c.repeatUntil({CONDITION: (i === repeat)}, util);
}
};
// Execute test
c.repeatUntil({CONDITION: (i === repeat)}, util);
t.strictEqual(i, repeat);
t.end();
});
test('repeatWhile', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let i = 0;
const repeat = 10;
const util = {
stackFrame: Object.create(null),
startBranch: function () {
i++;
// Note !== instead of ===
c.repeatWhile({CONDITION: (i !== repeat)}, util);
}
};
// Execute test
c.repeatWhile({CONDITION: (i !== repeat)}, util);
t.strictEqual(i, repeat);
t.end();
});
test('forEach', t => {
const rt = new Runtime();
const c = new Control(rt);
const variableValues = [];
const variable = {value: 0};
let value;
const util = {
stackFrame: Object.create(null),
target: {
lookupOrCreateVariable: function () {
return variable;
}
},
startBranch: function () {
variableValues.push(variable.value);
c.forEach({VARIABLE: {}, VALUE: value}, util);
}
};
// for each (variable) in "5"
// ..should yield variable values 1, 2, 3, 4, 5
util.stackFrame = Object.create(null);
variableValues.splice(0);
variable.value = 0;
value = '5';
c.forEach({VARIABLE: {}, VALUE: value}, util);
t.deepEqual(variableValues, [1, 2, 3, 4, 5]);
// for each (variable) in 4
// ..should yield variable values 1, 2, 3, 4
util.stackFrame = Object.create(null);
variableValues.splice(0);
variable.value = 0;
value = 4;
c.forEach({VARIABLE: {}, VALUE: value}, util);
t.deepEqual(variableValues, [1, 2, 3, 4]);
t.end();
});
test('forever', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let i = 0;
const util = {
startBranch: function (branchNum, isLoop) {
i++;
t.strictEqual(branchNum, 1);
t.strictEqual(isLoop, true);
}
};
// Execute test
c.forever(null, util);
t.strictEqual(i, 1);
t.end();
});
test('if / ifElse', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let i = 0;
const util = {
startBranch: function (branchNum) {
i += branchNum;
}
};
// Execute test
c.if({CONDITION: true}, util);
t.strictEqual(i, 1);
c.if({CONDITION: false}, util);
t.strictEqual(i, 1);
c.ifElse({CONDITION: true}, util);
t.strictEqual(i, 2);
c.ifElse({CONDITION: false}, util);
t.strictEqual(i, 4);
t.end();
});
test('stop', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
const state = {
stopAll: 0,
stopOtherTargetThreads: 0,
stopThisScript: 0
};
const util = {
stopAll: function () {
state.stopAll++;
},
stopOtherTargetThreads: function () {
state.stopOtherTargetThreads++;
},
stopThisScript: function () {
state.stopThisScript++;
}
};
// Execute test
c.stop({STOP_OPTION: 'all'}, util);
c.stop({STOP_OPTION: 'other scripts in sprite'}, util);
c.stop({STOP_OPTION: 'other scripts in stage'}, util);
c.stop({STOP_OPTION: 'this script'}, util);
t.strictEqual(state.stopAll, 1);
t.strictEqual(state.stopOtherTargetThreads, 2);
t.strictEqual(state.stopThisScript, 1);
t.end();
});
test('counter, incrCounter, clearCounter', t => {
const rt = new Runtime();
const c = new Control(rt);
// Default value
t.strictEqual(c.getCounter(), 0);
c.incrCounter();
c.incrCounter();
t.strictEqual(c.getCounter(), 2);
c.clearCounter();
t.strictEqual(c.getCounter(), 0);
t.end();
});
test('allAtOnce', t => {
const rt = new Runtime();
const c = new Control(rt);
// Test harness (mocks `util`)
let ran = false;
const util = {
startBranch: function () {
ran = true;
}
};
// Execute test
c.allAtOnce({}, util);
t.true(ran);
t.end();
});
test('wait', t => {
const rt = new Runtime();
const c = new Control(rt);
const args = {DURATION: .01};
const waitTime = args.DURATION * 1000;
const startTest = Date.now();
const thresholdSmall = 1000 / 60; // only allow the wait to end one 60Hz frame early
const thresholdLarge = 1000 / 3; // be less picky about when the wait ends, in case CPU load makes the VM run slowly
let yields = 0;
const util = new BlockUtility();
const mockUtil = {
stackFrame: {},
yield: () => yields++,
stackTimerNeedsInit: util.stackTimerNeedsInit,
startStackTimer: util.startStackTimer,
stackTimerFinished: util.stackTimerFinished
};
c.wait(args, mockUtil);
t.equal(yields, 1, 'First wait block yielded');
// Spin the cpu until enough time passes
let timeElapsed = 0;
while (timeElapsed < waitTime) {
timeElapsed = mockUtil.stackFrame.timer.timeElapsed();
// In case util.timer is broken - have our own "exit"
if (Date.now() - startTest > timeElapsed + thresholdSmall) {
break;
}
}
c.wait(args, mockUtil);
t.equal(yields, 1, 'Second call after timeElapsed does not yield');
t.equal(waitTime, mockUtil.stackFrame.duration);
t.ok(timeElapsed >= (waitTime - thresholdSmall),
`Wait block ended too early: ${timeElapsed} < ${waitTime} - ${thresholdSmall}`);
t.ok(timeElapsed <= (waitTime + thresholdLarge),
`Wait block ended too late: ${timeElapsed} > ${waitTime} + ${thresholdLarge}`);
t.end();
});

View File

@@ -0,0 +1,49 @@
const test = require('tap').test;
const Data = require('../../src/blocks/scratch3_data');
const blocks = new Data();
const lists = {};
const util = {
target: {
lookupOrCreateList (id, name) {
if (!(name in lists)) {
lists[name] = {value: []};
}
return lists[name];
}
}
};
test('getItemNumOfList returns the index of an item (basic)', t => {
lists.list = {value: ['apple', 'taco', 'burrito', 'extravaganza']};
const args = {ITEM: 'burrito', LIST: {name: 'list'}};
const index = blocks.getItemNumOfList(args, util);
t.strictEqual(index, 3);
t.end();
});
test('getItemNumOfList returns 0 when an item is not found', t => {
lists.list = {value: ['aaaaapple', 'burrito']};
const args = {ITEM: 'jump', LIST: {name: 'list'}};
const index = blocks.getItemNumOfList(args, util);
t.strictEqual(index, 0);
t.end();
});
test('getItemNumOfList uses Scratch comparison', t => {
lists.list = {value: ['jump', 'Jump', '123', 123, 800]};
const args = {LIST: {name: 'list'}};
// Be case-insensitive:
args.ITEM = 'Jump';
t.strictEqual(blocks.getItemNumOfList(args, util), 1);
// Be type-insensitive:
args.ITEM = 123;
t.strictEqual(blocks.getItemNumOfList(args, util), 3);
args.ITEM = '800';
t.strictEqual(blocks.getItemNumOfList(args, util), 5);
t.end();
});

View File

@@ -0,0 +1,114 @@
const test = require('tap').test;
const Data = require('../../src/blocks/scratch3_data');
const blocks = new Data();
const lists = {};
const util = {
target: {
lookupOrCreateList (id, name) {
if (!(name in lists)) {
lists[name] = {value: []};
}
return lists[name];
}
}
};
test('List with postive infinity primitive contains postive infinity', t => {
lists.list = {value: [Infinity]};
let args = {ITEM: Infinity, LIST: {name: 'list'}};
let contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[Infinity] contains Infinity');
lists.list = {value: [Infinity]};
args = {ITEM: 'Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[Infinity] contains "Infinity"');
lists.list = {value: [Infinity]};
args = {ITEM: 'INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[Infinity] contains "INFINITY"');
lists.list = {value: ['Infinity']};
args = {ITEM: Infinity, LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["Infinity"] contains Infinity');
lists.list = {value: ['Infinity']};
args = {ITEM: 'Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["Infinity"] contains "Infinity"');
lists.list = {value: ['Infinity']};
args = {ITEM: 'INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["Infinity"] contains "INFINITY"');
lists.list = {value: ['INFINITY']};
args = {ITEM: Infinity, LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["INFINITY"] contains Infinity');
lists.list = {value: ['INFINITY']};
args = {ITEM: 'Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["INFINITY"] contains "Infinity"');
lists.list = {value: ['INFINITY']};
args = {ITEM: 'INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["INFINITY"] contains "INFINITY"');
t.end();
});
test('List with negative infinity primitive contains negative infinity', t => {
lists.list = {value: [-Infinity]};
let args = {ITEM: -Infinity, LIST: {name: 'list'}};
let contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[-Infinity] contains -Infinity');
lists.list = {value: [-Infinity]};
args = {ITEM: '-Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[-Infinity] contains "-Infinity"');
lists.list = {value: [-Infinity]};
args = {ITEM: '-INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '[-Infinity] contains "-INFINITY"');
lists.list = {value: ['-Infinity']};
args = {ITEM: -Infinity, LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-Infinity"] contains -Infinity');
lists.list = {value: ['-Infinity']};
args = {ITEM: '-Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-Infinity"] contains "-Infinity"');
lists.list = {value: ['-Infinity']};
args = {ITEM: '-INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-Infinity"] contains "-INFINITY"');
lists.list = {value: ['-INFINITY']};
args = {ITEM: -Infinity, LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-INFINITY"] contains -Infinity');
lists.list = {value: ['-INFINITY']};
args = {ITEM: '-Infinity', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-INFINITY"] contains "-Infinity"');
lists.list = {value: ['-INFINITY']};
args = {ITEM: '-INFINITY', LIST: {name: 'list'}};
contains = blocks.listContainsItem(args, util);
t.strictEqual(contains, true, '["-INFINITY"] contains "-INFINITY"');
t.end();
});

View File

@@ -0,0 +1,116 @@
const test = require('tap').test;
const Blocks = require('../../src/engine/blocks');
const BlockUtility = require('../../src/engine/block-utility');
const Event = require('../../src/blocks/scratch3_event');
const Runtime = require('../../src/engine/runtime');
const Target = require('../../src/engine/target');
const Thread = require('../../src/engine/thread');
const Variable = require('../../src/engine/variable');
test('#760 - broadcastAndWait', t => {
const broadcastAndWaitBlock = {
id: 'broadcastAndWaitBlock',
fields: {
BROADCAST_OPTION: {
id: 'testBroadcastID',
value: 'message'
}
},
inputs: Object,
block: 'fakeBlock',
opcode: 'event_broadcastandwait',
next: null,
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
const receiveMessageBlock = {
id: 'receiveMessageBlock',
fields: {
BROADCAST_OPTION: {
id: 'testBroadcastID',
value: 'message'
}
},
inputs: Object,
block: 'fakeBlock',
opcode: 'event_whenbroadcastreceived',
next: null,
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
const rt = new Runtime();
const e = new Event(rt);
const b = new Blocks(rt);
b.createBlock(broadcastAndWaitBlock);
b.createBlock(receiveMessageBlock);
const tgt = new Target(rt, b);
tgt.isStage = true;
tgt.createVariable('testBroadcastID', 'message', Variable.BROADCAST_MESSAGE_TYPE);
rt.addTarget(tgt);
let th = rt._pushThread('broadcastAndWaitBlock', t);
const util = new BlockUtility();
util.sequencer = rt.sequencer;
util.thread = th;
util.runtime = rt;
// creates threads
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(rt.threads.length, 2);
t.strictEqual(rt.threads[1].topBlock, 'receiveMessageBlock');
// yields when some thread is active
t.strictEqual(th.status, Thread.STATUS_YIELD);
th.status = Thread.STATUS_RUNNING;
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(th.status, Thread.STATUS_YIELD);
// does not yield once all threads are done
th.status = Thread.STATUS_RUNNING;
rt.threads[1].status = Thread.STATUS_DONE;
rt.threads.splice(1, 1);
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(th.status, Thread.STATUS_RUNNING);
// restarts done threads that are in runtime threads
rt.updateThreadMap();
th = rt._pushThread('broadcastAndWaitBlock', tgt);
util.thread = th;
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(rt.threads.length, 3);
t.strictEqual(rt.threads[2].status, Thread.STATUS_RUNNING);
t.strictEqual(th.status, Thread.STATUS_YIELD);
// yields when some restarted thread is active
th.status = Thread.STATUS_RUNNING;
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(th.status, Thread.STATUS_YIELD);
// does not yield once all threads are done
th.status = Thread.STATUS_RUNNING;
rt.threads[2].status = Thread.STATUS_DONE;
rt.threads.splice(2, 1);
e.broadcastAndWait({BROADCAST_OPTION: {id: 'testBroadcastID', name: 'message'}}, util);
t.strictEqual(th.status, Thread.STATUS_RUNNING);
t.end();
});
test('When > hat - loudness', t => {
const rt = new Runtime();
rt.audioEngine = {getLoudness: () => 10};
const e = new Event(rt);
const args = {
WHENGREATERTHANMENU: 'LOUDNESS',
VALUE: '11'
};
t.equal(e.hatGreaterThanPredicate(args), false);
args.VALUE = '5';
t.equal(e.hatGreaterThanPredicate(args), true);
t.end();
});

View File

@@ -0,0 +1,277 @@
const test = require('tap').test;
const Looks = require('../../src/blocks/scratch3_looks');
const Runtime = require('../../src/engine/runtime');
const Sprite = require('../../src/sprites/sprite.js');
const RenderedTarget = require('../../src/sprites/rendered-target.js');
const util = {
target: {
currentCostume: 0, // Internally, current costume is 0 indexed
getCostumes: function () {
return this.sprite.costumes;
},
sprite: {
costumes: [
{name: 'first name'},
{name: 'second name'},
{name: 'third name'}
]
},
_customState: {},
getCustomState: () => util.target._customState
}
};
const fakeRuntime = {
getTargetForStage: () => util.target, // Just return the dummy target above.
on: () => {} // Stub out listener methods used in constructor.
};
const blocks = new Looks(fakeRuntime);
/**
* Test which costume index the `switch costume`
* block will jump to given an argument and array
* of costume names. Works for backdrops if isStage is set.
*
* @param {string[]} costumes List of costume names as strings
* @param {string|number|boolean} arg The argument to provide to the block.
* @param {number} [currentCostume=1] The 1-indexed default costume for the sprite to start at.
* @param {boolean} [isStage=false] Whether the sprite is the stage
* @return {number} The 1-indexed costume index on which the sprite lands.
*/
const testCostume = (costumes, arg, currentCostume = 1, isStage = false) => {
const rt = new Runtime();
const looks = new Looks(rt);
const sprite = new Sprite(null, rt);
const target = new RenderedTarget(sprite, rt);
sprite.costumes = costumes.map(name => ({name: name}));
target.currentCostume = currentCostume - 1; // Convert to 0-indexed.
if (isStage) {
target.isStage = true;
rt.addTarget(target);
looks.switchBackdrop({BACKDROP: arg}, {target});
} else {
looks.switchCostume({COSTUME: arg}, {target});
}
return target.currentCostume + 1; // Convert to 1-indexed.
};
/**
* Test which backdrop index the `switch backdrop`
* block will jump to given an argument and array
* of backdrop names.
*
* @param {string[]} backdrops List of backdrop names as strings
* @param {string|number|boolean} arg The argument to provide to the block.
* @param {number} [currentCostume=1] The 1-indexed default backdrop for the stage to start at.
* @return {number} The 1-indexed backdrop index on which the stage lands.
*/
const testBackdrop = (backdrops, arg, currentCostume = 1) => testCostume(backdrops, arg, currentCostume, true);
test('switch costume block runs correctly', t => {
// Non-existant costumes do nothing
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 'e', 3), 3);
// Numeric arguments are always the costume index
// String arguments are treated as costume names, and coerced to
// a costume index as a fallback
t.strictEqual(testCostume(['a', 'b', 'c', '2'], 2), 2);
t.strictEqual(testCostume(['a', 'b', 'c', '2'], '2'), 4);
t.strictEqual(testCostume(['a', 'b', 'c'], '2'), 2);
// 'previous costume' and 'next costume' increment/decrement
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 'previous costume', 3), 2);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 'next costume', 2), 3);
// 'previous costume' and 'next costume' can be overriden
t.strictEqual(testCostume(['a', 'previous costume', 'c', 'd'], 'previous costume'), 2);
t.strictEqual(testCostume(['next costume', 'b', 'c', 'd'], 'next costume'), 1);
// NaN, Infinity, and true are the first costume
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], NaN, 2), 1);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], true, 2), 1);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], Infinity, 2), 1);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], -Infinity, 2), 1);
// 'previous backdrop' and 'next backdrop' have no effect
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 'previous backdrop', 3), 3);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 'next backdrop', 3), 3);
// Strings with no digits are not numeric
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], ' ', 2), 2);
// False is 0 (the last costume)
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], false), 4);
// Booleans are costume names where possible.
t.strictEqual(testCostume(['a', 'true', 'false', 'd'], false), 3);
t.strictEqual(testCostume(['a', 'true', 'false', 'd'], true), 2);
// Costume indices should wrap around.
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], -1), 3);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], -4), 4);
t.strictEqual(testCostume(['a', 'b', 'c', 'd'], 10), 2);
t.end();
});
test('switch backdrop block runs correctly', t => {
// Non-existant backdrops do nothing
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 'e', 3), 3);
// Difference between string and numeric arguments
t.strictEqual(testBackdrop(['a', 'b', 'c', '2'], 2), 2);
t.strictEqual(testBackdrop(['a', 'b', 'c', '2'], '2'), 4);
// 'previous backdrop' and 'next backdrop' increment/decrement
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 'previous backdrop', 3), 2);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 'next backdrop', 2), 3);
// 'previous backdrop', 'previous backdrop', 'random backdrop' can be overriden
// Test is deterministic since 'random backdrop' will not pick the same backdrop as currently selected
t.strictEqual(testBackdrop(['a', 'previous backdrop', 'c', 'd'], 'previous backdrop', 4), 2);
t.strictEqual(testBackdrop(['next backdrop', 'b', 'c', 'd'], 'next backdrop', 3), 1);
t.strictEqual(testBackdrop(['random backdrop', 'b', 'c', 'd'], 'random backdrop'), 1);
// NaN, Infinity, and true are the first costume
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], NaN, 2), 1);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], true, 2), 1);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], Infinity, 2), 1);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], -Infinity, 2), 1);
// 'previous costume' and 'next costume' have no effect
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 'previous costume', 3), 3);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 'next costume', 3), 3);
// Strings with no digits are not numeric
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], ' ', 2), 2);
// False is 0 (the last costume)
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], false), 4);
// Booleans are backdrop names where possible.
t.strictEqual(testBackdrop(['a', 'true', 'false', 'd'], false), 3);
t.strictEqual(testBackdrop(['a', 'true', 'false', 'd'], true), 2);
// Backdrop indices should wrap around.
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], -1), 3);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], -4), 4);
t.strictEqual(testBackdrop(['a', 'b', 'c', 'd'], 10), 2);
t.end();
});
test('getCostumeNumberName returns 1-indexed costume number', t => {
util.target.currentCostume = 0; // This is 0-indexed.
const args = {NUMBER_NAME: 'number'};
const number = blocks.getCostumeNumberName(args, util);
t.strictEqual(number, 1);
t.end();
});
test('getCostumeNumberName can return costume name', t => {
util.target.currentCostume = 0; // This is 0-indexed.
const args = {NUMBER_NAME: 'name'};
const name = blocks.getCostumeNumberName(args, util);
t.strictEqual(name, 'first name');
t.end();
});
test('getBackdropNumberName returns 1-indexed costume number', t => {
util.target.currentCostume = 2; // This is 0-indexed.
const args = {NUMBER_NAME: 'number'};
const number = blocks.getBackdropNumberName(args, util);
t.strictEqual(number, 3);
t.end();
});
test('getBackdropNumberName can return costume name', t => {
util.target.currentCostume = 2; // This is 0-indexed.
const args = {NUMBER_NAME: 'name'};
const number = blocks.getBackdropNumberName(args, util);
t.strictEqual(number, 'third name');
t.end();
});
test('numbers should be rounded properly in say/think', t => {
const rt = new Runtime();
const looks = new Looks(rt);
let expectedSayString;
rt.addListener('SAY', () => {
const bubbleState = util.target.getCustomState(Looks.STATE_KEY);
t.strictEqual(bubbleState.text, expectedSayString);
});
expectedSayString = '3.14';
looks.say({MESSAGE: 3.14159}, util, 'say bubble should round to 2 decimal places');
looks.think({MESSAGE: 3.14159}, util, 'think bubble should round to 2 decimal places');
expectedSayString = '3';
looks.say({MESSAGE: 3}, util, 'say bubble should not add decimal places to integers');
looks.think({MESSAGE: 3}, util, 'think bubble should not add decimal places to integers');
expectedSayString = '3.10';
looks.say({MESSAGE: 3.1}, util, 'say bubble should round to 2 decimal places, even if only 1 is needed');
looks.think({MESSAGE: 3.1}, util, 'think bubble should round to 2 decimal places, even if only 1 is needed');
expectedSayString = '0.00125';
looks.say({MESSAGE: 0.00125}, util, 'say bubble should not round if it would display small numbers as 0');
looks.think({MESSAGE: 0.00125}, util, 'think bubble should not round if it would display small numbers as 0');
expectedSayString = '1.99999';
looks.say({MESSAGE: '1.99999'}, util, 'say bubble should not round strings');
looks.think({MESSAGE: '1.99999'}, util, 'think bubble should not round strings');
t.end();
});
test('clamp graphic effects', t => {
const rt = new Runtime();
const looks = new Looks(rt);
const expectedValues = {
brightness: {high: 100, low: -100},
ghost: {high: 100, low: 0},
color: {high: 500, low: -500},
fisheye: {high: 500, low: -500},
whirl: {high: 500, low: -500},
pixelate: {high: 500, low: -500},
mosaic: {high: 500, low: -500}
};
const args = [
{EFFECT: 'brightness', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'brightness', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'ghost', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'ghost', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'color', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'color', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'fisheye', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'fisheye', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'whirl', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'whirl', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'pixelate', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'pixelate', VALUE: -500, CLAMP: 'low'},
{EFFECT: 'mosaic', VALUE: 500, CLAMP: 'high'},
{EFFECT: 'mosaic', VALUE: -500, CLAMP: 'low'}
];
util.target.setEffect = function (effectName, actualValue) {
const clamp = actualValue > 0 ? 'high' : 'low';
rt.emit(effectName + clamp, effectName, actualValue);
};
for (const arg of args) {
rt.addListener(arg.EFFECT + arg.CLAMP, (effectName, actualValue) => {
const expected = expectedValues[arg.EFFECT][arg.CLAMP];
t.strictEqual(actualValue, expected);
});
looks.setEffect(arg, util);
}
t.end();
});

View File

@@ -0,0 +1,26 @@
const test = require('tap').test;
const Motion = require('../../src/blocks/scratch3_motion');
const Runtime = require('../../src/engine/runtime');
const Sprite = require('../../src/sprites/sprite.js');
const RenderedTarget = require('../../src/sprites/rendered-target.js');
test('getPrimitives', t => {
const rt = new Runtime();
const motion = new Motion(rt);
t.type(motion.getPrimitives(), 'object');
t.end();
});
test('Coordinates have limited precision', t => {
const rt = new Runtime();
const motion = new Motion(rt);
const sprite = new Sprite(null, rt);
const target = new RenderedTarget(sprite, rt);
const util = {target};
motion.goToXY({X: 0.999999999, Y: 0.999999999}, util);
t.equals(motion.getX({}, util), 1);
t.equals(motion.getY({}, util), 1);
t.end();
});

View File

@@ -0,0 +1,188 @@
const test = require('tap').test;
const Operators = require('../../src/blocks/scratch3_operators');
const blocks = new Operators(null);
test('getPrimitives', t => {
t.type(blocks.getPrimitives(), 'object');
t.end();
});
test('add', t => {
t.strictEqual(blocks.add({NUM1: '1', NUM2: '1'}), 2);
t.strictEqual(blocks.add({NUM1: 'foo', NUM2: 'bar'}), 0);
t.end();
});
test('subtract', t => {
t.strictEqual(blocks.subtract({NUM1: '1', NUM2: '1'}), 0);
t.strictEqual(blocks.subtract({NUM1: 'foo', NUM2: 'bar'}), 0);
t.end();
});
test('multiply', t => {
t.strictEqual(blocks.multiply({NUM1: '2', NUM2: '2'}), 4);
t.strictEqual(blocks.multiply({NUM1: 'foo', NUM2: 'bar'}), 0);
t.end();
});
test('divide', t => {
t.strictEqual(blocks.divide({NUM1: '2', NUM2: '2'}), 1);
t.ok(isNaN(blocks.divide({NUM1: 'foo', NUM2: 'bar'}))); // @todo
t.end();
});
test('lt', t => {
t.strictEqual(blocks.lt({OPERAND1: '1', OPERAND2: '2'}), true);
t.strictEqual(blocks.lt({OPERAND1: '2', OPERAND2: '1'}), false);
t.strictEqual(blocks.lt({OPERAND1: '1', OPERAND2: '1'}), false);
t.strictEqual(blocks.lt({OPERAND1: '10', OPERAND2: '2'}), false);
t.strictEqual(blocks.lt({OPERAND1: 'a', OPERAND2: 'z'}), true);
t.end();
});
test('equals', t => {
t.strictEqual(blocks.equals({OPERAND1: '1', OPERAND2: '2'}), false);
t.strictEqual(blocks.equals({OPERAND1: '2', OPERAND2: '1'}), false);
t.strictEqual(blocks.equals({OPERAND1: '1', OPERAND2: '1'}), true);
t.strictEqual(blocks.equals({OPERAND1: 'あ', OPERAND2: 'ア'}), false);
t.end();
});
test('gt', t => {
t.strictEqual(blocks.gt({OPERAND1: '1', OPERAND2: '2'}), false);
t.strictEqual(blocks.gt({OPERAND1: '2', OPERAND2: '1'}), true);
t.strictEqual(blocks.gt({OPERAND1: '1', OPERAND2: '1'}), false);
t.end();
});
test('and', t => {
t.strictEqual(blocks.and({OPERAND1: true, OPERAND2: true}), true);
t.strictEqual(blocks.and({OPERAND1: true, OPERAND2: false}), false);
t.strictEqual(blocks.and({OPERAND1: false, OPERAND2: false}), false);
t.end();
});
test('or', t => {
t.strictEqual(blocks.or({OPERAND1: true, OPERAND2: true}), true);
t.strictEqual(blocks.or({OPERAND1: true, OPERAND2: false}), true);
t.strictEqual(blocks.or({OPERAND1: false, OPERAND2: false}), false);
t.end();
});
test('not', t => {
t.strictEqual(blocks.not({OPERAND: true}), false);
t.strictEqual(blocks.not({OPERAND: false}), true);
t.end();
});
test('random', t => {
const min = 0;
const max = 100;
const result = blocks.random({FROM: min, TO: max});
t.ok(result >= min);
t.ok(result <= max);
t.end();
});
test('random - equal', t => {
const min = 1;
const max = 1;
t.strictEqual(blocks.random({FROM: min, TO: max}), min);
t.end();
});
test('random - decimal', t => {
const min = 0.1;
const max = 10;
const result = blocks.random({FROM: min, TO: max});
t.ok(result >= min);
t.ok(result <= max);
t.end();
});
test('random - int', t => {
const min = 0;
const max = 10;
const result = blocks.random({FROM: min, TO: max});
t.ok(result >= min);
t.ok(result <= max);
t.end();
});
test('random - reverse', t => {
const min = 0;
const max = 10;
const result = blocks.random({FROM: max, TO: min});
t.ok(result >= min);
t.ok(result <= max);
t.end();
});
test('join', t => {
t.strictEqual(blocks.join({STRING1: 'foo', STRING2: 'bar'}), 'foobar');
t.strictEqual(blocks.join({STRING1: '1', STRING2: '2'}), '12');
t.end();
});
test('letterOf', t => {
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 0}), '');
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 1}), 'f');
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 2}), 'o');
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 3}), 'o');
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 4}), '');
t.strictEqual(blocks.letterOf({STRING: 'foo', LETTER: 'bar'}), '');
t.end();
});
test('length', t => {
t.strictEqual(blocks.length({STRING: ''}), 0);
t.strictEqual(blocks.length({STRING: 'foo'}), 3);
t.strictEqual(blocks.length({STRING: '1'}), 1);
t.strictEqual(blocks.length({STRING: '100'}), 3);
t.end();
});
test('contains', t => {
t.strictEqual(blocks.contains({STRING1: 'hello world', STRING2: 'hello'}), true);
t.strictEqual(blocks.contains({STRING1: 'foo', STRING2: 'bar'}), false);
t.strictEqual(blocks.contains({STRING1: 'HeLLo world', STRING2: 'hello'}), true);
t.end();
});
test('mod', t => {
t.strictEqual(blocks.mod({NUM1: 1, NUM2: 1}), 0);
t.strictEqual(blocks.mod({NUM1: 3, NUM2: 6}), 3);
t.strictEqual(blocks.mod({NUM1: -3, NUM2: 6}), 3);
t.end();
});
test('round', t => {
t.strictEqual(blocks.round({NUM: 1}), 1);
t.strictEqual(blocks.round({NUM: 1.1}), 1);
t.strictEqual(blocks.round({NUM: 1.5}), 2);
t.end();
});
test('mathop', t => {
t.strictEqual(blocks.mathop({OPERATOR: 'abs', NUM: -1}), 1);
t.strictEqual(blocks.mathop({OPERATOR: 'floor', NUM: 1.5}), 1);
t.strictEqual(blocks.mathop({OPERATOR: 'ceiling', NUM: 0.1}), 1);
t.strictEqual(blocks.mathop({OPERATOR: 'sqrt', NUM: 1}), 1);
t.strictEqual(blocks.mathop({OPERATOR: 'sin', NUM: 1}), 0.0174524064);
t.strictEqual(blocks.mathop({OPERATOR: 'sin', NUM: 90}), 1);
t.strictEqual(blocks.mathop({OPERATOR: 'cos', NUM: 1}), 0.9998476952);
t.strictEqual(blocks.mathop({OPERATOR: 'cos', NUM: 180}), -1);
t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 1}), 0.0174550649);
t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 90}), Infinity);
t.strictEqual(blocks.mathop({OPERATOR: 'tan', NUM: 180}), 0);
t.strictEqual(blocks.mathop({OPERATOR: 'asin', NUM: 1}), 90);
t.strictEqual(blocks.mathop({OPERATOR: 'acos', NUM: 1}), 0);
t.strictEqual(blocks.mathop({OPERATOR: 'atan', NUM: 1}), 45);
t.strictEqual(blocks.mathop({OPERATOR: 'ln', NUM: 1}), 0);
t.strictEqual(blocks.mathop({OPERATOR: 'log', NUM: 1}), 0);
t.strictEqual(blocks.mathop({OPERATOR: 'e ^', NUM: 1}), 2.718281828459045);
t.strictEqual(blocks.mathop({OPERATOR: '10 ^', NUM: 1}), 10);
t.strictEqual(blocks.mathop({OPERATOR: 'undefined', NUM: 1}), 0);
t.end();
});

View File

@@ -0,0 +1,327 @@
const test = require('tap').test;
const Operators = require('../../src/blocks/scratch3_operators');
const blocks = new Operators(null);
test('divide: (1) / (0) = Infinity', t => {
t.strictEqual(
blocks.divide({NUM1: '1', NUM2: '0'}), Infinity, '1 / 0 = Infinity'
);
t.end();
});
test('divide: division with Infinity', t => {
t.strictEqual(
blocks.divide({NUM1: 'Infinity', NUM2: 111}), Infinity, '"Infinity" / 111 = Infinity'
);
t.strictEqual(
blocks.divide({NUM1: 'INFINITY', NUM2: 222}), 0, '"INFINITY" / 222 = 0'
);
t.strictEqual(
blocks.divide({NUM1: Infinity, NUM2: 333}), Infinity, 'Infinity / 333 = Infinity'
);
t.strictEqual(
blocks.divide({NUM1: 111, NUM2: 'Infinity'}), 0, '111 / "Infinity" = 0'
);
t.strictEqual(
blocks.divide({NUM1: 222, NUM2: 'INFINITY'}), Infinity, '222 / "INFINITY" = Infinity'
);
t.strictEqual(
blocks.divide({NUM1: 333, NUM2: Infinity}), 0, '333 / Infinity = 0'
);
t.strictEqual(
blocks.divide({NUM1: '-Infinity', NUM2: 111}), -Infinity, '"-Infinity" / 111 = -Infinity'
);
t.strictEqual(
blocks.divide({NUM1: '-INFINITY', NUM2: 222}), 0, '"-INFINITY" / 222 = 0'
);
t.strictEqual(
blocks.divide({NUM1: -Infinity, NUM2: 333}), -Infinity, '-Infinity / 333 = -Infinity'
);
t.strictEqual(
blocks.divide({NUM1: 111, NUM2: '-Infinity'}), 0, '111 / "-Infinity" = 0'
);
t.strictEqual(
blocks.divide({NUM1: 222, NUM2: '-INFINITY'}), Infinity, '222 / "-INFINITY" = Infinity'
);
t.strictEqual(
blocks.divide({NUM1: 333, NUM2: -Infinity}), 0, '333 / -Infinity = 0'
);
t.end();
});
test('multiply: multiply Infinity with numbers', t => {
t.strictEqual(
blocks.multiply({NUM1: 'Infinity', NUM2: 111}), Infinity, '"Infinity" * 111 = Infinity'
);
t.strictEqual(
blocks.multiply({NUM1: 'INFINITY', NUM2: 222}), 0, '"INFINITY" * 222 = 0'
);
t.strictEqual(
blocks.multiply({NUM1: Infinity, NUM2: 333}), Infinity, 'Infinity * 333 = Infinity'
);
t.strictEqual(
blocks.multiply({NUM1: '-Infinity', NUM2: 111}), -Infinity, '"-Infinity" * 111 = -Infinity'
);
t.strictEqual(
blocks.multiply({NUM1: '-INFINITY', NUM2: 222}), 0, '"-INFINITY" * 222 = 0'
);
t.strictEqual(
blocks.multiply({NUM1: -Infinity, NUM2: 333}), -Infinity, '-Infinity * 333 = -Infinity'
);
t.strictEqual(
blocks.multiply({NUM1: -Infinity, NUM2: Infinity}), -Infinity, '-Infinity * Infinity = -Infinity'
);
t.strictEqual(
Number.isNaN(blocks.multiply({NUM1: Infinity, NUM2: 0})), true, 'Infinity * 0 = NaN'
);
t.end();
});
test('add: add Infinity to a number', t => {
t.strictEqual(
blocks.add({NUM1: 'Infinity', NUM2: 111}), Infinity, '"Infinity" + 111 = Infinity'
);
t.strictEqual(
blocks.add({NUM1: 'INFINITY', NUM2: 222}), 222, '"INFINITY" + 222 = 222'
);
t.strictEqual(
blocks.add({NUM1: Infinity, NUM2: 333}), Infinity, 'Infinity + 333 = Infinity'
);
t.strictEqual(
blocks.add({NUM1: '-Infinity', NUM2: 111}), -Infinity, '"-Infinity" + 111 = -Infinity'
);
t.strictEqual(
blocks.add({NUM1: '-INFINITY', NUM2: 222}), 222, '"-INFINITY" + 222 = 222'
);
t.strictEqual(
blocks.add({NUM1: -Infinity, NUM2: 333}), -Infinity, '-Infinity + 333 = -Infinity'
);
t.strictEqual(
Number.isNaN(blocks.add({NUM1: -Infinity, NUM2: Infinity})), true, '-Infinity + Infinity = NaN'
);
t.end();
});
test('subtract: subtract Infinity with a number', t => {
t.strictEqual(
blocks.subtract({NUM1: 'Infinity', NUM2: 111}), Infinity, '"Infinity" - 111 = Infinity'
);
t.strictEqual(
blocks.subtract({NUM1: 'INFINITY', NUM2: 222}), -222, '"INFINITY" - 222 = -222'
);
t.strictEqual(
blocks.subtract({NUM1: Infinity, NUM2: 333}), Infinity, 'Infinity - 333 = Infinity'
);
t.strictEqual(
blocks.subtract({NUM1: 111, NUM2: 'Infinity'}), -Infinity, '111 - "Infinity" = -Infinity'
);
t.strictEqual(
blocks.subtract({NUM1: 222, NUM2: 'INFINITY'}), 222, '222 - "INFINITY" = 222'
);
t.strictEqual(
blocks.subtract({NUM1: 333, NUM2: Infinity}), -Infinity, '333 - Infinity = -Infinity'
);
t.strictEqual(
Number.isNaN(blocks.subtract({NUM1: Infinity, NUM2: Infinity})), true, 'Infinity - Infinity = NaN'
);
t.end();
});
test('equals: compare string infinity and numeric Infinity', t => {
t.strictEqual(
blocks.equals({OPERAND1: 'Infinity', OPERAND2: 'INFINITY'}), true, '"Infinity" = "INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: 'INFINITY', OPERAND2: 'Infinity'}), true, '"INFINITY" = "Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: 'Infinity', OPERAND2: 'Infinity'}), true, '"Infinity" = "Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: 'INFINITY', OPERAND2: 'INFINITY'}), true, '"INFINITY" = "INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: 'INFINITY', OPERAND2: 'infinity'}), true, '"INFINITY" = "infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: Infinity, OPERAND2: Infinity}), true, 'Infinity = Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: 'Infinity', OPERAND2: Infinity}), true, '"Infinity" = Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: 'INFINITY', OPERAND2: Infinity}), true, '"INFINITY" = Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: Infinity, OPERAND2: 'Infinity'}), true, 'Infinity = "Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: Infinity, OPERAND2: 'INFINITY'}), true, 'Infinity = "INFINITY'
);
t.end();
});
test('equals: compare string negative infinity and numeric negative Infinity', t => {
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: '-INFINITY'}), true, '"-Infinity" = "-INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: '-Infinity'}), true, '"-INFINITY" = "-Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: '-Infinity'}), true, '"-Infinity" = "-Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: '-INFINITY'}), true, '"-INFINITY" = "-INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: '-infinity'}), true, '"-INFINITY" = "-infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: -Infinity, OPERAND2: -Infinity}), true, '-Infinity = -Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: -Infinity}), true, '"-Infinity" = -Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: -Infinity}), true, '"-INFINITY" = -Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: -Infinity, OPERAND2: '-Infinity'}), true, '-Infinity = "-Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: -Infinity, OPERAND2: '-INFINITY'}), true, '-Infinity = "-INFINITY'
);
t.end();
});
test('equals: compare negative to postive string and numeric Infinity', t => {
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: 'Infinity'}), false, '"-Infinity" != "Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: 'INFINITY'}), false, '"-infinity" != "INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: 'Infinity'}), false, '"-INFINITY" != "Infinity"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: 'INFINITY'}), false, '"-INFINITY" != "INFINITY"'
);
t.strictEqual(
blocks.equals({OPERAND1: '-Infinity', OPERAND2: Infinity}), false, '"-Infinity" != Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: '-INFINITY', OPERAND2: Infinity}), false, '"-INFINITY" != Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: 'Infinity', OPERAND2: -Infinity}), false, '"Infinity" != -Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: 'INFINITY', OPERAND2: -Infinity}), false, '"INFINITY" != -Infinity'
);
t.strictEqual(
blocks.equals({OPERAND1: Infinity, OPERAND2: -Infinity}), false, 'Infinity != -Infinity'
);
t.end();
});
test('less than: compare string infinity and numeric Infinity', t => {
t.strictEqual(
blocks.lt({OPERAND1: 'Infinity', OPERAND2: 'INFINITY'}), false, '"Infinity" !< "INFINITY"'
);
t.strictEqual(
blocks.lt({OPERAND1: 'INFINITY', OPERAND2: Infinity}), false, '"INFINITY" !< "Infinity"'
);
t.strictEqual(
blocks.lt({OPERAND1: '-INFINITY', OPERAND2: 'INFINITY'}), true, '"-Infinity" < "INFINITY"'
);
t.strictEqual(
blocks.lt({OPERAND1: -Infinity, OPERAND2: 'INFINITY'}), true, '-Infinity < "INFINITY"'
);
t.strictEqual(
blocks.lt({OPERAND1: 'Infinity', OPERAND2: 111}), false, '"Infinity" !< 111'
);
t.strictEqual(
blocks.lt({OPERAND1: 'INFINITY', OPERAND2: 222}), false, '"INFINITY" !< 222'
);
t.strictEqual(
blocks.lt({OPERAND1: Infinity, OPERAND2: 333}), false, 'Infinity !< 333'
);
t.strictEqual(
blocks.lt({OPERAND1: 111, OPERAND2: 'Infinity'}), true, '111 < "Infinity"'
);
t.strictEqual(
blocks.lt({OPERAND1: 222, OPERAND2: 'INFINITY'}), true, '222 < "INFINITY"'
);
t.strictEqual(
blocks.lt({OPERAND1: 333, OPERAND2: Infinity}), true, '333 < Infinity'
);
t.end();
});
test('more than: compare string infinity and numeric Infinity', t => {
t.strictEqual(
blocks.gt({OPERAND1: 'Infinity', OPERAND2: 'INFINITY'}), false, '"Infinity" !> "INFINITY"'
);
t.strictEqual(
blocks.gt({OPERAND1: 'INFINITY', OPERAND2: Infinity}), false, '"INFINITY" !> "Infinity"'
);
t.strictEqual(
blocks.gt({OPERAND1: 'INFINITY', OPERAND2: '-INFINITY'}), true, '"Infinity" < "-INFINITY"'
);
t.strictEqual(
blocks.gt({OPERAND1: Infinity, OPERAND2: '-INFINITY'}), true, 'Infinity < "-INFINITY"'
);
t.strictEqual(
blocks.gt({OPERAND1: 'Infinity', OPERAND2: 111}), true, '"Infinity" > 111'
);
t.strictEqual(
blocks.gt({OPERAND1: 'INFINITY', OPERAND2: 222}), true, '"INFINITY" > 222'
);
t.strictEqual(
blocks.gt({OPERAND1: Infinity, OPERAND2: 333}), true, 'Infinity > 333'
);
t.strictEqual(
blocks.gt({OPERAND1: 111, OPERAND2: 'Infinity'}), false, '111 !> "Infinity"'
);
t.strictEqual(
blocks.gt({OPERAND1: 222, OPERAND2: 'INFINITY'}), false, '222 !> "INFINITY"'
);
t.strictEqual(
blocks.gt({OPERAND1: 333, OPERAND2: Infinity}), false, '333 !> Infinity'
);
t.end();
});

View File

@@ -0,0 +1,32 @@
const test = require('tap').test;
const Runtime = require('../../src/engine/runtime');
const Scratch3PenBlocks = require('../../src/extensions/scratch3_pen/index');
test('_clampPenSize', t => {
const rt = new Runtime();
const pen = new Scratch3PenBlocks(rt);
t.equal(pen._clampPenSize(-1), 1);
t.equal(pen._clampPenSize(0), 1);
t.equal(pen._clampPenSize(0.25), 1);
t.equal(pen._clampPenSize(1), 1);
t.equal(pen._clampPenSize(10), 10);
t.equal(pen._clampPenSize(1000), 1000);
t.equal(pen._clampPenSize(1200), 1200);
t.equal(pen._clampPenSize(1201), 1200);
rt.setRuntimeOptions({
miscLimits: false
});
t.equal(pen._clampPenSize(-1), 0);
t.equal(pen._clampPenSize(0), 0);
t.equal(pen._clampPenSize(0.25), 0.25);
t.equal(pen._clampPenSize(1), 1);
t.equal(pen._clampPenSize(10), 10);
t.equal(pen._clampPenSize(1000), 1000);
t.equal(pen._clampPenSize(1200), 1200);
t.equal(pen._clampPenSize(1201), 1201);
t.end();
});

View File

@@ -0,0 +1,28 @@
const test = require('tap').test;
const Procedures = require('../../src/blocks/scratch3_procedures');
const blocks = new Procedures(null);
test('getPrimitives', t => {
t.type(blocks.getPrimitives(), 'object');
t.end();
});
// Originally inspired by https://github.com/scratchfoundation/scratch-gui/issues/809
test('calling a custom block with no definition does not throw', t => {
const args = {
mutation: {
proccode: 'undefined proc'
}
};
const util = {
getProcedureParamNamesIdsAndDefaults: () => null,
stackFrame: {
executed: false
}
};
t.doesNotThrow(() => {
blocks.call(args, util);
});
t.end();
});

View File

@@ -0,0 +1,285 @@
const test = require('tap').test;
const Sensing = require('../../src/blocks/scratch3_sensing');
const Runtime = require('../../src/engine/runtime');
const Sprite = require('../../src/sprites/sprite');
const RenderedTarget = require('../../src/sprites/rendered-target');
const BlockUtility = require('../../src/engine/block-utility');
test('getPrimitives', t => {
const rt = new Runtime();
const s = new Sensing(rt);
t.type(s.getPrimitives(), 'object');
t.end();
});
test('ask and answer with a hidden target', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: false}};
const expectedQuestion = 'a question';
const expectedAnswer = 'the answer';
// Test is written out of order because of promises, follow the (#) comments.
rt.addListener('QUESTION', question => {
// (2) Assert the question is correct, then emit the answer
t.strictEqual(question, expectedQuestion);
rt.emit('ANSWER', expectedAnswer);
});
// (1) Emit the question.
const promise = s.askAndWait({QUESTION: expectedQuestion}, util);
// (3) Ask block resolves after the answer is emitted.
promise.then(() => {
t.strictEqual(s.getAnswer(), expectedAnswer);
t.end();
});
});
test('ask and stop all dismisses question', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: false}};
const expectedQuestion = 'a question';
let call = 0;
rt.addListener('QUESTION', question => {
if (call === 0) {
// (2) Assert the question was passed.
t.strictEqual(question, expectedQuestion);
} else if (call === 1) {
// (4) Assert the question was dismissed.
t.strictEqual(question, null);
t.end();
}
call += 1;
});
// (1) Emit the question.
s.askAndWait({QUESTION: expectedQuestion}, util);
// (3) Emit the stop all event.
rt.stopAll();
});
test('ask and stop other scripts dismisses if it is the last question', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: false, sprite: {}, getCustomState: () => ({})}, thread: {}};
const expectedQuestion = 'a question';
let call = 0;
rt.addListener('QUESTION', question => {
if (call === 0) {
// (2) Assert the question was passed.
t.strictEqual(question, expectedQuestion);
} else if (call === 1) {
// (4) Assert the question was dismissed.
t.strictEqual(question, null);
t.end();
}
call += 1;
});
// (1) Emit the questions.
s.askAndWait({QUESTION: expectedQuestion}, util);
// (3) Emit the stop for target event.
rt.stopForTarget(util.target, util.thread);
});
test('ask and stop other scripts asks next question', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: false, sprite: {}, getCustomState: () => ({})}, thread: {}};
const util2 = {target: {visible: false, sprite: {}, getCustomState: () => ({})}, thread: {}};
const expectedQuestion = 'a question';
const nextQuestion = 'a followup';
let call = 0;
rt.addListener('QUESTION', question => {
if (call === 0) {
// (2) Assert the question was passed.
t.strictEqual(question, expectedQuestion);
} else if (call === 1) {
// (4) Assert the next question was passed.
t.strictEqual(question, nextQuestion);
t.end();
}
call += 1;
});
// (1) Emit the questions.
s.askAndWait({QUESTION: expectedQuestion}, util);
s.askAndWait({QUESTION: nextQuestion}, util2);
// (3) Emit the stop for target event.
rt.stopForTarget(util.target, util.thread);
});
test('ask and answer with a visible target', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: true}};
const expectedQuestion = 'a question';
const expectedAnswer = 'the answer';
rt.removeAllListeners('SAY'); // Prevent say blocks from executing
rt.addListener('SAY', (target, type, question) => {
// Should emit SAY with the question
t.strictEqual(question, expectedQuestion);
});
rt.addListener('QUESTION', question => {
// Question should be blank for a visible target
t.strictEqual(question, '');
// Remove the say listener and add a new one to assert bubble is cleared
// by setting say to empty string after answer is received.
rt.removeAllListeners('SAY');
rt.addListener('SAY', (target, type, text) => {
t.strictEqual(text, '');
t.end();
});
rt.emit('ANSWER', expectedAnswer);
});
s.askAndWait({QUESTION: expectedQuestion}, util);
});
test('answer gets reset when runtime is disposed', t => {
const rt = new Runtime();
const s = new Sensing(rt);
const util = {target: {visible: false}};
const expectedAnswer = 'the answer';
rt.addListener('QUESTION', () => rt.emit('ANSWER', expectedAnswer));
const promise = s.askAndWait({QUESTION: ''}, util);
promise.then(() => t.strictEqual(s.getAnswer(), expectedAnswer))
.then(() => rt.dispose())
.then(() => {
t.strictEqual(s.getAnswer(), '');
t.end();
});
});
test('set drag mode', t => {
const runtime = new Runtime();
runtime.requestTargetsUpdate = () => {}; // noop for testing
const sensing = new Sensing(runtime);
const s = new Sprite(null, runtime);
const rt = new RenderedTarget(s, runtime);
sensing.setDragMode({DRAG_MODE: 'not draggable'}, {target: rt});
t.strictEqual(rt.draggable, false);
sensing.setDragMode({DRAG_MODE: 'draggable'}, {target: rt});
t.strictEqual(rt.draggable, true);
t.end();
});
test('get loudness with caching', t => {
const rt = new Runtime();
const sensing = new Sensing(rt);
// It should report -1 when audio engine is not available.
t.strictEqual(sensing.getLoudness(), -1);
// Stub the audio engine with its getLoudness function, and set up different
// values to simulate it changing over time.
const firstLoudness = 1;
const secondLoudness = 2;
let simulatedLoudness = firstLoudness;
rt.audioEngine = {getLoudness: () => simulatedLoudness};
// It should report -1 when current step time is null.
// TW: The concept of a null current step time is inherently flawed and removed in TurboWarp.
// t.strictEqual(sensing.getLoudness(), -1);
// Stub the current step time.
rt.currentStepTime = 1000 / 30;
// The first time it works, it should report the result from the stubbed audio engine.
t.strictEqual(sensing.getLoudness(), firstLoudness);
// Update the simulated loudness to a new value.
simulatedLoudness = secondLoudness;
// Simulate time passing by advancing the timer forward a little bit.
// After less than a step, it should still report cached loudness.
let simulatedTime = Date.now() + (rt.currentStepTime / 2);
sensing._timer = {time: () => simulatedTime};
t.strictEqual(sensing.getLoudness(), firstLoudness);
// Simulate more than a step passing. It should now request the value
// from the audio engine again.
simulatedTime += rt.currentStepTime;
t.strictEqual(sensing.getLoudness(), secondLoudness);
t.end();
});
test('loud? boolean', t => {
const rt = new Runtime();
const sensing = new Sensing(rt);
// The simplest way to test this is to actually override the getLoudness
// method, which isLoud uses.
let simulatedLoudness = 0;
sensing.getLoudness = () => simulatedLoudness;
t.false(sensing.isLoud());
// Check for GREATER than 10, not equal.
simulatedLoudness = 10;
t.false(sensing.isLoud());
simulatedLoudness = 11;
t.true(sensing.isLoud());
t.end();
});
test('get attribute of sprite variable', t => {
const rt = new Runtime();
const sensing = new Sensing(rt);
const s = new Sprite(null, rt);
const target = new RenderedTarget(s, rt);
const variable = {
name: 'cars',
value: 'trucks',
type: ''
};
// Add variable to set the map (it should be empty before this).
target.variables.anId = variable;
rt.getSpriteTargetByName = () => target;
t.equal(sensing.getAttributeOf({PROPERTY: 'cars'}), 'trucks');
t.end();
});
test('get attribute of variable that does not exist', t => {
const rt = new Runtime();
const sensing = new Sensing(rt);
const s = new Sprite(null, rt);
const target = new RenderedTarget(s, rt);
rt.getTargetForStage = () => target;
t.equal(sensing.getAttributeOf({PROPERTY: 'variableThatDoesNotExist'}), 0);
t.end();
});
test('username block', t => {
const rt = new Runtime();
const sensing = new Sensing(rt);
const util = new BlockUtility(rt.sequencer);
t.equal(sensing.getUsername({}, util), '');
t.end();
});

View File

@@ -0,0 +1,35 @@
const test = require('tap').test;
const Runtime = require('../../src/engine/runtime');
const Target = require('../../src/engine/target');
const Sprite = require('../../src/sprites/sprite');
const Scratch3SoundBlocks = require('../../src/blocks/scratch3_sound');
test('effect clamping runtime option', t => {
const rt = new Runtime();
const target = new Target(rt);
const sprite = new Sprite();
target.sprite = sprite;
const sound = new Scratch3SoundBlocks(rt);
sound.setEffect({
EFFECT: 'pitch',
VALUE: 720
}, {
target
});
t.equal(sound._getSoundState(target).effects.pitch, 360);
rt.setRuntimeOptions({
miscLimits: false
});
sound.setEffect({
EFFECT: 'pitch',
VALUE: 720
}, {
target
});
t.equal(sound._getSoundState(target).effects.pitch, 720);
t.end();
});

View File

@@ -0,0 +1,73 @@
const test = require('tap').test;
const Sound = require('../../src/blocks/scratch3_sound');
let playedSound;
const blocks = new Sound();
const util = {
target: {
sprite: {
sounds: [
{name: 'first name', soundId: 'first soundId'},
{name: 'second name', soundId: 'second soundId'},
{name: 'third name', soundId: 'third soundId'},
{name: '6', soundId: 'fourth soundId'}
],
soundBank: {
playSound: (target, soundId) => (playedSound = soundId)
}
}
}
};
test('playSound with a name string works', t => {
const args = {SOUND_MENU: 'second name'};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'second soundId');
t.end();
});
test('playSound with a number string works 1-indexed', t => {
let args = {SOUND_MENU: '5'};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'first soundId');
args = {SOUND_MENU: '1'};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'first soundId');
args = {SOUND_MENU: '0'};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'fourth soundId');
t.end();
});
test('playSound with a number works 1-indexed', t => {
let args = {SOUND_MENU: 5};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'first soundId');
args = {SOUND_MENU: 1};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'first soundId');
args = {SOUND_MENU: 0};
blocks.playSound(args, util);
t.strictEqual(playedSound, 'fourth soundId');
t.end();
});
test('playSound prioritizes sound index if given a number', t => {
const args = {SOUND_MENU: 6};
blocks.playSound(args, util);
// Ignore the sound named '6', wrapClamp to the second instead
t.strictEqual(playedSound, 'second soundId');
t.end();
});
test('playSound prioritizes sound name if given a string', t => {
const args = {SOUND_MENU: '6'};
blocks.playSound(args, util);
// Use the sound named '6', which is the fourth
t.strictEqual(playedSound, 'fourth soundId');
t.end();
});

View File

@@ -0,0 +1,82 @@
const DispatchTestService = require('../fixtures/dispatch-test-service');
const Worker = require('tiny-worker');
const dispatch = require('../../src/dispatch/central-dispatch');
const path = require('path');
const test = require('tap').test;
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
const runServiceTest = function (serviceName, t) {
const promises = [];
promises.push(dispatch.call(serviceName, 'returnFortyTwo')
.then(
x => t.equal(x, 42),
e => t.fail(e)
));
promises.push(dispatch.call(serviceName, 'doubleArgument', 9)
.then(
x => t.equal(x, 18),
e => t.fail(e)
));
promises.push(dispatch.call(serviceName, 'doubleArgument', 123)
.then(
x => t.equal(x, 246),
e => t.fail(e)
));
// I tried using `t.rejects` here but ran into https://github.com/tapjs/node-tap/issues/384
promises.push(dispatch.call(serviceName, 'throwException')
.then(
() => t.fail('exception was not propagated as expected'),
() => t.pass('exception was propagated as expected')
));
return Promise.all(promises);
};
test('local', t => {
dispatch.setService('LocalDispatchTest', new DispatchTestService())
.catch(e => t.fail(e));
return runServiceTest('LocalDispatchTest', t);
});
test('remote', t => {
const fixturesDir = path.resolve(__dirname, '../fixtures');
const shimPath = path.resolve(fixturesDir, 'dispatch-test-worker-shim.js');
const worker = new Worker(shimPath, null, {cwd: fixturesDir});
dispatch.addWorker(worker);
const waitForWorker = new Promise(resolve => {
dispatch.setService('test', {onWorkerReady: resolve})
.catch(e => t.fail(e));
});
return waitForWorker
.then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e))
.then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e));
});
test('local, sync', t => {
dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService());
const a = dispatch.callSync('SyncDispatchTest', 'returnFortyTwo');
t.equal(a, 42);
const b = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9);
t.equal(b, 18);
const c = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123);
t.equal(c, 246);
t.throws(() => dispatch.callSync('SyncDispatchTest', 'throwException'),
new Error('This is a test exception thrown by DispatchTest'));
t.end();
});

View File

@@ -0,0 +1,220 @@
const test = require('tap').test;
const adapter = require('../../src/engine/adapter');
const events = require('../fixtures/events.json');
test('spec', t => {
t.type(adapter, 'function');
t.end();
});
test('invalid inputs', t => {
let nothing = adapter('not an object');
t.type(nothing, 'undefined');
nothing = adapter({noxmlproperty: true});
t.type(nothing, 'undefined');
t.end();
});
test('create event', t => {
const result = adapter(events.create);
t.ok(Array.isArray(result));
t.equal(result.length, 2);
// Outer block
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].comment, 'undefined');
t.type(result[0].fields, 'object');
t.type(result[0].inputs, 'object');
t.type(result[0].inputs.DURATION, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
// Enclosed shadow block
t.type(result[1].id, 'string');
t.type(result[1].opcode, 'string');
t.type(result[1].fields, 'object');
t.type(result[1].inputs, 'object');
t.type(result[1].fields.NUM, 'object');
t.type(result[1].fields.NUM.value, '10');
t.type(result[1].topLevel, 'boolean');
t.equal(result[1].topLevel, false);
t.end();
});
test('create with comment', t => {
const result = adapter(events.createComment);
// This test should be the same as above except that it also has a comment.
t.ok(Array.isArray(result));
t.equal(result.length, 2);
t.type(result[0].comment, 'string');
t.equal(result[0].comment, 'aCommentId');
t.end();
});
test('create with branch', t => {
const result = adapter(events.createbranch);
// Outer block
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].fields, 'object');
t.type(result[0].inputs, 'object');
t.type(result[0].inputs.SUBSTACK, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
// In branch
const branchBlockId = result[0].inputs.SUBSTACK.block;
const branchShadowId = result[0].inputs.SUBSTACK.shadow;
t.type(branchBlockId, 'string');
t.equal(branchShadowId, null);
// Find actual branch block
let branchBlock = null;
for (let i = 0; i < result.length; i++) {
if (result[i].id === branchBlockId) {
branchBlock = result[i];
}
}
t.type(branchBlock, 'object');
t.end();
});
test('create with two branches', t => {
const result = adapter(events.createtwobranches);
// Outer block
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].fields, 'object');
t.type(result[0].inputs, 'object');
t.type(result[0].inputs.SUBSTACK, 'object');
t.type(result[0].inputs.SUBSTACK2, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
// In branchs
const firstBranchBlockId = result[0].inputs.SUBSTACK.block;
const secondBranchBlockId = result[0].inputs.SUBSTACK2.block;
t.type(firstBranchBlockId, 'string');
t.type(secondBranchBlockId, 'string');
const firstBranchShadowBlockId = result[0].inputs.SUBSTACK.shadow;
const secondBranchShadowBlockId = result[0].inputs.SUBSTACK2.shadow;
t.equal(firstBranchShadowBlockId, null);
t.equal(secondBranchShadowBlockId, null);
// Find actual branch blocks
let firstBranchBlock = null;
let secondBranchBlock = null;
for (let i = 0; i < result.length; i++) {
if (result[i].id === firstBranchBlockId) {
firstBranchBlock = result[i];
}
if (result[i].id === secondBranchBlockId) {
secondBranchBlock = result[i];
}
}
t.type(firstBranchBlock, 'object');
t.type(secondBranchBlock, 'object');
t.end();
});
test('create with top-level shadow', t => {
const result = adapter(events.createtoplevelshadow);
t.ok(Array.isArray(result));
t.equal(result.length, 1);
// Outer block
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].fields, 'object');
t.type(result[0].inputs, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
t.end();
});
test('create with next connection', t => {
const result = adapter(events.createwithnext);
t.ok(Array.isArray(result));
t.equal(result.length, 2);
// First block
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].fields, 'object');
t.type(result[0].inputs, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
t.type(result[0].next, 'string');
t.equal(result[0].next, result[1].id);
// Second block
t.type(result[1].id, 'string');
t.type(result[1].opcode, 'string');
t.type(result[1].fields, 'object');
t.type(result[1].inputs, 'object');
t.type(result[1].topLevel, 'boolean');
t.equal(result[1].topLevel, false);
t.equal(result[1].next, null);
t.end();
});
test('create with obscured shadow', t => {
const result = adapter(events.createobscuredshadow);
t.ok(Array.isArray(result));
t.equal(result.length, 4);
t.end();
});
test('create variable with entity in name', t => {
const result = adapter(events.createvariablewithentity);
t.ok(Array.isArray(result));
t.equal(result.length, 1);
t.type(result[0].id, 'string');
t.type(result[0].opcode, 'string');
t.type(result[0].fields, 'object');
t.type(result[0].fields.VARIABLE, 'object');
t.type(result[0].fields.VARIABLE.value, 'string');
t.equal(result[0].fields.VARIABLE.value, 'this & that');
t.type(result[0].inputs, 'object');
t.type(result[0].topLevel, 'boolean');
t.equal(result[0].topLevel, true);
t.end();
});
test('create with invalid block xml', t => {
// Entirely invalid block XML
const result = adapter(events.createinvalid);
t.ok(Array.isArray(result));
t.equal(result.length, 0);
// Invalid grandchild tag
const result2 = adapter(events.createinvalidgrandchild);
t.ok(Array.isArray(result2));
t.equal(result2.length, 1);
t.type(result2[0].id, 'string');
t.equal(Object.keys(result2[0].inputs).length, 0);
t.equal(Object.keys(result2[0].fields).length, 0);
t.end();
});
test('create with invalid xml', t => {
const result = adapter(events.createbadxml);
t.ok(Array.isArray(result));
t.equal(result.length, 0);
t.end();
});
test('create with empty field', t => {
const result = adapter(events.createemptyfield);
t.ok(Array.isArray(result));
t.equal(result.length, 3);
t.end();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
const test = require('tap').test;
const mutationAdapter = require('../../src/engine/mutation-adapter');
test('spec', t => {
t.type(mutationAdapter, 'function');
t.end();
});
test('convert DOM to Scratch object', t => {
const testStringRaw = '"arbitrary" & \'complicated\' test string';
const testStringEscaped = '\\&quot;arbitrary\\&quot; &amp; &apos;complicated&apos; test string';
const xml = `<mutation blockInfo="{&quot;text&quot;:&quot;${testStringEscaped}&quot;}"></mutation>`;
const expectedMutation = {
tagName: 'mutation',
children: [],
blockInfo: {
text: testStringRaw
}
};
// TODO: do we want to test passing a DOM node to `mutationAdapter`? Node.js doesn't have built-in DOM support...
const mutationFromString = mutationAdapter(xml);
t.deepEqual(mutationFromString, expectedMutation);
t.end();
});

View File

@@ -0,0 +1,276 @@
const tap = require('tap');
const path = require('path');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/virtual-machine');
const Runtime = require('../../src/engine/runtime');
const MonitorRecord = require('../../src/engine/monitor-record');
const {Map} = require('immutable');
const test = tap.test;
test('spec', t => {
const r = new Runtime();
t.type(Runtime, 'function');
t.type(r, 'object');
// Test types of cloud data managing functions
t.type(r.hasCloudData, 'function');
t.type(r.canAddCloudVariable, 'function');
t.type(r.addCloudVariable, 'function');
t.type(r.removeCloudVariable, 'function');
t.ok(r instanceof Runtime);
t.end();
});
test('monitorStateEquals', t => {
const r = new Runtime();
const id = 'xklj4#!';
const prevMonitorState = MonitorRecord({
id,
opcode: 'turtle whereabouts',
value: '25'
});
const newMonitorDelta = Map({
id,
value: String(25)
});
r.requestAddMonitor(prevMonitorState);
r.requestUpdateMonitor(newMonitorDelta);
t.equals(true, prevMonitorState === r._monitorState.get(id));
t.equals(String(25), r._monitorState.get(id).get('value'));
t.end();
});
test('monitorStateDoesNotEqual', t => {
const r = new Runtime();
const id = 'xklj4#!';
const params = {seven: 7};
const prevMonitorState = MonitorRecord({
id,
opcode: 'turtle whereabouts',
value: '25'
});
// Value change
let newMonitorDelta = Map({
id,
value: String(24)
});
r.requestAddMonitor(prevMonitorState);
r.requestUpdateMonitor(newMonitorDelta);
t.equals(false, prevMonitorState.equals(r._monitorState.get(id)));
t.equals(String(24), r._monitorState.get(id).get('value'));
// Prop change
newMonitorDelta = Map({
id: 'xklj4#!',
params: params
});
r.requestUpdateMonitor(newMonitorDelta);
t.equals(false, prevMonitorState.equals(r._monitorState.get(id)));
t.equals(String(24), r._monitorState.get(id).value);
t.equals(params, r._monitorState.get(id).params);
t.end();
});
test('getLabelForOpcode', t => {
const r = new Runtime();
const fakeExtension = {
id: 'fakeExtension',
name: 'Fake Extension',
blocks: [
{
info: {
opcode: 'foo',
json: {},
text: 'Foo',
xml: ''
}
},
{
info: {
opcode: 'foo_2',
json: {},
text: 'Foo 2',
xml: ''
}
}
]
};
r._blockInfo.push(fakeExtension);
const result1 = r.getLabelForOpcode('fakeExtension_foo');
t.type(result1.category, 'string');
t.type(result1.label, 'string');
t.equals(result1.label, 'Fake Extension: Foo');
const result2 = r.getLabelForOpcode('fakeExtension_foo_2');
t.type(result2.category, 'string');
t.type(result2.label, 'string');
t.equals(result2.label, 'Fake Extension: Foo 2');
t.end();
});
test('Project loaded emits runtime event', t => {
const vm = new VirtualMachine();
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
let projectLoaded = false;
vm.runtime.addListener('PROJECT_LOADED', () => {
projectLoaded = true;
});
vm.loadProject(project).then(() => {
t.equal(projectLoaded, true, 'Project load event emitted');
t.end();
});
});
test('Cloud variable limit allows only 10 cloud variables', t => {
// This is a test of just the cloud variable limit mechanism
// The functions being tested below need to be used when
// creating and deleting cloud variables in the runtime.
const rt = new Runtime();
t.equal(rt.hasCloudData(), false);
for (let i = 0; i < 10; i++) {
t.equal(rt.canAddCloudVariable(), true);
rt.addCloudVariable();
// Adding a cloud variable should change the
// result of the hasCloudData check
t.equal(rt.hasCloudData(), true);
}
// We should be at the cloud variable limit now
t.equal(rt.canAddCloudVariable(), false);
// Removing a cloud variable should allow the addition of exactly one more
// when we are at the cloud variable limit
rt.removeCloudVariable();
t.equal(rt.canAddCloudVariable(), true);
rt.addCloudVariable();
t.equal(rt.canAddCloudVariable(), false);
// Disposing of the runtime should reset the cloud variable limitations
rt.dispose();
t.equal(rt.hasCloudData(), false);
for (let i = 0; i < 10; i++) {
t.equal(rt.canAddCloudVariable(), true);
rt.addCloudVariable();
t.equal(rt.hasCloudData(), true);
}
// We should be at the cloud variable limit now
t.equal(rt.canAddCloudVariable(), false);
t.end();
});
test('Starting the runtime emits an event', t => {
let started = false;
const rt = new Runtime();
rt.addListener('RUNTIME_STARTED', () => {
started = true;
});
rt.start();
t.equal(started, true);
rt.quit();
t.end();
});
test('Runtime cannot be started while already running', t => {
const rt = new Runtime();
rt.start(); // Start the first time
// Set up a flag/listener to check if it can be started again
let started = false;
rt.addListener('RUNTIME_STARTED', () => {
started = true;
});
// Starting again should not emit another event
rt.start();
t.equal(started, false);
rt.quit();
t.end();
});
test('setCompatibilityMode restarts if it was already running', t => {
const rt = new Runtime();
rt.start(); // Start the first time
// Set up a flag/listener to check if it gets started again
let started = false;
rt.addListener('RUNTIME_STARTED', () => {
started = true;
});
rt.setCompatibilityMode(true);
// TW: We make an intentional API change here. Changing compatibility mode won't emit a RUNTIME_STARTED
// if the runtime is already running.
t.equal(started, false);
rt.quit();
t.end();
});
test('setCompatibilityMode does not restart if it was not running', t => {
const rt = new Runtime();
let started = false;
rt.addListener('RUNTIME_STARTED', () => {
started = true;
});
rt.setCompatibilityMode(true);
t.equal(started, false);
t.end();
});
test('Disposing the runtime emits an event', t => {
let disposed = false;
const rt = new Runtime();
rt.addListener('RUNTIME_DISPOSED', () => {
disposed = true;
});
rt.dispose();
t.equal(disposed, true);
t.end();
});
test('Clock is reset on runtime dispose', t => {
const rt = new Runtime();
const c = rt.ioDevices.clock;
let simulatedTime = 0;
c._projectTimer = {
timeElapsed: () => simulatedTime,
start: () => {
simulatedTime = 0;
}
};
t.ok(c.projectTimer() === 0);
simulatedTime += 1000;
t.ok(c.projectTimer() === 1);
rt.dispose();
// When the runtime is disposed, the clock should be reset
t.ok(c.projectTimer() === 0);
t.end();
});

View File

@@ -0,0 +1,255 @@
const tap = require('tap');
const Runtime = require('../../src/engine/runtime');
const {Map} = require('immutable');
const makeTestStorage = require('../fixtures/make-test-storage');
const test = tap.test;
test('setFramerate emits an event', t => {
t.plan(1);
const rt = new Runtime();
rt.addListener('FRAMERATE_CHANGED', framerate => {
if (framerate === 13) {
t.pass();
}
});
rt.setFramerate(13);
t.end();
});
test('setFramerate and setCompatibilityMode do not emit a stop event if not running', t => {
const rt = new Runtime();
rt.addListener('RUNTIME_STOPPED', () => {
t.fail();
});
rt.setFramerate(13);
rt.setCompatibilityMode(true);
t.end();
});
test('setInterpolation emits an event', t => {
t.plan(1);
const rt = new Runtime();
rt.addListener('INTERPOLATION_CHANGED', enabled => {
if (enabled) {
t.pass();
}
});
rt.setInterpolation(true);
t.end();
});
test('setInterpolation does not restart runtime if not running', t => {
const rt = new Runtime();
let started = false;
let stopped = false;
rt.addListener('RUNTIME_STARTED', () => {
started = true;
});
rt.addListener('RUNTIME_STOPPED', () => {
stopped = true;
});
rt.setInterpolation(true);
t.equal(started, false);
t.equal(stopped, false);
t.end();
});
test('Stopping the runtime emits an event', t => {
const rt = new Runtime();
rt.start();
let stopped = false;
rt.addListener('RUNTIME_STOPPED', () => {
stopped = true;
});
rt.stop();
t.equal(stopped, true);
t.end();
});
test('Stop does not emit an event if already stopped', t => {
const rt = new Runtime();
let stopped = false;
rt.addListener('RUNTIME_STOPPED', () => {
stopped = true;
});
rt.stop();
t.equal(stopped, false);
t.end();
});
test('setRuntimeOptions emits an event', t => {
t.plan(1);
const rt = new Runtime();
rt.addListener('RUNTIME_OPTIONS_CHANGED', options => {
if (options.option === 17) {
t.pass();
}
});
rt.setRuntimeOptions({option: 17});
t.end();
});
test('setRuntimeOptions supports partial updates', t => {
t.plan(1);
const rt = new Runtime();
rt.setRuntimeOptions({option: 17});
rt.addListener('RUNTIME_OPTIONS_CHANGED', options => {
if (options.option === 17) {
t.pass();
}
});
rt.setRuntimeOptions({otherOption: 1});
t.end();
});
test('setCompilerOptions emits an event', t => {
t.plan(1);
const rt = new Runtime();
rt.addListener('COMPILER_OPTIONS_CHANGED', options => {
if (options.option === 17) {
t.pass();
}
});
rt.setCompilerOptions({option: 17});
t.end();
});
test('setCompilerOptions supports partial updates', t => {
t.plan(1);
const rt = new Runtime();
rt.setCompilerOptions({option: 17});
rt.addListener('COMPILER_OPTIONS_CHANGED', options => {
if (options.option === 17) {
t.pass();
}
});
rt.setCompilerOptions({otherOption: 1});
t.end();
});
test('maxClones runtime option', t => {
const rt = new Runtime();
rt.setRuntimeOptions({maxClones: 10});
for (let i = 0; i < 10; i++) {
t.equal(rt.clonesAvailable(), true);
rt.changeCloneCounter(1);
}
rt.changeCloneCounter(1);
t.equal(rt.clonesAvailable(), false);
t.end();
});
test('stageWidth and stageHeight', t => {
const rt = new Runtime();
t.equal(rt.stageWidth, 480);
t.equal(rt.stageHeight, 360);
t.end();
});
test('debug', t => {
const rt = new Runtime();
t.equal(rt.debug, false);
rt.enableDebug();
t.equal(rt.debug, true);
t.end();
});
test('setStageSize preserves monitor position relative to center of stage', t => {
const rt = new Runtime();
rt.requestAddMonitor(new Map([
['id', 'abc'],
// top right corner
['x', 0],
['y', 0]
]));
rt.setStageSize(640, 362);
const finalState = rt.getMonitorState().get('abc');
t.equal(finalState.get('x'), 80);
t.equal(finalState.get('y'), 1);
t.end();
});
test('setStageSize argument range', t => {
t.plan(6);
const rt = new Runtime();
rt.once('STAGE_SIZE_CHANGED', (width, height) => {
t.equal(width, 101);
t.equal(height, 103);
});
rt.setStageSize(101, 103);
rt.once('STAGE_SIZE_CHANGED', (width, height) => {
t.equal(width, 1);
t.equal(height, 1);
});
rt.setStageSize(-3.1, 0);
rt.once('STAGE_SIZE_CHANGED', (width, height) => {
t.equal(width, 99);
t.equal(height, 10000);
});
rt.setStageSize(99.3, 10000);
t.end();
});
test('STAGE_SIZE_CHANGED does not fire if no change', t => {
const rt = new Runtime();
rt.on('STAGE_SIZE_CHANGED', () => {
t.fail('STAGE_SIZE_CHANGED emitted');
});
rt.setStageSize(rt.stageWidth, rt.stageHeight);
t.end();
});
test('getNumberOfCloudVariables', t => {
const rt = new Runtime();
t.equal(rt.getNumberOfCloudVariables(), 0);
rt.addCloudVariable();
t.equal(rt.getNumberOfCloudVariables(), 1);
rt.addCloudVariable();
t.equal(rt.getNumberOfCloudVariables(), 2);
rt.removeCloudVariable();
t.equal(rt.getNumberOfCloudVariables(), 1);
rt.removeCloudVariable();
t.equal(rt.getNumberOfCloudVariables(), 0);
rt.dispose();
t.equal(rt.getNumberOfCloudVariables(), 0);
rt.addCloudVariable();
t.equal(rt.getNumberOfCloudVariables(), 1);
t.end();
});
test('currentStepTime default value', t => {
const rt = new Runtime();
t.type(rt.currentStepTime, 'number');
t.ok(rt.currentStepTime > 0);
t.end();
});
test('convertToPackagedRuntime', t => {
const rt = new Runtime();
t.equal(rt.isPackaged, false);
rt.convertToPackagedRuntime();
t.equal(rt.isPackaged, true);
t.end();
});
test('convertToPackagedRuntime and attachStorage call order', t => {
try {
const rt1 = new Runtime();
rt1.attachStorage(makeTestStorage());
rt1.convertToPackagedRuntime();
} catch (e) {
t.equal(e.message, 'convertToPackagedRuntime must be called before attachStorage');
}
const rt2 = new Runtime();
rt2.convertToPackagedRuntime();
rt2.attachStorage(makeTestStorage());
t.end();
});

View File

@@ -0,0 +1,193 @@
const test = require('tap').test;
const Sequencer = require('../../src/engine/sequencer');
const Runtime = require('../../src/engine/runtime');
const Thread = require('../../src/engine/thread');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
test('spec', t => {
t.type(Sequencer, 'function');
const r = new Runtime();
const s = new Sequencer(r);
t.type(s, 'object');
t.ok(s instanceof Sequencer);
t.type(s.stepThreads, 'function');
t.type(s.stepThread, 'function');
t.type(s.stepToBranch, 'function');
t.type(s.stepToProcedure, 'function');
t.type(s.retireThread, 'function');
t.end();
});
const randomString = function () {
const top = Math.random().toString(36);
return top.substring(7);
};
const generateBlock = function (id) {
const block = {fields: Object,
id: id,
inputs: {},
STEPS: Object,
block: 'fakeBlock',
name: 'fakeName',
next: null,
opcode: 'procedures_definition',
mutation: {proccode: 'fakeCode'},
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
return block;
};
const generateBlockInput = function (id, next, inp) {
const block = {fields: Object,
id: id,
inputs: {SUBSTACK: {block: inp, name: 'SUBSTACK'}},
STEPS: Object,
block: 'fakeBlock',
name: 'fakeName',
next: next,
opcode: 'procedures_definition',
mutation: {proccode: 'fakeCode'},
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
return block;
};
const generateThread = function (runtime) {
const s = new Sprite(null, runtime);
const rt = new RenderedTarget(s, runtime);
const th = new Thread(randomString());
let next = randomString();
let inp = randomString();
let name = th.topBlock;
const pushStack = id => {
th.pushStack(id);
th.peekStackFrame().op = {id};
};
rt.blocks.createBlock(generateBlockInput(name, next, inp));
pushStack(name);
rt.blocks.createBlock(generateBlock(inp));
for (let i = 0; i < 10; i++) {
name = next;
next = randomString();
inp = randomString();
rt.blocks.createBlock(generateBlockInput(name, next, inp));
pushStack(name);
rt.blocks.createBlock(generateBlock(inp));
}
rt.blocks.createBlock(generateBlock(next));
pushStack(next);
th.target = rt;
th.blockContainer = rt.blocks;
runtime.threads.push(th);
return th;
};
test('stepThread', t => {
const r = new Runtime();
const s = new Sequencer(r);
let th = generateThread(r);
t.notEquals(th.status, Thread.STATUS_DONE);
s.stepThread(th);
t.strictEquals(th.status, Thread.STATUS_DONE);
th = generateThread(r);
th.status = Thread.STATUS_YIELD;
s.stepThread(th);
t.notEquals(th.status, Thread.STATUS_DONE);
th.status = Thread.STATUS_PROMISE_WAIT;
s.stepThread(th);
t.notEquals(th.status, Thread.STATUS_DONE);
t.end();
});
test('stepToBranch', t => {
const r = new Runtime();
const s = new Sequencer(r);
const th = generateThread(r);
s.stepToBranch(th, 2, false);
t.strictEquals(th.peekStack(), null);
th.popStack();
s.stepToBranch(th, 1, false);
t.strictEquals(th.peekStack(), null);
th.popStack();
th.popStack();
s.stepToBranch(th, 1, false);
t.notEquals(th.peekStack(), null);
t.end();
});
test('retireThread', t => {
const r = new Runtime();
const s = new Sequencer(r);
const th = generateThread(r);
t.strictEquals(th.stack.length, 12);
s.retireThread(th);
t.strictEquals(th.stack.length, 0);
t.strictEquals(th.status, Thread.STATUS_DONE);
t.end();
});
test('stepToProcedure', t => {
const r = new Runtime();
const s = new Sequencer(r);
const th = generateThread(r);
let expectedBlock = th.peekStack();
s.stepToProcedure(th, '');
t.strictEquals(th.peekStack(), expectedBlock);
s.stepToProcedure(th, 'faceCode');
t.strictEquals(th.peekStack(), expectedBlock);
th.target.blocks.createBlock({
id: 'internalId',
opcode: 'procedures_prototype',
mutation: {
proccode: 'othercode'
}
});
expectedBlock = th.stack[th.stack.length - 4];
th.target.blocks.getBlock(expectedBlock).inputs.custom_block = {
type: 'custom_block',
block: 'internalId'
};
s.stepToProcedure(th, 'othercode');
t.strictEquals(th.peekStack(), expectedBlock);
t.end();
});
test('stepThreads', t => {
const r = new Runtime();
r.currentStepTime = Infinity;
const s = new Sequencer(r);
t.strictEquals(s.stepThreads().length, 0);
generateThread(r);
t.strictEquals(r.threads.length, 1);
// Threads should be marked DONE and removed in the same step they finish.
t.strictEquals(s.stepThreads().length, 1);
t.end();
});

View File

@@ -0,0 +1,813 @@
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(new Runtime());
t.type(Target, 'function');
t.type(target, 'object');
t.ok(target instanceof Target);
t.type(target.id, 'string');
t.type(target.blocks, 'object');
t.type(target.variables, 'object');
t.type(target.comments, 'object');
t.type(target._customState, 'object');
t.type(target.createVariable, 'function');
t.type(target.renameVariable, 'function');
t.end();
});
// Create Variable tests.
test('createVariable', t => {
const target = new Target(new Runtime());
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE);
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar');
t.equal(variable.type, Variable.SCALAR_TYPE);
t.equal(variable.value, 0);
t.equal(variable.isCloud, false);
t.end();
});
// Create Same Variable twice.
test('createVariable2', t => {
const target = new Target(new Runtime());
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE);
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE);
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
t.end();
});
// Create a list
test('createListVariable creates a list', t => {
const target = new Target(new Runtime());
target.createVariable('foo', 'bar', Variable.LIST_TYPE);
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar');
t.equal(variable.type, Variable.LIST_TYPE);
t.assert(variable.value instanceof Array, true);
t.equal(variable.value.length, 0);
t.equal(variable.isCloud, false);
t.end();
});
test('createVariable calls cloud io device\'s requestCreateVariable', t => {
const runtime = new Runtime();
// Mock the requestCreateVariable function
let requestCreateCloudWasCalled = false;
runtime.ioDevices.cloud.requestCreateVariable = () => {
requestCreateCloudWasCalled = true;
};
const target = new Target(runtime);
target.isStage = true;
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE, true /* isCloud */);
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar');
t.equal(variable.type, Variable.SCALAR_TYPE);
t.equal(variable.value, 0);
t.equal(variable.isCloud, true);
t.equal(requestCreateCloudWasCalled, true);
t.end();
});
test('createVariable does not call cloud io device\'s requestCreateVariable if target is not stage', t => {
const runtime = new Runtime();
// Mock the requestCreateVariable function
let requestCreateCloudWasCalled = false;
runtime.ioDevices.cloud.requestCreateVariable = () => {
requestCreateCloudWasCalled = true;
};
const target = new Target(runtime);
target.isStage = false;
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE, true /* isCloud */);
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar');
t.equal(variable.type, Variable.SCALAR_TYPE);
t.equal(variable.value, 0);
// isCloud flag doesn't get set if the target is not the stage
t.equal(variable.isCloud, false);
t.equal(requestCreateCloudWasCalled, false);
t.end();
});
test('createVariable throws when given invalid type', t => {
const target = new Target(new Runtime());
t.throws(
(() => target.createVariable('foo', 'bar', 'baz')),
new Error('Invalid variable type: baz')
);
t.end();
});
// Rename Variable tests.
test('renameVariable', t => {
const target = new Target(new Runtime());
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE);
target.renameVariable('foo', 'bar2');
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar2');
t.equal(variable.value, 0);
t.equal(variable.isCloud, false);
t.end();
});
// Rename Variable that doesn't exist.
test('renameVariable2', t => {
const target = new Target(new Runtime());
target.renameVariable('foo', 'bar2');
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
t.end();
});
// Rename Variable that with id that exists as another variable's name.
// Expect no change.
test('renameVariable3', t => {
const target = new Target(new Runtime());
target.createVariable('foo1', 'foo', Variable.SCALAR_TYPE);
target.renameVariable('foo', 'bar2');
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo1');
t.equal(variable.name, 'foo');
t.end();
});
test('renameVariable calls cloud io device\'s requestRenameVariable function', t => {
const runtime = new Runtime();
let requestRenameVariableWasCalled = false;
runtime.ioDevices.cloud.requestRenameVariable = () => {
requestRenameVariableWasCalled = true;
};
const target = new Target(runtime);
target.isStage = true;
const mockCloudVar = new Variable('foo', 'bar', Variable.SCALAR_TYPE, true);
target.variables[mockCloudVar.id] = mockCloudVar;
runtime.addTarget(target);
target.renameVariable('foo', 'bar2');
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar2');
t.equal(variable.value, 0);
t.equal(variable.isCloud, true);
t.equal(requestRenameVariableWasCalled, true);
t.end();
});
test('renameVariable does not call cloud io device\'s requestRenameVariable function if target is not stage', t => {
const runtime = new Runtime();
let requestRenameVariableWasCalled = false;
runtime.ioDevices.cloud.requestRenameVariable = () => {
requestRenameVariableWasCalled = true;
};
const target = new Target(runtime);
const mockCloudVar = new Variable('foo', 'bar', Variable.SCALAR_TYPE, true);
target.variables[mockCloudVar.id] = mockCloudVar;
runtime.addTarget(target);
target.renameVariable('foo', 'bar2');
const variables = target.variables;
t.equal(Object.keys(variables).length, 1);
const variable = variables[Object.keys(variables)[0]];
t.equal(variable.id, 'foo');
t.equal(variable.name, 'bar2');
t.equal(variable.value, 0);
t.equal(variable.isCloud, true);
t.equal(requestRenameVariableWasCalled, false);
t.end();
});
// Delete Variable tests.
test('deleteVariable', t => {
const target = new Target(new Runtime());
target.createVariable('foo', 'bar', Variable.SCALAR_TYPE);
target.deleteVariable('foo');
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
t.end();
});
// Delete Variable that doesn't exist.
test('deleteVariable2', t => {
const target = new Target(new Runtime());
target.deleteVariable('foo');
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
t.end();
});
test('deleteVariable calls cloud io device\'s requestRenameVariable function', t => {
const runtime = new Runtime();
let requestDeleteVariableWasCalled = false;
runtime.ioDevices.cloud.requestDeleteVariable = () => {
requestDeleteVariableWasCalled = true;
};
const target = new Target(runtime);
target.isStage = true;
const mockCloudVar = new Variable('foo', 'bar', Variable.SCALAR_TYPE, true);
target.variables[mockCloudVar.id] = mockCloudVar;
runtime.addTarget(target);
target.deleteVariable('foo');
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
t.equal(requestDeleteVariableWasCalled, true);
t.end();
});
test('deleteVariable calls cloud io device\'s requestRenameVariable function', t => {
const runtime = new Runtime();
let requestDeleteVariableWasCalled = false;
runtime.ioDevices.cloud.requestDeleteVariable = () => {
requestDeleteVariableWasCalled = true;
};
const target = new Target(runtime);
const mockCloudVar = new Variable('foo', 'bar', Variable.SCALAR_TYPE, true);
target.variables[mockCloudVar.id] = mockCloudVar;
runtime.addTarget(target);
target.deleteVariable('foo');
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
t.equal(requestDeleteVariableWasCalled, false);
t.end();
});
test('duplicateVariable creates a new variable with a new ID by default', t => {
const target = new Target(new Runtime());
target.createVariable('a var ID', 'foo', Variable.SCALAR_TYPE);
t.equal(Object.keys(target.variables).length, 1);
const originalVariable = target.variables['a var ID'];
originalVariable.value = 10;
const newVariable = target.duplicateVariable('a var ID');
// Duplicating a variable should not add the variable to the current target
t.equal(Object.keys(target.variables).length, 1);
// Duplicate variable should have a different ID from the original unless specified to keep the original ID.
t.notEqual(newVariable.id, 'a var ID');
t.type(target.variables[newVariable.id], 'undefined');
// Duplicate variable should start out with the same value as the original variable
t.equal(newVariable.value, originalVariable.value);
// Modifying one variable should not modify the other
newVariable.value = 15;
t.notEqual(newVariable.value, originalVariable.value);
t.equal(originalVariable.value, 10);
t.end();
});
test('duplicateVariable creates new array reference for list variable.value', t => {
const target = new Target(new Runtime());
const arr = [1, 2, 3];
target.createVariable('a var ID', 'arr', Variable.LIST_TYPE);
const originalVariable = target.variables['a var ID'];
originalVariable.value = arr;
const newVariable = target.duplicateVariable('a var ID');
// Values are deeply equal but not the same object
t.deepEqual(originalVariable.value, newVariable.value);
t.notEqual(originalVariable.value, newVariable.value);
t.end();
});
test('duplicateVariable creates a new variable with a original ID if specified', t => {
const target = new Target(new Runtime());
target.createVariable('a var ID', 'foo', Variable.SCALAR_TYPE);
t.equal(Object.keys(target.variables).length, 1);
const originalVariable = target.variables['a var ID'];
originalVariable.value = 10;
const newVariable = target.duplicateVariable('a var ID', true);
// Duplicating a variable should not add the variable to the current target
t.equal(Object.keys(target.variables).length, 1);
// Duplicate variable should have the same ID as the original when specified
t.equal(newVariable.id, 'a var ID');
// Duplicate variable should start out with the same value as the original variable
t.equal(newVariable.value, originalVariable.value);
// Modifying one variable should not modify the other
newVariable.value = 15;
t.notEqual(newVariable.value, originalVariable.value);
t.equal(originalVariable.value, 10);
// The target should still have the original variable with the original value
t.equal(target.variables['a var ID'].value, 10);
t.end();
});
test('duplicateVariable returns null if variable with specified ID does not exist', t => {
const target = new Target(new Runtime());
const variable = target.duplicateVariable('a var ID');
t.equal(variable, null);
t.equal(Object.keys(target.variables).length, 0);
target.createVariable('var id', 'foo', Variable.SCALAR_TYPE);
t.equal(Object.keys(target.variables).length, 1);
const anotherVariable = target.duplicateVariable('another var ID');
t.equal(anotherVariable, null);
t.equal(Object.keys(target.variables).length, 1);
t.type(target.variables['another var ID'], 'undefined');
t.type(target.variables['var id'], 'object');
t.notEqual(target.variables['var id'], null);
t.end();
});
test('duplicateVariables duplicates all variables', t => {
const target = new Target(new Runtime());
target.createVariable('var ID 1', 'var1', Variable.SCALAR_TYPE);
target.createVariable('var ID 2', 'var2', Variable.SCALAR_TYPE);
t.equal(Object.keys(target.variables).length, 2);
const var1 = target.variables['var ID 1'];
const var2 = target.variables['var ID 2'];
var1.value = 3;
var2.value = 'foo';
const duplicateVariables = target.duplicateVariables();
// Duplicating a target's variables should not change the target's own variables.
t.equal(Object.keys(target.variables).length, 2);
t.equal(Object.keys(duplicateVariables).length, 2);
// Should be able to find original var IDs in both this target's variables and
// the duplicate variables since a blocks container was not specified.
t.equal(Object.prototype.hasOwnProperty.call(target.variables, 'var ID 1'), true);
t.equal(Object.prototype.hasOwnProperty.call(target.variables, 'var ID 2'), true);
t.equal(Object.prototype.hasOwnProperty.call(duplicateVariables, 'var ID 1'), true);
t.equal(Object.prototype.hasOwnProperty.call(duplicateVariables, 'var ID 1'), true);
// Values of the duplicate varaiables should match the value of the original values at the time of duplication
t.equal(target.variables['var ID 1'].value, duplicateVariables['var ID 1'].value);
t.equal(duplicateVariables['var ID 1'].value, 3);
t.equal(target.variables['var ID 2'].value, duplicateVariables['var ID 2'].value);
t.equal(duplicateVariables['var ID 2'].value, 'foo');
// The two sets of variables should still be distinct, modifying the target's variables
// should not affect the duplicated variables, and vice-versa
var1.value = 10;
t.equal(target.variables['var ID 1'].value, 10);
t.equal(duplicateVariables['var ID 1'].value, 3); // should remain unchanged from initial value
duplicateVariables['var ID 2'].value = 'bar';
t.equal(target.variables['var ID 2'].value, 'foo');
// Deleting a variable on the target should not change the duplicated variables
target.deleteVariable('var ID 1');
t.equal(Object.keys(target.variables).length, 1);
t.equal(Object.keys(duplicateVariables).length, 2);
t.type(duplicateVariables['var ID 1'], 'object');
t.notEqual(duplicateVariables['var ID 1'], null);
t.end();
});
test('duplicateVariables re-IDs variables when a block container is provided', t => {
const target = new Target(new Runtime());
target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE);
target.createVariable('another var id', 'var2', Variable.SCALAR_TYPE);
// Create a block on the target which references the variable with id 'mock var id'
target.blocks.createBlock(adapter(events.mockVariableBlock)[0]);
t.type(target.blocks.getBlock('a block'), '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.blocks.getBlock('a block').fields.VARIABLE.value, 'a mock variable');
// Deep clone this target's blocks to pass in to 'duplicateVariables'
const copiedBlocks = target.blocks.duplicate();
// The copied block should still have the same ID, and its VARIABLE field should still refer to
// the original variable id
t.type(copiedBlocks.getBlock('a block'), 'object');
t.type(copiedBlocks.getBlock('a block').fields.VARIABLE, 'object');
t.equal(copiedBlocks.getBlock('a block').fields.VARIABLE.id, 'mock var id');
t.equal(copiedBlocks.getBlock('a block').fields.VARIABLE.value, 'a mock variable');
const duplicateVariables = target.duplicateVariables(copiedBlocks);
// Duplicate variables should have new IDs
t.equal(Object.keys(duplicateVariables).length, 2);
t.type(duplicateVariables['mock var id'], 'undefined');
t.type(duplicateVariables['another var id'], 'undefined');
// Duplicate variables still have the same names..
const dupes = Object.values(duplicateVariables);
const dupeVarNames = dupes.map(v => v.name);
t.notEqual(dupeVarNames.indexOf('a mock variable'), -1);
t.notEqual(dupeVarNames.indexOf('var2'), -1);
// Duplicating variables should not change blocks on current target
t.type(target.blocks.getBlock('a block'), 'object');
t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id');
t.equal(target.blocks.getBlock('a block').fields.VARIABLE.value, 'a mock variable');
// The copied blocks passed into duplicateVariables should now reference the new
// variable ID
const mockVariableDupe = dupes[dupeVarNames.indexOf('a mock variable')];
const mockVarDupeID = mockVariableDupe.id;
t.type(copiedBlocks.getBlock('a block'), 'object');
t.equal(copiedBlocks.getBlock('a block').fields.VARIABLE.id, mockVarDupeID);
t.equal(copiedBlocks.getBlock('a block').fields.VARIABLE.value, 'a mock variable');
t.end();
});
test('lookupOrCreateList creates a list if var with given id or var with given name does not exist', t => {
const target = new Target(new Runtime());
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
const listVar = target.lookupOrCreateList('foo', 'bar');
t.equal(Object.keys(variables).length, 1);
t.equal(listVar.id, 'foo');
t.equal(listVar.name, 'bar');
t.end();
});
test('lookupOrCreateList returns list if one with given id exists', t => {
const target = new Target(new Runtime());
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('foo', 'bar');
t.equal(Object.keys(variables).length, 1);
t.equal(listVar.id, 'foo');
t.equal(listVar.name, 'bar');
t.end();
});
test('lookupOrCreateList succeeds in finding list if id is incorrect but name matches', t => {
const target = new Target(new Runtime());
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(new Runtime());
const variables = target.variables;
t.equal(Object.keys(variables).length, 0);
target.createVariable('foo', 'bar', Variable.BROADCAST_MESSAGE_TYPE);
t.equal(Object.keys(variables).length, 1);
const broadcastMsg = target.lookupBroadcastMsg('foo', 'bar');
t.equal(Object.keys(variables).length, 1);
t.equal(broadcastMsg.id, 'foo');
t.equal(broadcastMsg.name, 'bar');
t.end();
});
test('createComment adds a comment to the target', t => {
const target = new Target(new Runtime());
const comments = target.comments;
t.equal(Object.keys(comments).length, 0);
target.createComment('a comment', null, 'some comment text',
10, 20, 200, 300, true);
t.equal(Object.keys(comments).length, 1);
const comment = comments['a comment'];
t.notEqual(comment, null);
t.equal(comment.blockId, null);
t.equal(comment.text, 'some comment text');
t.equal(comment.x, 10);
t.equal(comment.y, 20);
t.equal(comment.width, 200);
t.equal(comment.height, 300);
t.equal(comment.minimized, true);
t.end();
});
test('creating comment with id that already exists does not change existing comment', t => {
const target = new Target(new Runtime());
const comments = target.comments;
t.equal(Object.keys(comments).length, 0);
target.createComment('a comment', null, 'some comment text',
10, 20, 200, 300, true);
t.equal(Object.keys(comments).length, 1);
target.createComment('a comment', null,
'some new comment text', 40, 50, 300, 400, false);
const comment = comments['a comment'];
t.notEqual(comment, null);
// All of the comment properties should remain unchanged from the first
// time createComment was called
t.equal(comment.blockId, null);
t.equal(comment.text, 'some comment text');
t.equal(comment.x, 10);
t.equal(comment.y, 20);
t.equal(comment.width, 200);
t.equal(comment.height, 300);
t.equal(comment.minimized, true);
t.end();
});
test('creating a comment with a blockId also updates the comment property on the block', t => {
const target = new Target(new Runtime());
const comments = target.comments;
// Create a mock block on the target
target.blocks = {
'a mock block': {
id: 'a mock block'
}
};
// Mock the getBlock function that's used in commentCreate
target.blocks.getBlock = id => target.blocks[id];
t.equal(Object.keys(comments).length, 0);
target.createComment('a comment', 'a mock block', 'some comment text',
10, 20, 200, 300, true);
t.equal(Object.keys(comments).length, 1);
const comment = comments['a comment'];
t.equal(comment.blockId, 'a mock block');
t.equal(target.blocks.getBlock('a mock block').comment, 'a comment');
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 fixes sprite global var conflicting with other sprite\'s local var', t => {
const runtime = new Runtime();
const stage = new Target(runtime);
stage.isStage = true;
const target = new Target(runtime);
target.isStage = false;
const existingTarget = new Target(runtime);
existingTarget.isStage = false;
runtime.targets = [stage, target, existingTarget];
// Create a local variable on the pre-existing target
existingTarget.createVariable('pre-existing local var id', 'a mock variable', Variable.SCALAR_TYPE);
target.blocks.createBlock(adapter(events.mockVariableBlock)[0]);
t.equal(Object.keys(existingTarget.variables).length, 1);
const existingVariable = Object.values(existingTarget.variables)[0];
t.equal(existingVariable.name, 'a mock variable');
t.equal(Object.keys(target.variables).length, 0);
t.equal(Object.keys(stage.variables).length, 0);
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(existingTarget.variables).length, 1);
t.equal(existingVariable.name, 'a mock variable');
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');
const newGlobal = stage.variables[Object.keys(stage.variables)[0]];
t.equal(newGlobal.name, 'a mock variable2');
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();
});

View File

@@ -0,0 +1,290 @@
const test = require('tap').test;
const Thread = require('../../src/engine/thread');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
t.type(Thread, 'function');
const th = new Thread('arbitraryString');
t.type(th, 'object');
t.ok(th instanceof Thread);
t.type(th.pushStack, 'function');
t.type(th.reuseStackForNextBlock, 'function');
t.type(th.popStack, 'function');
t.type(th.stopThisScript, 'function');
t.type(th.peekStack, 'function');
t.type(th.peekStackFrame, 'function');
t.type(th.peekParentStackFrame, 'function');
t.type(th.pushReportedValue, 'function');
t.type(th.initParams, 'function');
t.type(th.pushParam, 'function');
t.type(th.peekStack, 'function');
t.type(th.getParam, 'function');
t.type(th.atStackTop, 'function');
t.type(th.goToNextBlock, 'function');
t.type(th.isRecursiveCall, 'function');
t.end();
});
test('pushStack', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
t.end();
});
test('popStack', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
t.strictEquals(th.popStack(), 'arbitraryString');
t.strictEquals(th.popStack(), undefined);
t.end();
});
test('atStackTop', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
th.pushStack('secondString');
t.strictEquals(th.atStackTop(), false);
th.popStack();
t.strictEquals(th.atStackTop(), true);
t.end();
});
test('reuseStackForNextBlock', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
th.reuseStackForNextBlock('secondString');
t.strictEquals(th.popStack(), 'secondString');
t.end();
});
test('peekStackFrame', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
t.strictEquals(th.peekStackFrame().warpMode, false);
th.popStack();
t.strictEquals(th.peekStackFrame(), null);
t.end();
});
test('peekParentStackFrame', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
th.peekStackFrame().warpMode = true;
t.strictEquals(th.peekParentStackFrame(), null);
th.pushStack('secondString');
t.strictEquals(th.peekParentStackFrame().warpMode, true);
t.end();
});
test('pushReportedValue', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
th.pushStack('secondString');
th.pushReportedValue('value');
t.strictEquals(th.justReported, 'value');
t.end();
});
test('peekStack', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
t.strictEquals(th.peekStack(), 'arbitraryString');
th.popStack();
t.strictEquals(th.peekStack(), null);
t.end();
});
test('PushGetParam', t => {
const th = new Thread('arbitraryString');
th.pushStack('arbitraryString');
th.initParams();
th.pushParam('testParam', 'testValue');
t.strictEquals(th.peekStackFrame().params.testParam, 'testValue');
t.strictEquals(th.getParam('testParam'), 'testValue');
// Params outside of define stack always evaluate to null
t.strictEquals(th.getParam('nonExistentParam'), null);
t.end();
});
test('goToNextBlock', t => {
const th = new Thread('arbitraryString');
const r = new Runtime();
const s = new Sprite(null, r);
const rt = new RenderedTarget(s, r);
const block1 = {fields: Object,
id: 'arbitraryString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: 'secondString',
opcode: 'motion_movesteps',
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
const block2 = {fields: Object,
id: 'secondString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: null,
opcode: 'procedures_call',
mutation: {proccode: 'fakeCode'},
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
rt.blocks.createBlock(block1);
rt.blocks.createBlock(block2);
rt.blocks.createBlock(block2);
th.target = rt;
t.strictEquals(th.peekStack(), null);
th.pushStack('secondString');
t.strictEquals(th.peekStack(), 'secondString');
th.goToNextBlock();
t.strictEquals(th.peekStack(), null);
th.pushStack('secondString');
th.pushStack('arbitraryString');
t.strictEquals(th.peekStack(), 'arbitraryString');
th.goToNextBlock();
t.strictEquals(th.peekStack(), 'secondString');
th.goToNextBlock();
t.strictEquals(th.peekStack(), null);
t.end();
});
test('stopThisScript', t => {
const th = new Thread('arbitraryString');
const r = new Runtime();
const s = new Sprite(null, r);
const rt = new RenderedTarget(s, r);
const block1 = {fields: Object,
id: 'arbitraryString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: null,
opcode: 'motion_movesteps',
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
const block2 = {fields: Object,
id: 'secondString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: null,
opcode: 'procedures_call',
mutation: {proccode: 'fakeCode'},
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
rt.blocks.createBlock(block1);
rt.blocks.createBlock(block2);
th.target = rt;
th.stopThisScript();
t.strictEquals(th.peekStack(), null);
th.pushStack('arbitraryString');
t.strictEquals(th.peekStack(), 'arbitraryString');
th.stopThisScript();
t.strictEquals(th.peekStack(), null);
th.pushStack('arbitraryString');
th.pushStack('secondString');
th.stopThisScript();
t.strictEquals(th.peekStack(), null);
t.end();
});
test('isRecursiveCall', t => {
const th = new Thread('arbitraryString');
const r = new Runtime();
const s = new Sprite(null, r);
const rt = new RenderedTarget(s, r);
const block1 = {fields: Object,
id: 'arbitraryString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: null,
opcode: 'motion_movesteps',
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
const block2 = {fields: Object,
id: 'secondString',
inputs: Object,
STEPS: Object,
block: 'fakeBlock',
name: 'STEPS',
next: null,
opcode: 'procedures_call',
mutation: {proccode: 'fakeCode'},
parent: null,
shadow: false,
topLevel: true,
x: 0,
y: 0
};
rt.blocks.createBlock(block1);
rt.blocks.createBlock(block2);
th.target = rt;
const pushStack = id => {
th.pushStack(id);
th.peekStackFrame().op = {id};
};
t.strictEquals(th.isRecursiveCall('fakeCode'), false);
pushStack('secondString');
t.strictEquals(th.isRecursiveCall('fakeCode'), false);
pushStack('arbitraryString');
t.strictEquals(th.isRecursiveCall('fakeCode'), true);
pushStack('arbitraryString');
t.strictEquals(th.isRecursiveCall('fakeCode'), true);
th.popStack();
t.strictEquals(th.isRecursiveCall('fakeCode'), true);
th.popStack();
t.strictEquals(th.isRecursiveCall('fakeCode'), false);
th.popStack();
t.strictEquals(th.isRecursiveCall('fakeCode'), false);
t.end();
});

View File

@@ -0,0 +1,110 @@
const test = require('tap').test;
const Variable = require('../../src/engine/variable');
const htmlparser = require('htmlparser2');
test('spec', t => {
t.type(typeof Variable.SCALAR_TYPE, typeof Variable.LIST_TYPE);
t.type(typeof Variable.SCALAR_TYPE, typeof Variable.BROADCAST_MESSAGE_TYPE);
const varId = 'varId';
const varName = 'varName';
const varIsCloud = false;
let v = new Variable(
varId,
varName,
Variable.SCALAR_TYPE,
varIsCloud
);
t.type(Variable, 'function');
t.type(v, 'object');
t.ok(v instanceof Variable);
t.equal(v.id, varId);
t.equal(v.name, varName);
t.equal(v.type, Variable.SCALAR_TYPE);
t.type(v.value, 'number');
t.equal(v.isCloud, varIsCloud);
t.type(v.toXML, 'function');
v = new Variable(
varId,
varName,
Variable.LIST_TYPE,
varIsCloud
);
t.ok(Array.isArray(v.value));
v = new Variable(
varId,
varName,
Variable.BROADCAST_MESSAGE_TYPE,
varIsCloud
);
t.equal(v.value, 'varName');
t.end();
});
test('toXML', t => {
const varId = 'varId';
const varName = 'varName';
const varIsCloud = false;
const varIsLocal = false;
const v = new Variable(
varId,
varName,
Variable.SCALAR_TYPE,
varIsCloud
);
const parser = new htmlparser.Parser({
onopentag: function (name, attribs){
if (name === 'variable'){
t.equal(attribs.type, Variable.SCALAR_TYPE);
t.equal(attribs.id, varId);
t.equal(attribs.iscloud, varIsCloud.toString());
t.equal(attribs.islocal, varIsLocal.toString());
}
},
ontext: function (text){
t.equal(text, varName);
}
}, {decodeEntities: false});
parser.write(v.toXML(false));
parser.end();
t.end();
});
test('escape variable name for XML', t => {
const varId = 'varId';
const varName = '<>&\'"';
const varIsCloud = false;
const varIsLocal = false;
const v = new Variable(
varId,
varName,
Variable.SCALAR_TYPE,
varIsCloud
);
const parser = new htmlparser.Parser({
onopentag: function (name, attribs){
if (name === 'variable'){
t.equal(attribs.type, Variable.SCALAR_TYPE);
t.equal(attribs.id, varId);
t.equal(attribs.iscloud, varIsCloud.toString());
t.equal(attribs.islocal, varIsLocal.toString());
}
},
ontext: function (text){
t.equal(text, '&lt;&gt;&amp;&apos;&quot;');
}
}, {decodeEntities: false});
parser.write(v.toXML(false));
parser.end();
t.end();
});

View File

@@ -0,0 +1,327 @@
const test = require('tap').test;
const ArgumentType = require('../../src/extension-support/argument-type');
const BlockType = require('../../src/extension-support/block-type');
const Runtime = require('../../src/engine/runtime');
const ScratchBlocksConstants = require('../../src/engine/scratch-blocks-constants');
/**
* @type {ExtensionMetadata}
*/
const testExtensionInfo = {
id: 'test',
name: 'fake test extension',
color1: '#111111',
color2: '#222222',
color3: '#333333',
blocks: [
{
func: 'MAKE_A_VARIABLE',
blockType: BlockType.BUTTON,
text: 'this is a button'
},
{
opcode: 'reporter',
blockType: BlockType.REPORTER,
text: 'simple text',
blockIconURI: 'invalid icon URI' // trigger the 'scratch_extension' path
},
{
opcode: 'inlineImage',
blockType: BlockType.REPORTER,
text: 'text and [IMAGE]',
arguments: {
IMAGE: {
type: ArgumentType.IMAGE,
dataURI: 'invalid image URI'
}
}
},
'---', // separator between groups of blocks in an extension
{
opcode: 'command',
blockType: BlockType.COMMAND,
text: 'text with [ARG] [ARG_WITH_DEFAULT]',
arguments: {
ARG: {
type: ArgumentType.STRING
},
ARG_WITH_DEFAULT: {
type: ArgumentType.STRING,
defaultValue: 'default text'
}
}
},
{
opcode: 'ifElse',
blockType: BlockType.CONDITIONAL,
branchCount: 2,
text: [
'test if [THING] is spiffy and if so then',
'or elsewise'
],
arguments: {
THING: {
type: ArgumentType.BOOLEAN
}
}
},
{
opcode: 'loop',
blockType: BlockType.LOOP, // implied branchCount of 1 unless otherwise stated
isTerminal: true,
text: [
'loopty [MANY] loops'
],
arguments: {
MANY: {
type: ArgumentType.NUMBER
}
}
}
]
};
const extensionInfoWithCustomFieldTypes = {
id: 'test_custom_fieldType',
name: 'fake test extension with customFieldTypes',
color1: '#111111',
color2: '#222222',
color3: '#333333',
blocks: [
{ // Block that uses custom field types
opcode: 'motorTurnFor',
blockType: BlockType.COMMAND,
text: '[PORT] run [DIRECTION] for [VALUE] [UNIT]',
arguments: {
PORT: {
defaultValue: 'A',
type: 'single-port-selector'
},
DIRECTION: {
defaultValue: 'clockwise',
type: 'custom-direction'
}
}
}
],
customFieldTypes: {
'single-port-selector': {
output: 'string',
outputShape: 2,
implementation: {
fromJson: () => null
}
},
'custom-direction': {
output: 'string',
outputShape: 3,
implementation: {
fromJson: () => null
}
}
}
};
const testCategoryInfo = function (t, block) {
t.equal(block.json.category, 'fake test extension');
t.equal(block.json.colour, '#111111');
t.equal(block.json.colourSecondary, '#222222');
t.equal(block.json.colourTertiary, '#333333');
t.equal(block.json.inputsInline, true);
};
const testButton = function (t, button) {
t.same(button.json, null); // should be null or undefined
t.equal(button.xml, '<button text="this is a button" callbackKey="MAKE_A_VARIABLE"></button>');
};
const testReporter = function (t, reporter) {
t.equal(reporter.json.type, 'test_reporter');
testCategoryInfo(t, reporter);
t.equal(reporter.json.checkboxInFlyout, true);
t.equal(reporter.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND);
t.equal(reporter.json.output, 'String');
t.notOk(Object.prototype.hasOwnProperty.call(reporter.json, 'previousStatement'));
t.notOk(Object.prototype.hasOwnProperty.call(reporter.json, 'nextStatement'));
t.same(reporter.json.extensions, ['from_extension', 'scratch_extension']);
t.equal(reporter.json.message0, '%1 %2simple text'); // "%1 %2" from the block icon
t.notOk(Object.prototype.hasOwnProperty.call(reporter.json, 'message1'));
t.same(reporter.json.args0, [
// %1 in message0: the block icon
{
type: 'field_image',
src: 'invalid icon URI',
width: 40,
height: 40
},
// %2 in message0: separator between icon and text (only added when there's also an icon)
{
type: 'field_vertical_separator'
}
]);
t.notOk(Object.prototype.hasOwnProperty.call(reporter.json, 'args1'));
t.equal(reporter.xml, '<block type="test_reporter"></block>');
};
const testInlineImage = function (t, inlineImage) {
t.equal(inlineImage.json.type, 'test_inlineImage');
testCategoryInfo(t, inlineImage);
t.equal(inlineImage.json.checkboxInFlyout, true);
t.equal(inlineImage.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND);
t.equal(inlineImage.json.output, 'String');
t.notOk(Object.prototype.hasOwnProperty.call(inlineImage.json, 'previousStatement'));
t.notOk(Object.prototype.hasOwnProperty.call(inlineImage.json, 'nextStatement'));
t.same(inlineImage.json.extensions, ['from_extension']);
t.equal(inlineImage.json.message0, 'text and %1'); // block text followed by inline image
t.notOk(Object.prototype.hasOwnProperty.call(inlineImage.json, 'message1'));
t.same(inlineImage.json.args0, [
// %1 in message0: the block icon
{
type: 'field_image',
src: 'invalid image URI',
width: 24,
height: 24,
flip_rtl: false // False by default
}
]);
t.notOk(Object.prototype.hasOwnProperty.call(inlineImage.json, 'args1'));
t.equal(inlineImage.xml, '<block type="test_inlineImage"></block>');
};
const testSeparator = function (t, separator) {
t.same(separator.json, null); // should be null or undefined
t.equal(separator.xml, '<sep gap="36"/>');
};
const testCommand = function (t, command) {
t.equal(command.json.type, 'test_command');
testCategoryInfo(t, command);
t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.assert(Object.prototype.hasOwnProperty.call(command.json, 'previousStatement'));
t.assert(Object.prototype.hasOwnProperty.call(command.json, 'nextStatement'));
t.same(command.json.extensions, ['from_extension']);
t.equal(command.json.message0, 'text with %1 %2');
t.notOk(Object.prototype.hasOwnProperty.call(command.json, 'message1'));
t.strictSame(command.json.args0[0], {
type: 'input_value',
name: 'ARG'
});
t.notOk(Object.prototype.hasOwnProperty.call(command.json, 'args1'));
t.equal(command.xml,
'<block type="test_command"><value name="ARG"><shadow type="text"></shadow></value>' +
'<value name="ARG_WITH_DEFAULT"><shadow type="text"><field name="TEXT">' +
'default text</field></shadow></value></block>');
};
const testConditional = function (t, conditional) {
t.equal(conditional.json.type, 'test_ifElse');
testCategoryInfo(t, conditional);
t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.ok(Object.prototype.hasOwnProperty.call(conditional.json, 'previousStatement'));
t.ok(Object.prototype.hasOwnProperty.call(conditional.json, 'nextStatement'));
t.same(conditional.json.extensions, ['from_extension']);
t.equal(conditional.json.message0, 'test if %1 is spiffy and if so then');
t.equal(conditional.json.message1, '%1'); // placeholder for substack #1
t.equal(conditional.json.message2, 'or elsewise');
t.equal(conditional.json.message3, '%1'); // placeholder for substack #2
t.notOk(Object.prototype.hasOwnProperty.call(conditional.json, 'message4'));
t.strictSame(conditional.json.args0[0], {
type: 'input_value',
name: 'THING',
check: 'Boolean'
});
t.strictSame(conditional.json.args1[0], {
type: 'input_statement',
name: 'SUBSTACK'
});
t.notOk(Object.prototype.hasOwnProperty.call(conditional.json, conditional.json.args2));
t.strictSame(conditional.json.args3[0], {
type: 'input_statement',
name: 'SUBSTACK2'
});
t.notOk(Object.prototype.hasOwnProperty.call(conditional.json, 'args4'));
t.equal(conditional.xml, '<block type="test_ifElse"><value name="THING"></value></block>');
};
const testLoop = function (t, loop) {
t.equal(loop.json.type, 'test_loop');
testCategoryInfo(t, loop);
t.equal(loop.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.ok(Object.prototype.hasOwnProperty.call(loop.json, 'previousStatement'));
t.notOk(Object.prototype.hasOwnProperty.call(loop.json, 'nextStatement')); // isTerminal is set on this block
t.same(loop.json.extensions, ['from_extension']);
t.equal(loop.json.message0, 'loopty %1 loops');
t.equal(loop.json.message1, '%1'); // placeholder for substack
t.equal(loop.json.message2, '%1'); // placeholder for loop arrow
t.notOk(Object.prototype.hasOwnProperty.call(loop.json, 'message3'));
t.strictSame(loop.json.args0[0], {
type: 'input_value',
name: 'MANY'
});
t.strictSame(loop.json.args1[0], {
type: 'input_statement',
name: 'SUBSTACK'
});
t.equal(loop.json.lastDummyAlign2, 'RIGHT'); // move loop arrow to right side
t.equal(loop.json.args2[0].type, 'field_image');
t.equal(loop.json.args2[0].flip_rtl, true);
t.notOk(Object.prototype.hasOwnProperty.call(loop.json, 'args3'));
t.equal(loop.xml,
'<block type="test_loop"><value name="MANY"><shadow type="math_number"></shadow></value></block>');
};
test('registerExtensionPrimitives', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
const blocksInfo = categoryInfo.blocks;
t.equal(blocksInfo.length, testExtensionInfo.blocks.length);
blocksInfo.forEach(blockInfo => {
// `true` here means "either an object or a non-empty string but definitely not null or undefined"
t.true(blockInfo.info, 'Every block and pseudo-block must have a non-empty "info" field');
});
// Note that this also implicitly tests that block order is preserved
const [button, reporter, inlineImage, separator, command, conditional, loop] = blocksInfo;
testButton(t, button);
testReporter(t, reporter);
testInlineImage(t, inlineImage);
testSeparator(t, separator);
testCommand(t, command);
testConditional(t, conditional);
testLoop(t, loop);
t.end();
});
runtime._registerExtensionPrimitives(testExtensionInfo);
});
test('custom field types should be added to block and EXTENSION_FIELD_ADDED callback triggered', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
const blockInfo = categoryInfo.blocks[0];
// We expect that for each argument there's a corresponding <field>-tag in the block XML
Object.values(blockInfo.info.arguments).forEach(argument => {
const regex = new RegExp(`<field name="field_${categoryInfo.id}_${argument.type}">`);
t.true(regex.test(blockInfo.xml));
});
});
let fieldAddedCallbacks = 0;
runtime.on(Runtime.EXTENSION_FIELD_ADDED, () => {
fieldAddedCallbacks++;
});
runtime._registerExtensionPrimitives(extensionInfoWithCustomFieldTypes);
// Extension includes two custom field types
t.equal(fieldAddedCallbacks, 2);
t.end();
});

View File

@@ -0,0 +1,12 @@
const test = require('tap').test;
// const MicroBit = require('../../src/extensions/scratch3_microbit/index.js');
test('displayText', t => {
t.end();
});
test('displayMatrix', t => {
t.end();
});
// etc...

View File

@@ -0,0 +1,49 @@
const test = require('tap').test;
const Music = require('../../src/extensions/scratch3_music/index.js');
const fakeRuntime = {
getTargetForStage: () => ({tempo: 60}),
on: () => {} // Stub out listener methods used in constructor.
};
const blocks = new Music(fakeRuntime);
const util = {
stackFrame: Object.create(null),
target: {
audioPlayer: null
},
yield: () => null
};
test('playDrum uses 1-indexing and wrap clamps', t => {
// Stub playDrumNum
let playedDrum;
blocks._playDrumNum = (_util, drum) => (playedDrum = drum);
let args = {DRUM: 1};
blocks.playDrumForBeats(args, util);
t.strictEqual(playedDrum, 0);
args = {DRUM: blocks.DRUM_INFO.length + 1};
blocks.playDrumForBeats(args, util);
t.strictEqual(playedDrum, 0);
t.end();
});
test('setInstrument uses 1-indexing and wrap clamps', t => {
// Stub getMusicState
const state = {currentInstrument: 0};
blocks._getMusicState = () => state;
let args = {INSTRUMENT: 1};
blocks.setInstrument(args, util);
t.strictEqual(state.currentInstrument, 0);
args = {INSTRUMENT: blocks.INSTRUMENT_INFO.length + 1};
blocks.setInstrument(args, util);
t.strictEqual(state.currentInstrument, 0);
t.end();
});

View File

@@ -0,0 +1,44 @@
const test = require('tap').test;
const TextToSpeech = require('../../src/extensions/scratch3_text2speech/index.js');
const fakeStage = {
textToSpeechLanguage: null
};
const fakeRuntime = {
getTargetForStage: () => fakeStage,
on: () => {} // Stub out listener methods used in constructor.
};
const ext = new TextToSpeech(fakeRuntime);
test('if no language is saved in the project, use default', t => {
t.strictEqual(ext.getCurrentLanguage(), 'en');
t.end();
});
test('if an unsupported language is dropped onto the set language block, use default', t => {
ext.setLanguage({LANGUAGE: 'nope'});
t.strictEqual(ext.getCurrentLanguage(), 'en');
t.end();
});
test('if a supported language name is dropped onto the set language block, use it', t => {
ext.setLanguage({LANGUAGE: 'español'});
t.strictEqual(ext.getCurrentLanguage(), 'es');
t.end();
});
test('get the extension locale for a supported locale that differs', t => {
ext.setLanguage({LANGUAGE: 'ja-hira'});
t.strictEqual(ext.getCurrentLanguage(), 'ja');
t.end();
});
test('use localized spoken language name in place of localized written language name', t => {
ext.getEditorLanguage = () => 'es';
const languageMenu = ext.getLanguageMenu();
const localizedNameForChineseInSpanish = languageMenu.find(el => el.value === 'zh-cn').text;
t.strictEqual(localizedNameForChineseInSpanish, 'Chino (Mandarín)'); // i.e. should not be 'Chino (simplificado)'
t.end();
});

View File

@@ -0,0 +1,411 @@
const {createReadStream} = require('fs');
const {join} = require('path');
const {PNG} = require('pngjs');
const {test} = require('tap');
const {wrapClamp} = require('../../src/util/math-util');
const VideoSensing = require('../../src/extensions/scratch3_video_sensing/index.js');
const VideoMotion = require('../../src/extensions/scratch3_video_sensing/library.js');
/**
* Prefix to the mock frame images used to test the video sensing extension.
* @type {string}
*/
const pngPrefix = 'extension_video_sensing_';
/**
* Map of frame keys to the image filenames appended to the pngPrefix.
* @type {object}
*/
const framesMap = {
center: 'center',
left: 'left-5',
left2: 'left-10',
down: 'down-10'
};
/**
* Asynchronously read a png file and copy its pixel data into a typed array
* VideoMotion will accept.
* @param {string} name - partial filename to read
* @returns {Promise.<Uint32Array>} pixel data of the image
*/
const readPNG = name => (
new Promise((resolve, reject) => {
const png = new PNG();
createReadStream(join(__dirname, `${pngPrefix}${name}.png`))
.pipe(png)
.on('parsed', () => {
// Copy the RGBA pixel values into a separate typed array and
// cast the array to Uint32, the array format VideoMotion takes.
resolve(new Uint32Array(new Uint8ClampedArray(png.data).buffer));
})
.on('error', reject);
})
);
/**
* Read all the frames for testing asynchrnously and produce an object with
* keys following the keys in framesMap.
* @returns {object} mapping of keys in framesMap to image data read from disk
*/
const readFrames = (() => {
// Use this immediately invoking function expression (IIFE) to delay reading
// once to the first test that calls readFrames.
let _promise = null;
return () => {
if (_promise === null) {
_promise = Promise.all(Object.keys(framesMap).map(key => readPNG(framesMap[key])))
.then(pngs => (
Object.keys(framesMap).reduce((frames, key, i) => {
frames[key] = pngs[i];
return frames;
}, {})
));
}
return _promise;
};
})();
/**
* Match if actual is within optMargin to expect. If actual is under -180,
* match if actual + 360 is near expect. If actual is over 180, match if actual
* - 360 is near expect.
* @param {number} actual - actual angle in degrees
* @param {number} expect - expected angle in degrees
* @param {number} optMargin - allowed margin between actual and expect in degrees
* @returns {boolean} true if actual is close to expect
*/
const isNearAngle = (actual, expect, optMargin = 10) => (
(wrapClamp(actual - expect, 0, 359) < optMargin) ||
(wrapClamp(actual - expect, 0, 359) > 360 - optMargin)
);
// A fake scratch-render drawable that will be used by VideoMotion to restrain
// the area considered for motion detection in VideoMotion.getLocalMotion
const fakeDrawable = {
updateCPURenderAttributes () {}, // no-op, since isTouching always returns true
getFastBounds () {
return {
left: -120,
top: 60,
right: 0,
bottom: -60
};
},
isTouching () {
return true;
}
};
// A fake MotionState used to test the stored values in
// VideoMotion.getLocalMotion, VideoSensing.videoOn and
// VideoSensing.whenMotionGreaterThan.
const fakeMotionState = {
motionFrameNumber: -1,
motionAmount: -1,
motionDirection: -Infinity
};
// A fake target referring to the fake drawable and MotionState.
const fakeTarget = {
drawableID: 0,
getCustomState () {
return fakeMotionState;
},
setCustomState () {}
};
const fakeRuntime = {
targets: [fakeTarget],
// Without defined devices, VideoSensing will not try to start sampling from
// a video source.
ioDevices: null,
renderer: {
_allDrawables: [
fakeDrawable
]
}
};
const fakeBlockUtility = {
target: fakeTarget
};
test('detect motionAmount between frames', t => {
t.plan(6);
return readFrames()
.then(frames => {
const detect = new VideoMotion();
// Each of these pairs should have enough motion for the detector.
const framePairs = [
[frames.center, frames.left],
[frames.center, frames.left2],
[frames.left, frames.left2],
[frames.left, frames.center],
[frames.center, frames.down],
[frames.down, frames.center]
];
// Add both frames of a pair and test for motion.
let index = 0;
for (const [frame1, frame2] of framePairs) {
detect.addFrame(frame1);
detect.addFrame(frame2);
detect.analyzeFrame();
t.ok(
detect.motionAmount > 10,
`frame pair ${index + 1} has motion ${detect.motionAmount} over threshold (10)`
);
index += 1;
}
t.end();
});
});
test('detect local motionAmount between frames', t => {
t.plan(6);
return readFrames()
.then(frames => {
const detect = new VideoMotion();
// Each of these pairs should have enough motion for the detector.
const framePairs = [
[frames.center, frames.left],
[frames.center, frames.left2],
[frames.left, frames.left2],
[frames.left, frames.center],
[frames.center, frames.down],
[frames.down, frames.center]
];
// Add both frames of a pair and test for local motion.
let index = 0;
for (const [frame1, frame2] of framePairs) {
detect.addFrame(frame1);
detect.addFrame(frame2);
detect.analyzeFrame();
detect.getLocalMotion(fakeDrawable, fakeMotionState);
t.ok(
fakeMotionState.motionAmount > 10,
`frame pair ${index + 1} has motion ${fakeMotionState.motionAmount} over threshold (10)`
);
index += 1;
}
t.end();
});
});
test('detect motionDirection between frames', t => {
t.plan(6);
return readFrames()
.then(frames => {
const detect = new VideoMotion();
// Each of these pairs is moving in the given direction. Does the detector
// guess a value to that?
const directionMargin = 10;
const framePairs = [
{
frames: [frames.center, frames.left],
direction: -90
},
{
frames: [frames.center, frames.left2],
direction: -90
},
{
frames: [frames.left, frames.left2],
direction: -90
},
{
frames: [frames.left, frames.center],
direction: 90
},
{
frames: [frames.center, frames.down],
direction: 180
},
{
frames: [frames.down, frames.center],
direction: 0
}
];
// Add both frames of a pair and check if the motionDirection is near the
// expected angle.
let index = 0;
for (const {frames: [frame1, frame2], direction} of framePairs) {
detect.addFrame(frame1);
detect.addFrame(frame2);
detect.analyzeFrame();
t.ok(
isNearAngle(detect.motionDirection, direction, directionMargin),
`frame pair ${index + 1} is ${detect.motionDirection.toFixed(0)} ` +
`degrees and close to ${direction} degrees`
);
index += 1;
}
t.end();
});
});
test('detect local motionDirection between frames', t => {
t.plan(6);
return readFrames()
.then(frames => {
const detect = new VideoMotion();
// Each of these pairs is moving in the given direction. Does the detector
// guess a value to that?
const directionMargin = 10;
const framePairs = [
{
frames: [frames.center, frames.left],
direction: -90
},
{
frames: [frames.center, frames.left2],
direction: -90
},
{
frames: [frames.left, frames.left2],
direction: -90
},
{
frames: [frames.left, frames.center],
direction: 90
},
{
frames: [frames.center, frames.down],
direction: 180
},
{
frames: [frames.down, frames.center],
direction: 0
}
];
// Add both frames of a pair and check if the local motionDirection is near
// the expected angle.
let index = 0;
for (const {frames: [frame1, frame2], direction} of framePairs) {
detect.addFrame(frame1);
detect.addFrame(frame2);
detect.analyzeFrame();
detect.getLocalMotion(fakeDrawable, fakeMotionState);
const motionDirection = fakeMotionState.motionDirection;
t.ok(
isNearAngle(motionDirection, direction, directionMargin),
`frame pair ${index + 1} is ${motionDirection.toFixed(0)} degrees and close to ${direction} degrees`
);
index += 1;
}
t.end();
});
});
test('videoOn returns value dependent on arguments', t => {
t.plan(4);
return readFrames()
.then(frames => {
const sensing = new VideoSensing(fakeRuntime);
// With these two frame test if we get expected values depending on the
// arguments to videoOn.
sensing.detect.addFrame(frames.center);
sensing.detect.addFrame(frames.left);
const motionAmount = sensing.videoOn({
ATTRIBUTE: VideoSensing.SensingAttribute.MOTION,
SUBJECT: VideoSensing.SensingSubject.STAGE
}, fakeBlockUtility);
t.ok(
motionAmount > 10,
`stage motionAmount ${motionAmount} is over the threshold (10)`
);
const localMotionAmount = sensing.videoOn({
ATTRIBUTE: VideoSensing.SensingAttribute.MOTION,
SUBJECT: VideoSensing.SensingSubject.SPRITE
}, fakeBlockUtility);
t.ok(
localMotionAmount > 10,
`sprite motionAmount ${localMotionAmount} is over the threshold (10)`
);
const motionDirection = sensing.videoOn({
ATTRIBUTE: VideoSensing.SensingAttribute.DIRECTION,
SUBJECT: VideoSensing.SensingSubject.STAGE
}, fakeBlockUtility);
t.ok(
isNearAngle(motionDirection, -90),
`stage motionDirection ${motionDirection.toFixed(0)} degrees is close to ${90} degrees`
);
const localMotionDirection = sensing.videoOn({
ATTRIBUTE: VideoSensing.SensingAttribute.DIRECTION,
SUBJECT: VideoSensing.SensingSubject.SPRITE
}, fakeBlockUtility);
t.ok(
isNearAngle(localMotionDirection, -90),
`sprite motionDirection ${localMotionDirection.toFixed(0)} degrees is close to ${90} degrees`
);
t.end();
});
});
test('whenMotionGreaterThan returns true if local motion meets target', t => {
t.plan(2);
return readFrames()
.then(frames => {
const sensing = new VideoSensing(fakeRuntime);
// With these two frame test if we get expected values depending on the
// arguments to whenMotionGreaterThan.
sensing.detect.addFrame(frames.center);
sensing.detect.addFrame(frames.left);
const over20 = sensing.whenMotionGreaterThan({
REFERENCE: 20
}, fakeBlockUtility);
t.ok(
over20,
`enough motion in drawable bounds to reach reference of 20`
);
const over80 = sensing.whenMotionGreaterThan({
REFERENCE: 80
}, fakeBlockUtility);
t.notOk(
over80,
`not enough motion in drawable bounds to reach reference of 80`
);
t.end();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,37 @@
const test = require('tap').test;
const Clock = require('../../src/io/clock');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
const rt = new Runtime();
const c = new Clock(rt);
t.type(Clock, 'function');
t.type(c, 'object');
t.type(c.projectTimer, 'function');
t.type(c.pause, 'function');
t.type(c.resume, 'function');
t.type(c.resetProjectTimer, 'function');
t.end();
});
test('cycle', t => {
const rt = new Runtime();
const c = new Clock(rt);
t.ok(c.projectTimer() <= 0.1);
setTimeout(() => {
c.resetProjectTimer();
setTimeout(() => {
// The timer shouldn't advance until all threads have been stepped
t.ok(c.projectTimer() === 0);
c.pause();
t.ok(c.projectTimer() === 0);
c.resume();
t.ok(c.projectTimer() === 0);
t.end();
}, 100);
}, 100);
rt._step();
t.ok(c.projectTimer() > 0);
});

View File

@@ -0,0 +1,168 @@
const test = require('tap').test;
const Cloud = require('../../src/io/cloud');
const Target = require('../../src/engine/target');
const Variable = require('../../src/engine/variable');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
const runtime = new Runtime();
const cloud = new Cloud(runtime);
t.type(cloud, 'object');
t.type(cloud.postData, 'function');
t.type(cloud.requestCreateVariable, 'function');
t.type(cloud.requestUpdateVariable, 'function');
t.type(cloud.requestRenameVariable, 'function');
t.type(cloud.requestDeleteVariable, 'function');
t.type(cloud.updateCloudVariable, 'function');
t.type(cloud.setProvider, 'function');
t.type(cloud.setStage, 'function');
t.type(cloud.clear, 'function');
t.end();
});
test('stage and provider are null initially', t => {
const runtime = new Runtime();
const cloud = new Cloud(runtime);
t.strictEquals(cloud.provider, null);
t.strictEquals(cloud.stage, null);
t.end();
});
test('setProvider sets the provider', t => {
const runtime = new Runtime();
const cloud = new Cloud(runtime);
const provider = {
foo: 'a fake provider'
};
cloud.setProvider(provider);
t.strictEquals(cloud.provider, provider);
t.end();
});
test('postData update message updates the variable', t => {
const runtime = new Runtime();
const stage = new Target(runtime);
const fooVar = new Variable(
'a fake var id',
'foo',
Variable.SCALAR_TYPE,
true /* isCloud */
);
stage.variables[fooVar.id] = fooVar;
t.strictEquals(fooVar.value, 0);
const cloud = new Cloud(runtime);
cloud.setStage(stage);
cloud.postData({varUpdate: {
name: 'foo',
value: 3
}});
t.strictEquals(fooVar.value, 3);
t.end();
});
test('requestUpdateVariable calls provider\'s updateVariable function', t => {
let updateVariableCalled = false;
let mockVarName = '';
let mockVarValue = '';
const mockUpdateVariable = (name, value) => {
updateVariableCalled = true;
mockVarName = name;
mockVarValue = value;
return;
};
const provider = {
updateVariable: mockUpdateVariable
};
const runtime = new Runtime();
const cloud = new Cloud(runtime);
cloud.setProvider(provider);
cloud.requestUpdateVariable('foo', 3);
t.equals(updateVariableCalled, true);
t.strictEquals(mockVarName, 'foo');
t.strictEquals(mockVarValue, 3);
t.end();
});
test('requestCreateVariable calls provider\'s createVariable function', t => {
let createVariableCalled = false;
const mockVariable = new Variable('a var id', 'my var', Variable.SCALAR_TYPE, false);
let mockVarName;
let mockVarValue;
const mockCreateVariable = (name, value) => {
createVariableCalled = true;
mockVarName = name;
mockVarValue = value;
return;
};
const provider = {
createVariable: mockCreateVariable
};
const runtime = new Runtime();
const cloud = new Cloud(runtime);
cloud.setProvider(provider);
cloud.requestCreateVariable(mockVariable);
t.equals(createVariableCalled, true);
t.strictEquals(mockVarName, 'my var');
t.strictEquals(mockVarValue, 0);
// Calling requestCreateVariable does not set isCloud flag on variable
t.strictEquals(mockVariable.isCloud, false);
t.end();
});
test('requestRenameVariable calls provider\'s renameVariable function', t => {
let renameVariableCalled = false;
let mockVarOldName;
let mockVarNewName;
const mockRenameVariable = (oldName, newName) => {
renameVariableCalled = true;
mockVarOldName = oldName;
mockVarNewName = newName;
return;
};
const provider = {
renameVariable: mockRenameVariable
};
const runtime = new Runtime();
const cloud = new Cloud(runtime);
cloud.setProvider(provider);
cloud.requestRenameVariable('my var', 'new var name');
t.equals(renameVariableCalled, true);
t.strictEquals(mockVarOldName, 'my var');
t.strictEquals(mockVarNewName, 'new var name');
t.end();
});
test('requestDeleteVariable calls provider\'s deleteVariable function', t => {
let deleteVariableCalled = false;
let mockVarName;
const mockDeleteVariable = name => {
deleteVariableCalled = true;
mockVarName = name;
return;
};
const provider = {
deleteVariable: mockDeleteVariable
};
const runtime = new Runtime();
const cloud = new Cloud(runtime);
cloud.setProvider(provider);
cloud.requestDeleteVariable('my var');
t.equals(deleteVariableCalled, true);
t.strictEquals(mockVarName, 'my var');
t.end();
});

View File

@@ -0,0 +1,105 @@
const test = require('tap').test;
const Keyboard = require('../../src/io/keyboard');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
t.type(k, 'object');
t.type(k.postData, 'function');
t.type(k.getKeyIsDown, 'function');
t.end();
});
test('space key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: ' ',
isDown: true
});
t.strictDeepEquals(k._keysPressed, ['space']);
t.strictEquals(k.getKeyIsDown('space'), true);
t.strictEquals(k.getKeyIsDown('any'), true);
t.end();
});
test('letter key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: 'a',
isDown: true
});
t.strictDeepEquals(k._keysPressed, ['A']);
t.strictEquals(k.getKeyIsDown(65), true);
t.strictEquals(k.getKeyIsDown('a'), true);
t.strictEquals(k.getKeyIsDown('A'), true);
t.strictEquals(k.getKeyIsDown('any'), true);
t.end();
});
test('number key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: '1',
isDown: true
});
t.strictDeepEquals(k._keysPressed, ['1']);
t.strictEquals(k.getKeyIsDown(49), true);
t.strictEquals(k.getKeyIsDown('1'), true);
t.strictEquals(k.getKeyIsDown('any'), true);
t.end();
});
test('non-english key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: '日',
isDown: true
});
t.strictDeepEquals(k._keysPressed, ['日']);
t.strictEquals(k.getKeyIsDown('日'), true);
t.strictEquals(k.getKeyIsDown('any'), true);
t.end();
});
/* TW: This test is disabled because we intentionally add support for modifier keys.
test('ignore modifier key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: 'Shift',
isDown: true
});
t.strictDeepEquals(k._keysPressed, []);
t.strictEquals(k.getKeyIsDown('any'), false);
t.end();
});
*/
test('keyup', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: 'ArrowLeft',
isDown: true
});
k.postData({
key: 'ArrowLeft',
isDown: false
});
t.strictDeepEquals(k._keysPressed, []);
t.strictEquals(k.getKeyIsDown('left arrow'), false);
t.strictEquals(k.getKeyIsDown('any'), false);
t.end();
});

View File

@@ -0,0 +1,112 @@
const test = require('tap').test;
const Keyboard = require('../../src/io/keyboard');
const Runtime = require('../../src/engine/runtime');
test('extended spec', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
t.type(k.getLastKeyPressed, 'function');
t.end();
});
test('extended key support', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: 'Backspace',
isDown: true
});
t.strictDeepEquals(k._keysPressed, ['backspace']);
t.strictEqual(k.getKeyIsDown('backspace'), true);
t.end();
});
test('last key pressed', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
t.strictEqual(k.getLastKeyPressed(), '');
k.postData({
key: 'a',
isDown: true
});
t.strictEqual(k.getLastKeyPressed(), 'a');
k.postData({
key: 'b',
isDown: true
});
t.strictEqual(k.getLastKeyPressed(), 'b');
t.end();
});
test('holding shift and key, releasing shift, then releasing key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
// Press Shift+2 to produce @
k.postData({
key: '@',
isDown: true,
keyCode: 50
});
t.equal(k.getKeyIsDown('2'), false);
t.equal(k.getKeyIsDown('@'), true);
t.equal(k.getKeyIsDown('any'), true);
// Release shift, then release 2
k.postData({
key: 'Shift',
isDown: false,
keyCode: 16
});
k.postData({
key: '2',
isDown: false,
keyCode: 50
});
t.equal(k.getKeyIsDown('@'), false);
t.equal(k.getKeyIsDown('2'), false);
t.equal(k.getKeyIsDown('any'), false);
t.end();
});
test('holding shift and key, releasing shift, waiting, then releasing key', t => {
const rt = new Runtime();
const k = new Keyboard(rt);
k.postData({
key: '@',
isDown: true,
keyCode: 50
});
t.equal(k.getKeyIsDown('2'), false);
t.equal(k.getKeyIsDown('@'), true);
t.equal(k.getKeyIsDown('any'), true);
k.postData({
key: 'Shift',
isDown: false,
keyCode: 16
});
// But 2 is still being held, so it will send a press event
k.postData({
key: '2',
isDown: true,
keyCode: 50
});
t.equal(k.getKeyIsDown('@'), false);
t.equal(k.getKeyIsDown('2'), true);
t.equal(k.getKeyIsDown('any'), true);
// And now we release 2
k.postData({
key: '2',
isDown: false,
keyCode: 50
});
t.equal(k.getKeyIsDown('@'), false);
t.equal(k.getKeyIsDown('2'), false);
t.equal(k.getKeyIsDown('any'), false);
t.end();
});

View File

@@ -0,0 +1,133 @@
const test = require('tap').test;
const Mouse = require('../../src/io/mouse');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
const rt = new Runtime();
const m = new Mouse(rt);
t.type(m, 'object');
t.type(m.postData, 'function');
t.type(m.getClientX, 'function');
t.type(m.getClientY, 'function');
t.type(m.getScratchX, 'function');
t.type(m.getScratchY, 'function');
t.type(m.getIsDown, 'function');
t.end();
});
test('mouseUp', t => {
const rt = new Runtime();
const m = new Mouse(rt);
m.postData({
x: -20,
y: 10,
isDown: false,
canvasWidth: 480,
canvasHeight: 360
});
t.strictEquals(m.getClientX(), -20);
t.strictEquals(m.getClientY(), 10);
t.strictEquals(m.getScratchX(), -240);
t.strictEquals(m.getScratchY(), 170);
t.strictEquals(m.getIsDown(), false);
t.end();
});
test('mouseDown', t => {
const rt = new Runtime();
const m = new Mouse(rt);
m.postData({
x: 9.9,
y: 400.1,
isDown: true,
canvasWidth: 480,
canvasHeight: 360
});
t.strictEquals(m.getClientX(), 9.9);
t.strictEquals(m.getClientY(), 400.1);
t.strictEquals(m.getScratchX(), -230);
t.strictEquals(m.getScratchY(), -180);
t.strictEquals(m.getIsDown(), true);
t.end();
});
test('at zoomed scale', t => {
const rt = new Runtime();
const m = new Mouse(rt);
m.postData({
x: 240,
y: 540,
canvasWidth: 960,
canvasHeight: 720
});
t.strictEquals(m.getClientX(), 240);
t.strictEquals(m.getClientY(), 540);
t.strictEquals(m.getScratchX(), -120);
t.strictEquals(m.getScratchY(), -90);
t.end();
});
test('mousedown activating click hats', t => {
const rt = new Runtime();
const m = new Mouse(rt);
const mouseMoveEvent = {
x: 10,
y: 100,
canvasWidth: 480,
canvasHeight: 360
};
const dummyTarget = {
draggable: false
};
const mouseDownEvent = Object.assign({}, mouseMoveEvent, {
isDown: true
});
const mouseUpEvent = Object.assign({}, mouseMoveEvent, {
isDown: false
});
// Stub activateClickHats and pick function for testing
let ranClickHats = false;
m._activateClickHats = () => {
ranClickHats = true;
};
m._pickTarget = () => dummyTarget;
// Mouse move without mousedown
m.postData(mouseMoveEvent);
t.strictEquals(ranClickHats, false);
// Mouse down event triggers the hats if target is not draggable
dummyTarget.draggable = false;
m.postData(mouseDownEvent);
t.strictEquals(ranClickHats, true);
// But another mouse move while down doesn't trigger
ranClickHats = false;
m.postData(mouseDownEvent);
t.strictEquals(ranClickHats, false);
// And it does trigger on mouse up if target is draggable
ranClickHats = false;
dummyTarget.draggable = true;
m.postData(mouseUpEvent);
t.strictEquals(ranClickHats, true);
// And hats don't trigger if mouse down is outside canvas
ranClickHats = false;
m.postData(Object.assign({}, mouseDownEvent, {
x: 50000,
y: 50
}));
t.strictEquals(ranClickHats, false);
t.end();
});

View File

@@ -0,0 +1,148 @@
const test = require('tap').test;
const Mouse = require('../../src/io/mouse');
const Runtime = require('../../src/engine/runtime');
test('position clamping', t => {
const rt = new Runtime();
const m = new Mouse(rt);
const BIG = 9999;
m.postData({
x: BIG,
y: BIG,
canvasWidth: 480,
canvasHeight: 360
});
t.strictEquals(m.getClientX(), BIG);
t.strictEquals(m.getClientY(), BIG);
t.strictEquals(m.getScratchX(), 240);
t.strictEquals(m.getScratchY(), -180);
t.end();
});
test('mouseButtonDown', t => {
const rt = new Runtime();
const m = new Mouse(rt);
t.strictEquals(m.getButtonIsDown(0), false);
t.strictEquals(m.getButtonIsDown(1), false);
t.strictEquals(m.getButtonIsDown(2), false);
m.postData({
isDown: true,
button: 0
});
t.strictEquals(m.getButtonIsDown(0), true);
t.strictEquals(m.getButtonIsDown(1), false);
t.strictEquals(m.getButtonIsDown(2), false);
m.postData({
isDown: true,
button: 2
});
t.strictEquals(m.getButtonIsDown(0), true);
t.strictEquals(m.getButtonIsDown(1), false);
t.strictEquals(m.getButtonIsDown(2), true);
m.postData({
isDown: false,
button: 2
});
t.strictEquals(m.getButtonIsDown(0), true);
t.strictEquals(m.getButtonIsDown(1), false);
t.strictEquals(m.getButtonIsDown(2), false);
t.end();
});
test('mouseDown with buttons', t => {
const rt = new Runtime();
const m = new Mouse(rt);
t.strictEquals(m.getIsDown(), false);
m.postData({
isDown: true,
button: 0
});
t.strictEquals(m.getIsDown(), true);
m.postData({
isDown: true,
button: 2
});
t.strictEquals(m.getIsDown(), true);
m.postData({
isDown: false,
button: 2
});
t.strictEquals(m.getIsDown(), false);
t.end();
});
test('missing button is treated as left', t => {
const rt = new Runtime();
const m = new Mouse(rt);
t.strictEquals(m.getButtonIsDown(0), false);
m.postData({
isDown: true
});
t.strictEquals(m.getButtonIsDown(0), true);
m.postData({
isDown: false
});
t.strictEquals(m.getButtonIsDown(0), false);
t.end();
});
test('usesRightClickDown', t => {
const rt = new Runtime();
const m = new Mouse(rt);
t.strictEquals(m.usesRightClickDown, false);
t.strictEquals(m.getButtonIsDown(2), false);
t.strictEquals(m.usesRightClickDown, true);
t.end();
});
test('no rounding when misc limits disabled', t => {
const rt = new Runtime();
const m = new Mouse(rt);
m.postData({
x: 241,
y: 541,
canvasWidth: 960,
canvasHeight: 720
});
t.equal(m.getScratchX(), -119);
t.equal(m.getScratchY(), -90);
rt.setRuntimeOptions({
miscLimits: false
});
t.equal(m.getScratchX(), -119.5);
t.equal(m.getScratchY(), -90.5);
t.end();
});
test('accepts 0 as x and y position', t => {
const rt = new Runtime();
const m = new Mouse(rt);
m.postData({
x: 1,
y: 2,
canvasWidth: 480,
canvasHeight: 360
});
t.equal(m.getClientX(), 1);
t.equal(m.getClientY(), 2);
m.postData({
x: 0,
y: 0,
canvasWidth: 480,
canvasHeight: 360
});
t.equal(m.getClientX(), 0);
t.equal(m.getClientY(), 0);
t.end();
});

View File

@@ -0,0 +1,44 @@
const test = require('tap').test;
const MouseWheel = require('../../src/io/mouseWheel');
const Runtime = require('../../src/engine/runtime');
test('spec', t => {
const rt = new Runtime();
const mw = new MouseWheel(rt);
t.type(mw, 'object');
t.type(mw.postData, 'function');
t.end();
});
test('blocks activated by scrolling', t => {
let _startHatsArgs;
const rt = {
startHats: (...args) => {
_startHatsArgs = args;
}
};
const mw = new MouseWheel(rt);
_startHatsArgs = null;
mw.postData({
deltaY: -1
});
t.strictEquals(_startHatsArgs[0], 'event_whenkeypressed');
t.strictEquals(_startHatsArgs[1].KEY_OPTION, 'up arrow');
_startHatsArgs = null;
mw.postData({
deltaY: +1
});
t.strictEquals(_startHatsArgs[0], 'event_whenkeypressed');
t.strictEquals(_startHatsArgs[1].KEY_OPTION, 'down arrow');
_startHatsArgs = null;
mw.postData({
deltaY: 0
});
t.strictEquals(_startHatsArgs, null);
t.end();
});

View File

@@ -0,0 +1,26 @@
const test = require('tap').test;
// const ScratchBLE = require('../../src/io/scratchBLE');
test('constructor', t => {
t.end();
});
test('waitForSocket', t => {
t.end();
});
test('requestPeripheral', t => {
t.end();
});
test('didReceiveCall', t => {
t.end();
});
test('read', t => {
t.end();
});
test('write', t => {
t.end();
});

View File

@@ -0,0 +1,22 @@
const test = require('tap').test;
// const ScratchBT = require('../../src/io/scratchBT');
test('constructor', t => {
t.end();
});
test('requestPeripheral', t => {
t.end();
});
test('connectPeripheral', t => {
t.end();
});
test('sendMessage', t => {
t.end();
});
test('didReceiveCall', t => {
t.end();
});

View File

@@ -0,0 +1,25 @@
const test = require('tap').test;
const UserData = require('../../src/io/userData');
test('spec', t => {
const userData = new UserData();
t.type(userData, 'object');
t.type(userData.postData, 'function');
t.type(userData.getUsername, 'function');
t.end();
});
test('getUsername returns empty string initially', t => {
const userData = new UserData();
t.strictEquals(userData.getUsername(), '');
t.end();
});
test('postData sets the username', t => {
const userData = new UserData();
userData.postData({username: 'TEST'});
t.strictEquals(userData.getUsername(), 'TEST');
t.end();
});

View File

@@ -0,0 +1,77 @@
const test = require('tap').test;
const maybeFormatMessage = require('../../src/util/maybe-format-message');
const nonMessages = [
'hi',
42,
true,
function () {
return 'unused';
},
{
a: 1,
b: 2
},
{
id: 'almost a message',
notDefault: 'but missing the "default" property'
},
{
notId: 'this one is missing the "id" property',
default: 'but has "default"'
}
];
const argsQuick = {
speed: 'quick'
};
const argsOther = {
speed: 'slow'
};
const argsEmpty = {};
const simpleMessage = {
id: 'test.simpleMessage',
default: 'The quick brown fox jumped over the lazy dog.'
};
const complexMessage = {
id: 'test.complexMessage',
default: '{speed, select, quick {The quick brown fox jumped over the lazy dog.} other {Too slow, Gobo!}}'
};
const quickExpectedResult = 'The quick brown fox jumped over the lazy dog.';
const otherExpectedResult = 'Too slow, Gobo!';
test('preserve non-messages', t => {
t.plan(nonMessages.length);
for (const x of nonMessages) {
const result = maybeFormatMessage(x);
t.strictSame(x, result);
}
t.end();
});
test('format messages', t => {
const quickResult1 = maybeFormatMessage(simpleMessage);
t.strictNotSame(quickResult1, simpleMessage);
t.same(quickResult1, quickExpectedResult);
const quickResult2 = maybeFormatMessage(complexMessage, argsQuick);
t.strictNotSame(quickResult2, complexMessage);
t.same(quickResult2, quickExpectedResult);
const otherResult1 = maybeFormatMessage(complexMessage, argsOther);
t.strictNotSame(otherResult1, complexMessage);
t.same(otherResult1, otherExpectedResult);
const otherResult2 = maybeFormatMessage(complexMessage, argsEmpty);
t.strictNotSame(otherResult2, complexMessage);
t.same(otherResult2, otherExpectedResult);
t.end();
});

View File

@@ -0,0 +1,91 @@
const test = require('tap').test;
const MockTimer = require('../fixtures/mock-timer');
test('spec', t => {
const timer = new MockTimer();
t.type(MockTimer, 'function');
t.type(timer, 'object');
// Most members of MockTimer mimic members of Timer.
t.type(timer.startTime, 'number');
t.type(timer.time, 'function');
t.type(timer.start, 'function');
t.type(timer.timeElapsed, 'function');
t.type(timer.setTimeout, 'function');
t.type(timer.clearTimeout, 'function');
// A few members of MockTimer have no Timer equivalent and should only be used in tests.
t.type(timer.advanceMockTime, 'function');
t.type(timer.advanceMockTimeAsync, 'function');
t.type(timer.hasTimeouts, 'function');
t.end();
});
test('time', t => {
const timer = new MockTimer();
const delta = 1;
const time1 = timer.time();
const time2 = timer.time();
timer.advanceMockTime(delta);
const time3 = timer.time();
t.equal(time1, time2);
t.equal(time2 + delta, time3);
t.end();
});
test('start / timeElapsed', t => new Promise(resolve => {
const timer = new MockTimer();
const halfDelay = 1;
const fullDelay = halfDelay + halfDelay;
timer.start();
let timeoutCalled = 0;
// Wait and measure timer
timer.setTimeout(() => {
t.equal(timeoutCalled, 0);
++timeoutCalled;
const timeElapsed = timer.timeElapsed();
t.equal(timeElapsed, fullDelay);
t.end();
resolve();
}, fullDelay);
// this should not trigger the callback
timer.advanceMockTime(halfDelay);
// give the mock timer a chance to run tasks
global.setTimeout(() => {
// we've only mock-waited for half the delay so it should not have run yet
t.equal(timeoutCalled, 0);
// this should trigger the callback
timer.advanceMockTime(halfDelay);
}, 0);
}));
test('clearTimeout / hasTimeouts', t => new Promise((resolve, reject) => {
const timer = new MockTimer();
const timeoutId = timer.setTimeout(() => {
reject(new Error('Canceled task ran'));
}, 1);
timer.setTimeout(() => {
resolve('Non-canceled task ran');
t.end();
}, 2);
timer.clearTimeout(timeoutId);
while (timer.hasTimeouts()) {
timer.advanceMockTime(1);
}
}));

View File

@@ -0,0 +1,240 @@
const tap = require('tap');
const path = require('path');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const makeTestStorage = require('../fixtures/make-test-storage');
const VirtualMachine = require('../../src/virtual-machine');
let vm;
let projectChanged;
tap.beforeEach(() => {
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
vm = new VirtualMachine();
vm.runtime.addListener('PROJECT_CHANGED', () => {
projectChanged = true;
});
vm.attachStorage(makeTestStorage());
return vm.loadProject(project).then(() => {
// The test in project_load_changed_state.js tests
// that loading a project does not emit a project changed
// event. This setup tries to be agnostic of whether that
// test is passing or failing.
projectChanged = false;
});
});
const test = tap.test;
test('Adding a sprite (from sprite2) should emit a project changed event', t => {
const sprite2Uri = path.resolve(__dirname, '../fixtures/cat.sprite2');
const sprite2 = readFileToBuffer(sprite2Uri);
vm.addSprite(sprite2).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Adding a sprite (from sprite3) should emit a project changed event', t => {
const sprite3Uri = path.resolve(__dirname, '../fixtures/cat.sprite3');
const sprite3 = readFileToBuffer(sprite3Uri);
vm.addSprite(sprite3).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Adding a costume should emit a project changed event', t => {
const newCostume = {
name: 'costume1',
baseLayerID: 0,
baseLayerMD5: 'f9a1c175dbe2e5dee472858dd30d16bb.svg',
bitmapResolution: 1,
rotationCenterX: 47,
rotationCenterY: 55
};
vm.addCostume('f9a1c175dbe2e5dee472858dd30d16bb.svg', newCostume).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Adding a costume from library should emit a project changed event', t => {
const newCostume = {
name: 'costume1',
baseLayerID: 0,
baseLayerMD5: 'f9a1c175dbe2e5dee472858dd30d16bb.svg',
bitmapResolution: 1,
rotationCenterX: 47,
rotationCenterY: 55
};
vm.addCostumeFromLibrary('f9a1c175dbe2e5dee472858dd30d16bb.svg', newCostume).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Adding a backdrop should emit a project changed event', t => {
const newCostume = {
name: 'costume1',
baseLayerID: 0,
baseLayerMD5: 'f9a1c175dbe2e5dee472858dd30d16bb.svg',
bitmapResolution: 1,
rotationCenterX: 47,
rotationCenterY: 55
};
vm.addBackdrop('f9a1c175dbe2e5dee472858dd30d16bb.svg', newCostume).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Adding a sound should emit a project changed event', t => {
const newSound = {
soundName: 'meow',
soundID: 0,
md5: '83c36d806dc92327b9e7049a565c6bff.wav',
sampleCount: 18688,
rate: 22050
};
vm.addSound(newSound).then(() => {
t.equal(projectChanged, true);
t.end();
});
});
test('Deleting a sprite should emit a project changed event', t => {
const spriteId = vm.editingTarget.id;
vm.deleteSprite(spriteId);
t.equal(projectChanged, true);
t.end();
});
test('Deleting a costume should emit a project changed event', t => {
vm.deleteCostume(0);
t.equal(projectChanged, true);
t.end();
});
test('Deleting a sound should emit a project changed event', t => {
vm.deleteSound(0);
t.equal(projectChanged, true);
t.end();
});
test('Reordering a sprite should emit a project changed event', t => {
const sprite3Uri = path.resolve(__dirname, '../fixtures/cat.sprite3');
const sprite3 = readFileToBuffer(sprite3Uri);
// Add a new sprite so we have 2 to reorder
vm.addSprite(sprite3).then(() => {
// Reset the project changed flag to ignore change from adding new sprite
projectChanged = false;
t.equal(vm.runtime.targets.filter(target => !target.isStage).length, 2);
vm.reorderTarget(2, 1);
t.equal(projectChanged, true);
t.end();
});
});
test('Reordering a costume should emit a project changed event', t => {
t.equal(vm.editingTarget.sprite.costumes.length, 2);
const spriteId = vm.editingTarget.id;
const reordered = vm.reorderCostume(spriteId, 1, 0);
t.equal(reordered, true);
t.equal(projectChanged, true);
t.end();
});
test('Reordering a sound should emit a project changed event', t => {
const spriteId = vm.editingTarget.id;
const newSound = {
soundName: 'meow',
soundID: 0,
md5: '83c36d806dc92327b9e7049a565c6bff.wav',
sampleCount: 18688,
rate: 22050
};
vm.addSound(newSound).then(() => {
// Reset the project changed flag to ignore change from adding new sound
projectChanged = false;
t.equal(vm.editingTarget.sprite.sounds.length, 2);
const reordered = vm.reorderSound(spriteId, 1, 0);
t.equal(reordered, true);
t.equal(projectChanged, true);
t.end();
});
});
test('Renaming a sprite should emit a project changed event', t => {
const spriteId = vm.editingTarget.id;
vm.renameSprite(spriteId, 'My Sprite');
t.equal(projectChanged, true);
t.end();
});
test('Renaming a costume should emit a project changed event', t => {
vm.renameCostume(0, 'My Costume');
t.equal(projectChanged, true);
t.end();
});
test('Renaming a sound should emit a project changed event', t => {
vm.renameSound(0, 'My Sound');
t.equal(projectChanged, true);
t.end();
});
test('Changing sprite info should emit a project changed event', t => {
const newSpritePosition = {
x: 10,
y: 100
};
vm.postSpriteInfo(newSpritePosition);
t.equal(projectChanged, true);
projectChanged = false;
const newSpriteDirection = {
direction: -30
};
vm.postSpriteInfo(newSpriteDirection);
t.equal(projectChanged, true);
projectChanged = false;
t.end();
});
test('Editing a vector costume should emit a project changed event', t => {
const mockSvg = 'svg';
const mockRotationX = -13;
const mockRotationY = 25;
vm.updateSvg(0, mockSvg, mockRotationX, mockRotationY);
t.equal(projectChanged, true);
t.end();
});
test('Editing a sound should emit a project changed event', t => {
const mockSoundBuffer = [];
const mockSoundEncoding = [];
vm.updateSoundBuffer(0, mockSoundBuffer, mockSoundEncoding);
t.equal(projectChanged, true);
t.end();
});

View File

@@ -0,0 +1,463 @@
const tap = require('tap');
const path = require('path');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const makeTestStorage = require('../fixtures/make-test-storage');
const VirtualMachine = require('../../src/virtual-machine');
let vm;
let projectChanged;
let blockContainer;
tap.beforeEach(() => {
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
vm = new VirtualMachine();
vm.runtime.addListener('PROJECT_CHANGED', () => {
projectChanged = true;
});
vm.attachStorage(makeTestStorage());
return vm.loadProject(project).then(() => {
blockContainer = vm.editingTarget.blocks;
// Add mock blocks to use for tests
blockContainer.createBlock({
id: 'a parent block',
opcode: 'my_testParentBlock',
fields: {},
inputs: {}
});
blockContainer.createBlock({
id: 'a new block',
opcode: 'my_testBlock',
topLevel: true,
x: -10,
y: 35,
fields: {
A_FIELD: {
name: 'A_FIELD',
value: 10
}
},
inputs: {},
parent: 'a block'
});
// Reset project changes from new blocks
projectChanged = false;
});
});
const test = tap.test;
test('Creating a block should emit a project changed event', t => {
blockContainer.createBlock({
id: 'another block',
opcode: 'my_testBlock',
topLevel: true
});
t.equal(projectChanged, true);
t.end();
});
test('Deleting a block should emit a project changed event', t => {
blockContainer.deleteBlock('a new block');
t.equal(projectChanged, true);
t.end();
});
test('Changing a block should emit a project changed event', t => {
blockContainer.changeBlock({
element: 'field',
id: 'a new block',
name: 'A_FIELD',
value: 300
});
t.equal(projectChanged, true);
projectChanged = false;
blockContainer.changeBlock({
element: 'checkbox',
id: 'a new block',
value: true
});
t.equal(projectChanged, true);
projectChanged = false;
blockContainer.changeBlock({
element: 'mutation',
id: 'a new block',
value: '<mutation></mutation>'
});
t.equal(projectChanged, true);
t.end();
});
test('Moving a block to a new position should emit a project changed event', t => {
blockContainer.moveBlock({
id: 'a new block',
newCoordinate: {
x: -40,
y: 350
}
});
t.equal(projectChanged, true);
t.end();
});
test('Connecting a block to a new parent should emit a project changed event', t => {
blockContainer.createBlock({
id: 'another block',
opcode: 'my_testBlock'
});
projectChanged = false;
blockContainer.moveBlock({
id: 'a new block',
newParent: 'another block'
});
t.equal(projectChanged, true);
t.end();
});
test('Disconnecting a block from another should emit a project changed event', t => {
blockContainer.moveBlock({
id: 'a new block',
oldParent: 'a parent block'
});
t.equal(projectChanged, true);
t.end();
});
test('Creating a local variable should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: true,
isCloud: false
});
t.equal(projectChanged, true);
projectChanged = false;
// Creating the same variable twice should not emit a project changed event
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: true,
isCloud: false
});
t.equal(projectChanged, false);
t.end();
});
test('Creating a global variable should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: false,
isCloud: false
});
t.equal(projectChanged, true);
projectChanged = false;
// Creating the same variable twice should not emit a project changed event
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: false,
isCloud: false
});
t.equal(projectChanged, false);
t.end();
});
test('Renaming a variable should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: false,
isCloud: false
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'var_rename',
varId: 'a new variable',
oldName: 'foo',
newName: 'bar'
});
t.equal(projectChanged, true);
t.end();
});
test('Deleting a variable should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'var_create',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: false,
isCloud: false
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'var_delete',
varId: 'a new variable',
varName: 'foo',
varType: '',
isLocal: false,
isCloud: false
});
t.equal(projectChanged, true);
t.end();
});
test('Creating a block comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: 'a new block',
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
t.equal(projectChanged, true);
t.end();
});
test('Creating a workspace comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
t.equal(projectChanged, true);
t.end();
});
test('Changing a comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'comment_change',
blockId: null,
commentId: 'a new comment',
newContents_: {
minimized: true
},
oldContents_: {
minimized: false
}
});
t.equal(projectChanged, true);
t.end();
});
test('Attempting to change a comment that does not exist should not emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_change',
blockId: null,
commentId: 'a new comment',
newContents_: {
minimized: true
},
oldContents_: {
minimized: false
}
});
t.equal(projectChanged, false);
t.end();
});
test('Deleting a block comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: 'a new block',
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'comment_delete',
blockId: 'a new block',
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
t.equal(projectChanged, true);
t.end();
});
test('Deleting a workspace comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'comment_delete',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
t.equal(projectChanged, true);
t.end();
});
test('Deleting a comment that does not exist should not emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_delete',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
t.equal(projectChanged, false);
t.end();
});
test('Moving a comment should emit a project changed event', t => {
blockContainer.blocklyListen({
type: 'comment_create',
blockId: null,
commentId: 'a new comment',
height: 250,
width: 400,
xy: {
x: -40,
y: 27
},
minimized: false,
text: 'comment'
});
projectChanged = false;
blockContainer.blocklyListen({
type: 'comment_move',
blockId: null,
commentId: 'a new comment',
oldCoordinate_: {
x: -40,
y: 27
},
newCoordinate_: {
x: -35,
y: 50
}
});
t.equal(projectChanged, true);
t.end();
});

View File

@@ -0,0 +1,28 @@
const tap = require('tap');
const path = require('path');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const makeTestStorage = require('../fixtures/make-test-storage');
const VirtualMachine = require('../../src/virtual-machine');
const test = tap.test;
// Test that loading a project does not emit a project change
// This is in its own file so that it does not affect the test setup
// and results of the other project changed state tests
test('Loading a project should not emit a project changed event', t => {
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
const vm = new VirtualMachine();
let projectChanged = false;
vm.runtime.addListener('PROJECT_CHANGED', () => {
projectChanged = true;
});
vm.attachStorage(makeTestStorage());
return vm.loadProject(project).then(() => {
t.equal(projectChanged, false);
t.end();
});
});

View File

@@ -0,0 +1,104 @@
const path = require('path');
const test = require('tap').test;
const extractProjectJson = require('../fixtures/readProjectFile').extractProjectJson;
const RenderedTarget = require('../../src/sprites/rendered-target');
const Runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/serialization/sb2');
test('spec', t => {
t.type(sb2, 'object');
t.type(sb2.deserialize, 'function');
t.end();
});
test('default', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
const json = extractProjectJson(uri);
// Create runtime instance & load SB2 into it
const rt = new Runtime();
sb2.deserialize(json, rt).then(({targets}) => {
// Test
t.type(json, 'object');
t.type(rt, 'object');
t.type(targets, 'object');
t.ok(targets[0] instanceof RenderedTarget);
t.type(targets[0].id, 'string');
t.type(targets[0].blocks, 'object');
t.type(targets[0].variables, 'object');
t.type(targets[0].comments, 'object');
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].currentCostume, 0);
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].isStage, true);
t.ok(targets[1] instanceof RenderedTarget);
t.type(targets[1].id, 'string');
t.type(targets[1].blocks, 'object');
t.type(targets[1].variables, 'object');
t.type(targets[1].comments, 'object');
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].currentCostume, 0);
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].isStage, false);
t.end();
});
});
test('data scoping', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/data.sb2');
const json = extractProjectJson(uri);
// Create runtime instance & load SB2 into it
const rt = new Runtime();
sb2.deserialize(json, rt).then(({targets}) => {
const globalVariableIds = Object.keys(targets[0].variables);
const localVariableIds = Object.keys(targets[1].variables);
t.equal(targets[0].variables[globalVariableIds[0]].name, 'foo');
t.equal(targets[1].variables[localVariableIds[0]].name, 'local');
t.end();
});
});
test('whenclicked blocks imported separately', t => {
// This sb2 fixture has a single "whenClicked" block on both sprite and stage
const uri = path.resolve(__dirname, '../fixtures/when-clicked.sb2');
const json = extractProjectJson(uri);
// Create runtime instance & load SB2 into it
const rt = new Runtime();
sb2.deserialize(json, rt).then(({targets}) => {
const stage = targets[0];
t.equal(stage.isStage, true); // Make sure we have the correct target
const stageOpcode = stage.blocks.getBlock(stage.blocks.getScripts()[0]).opcode;
t.equal(stageOpcode, 'event_whenstageclicked');
const sprite = targets[1];
t.equal(sprite.isStage, false); // Make sure we have the correct target
const spriteOpcode = sprite.blocks.getBlock(sprite.blocks.getScripts()[0]).opcode;
t.equal(spriteOpcode, 'event_whenthisspriteclicked');
t.end();
});
});
test('Ordering', t => {
// This SB2 has 3 sprites that have been reordered in scratch 2
// so the order in the file is not the order specified by the indexInLibrary property.
const uri = path.resolve(__dirname, '../fixtures/ordering.sb2');
const json = extractProjectJson(uri);
const rt = new Runtime();
sb2.deserialize(json, rt).then(({targets}) => {
// Would fail with any other ordering.
t.equal(targets[1].sprite.name, 'First');
t.equal(targets[2].sprite.name, 'Second');
t.equal(targets[3].sprite.name, 'Third');
t.end();
});
});

View File

@@ -0,0 +1,363 @@
const test = require('tap').test;
const path = require('path');
const VirtualMachine = require('../../src/index');
const Runtime = require('../../src/engine/runtime');
const sb3 = require('../../src/serialization/sb3');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const exampleProjectPath = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2');
const commentsSB2ProjectPath = path.resolve(__dirname, '../fixtures/comments.sb2');
const commentsSB3ProjectPath = path.resolve(__dirname, '../fixtures/comments.sb3');
const commentsSB3NoDupeIds = path.resolve(__dirname, '../fixtures/comments_no_duplicate_id_serialization.sb3');
const variableReporterSB2ProjectPath = path.resolve(__dirname, '../fixtures/top-level-variable-reporter.sb2');
const topLevelReportersProjectPath = path.resolve(__dirname, '../fixtures/top-level-reporters.sb3');
const draggableSB3ProjectPath = path.resolve(__dirname, '../fixtures/draggable.sb3');
const originSB3ProjectPath = path.resolve(__dirname, '../fixtures/origin.sb3');
const originAbsentSB3ProjectPath = path.resolve(__dirname, '../fixtures/origin-absent.sb3');
const FakeRenderer = require('../fixtures/fake-renderer');
test('serialize', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(exampleProjectPath))
.then(() => {
const result = sb3.serialize(vm.runtime);
// @todo Analyze
t.type(JSON.stringify(result), 'string');
t.end();
});
});
test('deserialize', t => {
const vm = new VirtualMachine();
sb3.deserialize('', vm.runtime).then(({targets}) => {
// @todo Analyze
t.type(targets, 'object');
t.end();
});
});
test('serialize sb2 project with comments as sb3', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB2ProjectPath))
.then(() => {
const result = sb3.serialize(vm.runtime);
t.type(JSON.stringify(result), 'string');
t.type(result.targets, 'object');
t.equal(Array.isArray(result.targets), true);
t.equal(result.targets.length, 2);
const stage = result.targets[0];
t.equal(stage.isStage, true);
// The stage has 0 blocks, and 1 workspace comment
t.type(stage.blocks, 'object');
t.equal(Object.keys(stage.blocks).length, 0);
t.type(stage.comments, 'object');
t.equal(Object.keys(stage.comments).length, 1);
const stageBlockComments = Object.values(stage.comments).filter(comment => !!comment.blockId);
const stageWorkspaceComments = Object.values(stage.comments).filter(comment => comment.blockId === null);
t.equal(stageBlockComments.length, 0);
t.equal(stageWorkspaceComments.length, 1);
const sprite = result.targets[1];
t.equal(sprite.isStage, false);
t.type(sprite.blocks, 'object');
// Sprite 1 has 6 blocks, 5 block comments, and 1 workspace comment
t.equal(Object.keys(sprite.blocks).length, 6);
t.type(sprite.comments, 'object');
t.equal(Object.keys(sprite.comments).length, 6);
const spriteBlockComments = Object.values(sprite.comments).filter(comment => !!comment.blockId);
const spriteWorkspaceComments = Object.values(sprite.comments).filter(comment => comment.blockId === null);
t.equal(spriteBlockComments.length, 5);
t.equal(spriteWorkspaceComments.length, 1);
t.end();
});
});
test('deserialize sb3 project with comments', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB3ProjectPath))
.then(() => {
const runtime = vm.runtime;
t.type(runtime.targets, 'object');
t.equal(Array.isArray(runtime.targets), true);
t.equal(runtime.targets.length, 2);
const stage = runtime.targets[0];
t.equal(stage.isStage, true);
// The stage has 0 blocks, and 1 workspace comment
t.type(stage.blocks, 'object');
t.equal(Object.keys(stage.blocks._blocks).length, 0);
t.type(stage.comments, 'object');
t.equal(Object.keys(stage.comments).length, 1);
const stageBlockComments = Object.values(stage.comments).filter(comment => !!comment.blockId);
const stageWorkspaceComments = Object.values(stage.comments).filter(comment => comment.blockId === null);
t.equal(stageBlockComments.length, 0);
t.equal(stageWorkspaceComments.length, 1);
const sprite = runtime.targets[1];
t.equal(sprite.isStage, false);
t.type(sprite.blocks, 'object');
// Sprite 1 has 6 blocks, 5 block comments, and 1 workspace comment
t.equal(Object.values(sprite.blocks._blocks).filter(block => !block.shadow).length, 6);
t.type(sprite.comments, 'object');
t.equal(Object.keys(sprite.comments).length, 6);
const spriteBlockComments = Object.values(sprite.comments).filter(comment => !!comment.blockId);
const spriteWorkspaceComments = Object.values(sprite.comments).filter(comment => comment.blockId === null);
t.equal(spriteBlockComments.length, 5);
t.equal(spriteWorkspaceComments.length, 1);
t.end();
});
});
test('deserialize sb3 project with comments - no duplicate id serialization', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB3NoDupeIds))
.then(() => {
const runtime = vm.runtime;
t.type(runtime.targets, 'object');
t.equal(Array.isArray(runtime.targets), true);
t.equal(runtime.targets.length, 2);
const stage = runtime.targets[0];
t.equal(stage.isStage, true);
// The stage has 0 blocks, and 0 workspace comment
t.type(stage.blocks, 'object');
t.equal(Object.keys(stage.blocks._blocks).length, 0);
t.type(stage.comments, 'object');
t.equal(Object.keys(stage.comments).length, 0);
const sprite = runtime.targets[1];
t.equal(sprite.isStage, false);
t.type(sprite.blocks, 'object');
// Sprite1 has 1 blocks, 1 block comment, and 1 workspace comment
t.equal(Object.values(sprite.blocks._blocks).filter(block => !block.shadow).length, 1);
t.type(sprite.comments, 'object');
t.equal(Object.keys(sprite.comments).length, 2);
const spriteBlockComments = Object.values(sprite.comments).filter(comment => !!comment.blockId);
const spriteWorkspaceComments = Object.values(sprite.comments).filter(comment => comment.blockId === null);
t.equal(spriteBlockComments.length, 1);
t.equal(spriteWorkspaceComments.length, 1);
t.end();
});
});
test('serializing and deserializing sb3 preserves sprite layer order', t => {
const vm = new VirtualMachine();
vm.attachRenderer(new FakeRenderer());
return vm.loadProject(readFileToBuffer(path.resolve(__dirname, '../fixtures/ordering.sb2')))
.then(() => {
// Target get layer order needs a renderer,
// fake the numbers we would get back from the
// renderer in order to test that they are serialized
// correctly
vm.runtime.targets[0].getLayerOrder = () => 0;
vm.runtime.targets[1].getLayerOrder = () => 20;
vm.runtime.targets[2].getLayerOrder = () => 10;
vm.runtime.targets[3].getLayerOrder = () => 30;
const result = sb3.serialize(vm.runtime);
t.type(JSON.stringify(result), 'string');
t.type(result.targets, 'object');
t.equal(Array.isArray(result.targets), true);
t.equal(result.targets.length, 4);
// First check that the sprites are ordered correctly (as they would
// appear in the target pane)
t.equal(result.targets[0].name, 'Stage');
t.equal(result.targets[1].name, 'First');
t.equal(result.targets[2].name, 'Second');
t.equal(result.targets[3].name, 'Third');
// Check that they are in the correct layer order (as they would render
// back to front on the stage)
t.equal(result.targets[0].layerOrder, 0);
t.equal(result.targets[1].layerOrder, 2);
t.equal(result.targets[2].layerOrder, 1);
t.equal(result.targets[3].layerOrder, 3);
return result;
})
.then(serializedObject =>
sb3.deserialize(
JSON.parse(JSON.stringify(serializedObject)), new Runtime(), null, false)
.then(({targets}) => {
// First check that the sprites are ordered correctly (as they would
// appear in the target pane)
t.equal(targets[0].sprite.name, 'Stage');
t.equal(targets[1].sprite.name, 'First');
t.equal(targets[2].sprite.name, 'Second');
t.equal(targets[3].sprite.name, 'Third');
// Check that they are in the correct layer order (as they would render
// back to front on the stage)
t.equal(targets[0].layerOrder, 0);
t.equal(targets[1].layerOrder, 2);
t.equal(targets[2].layerOrder, 1);
t.equal(targets[3].layerOrder, 3);
t.end();
}));
});
test('serializeBlocks', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB3ProjectPath))
.then(() => {
const blocks = vm.runtime.targets[1].blocks._blocks;
const result = sb3.serializeBlocks(blocks);
// @todo Analyze
t.type(result[0], 'object');
t.ok(Object.keys(result[0]).length < Object.keys(blocks).length, 'less blocks in serialized format');
t.ok(Array.isArray(result[1]));
t.end();
});
});
test('serializeBlocks serializes x and y for topLevel blocks with x,y of 0,0', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(topLevelReportersProjectPath))
.then(() => {
// Verify that there are 2 blocks and they are both top level
const blocks = vm.runtime.targets[1].blocks._blocks;
const blockIds = Object.keys(blocks);
t.equal(blockIds.length, 2);
const blocksArray = blockIds.map(key => blocks[key]);
t.equal(blocksArray.every(b => b.topLevel), true);
// Simulate cleaning up the blocks by resetting x and y positions to 0
blockIds.forEach(blockId => {
blocks[blockId].x = 0;
blocks[blockId].y = 0;
});
const result = sb3.serializeBlocks(blocks);
const serializedBlocks = result[0];
t.type(serializedBlocks, 'object');
const serializedBlockIds = Object.keys(serializedBlocks);
t.equal(serializedBlockIds.length, 2);
const firstBlock = serializedBlocks[serializedBlockIds[0]];
const secondBlock = serializedBlocks[serializedBlockIds[1]];
t.equal(firstBlock.x, 0);
t.equal(firstBlock.y, 0);
t.equal(secondBlock.x, 0);
t.equal(secondBlock.y, 0);
t.end();
});
});
test('deserializeBlocks', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB3ProjectPath))
.then(() => {
const blocks = vm.runtime.targets[1].blocks._blocks;
const serialized = sb3.serializeBlocks(blocks)[0];
const deserialized = sb3.deserializeBlocks(serialized);
t.equal(Object.keys(deserialized).length, Object.keys(blocks).length, 'same number of blocks');
t.end();
});
});
test('deserializeBlocks on already deserialized input', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(commentsSB3ProjectPath))
.then(() => {
const blocks = vm.runtime.targets[1].blocks._blocks;
const serialized = sb3.serializeBlocks(blocks)[0];
const deserialized = sb3.deserializeBlocks(serialized);
const deserializedAgain = sb3.deserializeBlocks(deserialized);
t.deepEqual(deserialized, deserializedAgain, 'no change from second pass of deserialize');
t.end();
});
});
test('getExtensionIdForOpcode', t => {
t.equal(sb3.getExtensionIdForOpcode('wedo_loopy'), 'wedo');
// does not consider CORE to be extensions
t.false(sb3.getExtensionIdForOpcode('control_loopy'));
// only considers things before the first underscore
t.equal(sb3.getExtensionIdForOpcode('hello_there_loopy'), 'hello');
// does not return anything for opcodes with no extension
t.false(sb3.getExtensionIdForOpcode('hello'));
// forbidden characters must be replaced with '-'
t.equal(sb3.getExtensionIdForOpcode('hi:there/happy_people'), 'hi-there-happy');
t.end();
});
test('(#1608) serializeBlocks maintains top level variable reporters', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(variableReporterSB2ProjectPath))
.then(() => {
const blocks = vm.runtime.targets[0].blocks._blocks;
const result = sb3.serialize(vm.runtime);
// Project should have 1 block, a top-level variable reporter
t.equal(Object.keys(blocks).length, 1);
t.equal(Object.keys(result.targets[0].blocks).length, 1);
// Make sure deserializing these blocks works
t.doesNotThrow(() => {
sb3.deserialize(JSON.parse(JSON.stringify(result)), vm.runtime);
});
t.end();
});
});
test('(#1850) sprite draggability state read when loading SB3 file', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(draggableSB3ProjectPath))
.then(() => {
const sprite1Obj = vm.runtime.targets.find(target => target.sprite.name === 'Sprite1');
// Sprite1 in project should have draggable set to true
t.equal(sprite1Obj.draggable, true);
t.end();
});
});
test('load origin value from SB3 file json metadata', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(originSB3ProjectPath))
.then(() => {
t.type(vm.runtime.origin, 'string');
})
.then(() => vm.loadProject(readFileToBuffer(originAbsentSB3ProjectPath)))
.then(() => {
// After loading a project with an origin, then loading one without an origin,
// origin value should no longer be set.
t.equal(vm.runtime.origin, null);
t.end();
});
});
test('serialize origin value if it is present', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(originSB3ProjectPath))
.then(() => {
const result = sb3.serialize(vm.runtime);
t.type(result.meta.origin, 'string');
t.end();
});
});
test('do not serialize origin value if it is not present', t => {
const vm = new VirtualMachine();
vm.loadProject(readFileToBuffer(originAbsentSB3ProjectPath))
.then(() => {
const result = sb3.serialize(vm.runtime);
t.equal(result.meta.origin, undefined);
t.end();
});
});

View File

@@ -0,0 +1,36 @@
const test = require('tap').test;
const VirtualMachine = require('../../src/index');
test('interface', t => {
const vm = new VirtualMachine();
t.type(vm, 'object');
t.type(vm.start, 'function');
t.type(vm.greenFlag, 'function');
t.type(vm.setTurboMode, 'function');
t.type(vm.setCompatibilityMode, 'function');
t.type(vm.stopAll, 'function');
t.type(vm.clear, 'function');
t.type(vm.getPlaygroundData, 'function');
t.type(vm.postIOData, 'function');
t.type(vm.loadProject, 'function');
t.type(vm.addSprite, 'function');
t.type(vm.addCostume, 'function');
t.type(vm.addBackdrop, 'function');
t.type(vm.addSound, 'function');
t.type(vm.deleteCostume, 'function');
t.type(vm.deleteSound, 'function');
t.type(vm.renameSprite, 'function');
t.type(vm.deleteSprite, 'function');
t.type(vm.attachRenderer, 'function');
t.type(vm.blockListener, 'function');
t.type(vm.flyoutBlockListener, 'function');
t.type(vm.setEditingTarget, 'function');
t.type(vm.emitTargetsUpdate, 'function');
t.type(vm.emitWorkspaceUpdate, 'function');
t.type(vm.postSpriteInfo, 'function');
t.end();
});

View File

@@ -0,0 +1,585 @@
const test = require('tap').test;
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
const Runtime = require('../../src/engine/runtime');
const FakeRenderer = require('../fixtures/fake-renderer');
test('clone effects', t => {
// Create two clones and ensure they have different graphic effect objects.
// Regression test for Github issue #224
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
const b = new RenderedTarget(spr, r);
t.ok(a.effects !== b.effects);
t.end();
});
test('setxy', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setXY(123, 321, true);
t.equals(a.x, 123);
t.equals(a.y, 321);
renderer.getFencedPositionOfDrawable = () => [50, 50];
a.setXY(100, 100, true);
t.equals(a.x, 50);
t.equals(a.y, 50);
r.setRuntimeOptions({
fencing: false
});
a.setXY(100, 100, true);
t.equals(a.x, 100);
t.equals(a.y, 100);
t.end();
});
test('blocks get new id on duplicate', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const rt = new RenderedTarget(s, r);
const block = {
id: 'id1',
topLevel: true,
fields: {}
};
rt.blocks.createBlock(block);
return rt.duplicate().then(duplicate => {
t.notOk(Object.prototype.hasOwnProperty.call(duplicate.blocks._blocks, block.id));
t.end();
});
});
test('direction', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setDirection(123);
t.equals(a._getRenderedDirectionAndScale().direction, 123);
t.end();
});
test('setVisible', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setVisible(true);
t.end();
});
test('setSize', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setSize(123);
t.equals(a._getRenderedDirectionAndScale().scale[0], 123);
renderer.getCurrentSkinSize = () => [100, 100];
a.setSize(99999);
t.equals(a._getRenderedDirectionAndScale().scale[0], 540);
r.setRuntimeOptions({
fencing: false
});
a.setSize(99999);
t.equals(a._getRenderedDirectionAndScale().scale[0], 99999);
t.end();
});
test('set and clear effects', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
for (const effect in a.effects) {
a.setEffect(effect, 1);
t.equals(a.effects[effect], 1);
}
a.clearEffects();
for (const effect in a.effects) {
t.equals(a.effects[effect], 0);
}
t.end();
});
test('setCostume', t => {
const o = new Object();
const r = new Runtime();
const s = new Sprite(null, r);
s.costumes = [o];
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setCostume(0);
t.end();
});
test('deleteCostume', t => {
const o1 = {id: 1};
const o2 = {id: 2};
const o3 = {id: 3};
const o4 = {id: 4};
const o5 = {id: 5};
const r = new Runtime();
const s = new Sprite(null, r);
s.costumes = [o1, o2, o3];
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
// x* Costume 1 * Costume 2
// Costume 2 => Costume 3
// Costume 3
a.setCostume(0);
const deletedCostume = a.deleteCostume(0);
t.equals(a.sprite.costumes.length, 2);
t.equals(a.sprite.costumes[0].id, 2);
t.equals(a.sprite.costumes[1].id, 3);
t.equals(a.currentCostume, 0);
t.deepEqual(deletedCostume, o1);
// Costume 1 Costume 1
// x* Costume 2 => * Costume 3
// Costume 3
a.sprite.costumes = [o1, o2, o3];
a.setCostume(1);
const deletedCostume2 = a.deleteCostume(1);
t.equals(a.sprite.costumes.length, 2);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.sprite.costumes[1].id, 3);
t.equals(a.currentCostume, 1);
t.deepEqual(deletedCostume2, o2);
// Costume 1 Costume 1
// Costume 2 => * Costume 2
// x* Costume 3
a.sprite.costumes = [o1, o2, o3];
a.setCostume(2);
const deletedCostume3 = a.deleteCostume(2);
t.equals(a.sprite.costumes.length, 2);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.sprite.costumes[1].id, 2);
t.equals(a.currentCostume, 1);
t.deepEqual(deletedCostume3, o3);
// Refuses to delete only costume
a.sprite.costumes = [o1];
a.setCostume(0);
const noDeletedCostume = a.deleteCostume(0);
t.equals(a.sprite.costumes.length, 1);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.currentCostume, 0);
t.equal(noDeletedCostume, null);
// Costume 1 Costume 1
// x Costume 2 Costume 3
// Costume 3 => * Costume 4
// * Costume 4 Costume 5
// Costume 5
a.sprite.costumes = [o1, o2, o3, o4, o5];
a.setCostume(3);
a.deleteCostume(1);
t.equals(a.sprite.costumes.length, 4);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.sprite.costumes[1].id, 3);
t.equals(a.sprite.costumes[2].id, 4);
t.equals(a.sprite.costumes[3].id, 5);
t.equals(a.currentCostume, 2);
// Costume 1 Costume 1
// * Costume 2 * Costume 2
// Costume 3 => Costume 3
// x Costume 4 Costume 5
// Costume 5
a.sprite.costumes = [o1, o2, o3, o4, o5];
a.setCostume(1);
a.deleteCostume(3);
t.equals(a.sprite.costumes.length, 4);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.sprite.costumes[1].id, 2);
t.equals(a.sprite.costumes[2].id, 3);
t.equals(a.sprite.costumes[3].id, 5);
t.equals(a.currentCostume, 1);
// Costume 1 Costume 1
// * Costume 2 * Costume 2
// Costume 3 => Costume 3
// Costume 4 Costume 4
// x Costume 5
a.sprite.costumes = [o1, o2, o3, o4, o5];
a.setCostume(1);
a.deleteCostume(4);
t.equals(a.sprite.costumes.length, 4);
t.equals(a.sprite.costumes[0].id, 1);
t.equals(a.sprite.costumes[1].id, 2);
t.equals(a.sprite.costumes[2].id, 3);
t.equals(a.sprite.costumes[3].id, 4);
t.equals(a.currentCostume, 1);
t.end();
});
test('deleteSound', t => {
const o1 = {id: 1};
const o2 = {id: 2};
const o3 = {id: 3};
const r = new Runtime();
const s = new Sprite(null, r);
s.sounds = [o1, o2, o3];
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
const firstDeleted = a.deleteSound(0);
t.deepEqual(a.sprite.sounds, [o2, o3]);
t.deepEqual(firstDeleted, o1);
// Allows deleting the only sound
a.sprite.sounds = [o1];
a.deleteSound(0);
t.deepEqual(a.sprite.sounds, []);
t.end();
});
test('setRotationStyle', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
a.setRotationStyle(RenderedTarget.ROTATION_STYLE_NONE);
t.end();
});
test('getBounds', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.getBounds().top, 0);
a.setXY(241, 241);
t.equals(a.getBounds().top, 241);
t.end();
});
test('isTouchingPoint', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.isTouchingPoint(), true);
t.end();
});
test('isTouchingEdge', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.isTouchingEdge(), false);
a.setXY(1000, 1000);
t.equals(a.isTouchingEdge(), true);
t.end();
});
test('isTouchingSprite', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.isTouchingSprite('fake'), false);
t.end();
});
test('isTouchingColor', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.isTouchingColor(), false);
t.end();
});
test('colorIsTouchingColor', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.colorIsTouchingColor(), false);
t.end();
});
test('layers', t => { // TODO this tests fake functionality. Move layering tests into Render.
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
const o = new Object();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
a.goToFront();
t.equals(a.renderer.order, 5);
a.goBackwardLayers(2);
t.equals(a.renderer.order, 3);
a.goToBack();
// Note, there are only sprites in this test, no stage, and the addition
// of layer groups, goToBack no longer specifies a minimum order number
t.equals(a.renderer.order, 0);
a.goForwardLayers(1);
t.equals(a.renderer.order, 1);
o.drawableID = 999;
a.goBehindOther(o);
t.equals(a.renderer.order, 1);
t.end();
});
test('getLayerOrder returns result of renderer getDrawableOrder or null if renderer is not attached', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
// getLayerOrder should return null if there is no renderer attached to the runtime
t.equal(a.getLayerOrder(), null);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const b = new RenderedTarget(s, r);
t.equal(b.getLayerOrder(), 'stub');
t.end();
});
test('keepInFence', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const renderer = new FakeRenderer();
r.attachRenderer(renderer);
const a = new RenderedTarget(s, r);
a.renderer = renderer;
t.equals(a.keepInFence(1000, 1000)[0], 240);
t.equals(a.keepInFence(-1000, 1000)[0], -240);
t.equals(a.keepInFence(1000, 1000)[1], 180);
t.equals(a.keepInFence(1000, -1000)[1], -180);
t.end();
});
test('#stopAll clears graphics effects', t => {
const r = new Runtime();
const s = new Sprite(null, r);
const a = new RenderedTarget(s, r);
const effectName = 'brightness';
a.setEffect(effectName, 100);
a.onStopAll();
t.equals(a.effects[effectName], 0);
t.end();
});
test('#getCostumes returns the costumes', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
a.sprite.costumes = [{id: 1}, {id: 2}, {id: 3}];
t.equals(a.getCostumes().length, 3);
t.equals(a.getCostumes()[0].id, 1);
t.equals(a.getCostumes()[1].id, 2);
t.equals(a.getCostumes()[2].id, 3);
t.end();
});
test('#getSounds returns the sounds', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
const sounds = [1, 2, 3];
a.sprite.sounds = sounds;
t.equals(a.getSounds(), sounds);
t.end();
});
test('#toJSON returns the sounds and costumes', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
const sounds = [1, 2, 3];
a.sprite.sounds = sounds;
a.sprite.costumes = [{id: 1}, {id: 2}, {id: 3}];
t.same(a.toJSON().sounds, sounds);
t.same(a.toJSON().costumes, a.sprite.costumes);
t.end();
});
test('#addSound does not duplicate names', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
a.sprite.sounds = [{name: 'first'}];
a.addSound({name: 'first'});
t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'first2'}]);
t.end();
});
test('#addCostume does not duplicate names', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
a.addCostume({name: 'first'});
a.addCostume({name: 'first'});
t.equal(a.sprite.costumes.length, 2);
t.equal(a.sprite.costumes[0].name, 'first');
t.equal(a.sprite.costumes[1].name, 'first2');
t.end();
});
test('#renameSound does not duplicate names', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
a.sprite.sounds = [{name: 'first'}, {name: 'second'}];
a.renameSound(0, 'first'); // Shouldn't increment the name, noop
t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'second'}]);
a.renameSound(1, 'first');
t.deepEqual(a.sprite.sounds, [{name: 'first'}, {name: 'first2'}]);
t.end();
});
test('#renameCostume does not duplicate names', t => {
const r = new Runtime();
const spr = new Sprite(null, r);
const a = new RenderedTarget(spr, r);
a.sprite.costumes = [{name: 'first'}, {name: 'second'}];
a.renameCostume(0, 'first'); // Shouldn't increment the name, noop
t.equal(a.sprite.costumes.length, 2);
t.equal(a.sprite.costumes[0].name, 'first');
t.equal(a.sprite.costumes[1].name, 'second');
a.renameCostume(1, 'first');
t.equal(a.sprite.costumes.length, 2);
t.equal(a.sprite.costumes[0].name, 'first');
t.equal(a.sprite.costumes[1].name, 'first2');
t.end();
});
test('#reorderCostume', t => {
const o1 = {id: 0};
const o2 = {id: 1};
const o3 = {id: 2};
const o4 = {id: 3};
const o5 = {id: 4};
const r = new Runtime();
const s = new Sprite(null, r);
s.costumes = [o1, o2, o3, o4, o5];
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
const resetCostumes = () => {
a.setCostume(0);
s.costumes = [o1, o2, o3, o4, o5];
};
const costumeIds = () => a.sprite.costumes.map(c => c.id);
resetCostumes();
t.deepEquals(costumeIds(), [0, 1, 2, 3, 4]);
t.equals(a.currentCostume, 0);
// Returns false if the costumes are the same and no change occurred
t.equal(a.reorderCostume(3, 3), false);
t.equal(a.reorderCostume(999, 5000), false); // Clamped to the same values.
t.equal(a.reorderCostume(-999, -5000), false);
// Make sure reordering up and down works and current costume follows
resetCostumes();
t.equal(a.reorderCostume(0, 3), true);
t.deepEquals(costumeIds(), [1, 2, 3, 0, 4]);
t.equals(a.currentCostume, 3); // Index of id=0
resetCostumes();
a.setCostume(1);
t.equal(a.reorderCostume(3, 1), true);
t.deepEquals(costumeIds(), [0, 3, 1, 2, 4]);
t.equals(a.currentCostume, 2); // Index of id=1
// Out of bounds indices get clamped
resetCostumes();
t.equal(a.reorderCostume(10, 0), true);
t.deepEquals(costumeIds(), [4, 0, 1, 2, 3]);
t.equals(a.currentCostume, 1); // Index of id=0
resetCostumes();
t.equal(a.reorderCostume(2, -1000), true);
t.deepEquals(costumeIds(), [2, 0, 1, 3, 4]);
t.equals(a.currentCostume, 1); // Index of id=0
t.end();
});
test('#reorderSound', t => {
const o1 = {id: 0, name: 'name0'};
const o2 = {id: 1, name: 'name1'};
const o3 = {id: 2, name: 'name2'};
const o4 = {id: 3, name: 'name3'};
const o5 = {id: 4, name: 'name4'};
const r = new Runtime();
const s = new Sprite(null, r);
s.sounds = [o1, o2, o3, o4, o5];
const a = new RenderedTarget(s, r);
const renderer = new FakeRenderer();
a.renderer = renderer;
const resetSounds = () => {
s.sounds = [o1, o2, o3, o4, o5];
};
const soundIds = () => a.sprite.sounds.map(c => c.id);
resetSounds();
t.deepEquals(soundIds(), [0, 1, 2, 3, 4]);
// Return false if indices are the same and no change occurred.
t.equal(a.reorderSound(3, 3), false);
t.equal(a.reorderSound(100000, 99999), false); // Clamped to the same values
t.equal(a.reorderSound(-100000, -99999), false);
// Make sure reordering up and down works and current sound follows
resetSounds();
t.equal(a.reorderSound(0, 3), true);
t.deepEquals(soundIds(), [1, 2, 3, 0, 4]);
resetSounds();
t.equal(a.reorderSound(3, 1), true);
t.deepEquals(soundIds(), [0, 3, 1, 2, 4]);
// Out of bounds indices get clamped
resetSounds();
t.equal(a.reorderSound(10, 0), true);
t.deepEquals(soundIds(), [4, 0, 1, 2, 3]);
resetSounds();
t.equal(a.reorderSound(2, -1000), true);
t.deepEquals(soundIds(), [2, 0, 1, 3, 4]);
t.end();
});

View File

@@ -0,0 +1,57 @@
const {test} = require('tap');
const JSZip = require('@turbowarp/jszip');
const makeTestStorage = require('../fixtures/make-test-storage');
const AssetUtil = require('../../src/util/tw-asset-util');
const Runtime = require('../../src/engine/runtime');
test('getByMd5ext from zip root', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
rt.storage.load = () => t.fail('should not call storage.load()');
const zip = new JSZip();
zip.file('00000000000000000000000000000000.svg', new Uint8Array([1, 2, 3]));
AssetUtil.getByMd5ext(rt, zip, rt.storage.AssetType.SVG, '00000000000000000000000000000000.svg')
.then(asset => {
t.same(asset.data, new Uint8Array([1, 2, 3]));
t.end();
});
});
test('getByMd5ext from zip subdirectory', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
rt.storage.load = () => t.fail('should not call storage.load()');
const zip = new JSZip();
zip.file('folder/00000000000000000000000000000000.svg', new Uint8Array([25, 26, 27]));
AssetUtil.getByMd5ext(rt, zip, rt.storage.AssetType.SVG, '00000000000000000000000000000000.svg')
.then(asset => {
t.same(asset.data, new Uint8Array([25, 26, 27]));
t.end();
});
});
test('getByMd5ext from storage with null zip', t => {
t.plan(4);
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
rt.storage.load = (assetType, md5, ext) => {
t.equal(assetType, rt.storage.AssetType.SVG);
t.equal(md5, '00000000000000000000000000000000');
t.equal(ext, 'svg');
return Promise.resolve({
fromStorage: true
});
};
AssetUtil.getByMd5ext(rt, null, rt.storage.AssetType.SVG, '00000000000000000000000000000000.svg')
.then(asset => {
t.ok(asset.fromStorage);
t.end();
});
});

View File

@@ -0,0 +1,130 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('with explicit category colors', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
color1: '#ff0000',
color2: '#00ff00',
color3: '#0000ff',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'defaults'
},
{
blockType: BlockType.COMMAND,
opcode: 'color1',
color1: '#ffff00'
},
{
blockType: BlockType.COMMAND,
opcode: 'color2',
color2: '#ff00ff'
},
{
blockType: BlockType.COMMAND,
opcode: 'color3',
color3: '#00ffff'
},
{
blockType: BlockType.COMMAND,
opcode: 'colorAll',
color1: '#7fff00',
color2: '#ff007f',
color3: '#007fff'
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.equal(blocks[0].colour, '#ff0000');
t.equal(blocks[0].colourSecondary, '#00ff00');
t.equal(blocks[0].colourTertiary, '#0000ff');
t.equal(blocks[1].colour, '#ffff00');
t.equal(blocks[1].colourSecondary, '#00ff00');
t.equal(blocks[1].colourTertiary, '#0000ff');
t.equal(blocks[2].colour, '#ff0000');
t.equal(blocks[2].colourSecondary, '#ff00ff');
t.equal(blocks[2].colourTertiary, '#0000ff');
t.equal(blocks[3].colour, '#ff0000');
t.equal(blocks[3].colourSecondary, '#00ff00');
t.equal(blocks[3].colourTertiary, '#00ffff');
t.equal(blocks[4].colour, '#7fff00');
t.equal(blocks[4].colourSecondary, '#ff007f');
t.equal(blocks[4].colourTertiary, '#007fff');
t.end();
});
test('with the default colors', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'defaults'
},
{
blockType: BlockType.COMMAND,
opcode: 'color1',
color1: '#ffff00'
},
{
blockType: BlockType.COMMAND,
opcode: 'color2',
color2: '#ff00ff'
},
{
blockType: BlockType.COMMAND,
opcode: 'color3',
color3: '#00ffff'
},
{
blockType: BlockType.COMMAND,
opcode: 'colorAll',
color1: '#7fff00',
color2: '#ff007f',
color3: '#007fff'
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.equal(blocks[0].colour, '#0FBD8C');
t.equal(blocks[0].colourSecondary, '#0DA57A');
t.equal(blocks[0].colourTertiary, '#0B8E69');
t.equal(blocks[1].colour, '#ffff00');
t.equal(blocks[1].colourSecondary, '#0DA57A');
t.equal(blocks[1].colourTertiary, '#0B8E69');
t.equal(blocks[2].colour, '#0FBD8C');
t.equal(blocks[2].colourSecondary, '#ff00ff');
t.equal(blocks[2].colourTertiary, '#0B8E69');
t.equal(blocks[3].colour, '#0FBD8C');
t.equal(blocks[3].colourSecondary, '#0DA57A');
t.equal(blocks[3].colourTertiary, '#00ffff');
t.equal(blocks[4].colour, '#7fff00');
t.equal(blocks[4].colourSecondary, '#ff007f');
t.equal(blocks[4].colourTertiary, '#007fff');
t.end();
});

View File

@@ -0,0 +1,152 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('does not duplicate', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blockIconURI: 'data:whatever',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'test',
extensions: [
'something_invalid',
'from_extension',
'scratch_extension',
'default_extension_colors'
]
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.same(
blocks[0].extensions,
['from_extension', 'default_extension_colors', 'scratch_extension', 'something_invalid']
);
t.end();
});
test('block icon', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'nothing'
},
{
blockType: BlockType.COMMAND,
opcode: 'empty',
extensions: []
},
{
blockType: BlockType.COMMAND,
opcode: 'iconNothing',
blockIconURI: 'data:whatever'
},
{
blockType: BlockType.COMMAND,
opcode: 'iconEmpty',
blockIconURI: 'data:whatever',
extensions: []
},
{
blockType: BlockType.COMMAND,
opcode: 'iconSomething',
blockIconURI: 'data:whatever',
extensions: ['colours_sensing']
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.same(blocks[0].extensions, ['from_extension', 'default_extension_colors']);
t.same(blocks[1].extensions, ['from_extension', 'default_extension_colors']);
t.same(blocks[2].extensions, ['from_extension', 'default_extension_colors', 'scratch_extension']);
t.same(blocks[3].extensions, ['from_extension', 'default_extension_colors', 'scratch_extension']);
t.same(
blocks[4].extensions,
['from_extension', 'default_extension_colors', 'scratch_extension', 'colours_sensing']
);
t.end();
});
test('category icon', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blockIconURI: 'data:whatever',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'nothing'
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.same(blocks[0].extensions, ['from_extension', 'default_extension_colors', 'scratch_extension']);
t.end();
});
test('category color', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blockIconURI: 'data:whatever',
color1: '#123456',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'nothing'
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.same(blocks[0].extensions, ['from_extension', 'scratch_extension']);
t.end();
});
test('category color', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'default'
},
{
blockType: BlockType.COMMAND,
opcode: 'custom',
color3: '#123456'
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.same(blocks[0].extensions, ['from_extension', 'default_extension_colors']);
t.same(blocks[1].extensions, ['from_extension']);
t.end();
});

View File

@@ -0,0 +1,46 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('branchIconURI', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testextension',
name: 'test',
blocks: [
{
blockType: BlockType.LOOP,
opcode: 'block1',
text: 'no custom icon'
},
{
blockType: BlockType.LOOP,
opcode: 'block2',
text: 'LOOP with custom icon',
branchIconURI: 'data:whatever1'
},
{
blockType: BlockType.CONDITIONAL,
opcode: 'block3',
text: 'CONDITIONAL with custom icon',
branchIconURI: 'data:whatever2'
},
{
blockType: BlockType.LOOP,
opcode: 'block4',
text: 'LOOP with no icon',
branchIconURI: ''
}
]
})
});
const blocks = vm.runtime.getBlocksJSON();
t.equal(blocks[0].args2[0].src, 'media://repeat.svg', 'default custom icon');
t.equal(blocks[1].args2[0].src, 'data:whatever1', 'LOOP with custom icon');
t.equal(blocks[2].args2[0].src, 'data:whatever2', 'CONDITIONAL with custom icon');
t.same(blocks[3].args2, null, 'LOOP with no icon');
t.end();
});

View File

@@ -0,0 +1,83 @@
const Cast = require('../../src/util/cast');
const {test} = require('tap');
test('Cast.compare with assorted whitespace characters', t => {
t.equal(Cast.compare('', ''), 0);
t.equal(Cast.compare(' ', ''), 1);
t.equal(Cast.compare('', ' '), -1);
t.equal(Cast.compare(' ', ' '), -1);
t.equal(Cast.compare(' ', ' '), 1);
t.equal(Cast.compare(' \u00a0 ', '\r\n'), 1);
t.equal(Cast.compare('\r\n', ' \u00a0 '), -1);
t.equal(Cast.compare(' 0', 0), 0);
t.equal(Cast.compare(0, ' 0'), 0);
t.equal(Cast.compare(' 0 ', ' \r\n\u00a0 0 \n\n\n\n'), 0);
t.equal(Cast.compare(' \r\n\u00a0 0 \n\n\n\n', ' 0 '), 0);
t.equal(Cast.compare(' 0 ', ' \r\n\u00a0 0 \n\n\n\b'), 1);
t.equal(Cast.compare(' \r\n\u00a0 0 \n\n\n\b', ' 0 '), -1);
t.equal(Cast.compare(' 0', '0'), 0);
t.equal(Cast.compare('0', ' 0'), 0);
t.equal(Cast.compare('', 0), -1);
t.equal(Cast.compare(0, ''), 1);
t.equal(Cast.compare(' ', 0), -1);
t.equal(Cast.compare(0, ' '), 1);
t.equal(Cast.compare('0', ' '), 1);
t.equal(Cast.compare(' ', '0'), -1);
t.equal(Cast.compare('\n0', '\n-1'), 1);
t.equal(Cast.compare('\n-1', '\n0'), -1);
t.equal(Cast.compare('', 'false'), -1);
t.equal(Cast.compare('false', ''), 1);
t.equal(Cast.compare('', ' false'), -1);
t.equal(Cast.compare(' false', ''), 1);
t.equal(Cast.compare('\n', ' false'), -1);
t.equal(Cast.compare('false', '\n'), 1);
t.equal(Cast.compare(false, ''), 1);
t.equal(Cast.compare('', false), -1);
t.equal(Cast.compare(false, ' '), 1);
t.equal(Cast.compare(' ', false), -1);
t.equal(Cast.compare('\t', '0'), 0);
t.equal(Cast.compare('0', '\t'), 0);
t.equal(Cast.compare('\t', 0), 0);
t.equal(Cast.compare(0, '\t'), 0);
t.equal(Cast.compare('\t', ''), 1);
t.equal(Cast.compare('', '\t'), -1);
t.equal(Cast.compare(' \t ', '0'), 0);
t.equal(Cast.compare('0', ' \t '), 0);
t.equal(Cast.compare('\r\n \t\u00a0', 0), 0);
t.equal(Cast.compare(0, '\r\n \t\u00a0'), 0);
t.equal(Cast.compare('\t', false), 0);
t.equal(Cast.compare(false, '\t'), 0);
t.equal(Cast.compare('\t', 'false'), -1);
t.equal(Cast.compare('false', '\t'), 1);
t.equal(Cast.compare('\t', '1'), -1);
t.equal(Cast.compare('1', '\t'), 1);
t.equal(Cast.compare('\t', 1), -1);
t.equal(Cast.compare(1, '\t'), 1);
t.end();
});

View File

@@ -0,0 +1,18 @@
const Runtime = require('../../src/engine/runtime');
const Sprite = require('../../src/sprites/sprite');
const {test} = require('tap');
test('clone counter', t => {
const rt = new Runtime();
const sprite = new Sprite(null, rt);
const original = sprite.createClone();
t.equal(rt._cloneCounter, 0);
const clone = original.makeClone();
t.equal(rt._cloneCounter, 1);
clone.dispose();
t.equal(rt._cloneCounter, 0);
original.dispose();
t.equal(rt._cloneCounter, 0);
t.end();
});

View File

@@ -0,0 +1,318 @@
const {test} = require('tap');
const compress = require('../../src/serialization/tw-compress-sb3');
const uid = require('../../src/util/uid');
test('handles type INPUT_DIFF_BLOCK_SHADOW (3) compressed inputs', t => {
const data = {
targets: [
{
isStage: true,
name: 'Stage',
variables: {},
lists: {},
broadcasts: {},
blocks: {
'CmRa^i]o}QL77;hk:54o': {
opcode: 'looks_switchbackdropto',
next: null,
parent: null,
inputs: {
BACKDROP: [
3,
'cq84G6uywD{m2R,E03Ci',
'E3/*4H*xk38{=*U;bVWm'
]
},
fields: {},
shadow: false,
topLevel: true,
x: 409,
y: 300
},
'cq84G6uywD{m2R,E03Ci': {
opcode: 'operator_not',
next: null,
parent: 'CmRa^i]o}QL77;hk:54o',
inputs: {},
fields: {},
shadow: false,
topLevel: false
},
'E3/*4H*xk38{=*U;bVWm': {
opcode: 'looks_backdrops',
next: null,
parent: 'CmRa^i]o}QL77;hk:54o',
inputs: {},
fields: {
BACKDROP: [
'backdrop1',
null
]
},
shadow: true,
topLevel: false
}
},
comments: {},
currentCostume: 0,
costumes: [],
sounds: [],
volume: 100,
layerOrder: 0,
tempo: 60,
videoTransparency: 50,
videoState: 'on',
textToSpeechLanguage: null
}
],
monitors: [],
extensions: [],
meta: {
semver: '3.0.0',
vm: '0.2.0',
agent: ''
}
};
compress(data);
const blocks = Object.entries(data.targets[0].blocks);
t.equal(blocks.length, 3);
const [parentId, parentBlock] = blocks.find(i => i[1].opcode === 'looks_switchbackdropto');
const [inputId, inputBlock] = blocks.find(i => i[1].opcode === 'operator_not');
const [shadowId, shadowBlock] = blocks.find(i => i[1].opcode === 'looks_backdrops');
t.equal(parentBlock.inputs.BACKDROP.length, 3);
t.equal(parentBlock.inputs.BACKDROP[0], 3);
t.equal(parentBlock.inputs.BACKDROP[1], inputId);
t.equal(parentBlock.inputs.BACKDROP[2], shadowId);
t.equal(inputBlock.parent, parentId);
t.equal(shadowBlock.parent, parentId);
t.end();
});
test('Compressed IDs will not collide with uncompressed IDs', t => {
const soup = 'abcdefghjijklmnopqstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789';
const items = [
[soup, '', ''],
['', soup, ''],
['', '', soup]
];
for (const [variableSoup, listSoup, broadcastSoup] of items) {
const data = {
targets: [
{
isStage: true,
name: 'Stage',
variables: Object.fromEntries(
variableSoup.split('').map(id => [id, [id, 0]])
),
lists: Object.fromEntries(
listSoup.split('').map(id => [id, [id, []]])
),
broadcasts: Object.fromEntries(
broadcastSoup.split('').map(id => [id, id])
),
blocks: {
'CmRa^i]o}QL77;hk:54o': {
opcode: 'looks_switchbackdropto',
next: null,
parent: null,
inputs: {
BACKDROP: [
3,
'cq84G6uywD{m2R,E03Ci',
'E3/*4H*xk38{=*U;bVWm'
]
},
fields: {},
shadow: false,
topLevel: true,
x: 409,
y: 300
},
'cq84G6uywD{m2R,E03Ci': {
opcode: 'operator_not',
next: null,
parent: 'CmRa^i]o}QL77;hk:54o',
inputs: {},
fields: {},
shadow: false,
topLevel: false
},
'E3/*4H*xk38{=*U;bVWm': {
opcode: 'looks_backdrops',
next: null,
parent: 'CmRa^i]o}QL77;hk:54o',
inputs: {},
fields: {
BACKDROP: [
'backdrop1',
null
]
},
shadow: true,
topLevel: false
}
},
comments: {
'ds{.EoY%0^6vO1WH0/9d': {
blockId: null,
x: 400,
y: 401,
width: 402,
height: 403,
minimized: false,
text: '4'
},
'blh[bsi@XtCkGh!-J5aa': {
blockId: null,
x: 500,
y: 501,
width: 502,
height: 503,
minimized: false,
text: '5'
},
'7#YgytOiJHs(Ne6,2i9(': {
blockId: null,
x: 600,
y: 601,
width: 602,
height: 603,
minimized: false,
text: '6'
}
},
currentCostume: 0,
costumes: [],
sounds: [],
volume: 100,
layerOrder: 0,
tempo: 60,
videoTransparency: 50,
videoState: 'on',
textToSpeechLanguage: null
}
],
monitors: [],
extensions: [],
meta: {
semver: '3.0.0',
vm: '0.2.0',
agent: ''
}
};
compress(data);
const uncompressedIDs = [
...Object.keys(data.targets[0].variables),
...Object.keys(data.targets[0].lists),
...Object.keys(data.targets[0].broadcasts)
];
const compressedIDs = [
...Object.keys(data.targets[0].blocks),
...Object.keys(data.targets[0].comments)
];
for (const compressedID of compressedIDs) {
t.notOk(uncompressedIDs.includes(compressedID), `${compressedID} does not collide`);
}
}
t.end();
});
test('Script execution order is preserved', t => {
const originalBlocks = {};
const blockIds = [];
for (let i = 0; i < 1000; i++) {
if (i === 339) {
blockIds.push('muffin');
} else if (i === 555) {
blockIds.push('555');
}
blockIds.push(uid());
}
blockIds.push('apple');
blockIds.push('-1');
blockIds.push('45');
for (const blockId of blockIds) {
originalBlocks[blockId] = {
opcode: 'event_whenbroadcastreceived',
next: null,
parent: null,
inputs: {},
fields: {
BROADCAST_OPTION: [
`broadcast-name-${blockId}`,
`broadcast-id-${blockId}`
]
},
shadow: false,
topLevel: true,
x: -10,
y: 420
};
}
const data = {
targets: [
{
isStage: true,
name: 'Stage',
variables: {},
lists: {},
broadcasts: {},
blocks: originalBlocks,
comments: {},
currentCostume: 0,
costumes: [],
sounds: [],
volume: 100,
layerOrder: 0,
tempo: 60,
videoTransparency: 50,
videoState: 'on',
textToSpeechLanguage: null
}
],
monitors: [],
extensions: [],
meta: {
semver: '3.0.0',
vm: '0.2.0',
agent: ''
}
};
compress(data);
// Sanity check: Make sure the new object is actually different
const newBlocks = data.targets[0].blocks;
t.not(originalBlocks, newBlocks);
t.notSame(Object.keys(originalBlocks), Object.keys(newBlocks));
// Check that the order has not changed
const newBlockValues = Object.values(newBlocks);
t.same(Object.values(originalBlocks), newBlockValues);
t.equal(newBlockValues[0].fields.BROADCAST_OPTION[0], 'broadcast-name-45');
t.equal(newBlockValues[1].fields.BROADCAST_OPTION[0], 'broadcast-name-555');
t.equal(newBlockValues[339 + 2].fields.BROADCAST_OPTION[0], 'broadcast-name-muffin');
t.equal(newBlockValues[newBlockValues.length - 2].fields.BROADCAST_OPTION[0], 'broadcast-name-apple');
t.equal(newBlockValues[newBlockValues.length - 1].fields.BROADCAST_OPTION[0], 'broadcast-name--1');
// Check that the new IDs do not look like array indexes as their enumeration
// order could cause unexpected behavior in other places.
for (const newBlockId of Object.keys(newBlocks)) {
// The actual definition of an array index is: https://tc39.es/ecma262/#array-index
// This approximation is currently good enough
if (!Number.isNaN(+newBlockId)) {
t.fail(`${newBlockId} might be treated as an array index`);
}
}
t.end();
});

View File

@@ -0,0 +1,57 @@
const {test} = require('tap');
const Runtime = require('../../src/engine/runtime');
const BlockType = require('../../src/extension-support/block-type');
const ArgumentType = require('../../src/extension-support/argument-type');
const extension = {
id: 'costumesoundtest',
name: 'Costume & Sound',
blocks: [
{
blockType: BlockType.COMMAND,
opcode: 'costume',
text: 'costume [a] [b]',
arguments: {
a: {
type: ArgumentType.COSTUME
},
b: {
type: ArgumentType.COSTUME,
defaultValue: 'default costume'
}
}
},
{
blockType: BlockType.COMMAND,
opcode: 'sound',
text: 'sound [a] [b]',
arguments: {
a: {
type: ArgumentType.SOUND
},
b: {
type: ArgumentType.SOUND,
defaultValue: 'default sound'
}
}
}
]
};
test('COSTUME and SOUND inputs generate correct scratch-blocks XML', t => {
const rt = new Runtime();
rt.on('EXTENSION_ADDED', info => {
/* eslint-disable max-len */
t.equal(
info.blocks[0].xml,
'<block type="costumesoundtest_costume"><value name="a"><shadow type="looks_costume"></shadow></value><value name="b"><shadow type="looks_costume"><field name="COSTUME">default costume</field></shadow></value></block>'
);
t.equal(
info.blocks[1].xml,
'<block type="costumesoundtest_sound"><value name="a"><shadow type="sound_sounds_menu"></shadow></value><value name="b"><shadow type="sound_sounds_menu"><field name="SOUND_MENU">default sound</field></shadow></value></block>'
);
/* eslint-enable max-len */
t.end();
});
rt._registerExtensionPrimitives(extension);
});

View File

@@ -0,0 +1,76 @@
const {
parseVectorMetadata,
exportCostume
} = require('../../src/serialization/tw-costume-import-export');
const {test} = require('tap');
test('parseVectorMetadata', t => {
/* eslint-disable max-len */
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:0:0-->'),
[0, 0]
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:-0.0:-0.0-->'),
[0, 0]
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:-1:3-->'),
[-1, 3]
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:106.62300344745225:-11.822572945859918-->'),
[106.62300344745225, -11.822572945859918]
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:a:b-->'),
null
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:-1:-->'),
null
);
t.same(
parseVectorMetadata('<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>'),
null
);
/* eslint-enable max-len */
t.end();
});
test('exportCostume', t => {
// PNG and JPG costumes are exported as-is
t.same(exportCostume({
dataFormat: 'png',
asset: {
data: new Uint8Array([10, 20, 30])
}
}), new Uint8Array([10, 20, 30]));
t.same(exportCostume({
dataFormat: 'jpg',
asset: {
data: new Uint8Array([40, 50, 60])
}
}), new Uint8Array([40, 50, 60]));
t.same(exportCostume({
dataFormat: 'svg',
asset: {
data: new TextEncoder().encode('<svg></svg>')
},
rotationCenterX: 89.339393,
rotationCenterY: -3.7373
}), new TextEncoder().encode('<svg></svg><!--rotationCenter:89.339393:-3.7373-->'));
t.same(exportCostume({
dataFormat: 'svg',
asset: {
data: new TextEncoder().encode('<svg></svg><!--rotationCenter:78.23:-9-->')
},
rotationCenterX: 89.339393,
rotationCenterY: -3.7373
}), new TextEncoder().encode('<svg></svg><!--rotationCenter:89.339393:-3.7373-->'));
t.end();
});

View File

@@ -0,0 +1,34 @@
const ScratchCommon = require('../../src/extension-support/tw-extension-api-common');
const {test} = require('tap');
test('ArgumentType', t => {
t.equal(ScratchCommon.ArgumentType.ANGLE, 'angle');
t.end();
});
test('BlockType', t => {
t.equal(ScratchCommon.BlockType.BOOLEAN, 'Boolean');
t.end();
});
test('TargetType', t => {
t.equal(ScratchCommon.TargetType.SPRITE, 'sprite');
t.end();
});
test('Cast', t => {
// Cast is thoroughly tested elsewhere. We just want to make sure that the public methods
// don't get deleted unexpectedly.
t.equal(ScratchCommon.Cast.toNumber('5'), 5);
t.equal(ScratchCommon.Cast.toBoolean('true'), true);
t.equal(ScratchCommon.Cast.toString('something'), 'something');
t.same(ScratchCommon.Cast.toRgbColorList('#abcdef'), [0xab, 0xcd, 0xef]);
t.same(ScratchCommon.Cast.toRgbColorObject('#abcdef'), {r: 0xab, g: 0xcd, b: 0xef});
t.equal(ScratchCommon.Cast.isWhiteSpace(''), true);
t.equal(ScratchCommon.Cast.compare(1, 2), -1);
t.equal(ScratchCommon.Cast.isInt(5.5), false);
t.type(ScratchCommon.Cast.LIST_INVALID, 'string');
t.type(ScratchCommon.Cast.LIST_ALL, 'string');
t.equal(ScratchCommon.Cast.toListIndex('1.5', 10, false), 1);
t.end();
});

View File

@@ -0,0 +1,80 @@
const {test} = require('tap');
const ExtensionManager = require('../../src/extension-support/extension-manager');
const VM = require('../../src/virtual-machine');
test('isBuiltinExtension', t => {
const fakeRuntime = {};
const manager = new ExtensionManager(fakeRuntime);
t.equal(manager.isBuiltinExtension('pen'), true);
t.equal(manager.isBuiltinExtension('lksdfjlskdf'), false);
t.end();
});
test('_isValidExtensionURL', t => {
const fakeRuntime = {};
const manager = new ExtensionManager(fakeRuntime);
t.equal(manager._isValidExtensionURL('fetch'), false);
t.equal(manager._isValidExtensionURL(''), false);
t.equal(manager._isValidExtensionURL('extensions.turbowarp.org/fetch.js'), false);
t.equal(manager._isValidExtensionURL('https://extensions.turbowarp.org/fetch.js'), true);
t.equal(manager._isValidExtensionURL('http://extensions.turbowarp.org/fetch.js'), true);
t.equal(manager._isValidExtensionURL('http://localhost:8000'), true);
t.equal(manager._isValidExtensionURL('data:application/javascript;base64,YWxlcnQoMSk='), true);
t.equal(manager._isValidExtensionURL('file:///home/test/extension.js'), true);
t.end();
});
test('loadExtensionURL, getExtensionURLs, deduplication', async t => {
const vm = new VM();
let loadedExtensions = 0;
vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed';
global.document = {
createElement: () => {
loadedExtensions++;
const element = {};
setTimeout(() => {
global.Scratch.extensions.register({
getInfo: () => ({
id: `extension${loadedExtensions}`
})
});
});
return element;
},
body: {
appendChild: () => {}
}
};
const url1 = 'https://turbowarp.org/1.js';
t.equal(vm.extensionManager.isExtensionURLLoaded(url1), false);
t.same(vm.extensionManager.getExtensionURLs(), {});
await vm.extensionManager.loadExtensionURL(url1);
t.equal(vm.extensionManager.isExtensionURLLoaded(url1), true);
t.equal(loadedExtensions, 1);
t.same(vm.extensionManager.getExtensionURLs(), {
extension1: url1
});
// Loading the extension again should do nothing.
await vm.extensionManager.loadExtensionURL(url1);
t.equal(vm.extensionManager.isExtensionURLLoaded(url1), true);
t.equal(loadedExtensions, 1);
t.same(vm.extensionManager.getExtensionURLs(), {
extension1: url1
});
// Loading another extension should work
const url2 = 'https://turbowarp.org/2.js';
t.equal(vm.extensionManager.isExtensionURLLoaded(url2), false);
await vm.extensionManager.loadExtensionURL(url2);
t.equal(vm.extensionManager.isExtensionURLLoaded(url2), true);
t.equal(loadedExtensions, 2);
t.same(vm.extensionManager.getExtensionURLs(), {
extension1: url1,
extension2: url2
});
t.end();
});

View File

@@ -0,0 +1,63 @@
const {test} = require('tap');
const Runtime = require('../../src/engine/runtime');
const BlockType = require('../../src/extension-support/block-type');
const ArgumentType = require('../../src/extension-support/argument-type');
test('Boolean blocks can be monitors', t => {
const rt = new Runtime();
rt._registerExtensionPrimitives({
id: 'testextension',
blocks: [
{
blockType: BlockType.REPORTER,
opcode: 'reporter1',
text: 'reporter 1'
},
{
blockType: BlockType.REPORTER,
opcode: 'reporter2',
text: 'reporter 2',
disableMonitor: true
},
{
blockType: BlockType.REPORTER,
opcode: 'reporter3',
text: 'reporter 3 [INPUT]',
arguments: {
type: ArgumentType.STRING,
defaultValue: ''
}
},
{
blockType: BlockType.BOOLEAN,
opcode: 'boolean1',
text: 'boolean 1'
},
{
blockType: BlockType.BOOLEAN,
opcode: 'boolean2',
text: 'boolean 2',
disableMonitor: true
},
{
blockType: BlockType.BOOLEAN,
opcode: 'boolean3',
text: 'boolean 3 [INPUT]',
arguments: {
type: ArgumentType.STRING,
defaultValue: ''
}
}
]
});
const json = rt.getBlocksJSON();
t.equal(json.length, 6);
t.equal(json[0].checkboxInFlyout, true);
t.equal(json[1].checkboxInFlyout, undefined);
t.equal(json[2].checkboxInFlyout, undefined);
t.equal(json[3].checkboxInFlyout, true);
t.equal(json[4].checkboxInFlyout, undefined);
t.equal(json[5].checkboxInFlyout, undefined);
t.end();
});

View File

@@ -0,0 +1,27 @@
const test = require('tap').test;
const Music = require('../../src/extensions/scratch3_music/index.js');
const Runtime = require('../../src/engine/runtime.js');
test('_isConcurrencyLimited', t => {
const rt = new Runtime();
// sanity check so that the setRuntimeOptions() call below actually does something
t.equal(rt.runtimeOptions.miscLimits, true, 'misc limits enabled by default');
const blocks = new Music(rt);
t.equal(blocks._isConcurrencyLimited(), false, 'not limited initially');
// logic here is slightly weird but this matches Scratch
blocks._concurrencyCounter = Music.CONCURRENCY_LIMIT;
t.equal(blocks._isConcurrencyLimited(), false, 'not limited at limit');
blocks._concurrencyCounter = Music.CONCURRENCY_LIMIT + 1;
t.equal(blocks._isConcurrencyLimited(), true, 'limited above limit');
rt.setRuntimeOptions({
miscLimits: false
});
t.equal(blocks._isConcurrencyLimited(), false, 'not limited when miscLimits: false');
t.end();
});

View File

@@ -0,0 +1,76 @@
const {test} = require('tap');
const jsexecute = require('../../src/compiler/jsexecute');
const Cast = require('../../src/util/cast');
const {stringify} = require('@turbowarp/json');
const evaluateRuntimeFunction = functionName => jsexecute.scopedEval(functionName);
test('runtimeFunctions are valid', t => {
for (const functionName of Object.keys(jsexecute.runtimeFunctions)) {
const fn = evaluateRuntimeFunction(functionName);
t.type(fn, 'function', `${functionName} is function`);
}
t.end();
});
test('all runtimeFunctions can be used together', t => {
const script = Object.keys(jsexecute.runtimeFunctions).join(';');
jsexecute.scopedEval(script);
t.end();
});
test('comparison functions are equivalent to Cast.compare', t => {
const VALUES = [
0,
-0,
1,
'0',
'',
'.',
true,
false,
'true',
'false',
'true ',
'apple',
'Apple',
'Apple ',
' 123',
' 123.0',
'+123.5',
123,
0.23,
'0.23',
'.23',
'-.23',
'0.0',
NaN,
'NaN',
Infinity,
-Infinity,
'Infinity',
'-Infinity',
'\t',
'\r\n\u00a0'
];
const compareEqual = evaluateRuntimeFunction('compareEqual');
const compareGreaterThan = evaluateRuntimeFunction('compareGreaterThan');
const compareLessThan = evaluateRuntimeFunction('compareLessThan');
for (const a of VALUES) {
for (const b of VALUES) {
// Because there are so many tests, calling t.ok() each time is actually quite slow,
// so only call into tap when something failed.
const cast = Cast.compare(a, b);
if (compareEqual(a, b) !== (cast === 0)) {
t.fail(`${stringify(a)} should be === ${stringify(b)}`);
}
if (compareGreaterThan(a, b) !== (cast > 0)) {
t.fail(`${stringify(a)} should be > ${stringify(b)}`);
}
if (compareLessThan(a, b) !== (cast < 0)) {
t.fail(`${stringify(a)} should be < ${stringify(b)}`);
}
}
}
t.end();
});

View File

@@ -0,0 +1,77 @@
const {test} = require('tap');
const Runtime = require('../../src/engine/runtime');
const BlockType = require('../../src/extension-support/block-type');
const ArgumentType = require('../../src/extension-support/argument-type');
test('NUMBER argument defaultValue', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => {
/* eslint-disable max-len */
t.equal(
categoryInfo.blocks[0].xml,
'<block type="testextension_testNone"><value name="a"><shadow type="math_number"></shadow></value></block>'
);
t.equal(
categoryInfo.blocks[1].xml,
'<block type="testextension_testEmptyString"><value name="a"><shadow type="math_number"><field name="NUM"></field></shadow></value></block>'
);
t.equal(
categoryInfo.blocks[2].xml,
'<block type="testextension_testZeroString"><value name="a"><shadow type="math_number"><field name="NUM">0</field></shadow></value></block>'
);
t.equal(
categoryInfo.blocks[3].xml,
'<block type="testextension_testZeroNumber"><value name="a"><shadow type="math_number"><field name="NUM">0</field></shadow></value></block>'
);
/* eslint-enable max-len */
t.end();
});
runtime._registerExtensionPrimitives({
id: 'testextension',
blocks: [
{
type: BlockType.COMMAND,
opcode: 'testNone',
text: 'block [a]',
arguments: {
a: {
type: ArgumentType.NUMBER
}
}
},
{
type: BlockType.COMMAND,
opcode: 'testEmptyString',
text: 'block [a]',
arguments: {
a: {
type: ArgumentType.NUMBER,
defaultValue: ''
}
}
},
{
type: BlockType.COMMAND,
opcode: 'testZeroString',
text: 'block [a]',
arguments: {
a: {
type: ArgumentType.NUMBER,
defaultValue: '0'
}
}
},
{
type: BlockType.COMMAND,
opcode: 'testZeroNumber',
text: 'block [a]',
arguments: {
a: {
type: ArgumentType.NUMBER,
defaultValue: 0
}
}
}
]
});
});

View File

@@ -0,0 +1,19 @@
const test = require('tap').test;
const fs = require('fs');
const path = require('path');
const VirtualMachine = require('../../src/virtual-machine');
global.performance = {
mark () {
// No-op
},
measure () {
throw new Error('Mock error to simulate browser garbage collecting one of the marks before this code runs');
}
};
test('performance.measure() error in loadProject is ignored', async t => {
const vm = new VirtualMachine();
await vm.loadProject(fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-empty-project.sb3')));
t.end();
});

View File

@@ -0,0 +1,143 @@
const {test} = require('tap');
global.Request = class {
constructor (url) {
this.url = url;
}
};
global.fetch = (url, options = {}) => (
Promise.resolve(`[Response ${url instanceof Request ? url.url : url} options=${JSON.stringify(options)}]`)
);
// Remove navigator object from Node 21 and later
delete global.navigator;
// Need to trick the extension API to think it's running in a worker
// It will not actually use this object ever.
global.self = {};
// This will install extension worker APIs onto `global`
require('../../src/extension-support/extension-worker');
test('basic API', t => {
t.type(global.Scratch.extensions.register, 'function');
t.equal(global.Scratch.ArgumentType.BOOLEAN, 'Boolean');
t.equal(global.Scratch.BlockType.REPORTER, 'reporter');
t.end();
});
test('not unsandboxed', t => {
t.not(global.Scratch.extensions.unsandboxed, true);
t.end();
});
test('Cast', t => {
// Cast is thoroughly tested elsewhere
t.equal(global.Scratch.Cast.toString(5), '5');
t.equal(global.Scratch.Cast.toNumber(' 5'), 5);
t.equal(global.Scratch.Cast.toBoolean('true'), true);
t.end();
});
test('fetch', async t => {
t.equal(await global.Scratch.canFetch('https://untrusted.example/'), true);
t.equal(await global.Scratch.fetch('https://untrusted.example/'), '[Response https://untrusted.example/ options={}]');
t.equal(await global.Scratch.fetch('https://untrusted.example/', {
method: 'POST'
}), `[Response https://untrusted.example/ options={"method":"POST"}]`);
t.end();
});
test('openWindow', async t => {
t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false);
await t.rejects(global.Scratch.openWindow('https://example.com/'), /^Scratch\.openWindow not supported in sandboxed extensions$/);
t.end();
});
test('redirect', async t => {
t.equal(await global.Scratch.canRedirect('https://example.com/'), false);
await t.rejects(global.Scratch.redirect('https://example.com/'), /^Scratch\.redirect not supported in sandboxed extensions$/);
t.end();
});
test('translate', t => {
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'Message 1: test');
t.equal(global.Scratch.translate('test1'), 'test1');
t.equal(global.Scratch.translate('test1 {VAR}', {
VAR: '3'
}), 'test1 3');
t.equal(global.Scratch.translate.language, 'en');
const messages = {
en: {
test1: 'EN Message 1: {var}'
},
es: {
test1: 'ES Message 1: {var}'
}
};
// Should default to English when no navigator object
global.Scratch.translate.setup(messages);
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1',
description: 'Description'
}, {
var: 'ok'
}), 'EN Message 1: ok');
t.equal(global.Scratch.translate.language, 'en');
// But if there is a navigator object, it should use its language.
global.navigator = {
language: 'es'
};
// Note that real extensions will only generally call setup() once, but we need to do this
// again so that it realizes the language changed.
global.Scratch.translate.setup(messages);
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1',
description: 'Description'
}, {
var: 'ok'
}), 'ES Message 1: ok');
t.equal(global.Scratch.translate.language, 'es');
t.end();
});
test('canRecordAudio', async t => {
t.equal(await global.Scratch.canRecordAudio(), false);
t.end();
});
test('canRecordVideo', async t => {
t.equal(await global.Scratch.canRecordVideo(), false);
t.end();
});
test('canReadClipboard', async t => {
t.equal(await global.Scratch.canReadClipboard(), false);
t.end();
});
test('canNotify', async t => {
t.equal(await global.Scratch.canNotify(), false);
t.end();
});
test('canGeolocate', async t => {
t.equal(await global.Scratch.canGeolocate(), false);
t.end();
});
test('canEmbed', async t => {
t.equal(await global.Scratch.canEmbed('https://example.com/'), false);
t.end();
});

View File

@@ -0,0 +1,286 @@
const ScratchXUtilities = require('../../src/extension-support/tw-scratchx-utilities');
const createScratchX = require('../../src/extension-support/tw-scratchx-compatibility-layer');
const {test} = require('tap');
test('argument index to id', t => {
t.equal(ScratchXUtilities.argumentIndexToId(0), '0');
t.equal(ScratchXUtilities.argumentIndexToId(1), '1');
t.equal(ScratchXUtilities.argumentIndexToId(2), '2');
t.equal(ScratchXUtilities.argumentIndexToId(3), '3');
t.equal(ScratchXUtilities.argumentIndexToId(39), '39');
t.equal(ScratchXUtilities.argumentIndexToId(1000000000), '1000000000');
t.end();
});
test('generate extension id', t => {
t.equal(ScratchXUtilities.generateExtensionId('Spotify'), 'sbxspotify');
t.equal(ScratchXUtilities.generateExtensionId('Spo _t ify'), 'sbxspotify');
t.equal(ScratchXUtilities.generateExtensionId('Spo _t $#@! 3ify😮'), 'sbxspot3ify');
t.end();
});
const mockScratchExtensions = () => {
const mockScratch = {};
return createScratchX(mockScratch);
};
const convert = (...args) => {
let registered = null;
const mockScratch = {
extensions: {
register: extensionObject => {
if (registered) {
// In tests we don't want this
throw new Error('register() called twice');
}
registered = extensionObject;
}
}
};
const ScratchExtensions = createScratchX(mockScratch);
ScratchExtensions.register(...args);
if (!registered) {
throw new Error('Did not register()');
}
return registered;
};
test('register', t => {
const ScratchExtensions = mockScratchExtensions();
t.type(ScratchExtensions.register, 'function');
t.end();
});
test('complex extension', async t => {
let stepsMoved = 0;
const moveSteps = n => {
stepsMoved += n;
};
let doNothingCalled = false;
const doNothing = () => {
doNothingCalled = true;
};
const fetch = (url, callback) => {
callback(`Fetched: ${url}`);
return 'This value should be ignored.';
};
const multiplyAndAppend = (a, b, c) => `${a * b}${c}`;
const repeat = (string, count, callback) => {
callback(string.repeat(count));
return 'This value should be ignored.';
};
const touching = (sprite, bool) => sprite === 'Sprite9' && bool === true;
const converted = convert(
'My Extension',
{
blocks: [
['', 'move %n steps', 'moveSteps', 50],
[' ', 'do nothing', 'doNothing', 100, 200],
['w', 'fetch %m:urls1', 'fetch'],
[' '],
['r', 'multiply %n by %n and append %s', 'multiplyAndAppend'],
['R', 'repeat %m.myMenu %n', 'repeat', ''],
['-'],
['b', 'touching %s %b', 'touching', 'Sprite1', 'ignored']
],
menus: {
myMenu: ['abc', 'def', 123, true, false],
urls1: ['https://example.com/', 'https://example.org/']
},
url: 'https://turbowarp.org/myextensiondocs.html'
},
{
unusedGarbage: 10,
moveSteps,
doNothing,
fetch,
multiplyAndAppend,
repeat,
touching
}
);
const info = converted.getInfo();
t.equal(info.id, 'sbxmyextension');
t.equal(info.docsURI, 'https://turbowarp.org/myextensiondocs.html');
t.same(info.blocks, [
{
opcode: 'moveSteps',
text: 'move [0] steps',
blockType: 'command',
arguments: [
{
type: 'number',
defaultValue: 50
}
]
},
{
opcode: 'doNothing',
text: 'do nothing',
blockType: 'command',
arguments: []
},
{
opcode: 'fetch',
text: 'fetch [0]',
blockType: 'command',
arguments: [
{
type: 'string',
menu: 'urls1'
}
]
},
'---',
{
opcode: 'multiplyAndAppend',
text: 'multiply [0] by [1] and append [2]',
blockType: 'reporter',
arguments: [
{
type: 'number',
defaultValue: 0
},
{
type: 'number',
defaultValue: 0
},
{
type: 'string',
defaultValue: ''
}
]
},
{
opcode: 'repeat',
text: 'repeat [0] [1]',
blockType: 'reporter',
arguments: [
{
type: 'string',
menu: 'myMenu',
defaultValue: ''
},
{
type: 'number',
defaultValue: 0
}
]
},
'---',
{
opcode: 'touching',
text: 'touching [0] [1]',
blockType: 'Boolean',
arguments: [
{
type: 'string',
defaultValue: 'Sprite1'
},
{
type: 'Boolean'
}
]
}
]);
t.same(info.menus, {
myMenu: {
items: ['abc', 'def', 123, true, false]
},
urls1: {
items: ['https://example.com/', 'https://example.org/']
}
});
// Now let's make sure that the converter has properly wrapped our functions.
t.equal(stepsMoved, 0);
t.equal(converted.moveSteps({
0: 30
}), undefined);
t.equal(stepsMoved, 30);
t.equal(doNothingCalled, false);
t.equal(converted.doNothing({}), undefined);
t.equal(doNothingCalled, true);
t.type(converted.fetch({
0: 'https://example.com/'
}).then, 'function');
t.equal(await converted.fetch({
0: 'https://example.com/'
}), 'Fetched: https://example.com/');
t.equal(converted.multiplyAndAppend({
0: 31,
1: 7,
2: 'Cat'
}), '217Cat');
t.type(converted.repeat({
0: '',
1: 0
}).then, 'function');
t.equal(await converted.repeat({
0: 'scratchx',
1: 3
}), 'scratchxscratchxscratchx');
t.equal(converted.touching({
0: 'Sprite1',
1: true
}), false);
t.equal(converted.touching({
0: 'Sprite9',
1: true
}), true);
t.equal(converted.touching({
0: 'Sprite9',
1: false
}), false);
t.end();
});
test('display name', t => {
const converted = convert(
'Internal Name',
{
blocks: [],
displayName: 'Display Name'
},
{
}
);
t.equal(converted.getInfo().name, 'Display Name');
t.end();
});
test('_getStatus', t => {
const _getStatus = () => ({
status: 2,
msg: 'Ready'
});
const converted = convert(
'Name',
{
blocks: []
},
{
_getStatus: _getStatus,
unusedProperty: 10
}
);
t.equal(converted._getStatus, _getStatus);
t.equal('unusedProperty' in converted, false);
t.end();
});

View File

@@ -0,0 +1,38 @@
const {test} = require('tap');
const staticFetch = require('../../src/util/tw-static-fetch');
test('fetch simple base64', t => {
const res = staticFetch('data:text/plain;base64,VGVzdGluZyB0ZXN0aW5nIDEyMw==');
res.text().then(text => {
t.equal(text, 'Testing testing 123');
t.equal(res.status, 200);
t.equal(res.ok, true);
t.equal(res.headers.get('content-type'), 'text/plain');
t.equal(res.headers.get('content-length'), '19');
t.end();
});
});
test('fetch base64 with all possible bytes', t => {
// eslint-disable-next-line max-len
const res = staticFetch('Data:Application/Octet-Stream;BASE64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==');
res.arrayBuffer().then(buffer => {
t.same(Array.from(new Uint8Array(buffer)), Array(256)
.fill()
.map((_, index) => index)
);
t.equal(res.headers.get('content-type'), 'application/octet-stream');
t.equal(res.headers.get('content-length'), '256');
t.end();
});
});
test('fetch not data:', t => {
t.equal(staticFetch('blob:https://turbowarp.org/54346944-16cf-4ce9-aed4-e1df8ad0d779'), null);
t.equal(staticFetch('https://example.com/'), null);
t.equal(staticFetch('http://example.com/'), null);
t.equal(staticFetch('file:///etc/hosts'), null);
t.equal(staticFetch('oegirjdf'), null);
t.equal(staticFetch(''), null);
t.end();
});

View File

@@ -0,0 +1,52 @@
const {test} = require('tap');
const Thread = require('../../src/engine/thread');
const Runtime = require('../../src/engine/runtime');
const Target = require('../../src/engine/target');
test('stopThisScript procedures_call reporter form', t => {
const rt = new Runtime();
const target = new Target(rt, null);
target.blocks.createBlock({
id: 'reporterCall',
opcode: 'procedures_call',
inputs: {},
fields: {},
mutation: {
return: '1'
},
shadow: false,
topLevel: true,
parent: null,
next: 'afterReporterCall'
});
target.blocks.createBlock({
id: 'afterReporterCall',
opcode: 'motion_ifonedgebounce',
inputs: {},
fields: {},
mutation: null,
shadow: false,
topLevel: false,
parent: null,
next: null
});
const thread = new Thread('reporterCall');
thread.target = target;
// pretend to run reporterCall
thread.pushStack('reporterCall');
thread.peekStackFrame().waitingReporter = true;
// pretend to run scripts inside of the procedure
thread.pushStack('fakeBlock');
// stopping or returning should always return to reporterCall, not the block after
thread.stopThisScript();
t.same(thread.stack, [
'reporterCall'
]);
t.end();
});

View File

@@ -0,0 +1,100 @@
const tap = require('tap');
const path = require('path');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const makeTestStorage = require('../fixtures/make-test-storage');
const VirtualMachine = require('../../src/virtual-machine');
const test = tap.test;
const makeVM = () => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
return vm;
};
for (const file of ['empty-comment.sb3', 'no-comment.sb3']) {
test(`serializes and deserializes settings (${file})`, t => {
const project = readFileToBuffer(path.resolve(__dirname, `../fixtures/tw-stored-settings/${file}`));
const vm = makeVM();
vm.loadProject(project).then(() => {
vm.setFramerate(45);
vm.setTurboMode(true);
vm.setInterpolation(true);
vm.setRuntimeOptions({
maxClones: Infinity,
miscLimits: false,
fencing: false
});
vm.setStageSize(100, 101);
vm.storeProjectOptions();
const newVM = makeVM();
newVM.loadProject(vm.toJSON())
.then(() => {
t.equal(newVM.runtime.framerate, vm.runtime.framerate);
t.equal(newVM.runtime.turboMode, vm.runtime.turboMode);
t.same(newVM.runtime.runtimeOptions, vm.runtime.runtimeOptions);
t.equal(newVM.runtime.interpolationEnabled, vm.runtime.interpolationEnabled);
t.equal(newVM.runtime.stageWidth, vm.runtime.stageWidth);
t.equal(newVM.runtime.stageHeight, vm.runtime.stageHeight);
t.end();
});
});
});
}
test('Reuses comment if it already exists', t => {
const project = readFileToBuffer(path.resolve(__dirname, `../fixtures/tw-stored-settings/empty-comment.sb3`));
const vm = makeVM();
vm.loadProject(project)
.then(() => {
t.equal(Object.keys(vm.runtime.getTargetForStage().comments).length, 1);
vm.setFramerate(99);
vm.storeProjectOptions();
t.equal(Object.keys(vm.runtime.getTargetForStage().comments).length, 1);
t.end();
});
});
test('Storing settings emits workspace update only when stage open', t => {
const project = readFileToBuffer(path.resolve(__dirname, `../fixtures/tw-stored-settings/sprite.sb3`));
const vm = makeVM();
vm.loadProject(project)
.then(() => {
let didFireUpdate = false;
vm.on('workspaceUpdate', () => {
didFireUpdate = true;
});
vm.storeProjectOptions();
t.equal(didFireUpdate, false);
vm.setEditingTarget(vm.runtime.getTargetForStage().id);
vm.storeProjectOptions();
t.equal(didFireUpdate, true);
t.end();
});
});
test('Storing settings emits project changed', t => {
const project = readFileToBuffer(path.resolve(__dirname, `../fixtures/tw-stored-settings/sprite.sb3`));
const vm = makeVM();
vm.loadProject(project)
.then(() => {
t.plan(1);
vm.on('PROJECT_CHANGED', () => {
t.pass();
});
vm.storeProjectOptions();
t.end();
});
});
test('Stored turbo mode emits event on VM', async t => {
const vm = makeVM();
const project = readFileToBuffer(path.resolve(__dirname, '../fixtures/tw-stored-settings/turbo-mode.sb3'));
t.plan(1);
vm.on('TURBO_MODE_ON', () => {
t.pass('emitted TURBO_MODE_ON');
});
await vm.loadProject(project);
t.end();
});

View File

@@ -0,0 +1,28 @@
const {test} = require('tap');
// Simulate the network being down or filtered
// Run this before fetch-with-timeout.js is gets loaded.
global.fetch = () => Promise.reject(new Error('Simulated network error'));
const Scratch3TranslateBlocks = require('../../src/extensions/scratch3_translate/index');
// Node 21 and later defines a navigator object, but we want to override that for the test
Object.defineProperty(global, 'navigator', {
value: {
language: 'en-US'
}
});
// Translate tries to access AbortController from window, but does not require it to exist.
global.window = {};
test('translate returns original string on network error', t => {
t.plan(1);
const extension = new Scratch3TranslateBlocks();
extension.getTranslate({WORDS: 'My message 123123', LANGUAGE: 'es'})
.then(message => {
t.equal(message, 'My message 123123');
t.end();
});
});

View File

@@ -0,0 +1,429 @@
const tap = require('tap');
const UnsandboxedExtensionRunner = require('../../src/extension-support/tw-unsandboxed-extension-runner');
const VirtualMachine = require('../../src/virtual-machine');
// Mock enough of the document API for the extension runner to think it works.
// To more accurately test this, we want to make sure that the URLs we pass in are just strings.
// We use a bit of hacky state here to make our document mock know what function to run
// when a script with a given URL "loads"
const scriptCallbacks = new Map();
const setScript = (src, callback) => {
scriptCallbacks.set(src, callback);
};
global.document = {
createElement: tagName => {
if (tagName.toLowerCase() !== 'script') {
throw new Error(`Unknown element: ${tagName}`);
}
return {
tagName: 'SCRIPT',
src: '',
onload: () => {},
onerror: () => {}
};
},
body: {
appendChild: element => {
if (element.tagName === 'SCRIPT') {
setTimeout(() => {
const callback = scriptCallbacks.get(element.src);
if (callback) {
callback();
element.onload();
} else {
element.onerror();
}
}, 50);
}
}
}
};
// Mock various DOM APIs for fetching, window opening, redirecting, etc.
global.Request = class {
constructor (url) {
this.url = url;
}
};
global.fetch = (url, options = {}) => (
Promise.resolve(`[Response ${url instanceof Request ? url.url : url} options=${JSON.stringify(options)}]`)
);
global.window = {
open: (url, target, features) => `[Window ${url} target=${target || ''} features=${features || ''}]`
};
// Remove navigator object from Node 21 and later
delete global.navigator;
tap.beforeEach(() => {
scriptCallbacks.clear();
global.location = {
href: 'https://example.com/'
};
});
const {test} = tap;
test('basic API', async t => {
t.plan(9);
const vm = new VirtualMachine();
class MyExtension {}
setScript('https://turbowarp.org/1.js', () => {
t.equal(global.Scratch.vm, vm);
t.equal(global.Scratch.renderer, vm.runtime.renderer);
t.equal(global.Scratch.extensions.unsandboxed, true);
// These APIs are tested elsewhere, just make sure they're getting exported
t.equal(global.Scratch.ArgumentType.NUMBER, 'number');
t.equal(global.Scratch.BlockType.REPORTER, 'reporter');
t.equal(global.Scratch.TargetType.SPRITE, 'sprite');
t.equal(global.Scratch.Cast.toNumber('3.14'), 3.14);
global.Scratch.extensions.register(new MyExtension());
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm);
t.equal(extensions.length, 1);
t.ok(extensions[0] instanceof MyExtension);
t.end();
});
test('multiple VMs loading extensions', async t => {
const vm1 = new VirtualMachine();
const vm2 = new VirtualMachine();
class Extension1 {}
class Extension2 {}
let api1 = null;
setScript('https://turbowarp.org/1.js', async () => {
// Even if this extension takes a while to register, we should still have our own
// global.Scratch.
await new Promise(resolve => setTimeout(resolve, 100));
if (api1) throw new Error('already ran 1');
api1 = global.Scratch;
global.Scratch.extensions.register(new Extension1());
});
let api2 = null;
setScript('https://turbowarp.org/2.js', () => {
if (api2) throw new Error('already ran 2');
api2 = global.Scratch;
global.Scratch.extensions.register(new Extension2());
});
const extensions = await Promise.all([
UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm1),
UnsandboxedExtensionRunner.load('https://turbowarp.org/2.js', vm2)
]);
t.not(api1, api2);
t.type(api1.extensions.register, 'function');
t.type(api2.extensions.register, 'function');
t.equal(api1.vm, vm1);
t.equal(api2.vm, vm2);
t.equal(extensions.length, 2);
t.equal(extensions[0].length, 1);
t.equal(extensions[1].length, 1);
t.ok(extensions[0][0] instanceof Extension1);
t.ok(extensions[1][0] instanceof Extension2);
t.end();
});
test('register multiple extensions in one script', async t => {
const vm = new VirtualMachine();
class Extension1 {}
class Extension2 {}
setScript('https://turbowarp.org/multiple.js', () => {
global.Scratch.extensions.register(new Extension1());
global.Scratch.extensions.register(new Extension2());
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/multiple.js', vm);
t.equal(extensions.length, 2);
t.ok(extensions[0] instanceof Extension1);
t.ok(extensions[1] instanceof Extension2);
t.end();
});
test('extension error results in rejection', async t => {
const vm = new VirtualMachine();
try {
await UnsandboxedExtensionRunner.load('https://turbowarp.org/404.js', vm);
// Above should throw an error as the script will not load successfully
t.fail();
} catch (e) {
t.pass();
}
t.end();
});
test('ScratchX', async t => {
const vm = new VirtualMachine();
setScript('https://turbowarp.org/scratchx.js', () => {
const ext = {
test: () => 2
};
const descriptor = {
blocks: [
['r', 'test', 'test']
]
};
global.ScratchExtensions.register('Test', descriptor, ext);
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/scratchx.js', vm);
t.equal(extensions.length, 1);
t.equal(extensions[0].test(), 2);
t.end();
});
test('canFetch', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canFetch('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('fetch', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canFetch = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.fetch('https://example.com/1'), /Permission to fetch https:\/\/example.com\/1 rejected/);
await t.rejects(global.Scratch.fetch(new Request('https://example.com/1')), /Permission to fetch https:\/\/example.com\/1 rejected/);
t.equal(await global.Scratch.fetch('https://example.com/2'), '[Response https://example.com/2 options={}]');
t.equal(await global.Scratch.fetch(new Request('https://example.com/2')), '[Response https://example.com/2 options={}]');
t.equal(await global.Scratch.fetch('https://example.com/2', {
redirect: 'follow',
method: 'POST',
body: 'abc'
}), '[Response https://example.com/2 options={"redirect":"follow","method":"POST","body":"abc"}]');
t.end();
});
test('canOpenWindow', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canOpenWindow('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('openWindow', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canOpenWindow = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.openWindow('https://example.com/1'), /Permission to open tab https:\/\/example.com\/1 rejected/);
t.equal(await global.Scratch.openWindow('https://example.com/2'), '[Window https://example.com/2 target=_blank features=noreferrer]');
t.equal(await global.Scratch.openWindow('https://example.com/2', 'popup=1'), '[Window https://example.com/2 target=_blank features=noreferrer,popup=1]');
t.end();
});
test('canRedirect', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canRedirect('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('redirect', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canRedirect = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.redirect('https://example.com/1'), /Permission to redirect to https:\/\/example.com\/1 rejected/);
t.equal(global.location.href, 'https://example.com/');
await global.Scratch.redirect('https://example.com/2');
t.equal(global.location.href, 'https://example.com/2');
t.end();
});
test('translate', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'Message 1: test');
t.equal(global.Scratch.translate('test1 {var}', {
var: 'ok'
}), 'test1 ok');
t.equal(global.Scratch.translate.language, 'en');
global.Scratch.translate.setup({
en: {
test1: 'EN Message 1: {var}'
},
es: {
test1: 'ES Message 1: {var}'
}
});
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'EN Message 1: test');
t.equal(global.Scratch.translate('test1 {var}', {
var: 'ok'
}), 'test1 ok');
t.equal(global.Scratch.translate.language, 'en');
await vm.setLocale('es');
// do not call setup() again; real extensions will not do that.
// need to make sure that the translatiosn are saved after calling setLocale.
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'ES Message 1: test');
t.equal(global.Scratch.translate.language, 'es');
t.end();
});
test('canRecordAudio', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canRecordAudio = () => false;
t.equal(await global.Scratch.canRecordAudio(), false);
vm.securityManager.canRecordAudio = () => true;
t.equal(await global.Scratch.canRecordAudio(), true);
t.end();
});
test('canRecordVideo', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canRecordVideo = () => false;
t.equal(await global.Scratch.canRecordVideo(), false);
vm.securityManager.canRecordVideo = () => true;
t.equal(await global.Scratch.canRecordVideo(), true);
t.end();
});
test('canReadClipboard', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canReadClipboard = () => false;
t.equal(await global.Scratch.canReadClipboard(), false);
vm.securityManager.canReadClipboard = () => true;
t.equal(await global.Scratch.canReadClipboard(), true);
t.end();
});
test('canNotify', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canNotify = () => false;
t.equal(await global.Scratch.canNotify(), false);
vm.securityManager.canNotify = () => true;
t.equal(await global.Scratch.canNotify(), true);
t.end();
});
test('canGeolocate', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canGeolocate = () => false;
t.equal(await global.Scratch.canGeolocate(), false);
vm.securityManager.canGeolocate = () => true;
t.equal(await global.Scratch.canGeolocate(), true);
t.end();
});
test('rewriteExtensionURL', async t => {
const vm = new VirtualMachine();
let createdRewrittenExtension = false;
class RewrittenExtension {
getInfo () {
createdRewrittenExtension = true;
return {
id: 'extensionid',
blocks: []
};
}
}
setScript('https://turbowarp.org/rewritten.js', () => {
global.Scratch.extensions.register(new RewrittenExtension());
});
class OriginalExtension {
getInfo () {
t.fail('Should not create original extension');
return {
id: 'extensionid',
blocks: []
};
}
}
setScript('https://turbowarp.org/original.js', () => {
global.Scratch.extensions.register(new OriginalExtension());
});
vm.securityManager.getSandboxMode = () => 'unsandboxed';
vm.securityManager.rewriteExtensionURL = url => {
if (url === 'https://turbowarp.org/original.js') {
return 'https://turbowarp.org/rewritten.js';
}
return url;
};
await vm.extensionManager.loadExtensionURL('https://turbowarp.org/original.js');
t.ok(createdRewrittenExtension, 'used rewritten extension');
t.ok(vm.extensionManager.isExtensionURLLoaded('https://turbowarp.org/original.js'), 'marks original URL as loaded');
t.notOk(vm.extensionManager.isExtensionURLLoaded('https://turbowarp.org/rewritten.js'), 'does not mark new URL as loaded');
t.end();
});
test('canEmbed', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canEmbed = url => url === 'https://example.com/safe';
t.ok(await global.Scratch.canEmbed('https://example.com/safe'));
t.notOk(await global.Scratch.canEmbed('https://example.com/unsafe'));
t.end();
});
test('CREATE_UNSANDBOXED_EXTENSION_API', t => {
const vm = new VirtualMachine();
vm.on('CREATE_UNSANDBOXED_EXTENSION_API', api => {
api.extraStuff = 'aaaa';
});
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
t.equal(global.Scratch.extraStuff, 'aaaa');
t.end();
});

View File

@@ -0,0 +1,64 @@
const AsyncLimiter = require('../../src/util/async-limiter');
const {test} = require('tap');
test('Runs callback', async t => {
/* eslint-disable-next-line require-await */
const callback = async (a, b) => a + b;
const limiter = new AsyncLimiter(callback, 2);
t.same(await Promise.all([
limiter.do(1, 2),
limiter.do(3, 4),
limiter.do(5, 6),
limiter.do(7, 8),
limiter.do(9, 10)
]), [
3,
7,
11,
15,
19
]);
t.end();
});
test('Errors', async t => {
t.plan(1);
const errorObject = new Error('Testing testing 123'); // want to get the same *exact* object back
const callback = () => Promise.reject(errorObject);
const limiter = new AsyncLimiter(callback, 10);
try {
await limiter.do();
} catch (e) {
t.equal(e, errorObject);
}
t.end();
});
test('Limit and queue', async t => {
const calls = [];
const callback = () => new Promise(resolve => {
calls.push({
resolve
});
});
const limiter = new AsyncLimiter(callback, 5);
for (let i = 0; i < 12; i++) {
limiter.do();
}
t.equal(calls.length, 5);
calls.forEach(i => i.resolve());
await Promise.resolve();
t.equal(calls.length, 10);
calls.forEach(i => i.resolve());
await Promise.resolve();
t.equal(calls.length, 12);
t.end();
});

View File

@@ -0,0 +1,16 @@
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
test('exports exist', t => {
const vm = new VM();
t.type(vm.exports.Sprite, 'function');
t.type(vm.exports.RenderedTarget, 'function');
t.end();
});
test('JSZip', t => {
const JSZip = new VM().exports.JSZip;
const zip = new JSZip();
t.type(zip.file, 'function');
t.end();
});

View File

@@ -0,0 +1,41 @@
const test = require('tap').test;
const Base64Util = require('../../src/util/base64-util');
test('uint8ArrayToBase64', t => {
t.equal(Base64Util.uint8ArrayToBase64(new Uint8Array([0, 50, 80, 200])), 'ADJQyA==');
t.equal(Base64Util.uint8ArrayToBase64([0, 50, 80, 200]), 'ADJQyA==');
t.end();
});
test('arrayBufferToBase64', t => {
t.equal(Base64Util.arrayBufferToBase64(new Uint8Array([0, 50, 80, 200]).buffer), 'ADJQyA==');
t.end();
});
test('base64ToUint8Array', t => {
t.same(Base64Util.base64ToUint8Array('ADJQyA=='), new Uint8Array([0, 50, 80, 200]));
t.end();
});
test('round trips', t => {
const data = [
new Uint8Array(new Array(255)
.fill()
.map((_, index) => index)
),
new Uint8Array(0),
new Uint8Array([10, 90, 0, 255, 255, 255, 10, 2]),
new Uint8Array(10000),
new Uint8Array(100000)
];
for (const uint8array of data) {
const uint8ToBase64 = Base64Util.uint8ArrayToBase64(uint8array);
const arrayToBase64 = Base64Util.uint8ArrayToBase64(Array.from(uint8array));
const bufferToBase64 = Base64Util.arrayBufferToBase64(uint8array.buffer);
t.equal(uint8ToBase64, arrayToBase64);
t.equal(uint8ToBase64, bufferToBase64);
const decoded = Base64Util.base64ToUint8Array(uint8ToBase64);
t.same(uint8array, decoded);
}
t.end();
});

View File

@@ -0,0 +1,201 @@
const test = require('tap').test;
const cast = require('../../src/util/cast');
test('toNumber', t => {
// Numeric
t.strictEqual(cast.toNumber(0), 0);
t.strictEqual(cast.toNumber(1), 1);
t.strictEqual(cast.toNumber(3.14), 3.14);
// String
t.strictEqual(cast.toNumber('0'), 0);
t.strictEqual(cast.toNumber('1'), 1);
t.strictEqual(cast.toNumber('3.14'), 3.14);
t.strictEqual(cast.toNumber('0.1e10'), 1000000000);
t.strictEqual(cast.toNumber('foobar'), 0);
// Boolean
t.strictEqual(cast.toNumber(true), 1);
t.strictEqual(cast.toNumber(false), 0);
t.strictEqual(cast.toNumber('true'), 0);
t.strictEqual(cast.toNumber('false'), 0);
// Undefined & object
t.strictEqual(cast.toNumber(undefined), 0);
t.strictEqual(cast.toNumber({}), 0);
t.strictEqual(cast.toNumber(NaN), 0);
t.end();
});
test('toBoolean', t => {
// Numeric
t.strictEqual(cast.toBoolean(0), false);
t.strictEqual(cast.toBoolean(1), true);
t.strictEqual(cast.toBoolean(3.14), true);
// String
t.strictEqual(cast.toBoolean('0'), false);
t.strictEqual(cast.toBoolean('1'), true);
t.strictEqual(cast.toBoolean('3.14'), true);
t.strictEqual(cast.toBoolean('0.1e10'), true);
t.strictEqual(cast.toBoolean('foobar'), true);
// Boolean
t.strictEqual(cast.toBoolean(true), true);
t.strictEqual(cast.toBoolean(false), false);
// Undefined & object
t.strictEqual(cast.toBoolean(undefined), false);
t.strictEqual(cast.toBoolean({}), true);
t.end();
});
test('toString', t => {
// Numeric
t.strictEqual(cast.toString(0), '0');
t.strictEqual(cast.toString(1), '1');
t.strictEqual(cast.toString(3.14), '3.14');
// String
t.strictEqual(cast.toString('0'), '0');
t.strictEqual(cast.toString('1'), '1');
t.strictEqual(cast.toString('3.14'), '3.14');
t.strictEqual(cast.toString('0.1e10'), '0.1e10');
t.strictEqual(cast.toString('foobar'), 'foobar');
// Boolean
t.strictEqual(cast.toString(true), 'true');
t.strictEqual(cast.toString(false), 'false');
// Undefined & object
t.strictEqual(cast.toString(undefined), 'undefined');
t.strictEqual(cast.toString({}), '[object Object]');
t.end();
});
test('toRgbColorList', t => {
// Hex (minimal, see "color" util tests)
t.deepEqual(cast.toRgbColorList('#000'), [0, 0, 0]);
t.deepEqual(cast.toRgbColorList('#000000'), [0, 0, 0]);
t.deepEqual(cast.toRgbColorList('#fff'), [255, 255, 255]);
t.deepEqual(cast.toRgbColorList('#ffffff'), [255, 255, 255]);
// Decimal (minimal, see "color" util tests)
t.deepEqual(cast.toRgbColorList(0), [0, 0, 0]);
t.deepEqual(cast.toRgbColorList(1), [0, 0, 1]);
t.deepEqual(cast.toRgbColorList(16777215), [255, 255, 255]);
// Malformed
t.deepEqual(cast.toRgbColorList('ffffff'), [0, 0, 0]);
t.deepEqual(cast.toRgbColorList('foobar'), [0, 0, 0]);
t.deepEqual(cast.toRgbColorList('#nothex'), [0, 0, 0]);
t.end();
});
test('toRgbColorObject', t => {
// Hex (minimal, see "color" util tests)
t.deepEqual(cast.toRgbColorObject('#000'), {r: 0, g: 0, b: 0});
t.deepEqual(cast.toRgbColorObject('#000000'), {r: 0, g: 0, b: 0});
t.deepEqual(cast.toRgbColorObject('#fff'), {r: 255, g: 255, b: 255});
t.deepEqual(cast.toRgbColorObject('#ffffff'), {r: 255, g: 255, b: 255});
// Decimal (minimal, see "color" util tests)
t.deepEqual(cast.toRgbColorObject(0), {a: 255, r: 0, g: 0, b: 0});
t.deepEqual(cast.toRgbColorObject(1), {a: 255, r: 0, g: 0, b: 1});
t.deepEqual(cast.toRgbColorObject(16777215), {a: 255, r: 255, g: 255, b: 255});
t.deepEqual(cast.toRgbColorObject('0x80010203'), {a: 128, r: 1, g: 2, b: 3});
// Malformed
t.deepEqual(cast.toRgbColorObject('ffffff'), {a: 255, r: 0, g: 0, b: 0});
t.deepEqual(cast.toRgbColorObject('foobar'), {a: 255, r: 0, g: 0, b: 0});
t.deepEqual(cast.toRgbColorObject('#nothex'), {a: 255, r: 0, g: 0, b: 0});
t.end();
});
test('compare', t => {
// Numeric
t.strictEqual(cast.compare(0, 0), 0);
t.strictEqual(cast.compare(1, 0), 1);
t.strictEqual(cast.compare(0, 1), -1);
t.strictEqual(cast.compare(1, 1), 0);
// String
t.strictEqual(cast.compare('0', '0'), 0);
t.strictEqual(cast.compare('0.1e10', '1000000000'), 0);
t.strictEqual(cast.compare('foobar', 'FOOBAR'), 0);
t.ok(cast.compare('dog', 'cat') > 0);
// Boolean
t.strictEqual(cast.compare(true, true), 0);
t.strictEqual(cast.compare(true, false), 1);
t.strictEqual(cast.compare(false, true), -1);
t.strictEqual(cast.compare(true, true), 0);
// Undefined & object
t.strictEqual(cast.compare(undefined, undefined), 0);
t.strictEqual(cast.compare(undefined, 'undefined'), 0);
t.strictEqual(cast.compare({}, {}), 0);
t.strictEqual(cast.compare({}, '[object Object]'), 0);
t.end();
});
test('isInt', t => {
// Numeric
t.strictEqual(cast.isInt(0), true);
t.strictEqual(cast.isInt(1), true);
t.strictEqual(cast.isInt(0.0), true);
t.strictEqual(cast.isInt(3.14), false);
t.strictEqual(cast.isInt(NaN), true);
// String
t.strictEqual(cast.isInt('0'), true);
t.strictEqual(cast.isInt('1'), true);
t.strictEqual(cast.isInt('0.0'), false);
t.strictEqual(cast.isInt('0.1e10'), false);
t.strictEqual(cast.isInt('3.14'), false);
// Boolean
t.strictEqual(cast.isInt(true), true);
t.strictEqual(cast.isInt(false), true);
// Undefined & object
t.strictEqual(cast.isInt(undefined), false);
t.strictEqual(cast.isInt({}), false);
t.end();
});
test('toListIndex', t => {
const list = [0, 1, 2, 3, 4, 5];
const empty = [];
// Valid
t.strictEqual(cast.toListIndex(1, list.length, false), 1);
t.strictEqual(cast.toListIndex(6, list.length, false), 6);
// Invalid
t.strictEqual(cast.toListIndex(-1, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0.1, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(0, list.length, false), cast.LIST_INVALID);
t.strictEqual(cast.toListIndex(7, list.length, false), cast.LIST_INVALID);
// "all"
t.strictEqual(cast.toListIndex('all', list.length, true), cast.LIST_ALL);
t.strictEqual(cast.toListIndex('all', list.length, false), cast.LIST_INVALID);
// "last"
t.strictEqual(cast.toListIndex('last', list.length, false), list.length);
t.strictEqual(cast.toListIndex('last', empty.length, false), cast.LIST_INVALID);
// "random"
const random = cast.toListIndex('random', list.length, false);
t.ok(random <= list.length);
t.ok(random > 0);
t.strictEqual(cast.toListIndex('random', empty.length, false), cast.LIST_INVALID);
// "any" (alias for "random")
const any = cast.toListIndex('any', list.length, false);
t.ok(any <= list.length);
t.ok(any > 0);
t.strictEqual(cast.toListIndex('any', empty.length, false), cast.LIST_INVALID);
t.end();
});

View File

@@ -0,0 +1,135 @@
const test = require('tap').test;
const color = require('../../src/util/color');
/**
* Assert that two HSV colors are similar to each other, within a tolerance.
* @param {Test} t - the Tap test object.
* @param {HSVObject} actual - the first HSV color to compare.
* @param {HSVObject} expected - the other HSV color to compare.
*/
const hsvSimilar = function (t, actual, expected) {
if ((Math.abs(actual.h - expected.h) >= 1) ||
(Math.abs(actual.s - expected.s) >= 0.01) ||
(Math.abs(actual.v - expected.v) >= 0.01)
) {
t.fail('HSV colors not similar enough', {
actual: actual,
expected: expected
});
}
};
/**
* Assert that two RGB colors are similar to each other, within a tolerance.
* @param {Test} t - the Tap test object.
* @param {RGBObject} actual - the first RGB color to compare.
* @param {RGBObject} expected - the other RGB color to compare.
*/
const rgbSimilar = function (t, actual, expected) {
if ((Math.abs(actual.r - expected.r) >= 1) ||
(Math.abs(actual.g - expected.g) >= 1) ||
(Math.abs(actual.b - expected.b) >= 1)
) {
t.fail('RGB colors not similar enough', {
actual: actual,
expected: expected
});
}
};
test('decimalToHex', t => {
t.strictEqual(color.decimalToHex(0), '#000000');
t.strictEqual(color.decimalToHex(1), '#000001');
t.strictEqual(color.decimalToHex(16777215), '#ffffff');
t.strictEqual(color.decimalToHex(-16777215), '#000001');
t.strictEqual(color.decimalToHex(99999999), '#5f5e0ff');
t.end();
});
test('decimalToRgb', t => {
t.deepEqual(color.decimalToRgb(0), {a: 255, r: 0, g: 0, b: 0});
t.deepEqual(color.decimalToRgb(1), {a: 255, r: 0, g: 0, b: 1});
t.deepEqual(color.decimalToRgb(16777215), {a: 255, r: 255, g: 255, b: 255});
t.deepEqual(color.decimalToRgb(-16777215), {a: 255, r: 0, g: 0, b: 1});
t.deepEqual(color.decimalToRgb(99999999), {a: 5, r: 245, g: 224, b: 255});
t.end();
});
test('hexToRgb', t => {
t.deepEqual(color.hexToRgb('#000'), {r: 0, g: 0, b: 0});
t.deepEqual(color.hexToRgb('#000000'), {r: 0, g: 0, b: 0});
t.deepEqual(color.hexToRgb('#fff'), {r: 255, g: 255, b: 255});
t.deepEqual(color.hexToRgb('#ffffff'), {r: 255, g: 255, b: 255});
t.deepEqual(color.hexToRgb('#0fa'), {r: 0, g: 255, b: 170});
t.deepEqual(color.hexToRgb('#00ffaa'), {r: 0, g: 255, b: 170});
t.deepEqual(color.hexToRgb('#00FFaA'), {r: 0, g: 255, b: 170});
t.deepEqual(color.hexToRgb('000'), {r: 0, g: 0, b: 0});
t.deepEqual(color.hexToRgb('fff'), {r: 255, g: 255, b: 255});
t.deepEqual(color.hexToRgb('aBc'), {r: 0xaa, g: 0xbb, b: 0xcc});
t.deepEqual(color.hexToRgb('00ffaa'), {r: 0, g: 255, b: 170});
t.deepEqual(color.hexToRgb('0'), null);
t.deepEqual(color.hexToRgb('hello world'), null);
t.deepEqual(color.hexToRgb('red'), null);
t.end();
});
test('rgbToHex', t => {
t.strictEqual(color.rgbToHex({r: 0, g: 0, b: 0}), '#000000');
t.strictEqual(color.rgbToHex({r: 255, g: 255, b: 255}), '#ffffff');
t.strictEqual(color.rgbToHex({r: 0, g: 255, b: 170}), '#00ffaa');
t.end();
});
test('rgbToDecimal', t => {
t.strictEqual(color.rgbToDecimal({r: 0, g: 0, b: 0}), 0);
t.strictEqual(color.rgbToDecimal({r: 255, g: 255, b: 255}), 16777215);
t.strictEqual(color.rgbToDecimal({r: 0, g: 255, b: 170}), 65450);
t.end();
});
test('hexToDecimal', t => {
t.strictEqual(color.hexToDecimal('#000'), 0);
t.strictEqual(color.hexToDecimal('#000000'), 0);
t.strictEqual(color.hexToDecimal('#fff'), 16777215);
t.strictEqual(color.hexToDecimal('#ffffff'), 16777215);
t.strictEqual(color.hexToDecimal('#0fa'), 65450);
t.strictEqual(color.hexToDecimal('#00ffaa'), 65450);
t.end();
});
test('hsvToRgb', t => {
rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 0}), {r: 0, g: 0, b: 0});
rgbSimilar(t, color.hsvToRgb({h: 123, s: 0.1234, v: 0}), {r: 0, g: 0, b: 0});
rgbSimilar(t, color.hsvToRgb({h: 0, s: 0, v: 1}), {r: 255, g: 255, b: 255});
rgbSimilar(t, color.hsvToRgb({h: 321, s: 0, v: 1}), {r: 255, g: 255, b: 255});
rgbSimilar(t, color.hsvToRgb({h: 0, s: 1, v: 1}), {r: 255, g: 0, b: 0});
rgbSimilar(t, color.hsvToRgb({h: 120, s: 1, v: 1}), {r: 0, g: 255, b: 0});
rgbSimilar(t, color.hsvToRgb({h: 240, s: 1, v: 1}), {r: 0, g: 0, b: 255});
t.end();
});
test('rgbToHsv', t => {
hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 0}), {h: 0, s: 0, v: 0});
hsvSimilar(t, color.rgbToHsv({r: 64, g: 64, b: 64}), {h: 0, s: 0, v: 0.25});
hsvSimilar(t, color.rgbToHsv({r: 128, g: 128, b: 128}), {h: 0, s: 0, v: 0.5});
hsvSimilar(t, color.rgbToHsv({r: 192, g: 192, b: 192}), {h: 0, s: 0, v: 0.75});
hsvSimilar(t, color.rgbToHsv({r: 255, g: 255, b: 255}), {h: 0, s: 0, v: 1});
hsvSimilar(t, color.rgbToHsv({r: 255, g: 0, b: 0}), {h: 0, s: 1, v: 1});
hsvSimilar(t, color.rgbToHsv({r: 0, g: 255, b: 0}), {h: 120, s: 1, v: 1});
hsvSimilar(t, color.rgbToHsv({r: 0, g: 0, b: 255}), {h: 240, s: 1, v: 1});
t.end();
});
test('mixRgb', t => {
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, -1), {r: 10, g: 20, b: 30});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0), {r: 10, g: 20, b: 30});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.25), {r: 15, g: 25, b: 35});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.5), {r: 20, g: 30, b: 40});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 0.75), {r: 25, g: 35, b: 45});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 1), {r: 30, g: 40, b: 50});
rgbSimilar(t, color.mixRgb({r: 10, g: 20, b: 30}, {r: 30, g: 40, b: 50}, 2), {r: 30, g: 40, b: 50});
t.end();
});

View File

@@ -0,0 +1,10 @@
const test = require('tap').test;
// const JSONRPCWebSocket = require('../../src/util/jsonrpc-web-socket');
test('constructor', t => {
t.end();
});
test('dispose', t => {
t.end();
});

View File

@@ -0,0 +1,18 @@
const test = require('tap').test;
// const JSONRPC = require('../../src/util/jsonrpc');
test('constructor', t => {
t.end();
});
test('sendRemoteRequest', t => {
t.end();
});
test('sendRemoteNotification', t => {
t.end();
});
test('didReceiveCall', t => {
t.end();
});

View File

@@ -0,0 +1,70 @@
const test = require('tap').test;
const math = require('../../src/util/math-util');
test('degToRad', t => {
t.strictEqual(math.degToRad(0), 0);
t.strictEqual(math.degToRad(1), 0.017453292519943295);
t.strictEqual(math.degToRad(180), Math.PI);
t.strictEqual(math.degToRad(360), 2 * Math.PI);
t.strictEqual(math.degToRad(720), 4 * Math.PI);
t.end();
});
test('radToDeg', t => {
t.strictEqual(math.radToDeg(0), 0);
t.strictEqual(math.radToDeg(1), 57.29577951308232);
t.strictEqual(math.radToDeg(180), 10313.240312354817);
t.strictEqual(math.radToDeg(360), 20626.480624709635);
t.strictEqual(math.radToDeg(720), 41252.96124941927);
t.end();
});
test('clamp', t => {
t.strictEqual(math.clamp(0, 0, 10), 0);
t.strictEqual(math.clamp(1, 0, 10), 1);
t.strictEqual(math.clamp(-10, 0, 10), 0);
t.strictEqual(math.clamp(100, 0, 10), 10);
t.end();
});
test('wrapClamp', t => {
t.strictEqual(math.wrapClamp(0, 0, 10), 0);
t.strictEqual(math.wrapClamp(1, 0, 10), 1);
t.strictEqual(math.wrapClamp(-10, 0, 10), 1);
t.strictEqual(math.wrapClamp(100, 0, 10), 1);
t.end();
});
test('tan', t => {
t.strictEqual(math.tan(90), Infinity);
t.strictEqual(math.tan(180), 0);
t.strictEqual(math.tan(-90), -Infinity);
t.strictEqual(math.tan(33), 0.6494075932);
t.end();
});
test('reducedSortOrdering', t => {
t.deepEqual(math.reducedSortOrdering([5, 18, 6, 3]), [1, 3, 2, 0]);
t.deepEqual(math.reducedSortOrdering([5, 1, 56, 19]), [1, 0, 3, 2]);
t.end();
});
test('inclusiveRandIntWithout', t => {
const withRandomValue = function (randValue, ...args) {
const oldMathRandom = Math.random;
Object.assign(global.Math, {random: () => randValue});
const result = math.inclusiveRandIntWithout(...args);
Object.assign(global.Math, {random: oldMathRandom});
return result;
};
t.strictEqual(withRandomValue(3 / 6, 0, 6, 2), 4);
t.strictEqual(withRandomValue(2 / 6, 0, 6, 2), 3);
t.strictEqual(withRandomValue(1 / 6, 0, 6, 2), 1);
t.strictEqual(withRandomValue(1.9 / 6, 0, 6, 2), 1);
t.strictEqual(withRandomValue(3 / 4, 10, 14, 10), 14);
t.strictEqual(withRandomValue(0 / 4, 10, 14, 10), 11);
t.end();
});

View File

@@ -0,0 +1,63 @@
const newBlockIds = require('../../src/util/new-block-ids');
const simpleStack = require('../fixtures/simple-stack');
const tap = require('tap');
const test = tap.test;
let originals;
let newBlocks;
tap.beforeEach(() => {
originals = simpleStack;
// Will be mutated so make a copy first
newBlocks = JSON.parse(JSON.stringify(simpleStack));
newBlockIds(newBlocks);
});
/**
* The structure of the simple stack is:
* moveTo (looks_size) -> stopAllSounds
* The list of blocks is
* 0: moveTo (TO input block: 1, shadow: 2)
* 1: looks_size (parent: 0)
* 2: obscured shadow for moveTo input (parent: 0)
* 3: stopAllSounds (parent: 0)
* Inspect fixtures/simple-stack for the full object.
*/
test('top-level block IDs have all changed', t => {
newBlocks.forEach((block, i) => {
t.notEqual(block.id, originals[i].id);
});
t.end();
});
test('input reference is maintained on parent for attached block', t => {
t.equal(newBlocks[0].inputs.TO.block, newBlocks[1].id);
t.end();
});
test('input reference is maintained on parent for obscured shadow', t => {
t.equal(newBlocks[0].inputs.TO.shadow, newBlocks[2].id);
t.end();
});
test('parent reference is maintained for attached input', t => {
t.equal(newBlocks[1].parent, newBlocks[0].id);
t.end();
});
test('parent reference is maintained for obscured shadow', t => {
t.equal(newBlocks[2].parent, newBlocks[0].id);
t.end();
});
test('parent reference is maintained for next block', t => {
t.equal(newBlocks[3].parent, newBlocks[0].id);
t.end();
});
test('next reference is maintained for previous block', t => {
t.equal(newBlocks[0].next, newBlocks[3].id);
t.end();
});

View File

@@ -0,0 +1,32 @@
const test = require('tap').test;
const RateLimiter = require('../../src/util/rateLimiter.js');
test('rate limiter', t => {
// Create a rate limiter with maximum of 20 sends per second
const rate = 20;
const limiter = new RateLimiter(rate);
// Simulate time passing with a stubbed timer
let simulatedTime = Date.now();
limiter._timer = {timeElapsed: () => simulatedTime};
// The rate limiter starts with a number of tokens equal to the max rate
t.equal(limiter._count, rate);
// Running okayToSend a number of times equal to the max rate
// uses up all of the tokens
for (let i = 0; i < rate; i++) {
t.true(limiter.okayToSend());
// Tokens are counting down
t.equal(limiter._count, rate - (i + 1));
}
t.false(limiter.okayToSend());
// Advance the timer enough so we get exactly one more token
// One extra millisecond is required to get over the threshold
simulatedTime += (1000 / rate) + 1;
t.true(limiter.okayToSend());
t.false(limiter.okayToSend());
t.end();
});

View File

@@ -0,0 +1,129 @@
const test = require('tap').test;
const StringUtil = require('../../src/util/string-util');
test('splitFirst', t => {
t.deepEqual(StringUtil.splitFirst('asdf.1234', '.'), ['asdf', '1234']);
t.deepEqual(StringUtil.splitFirst('asdf.', '.'), ['asdf', '']);
t.deepEqual(StringUtil.splitFirst('.1234', '.'), ['', '1234']);
t.deepEqual(StringUtil.splitFirst('foo', '.'), ['foo', null]);
t.end();
});
test('withoutTrailingDigits', t => {
t.strictEqual(StringUtil.withoutTrailingDigits('boeing747'), 'boeing');
t.strictEqual(StringUtil.withoutTrailingDigits('boeing747 '), 'boeing747 ');
t.strictEqual(StringUtil.withoutTrailingDigits('boeing𝟨'), 'boeing𝟨');
t.strictEqual(StringUtil.withoutTrailingDigits('boeing 747'), 'boeing ');
t.strictEqual(StringUtil.withoutTrailingDigits('747'), '');
t.end();
});
test('unusedName', t => {
t.strictEqual(
StringUtil.unusedName(
'name',
['not the same name']
),
'name'
);
t.strictEqual(
StringUtil.unusedName(
'name',
['name']
),
'name2'
);
t.strictEqual(
StringUtil.unusedName(
'name',
['name30']
),
'name'
);
t.strictEqual(
StringUtil.unusedName(
'name',
['name', 'name2']
),
'name3'
);
t.strictEqual(
StringUtil.unusedName(
'name',
['name', 'name3']
),
'name2'
);
t.strictEqual(
StringUtil.unusedName(
'boeing747',
['boeing747']
),
'boeing2' // Yup, this matches scratch-flash...
);
t.end();
});
test('stringify', t => {
const obj = {
a: Infinity,
b: NaN,
c: -Infinity,
d: 23,
e: 'str',
f: {
nested: Infinity
}
};
const parsed = JSON.parse(StringUtil.stringify(obj));
t.equal(parsed.a, 0);
t.equal(parsed.b, 0);
t.equal(parsed.c, 0);
t.equal(parsed.d, 23);
t.equal(parsed.e, 'str');
t.equal(parsed.f.nested, 0);
t.end();
});
test('replaceUnsafeChars', t => {
const empty = '';
t.equal(StringUtil.replaceUnsafeChars(empty), empty);
const safe = 'hello';
t.equal(StringUtil.replaceUnsafeChars(safe), safe);
const unsafe = '< > & \' "';
t.equal(StringUtil.replaceUnsafeChars(unsafe), 'lt gt amp apos quot');
const single = '&';
t.equal(StringUtil.replaceUnsafeChars(single), 'amp');
const mix = '<a>b& c\'def_-"';
t.equal(StringUtil.replaceUnsafeChars(mix), 'ltagtbamp caposdef_-quot');
const dupes = '<<&_"_"_&>>';
t.equal(StringUtil.replaceUnsafeChars(dupes), 'ltltamp_quot_quot_ampgtgt');
const emoji = '(>^_^)>';
t.equal(StringUtil.replaceUnsafeChars(emoji), '(gt^_^)gt');
t.end();
});
test('replaceUnsafeChars should handle non strings', t => {
const array = ['hello', 'world'];
t.equal(StringUtil.replaceUnsafeChars(array), String(array));
const arrayWithSpecialChar = ['hello', '<world>'];
t.equal(StringUtil.replaceUnsafeChars(arrayWithSpecialChar), 'hello,ltworldgt');
const arrayWithNumbers = [1, 2, 3];
t.equal(StringUtil.replaceUnsafeChars(arrayWithNumbers), '1,2,3');
// Objects shouldn't get provided to replaceUnsafeChars, but in the event
// they do, it should just return the object (and log an error)
const object = {hello: 'world'};
t.equal(StringUtil.replaceUnsafeChars(object), object);
t.end();
});

View File

@@ -0,0 +1,192 @@
const test = require('tap').test;
const TaskQueue = require('../../src/util/task-queue');
const MockTimer = require('../fixtures/mock-timer');
const testCompare = require('../fixtures/test-compare');
// Max tokens = 1000
// Refill 1000 tokens per second (1 per millisecond)
// Token bucket starts empty
// Max total cost of queued tasks = 10000 tokens = 10 seconds
const makeTestQueue = () => {
const bukkit = new TaskQueue(1000, 1000, {
startingTokens: 0,
maxTotalCost: 10000
});
const mockTimer = new MockTimer();
bukkit._timer = mockTimer;
mockTimer.start();
return bukkit;
};
test('spec', t => {
t.type(TaskQueue, 'function');
const bukkit = makeTestQueue();
t.type(bukkit, 'object');
t.type(bukkit.length, 'number');
t.type(bukkit.do, 'function');
t.type(bukkit.cancel, 'function');
t.type(bukkit.cancelAll, 'function');
t.end();
});
test('constructor', t => {
t.ok(new TaskQueue(1, 1));
t.ok(new TaskQueue(1, 1, {}));
t.ok(new TaskQueue(1, 1, {startingTokens: 0}));
t.ok(new TaskQueue(1, 1, {maxTotalCost: 999}));
t.ok(new TaskQueue(1, 1, {startingTokens: 0, maxTotalCost: 999}));
t.end();
});
test('run tasks', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const promises = [
bukkit.do(() => {
taskResults.push('a');
testCompare(t, bukkit._timer.timeElapsed(), '>=', 50, 'Costly task must wait');
}, 50),
bukkit.do(() => {
taskResults.push('b');
testCompare(t, bukkit._timer.timeElapsed(), '>=', 60, 'Tasks must run in serial');
}, 10),
bukkit.do(() => {
taskResults.push('c');
testCompare(t, bukkit._timer.timeElapsed(), '<=', 70, 'Cheap task should run soon');
}, 1)
];
// advance 10 simulated milliseconds per JS tick
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(10);
}
return Promise.all(promises).then(() => {
t.deepEqual(taskResults, ['a', 'b', 'c'], 'All tasks must run in correct order');
t.end();
});
});
test('cancel', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const goodCancelMessage = 'Task was canceled correctly';
const afterCancelMessage = 'Task was run correctly';
const cancelTaskPromise = bukkit.do(
() => {
taskResults.push('nope');
}, 999);
const cancelCheckPromise = cancelTaskPromise.then(
() => {
t.fail('Task should have been canceled');
},
() => {
taskResults.push(goodCancelMessage);
}
);
const keepTaskPromise = bukkit.do(
() => {
taskResults.push(afterCancelMessage);
testCompare(t, bukkit._timer.timeElapsed(), '<', 10, 'Canceled task must not delay other tasks');
}, 5);
// give the bucket a chance to make a mistake
await bukkit._timer.advanceMockTimeAsync(1);
t.equal(bukkit.length, 2);
const taskWasCanceled = bukkit.cancel(cancelTaskPromise);
t.ok(taskWasCanceled);
t.equal(bukkit.length, 1);
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(1);
}
return Promise.all([cancelCheckPromise, keepTaskPromise]).then(() => {
t.deepEqual(taskResults, [goodCancelMessage, afterCancelMessage]);
t.end();
});
});
test('cancelAll', async t => {
const bukkit = makeTestQueue();
const taskResults = [];
const goodCancelMessage1 = 'Task1 was canceled correctly';
const goodCancelMessage2 = 'Task2 was canceled correctly';
const promises = [
bukkit.do(() => taskResults.push('nope'), 999).then(
() => {
t.fail('Task1 should have been canceled');
},
() => {
taskResults.push(goodCancelMessage1);
}
),
bukkit.do(() => taskResults.push('nah'), 999).then(
() => {
t.fail('Task2 should have been canceled');
},
() => {
taskResults.push(goodCancelMessage2);
}
)
];
// advance time, but not enough that any task should run
await bukkit._timer.advanceMockTimeAsync(100);
bukkit.cancelAll();
// advance enough that both tasks would run if they hadn't been canceled
await bukkit._timer.advanceMockTimeAsync(10000);
return Promise.all(promises).then(() => {
t.deepEqual(taskResults, [goodCancelMessage1, goodCancelMessage2], 'Tasks should cancel in order');
t.end();
});
});
test('max total cost', async t => {
const bukkit = makeTestQueue();
let numTasks = 0;
const task = () => ++numTasks;
// Fill the queue
for (let i = 0; i < 10; ++i) {
bukkit.do(task, 1000);
}
// This one should be rejected because the queue is full
bukkit
.do(task, 1000)
.then(
() => {
t.fail('Full queue did not reject task');
},
() => {
t.pass();
}
);
while (bukkit.length > 0) {
await bukkit._timer.advanceMockTimeAsync(1000);
}
// this should be 10 if the last task is rejected or 11 if it runs
t.equal(numTasks, 10);
t.end();
});

View File

@@ -0,0 +1,64 @@
const test = require('tap').test;
const Timer = require('../../src/util/timer');
// Stubbed current time
let NOW = 0;
const testNow = {
now: () => {
NOW += 100;
return NOW;
}
};
test('spec', t => {
const timer = new Timer(testNow);
t.type(Timer, 'function');
t.type(timer, 'object');
t.type(timer.startTime, 'number');
t.type(timer.time, 'function');
t.type(timer.start, 'function');
t.type(timer.timeElapsed, 'function');
t.type(timer.setTimeout, 'function');
t.type(timer.clearTimeout, 'function');
t.end();
});
test('time', t => {
const timer = new Timer(testNow);
const time = timer.time();
t.ok(testNow.now() >= time);
t.end();
});
test('start / timeElapsed', t => {
const timer = new Timer(testNow);
const delay = 100;
const threshold = 1000 / 60; // 60 hz
// Start timer
timer.start();
// Measure timer
const timeElapsed = timer.timeElapsed();
t.ok(timeElapsed >= 0);
t.ok(timeElapsed >= (delay - threshold) &&
timeElapsed <= (delay + threshold));
t.end();
});
test('setTimeout / clearTimeout', t => new Promise((resolve, reject) => {
const timer = new Timer(testNow);
const cancelId = timer.setTimeout(() => {
reject(new Error('Canceled task ran'));
}, 1);
timer.setTimeout(() => {
resolve('Non-canceled task ran');
t.end();
}, 2);
timer.clearTimeout(cancelId);
}));

View File

@@ -0,0 +1,83 @@
const tap = require('tap');
const Target = require('../../src/engine/target');
const Runtime = require('../../src/engine/runtime');
const VariableUtil = require('../../src/util/variable-util');
let target1;
let target2;
tap.beforeEach(() => {
const runtime = new Runtime();
target1 = new Target(runtime);
target1.blocks.createBlock({
id: 'a block',
fields: {
VARIABLE: {
id: 'id1',
value: 'foo'
}
}
});
target1.blocks.createBlock({
id: 'another block',
fields: {
TEXT: {
value: 'not a variable'
}
}
});
target2 = new Target(runtime);
target2.blocks.createBlock({
id: 'a different block',
fields: {
VARIABLE: {
id: 'id2',
value: 'bar'
}
}
});
target2.blocks.createBlock({
id: 'another var block',
fields: {
VARIABLE: {
id: 'id1',
value: 'foo'
}
}
});
return Promise.resolve(null);
});
const test = tap.test;
test('get all var refs', t => {
const allVarRefs = VariableUtil.getAllVarRefsForTargets([target1, target2]);
t.equal(Object.keys(allVarRefs).length, 2);
t.equal(allVarRefs.id1.length, 2);
t.equal(allVarRefs.id2.length, 1);
t.equal(allVarRefs['not a variable'], undefined);
t.end();
});
test('merge variable ids', t => {
// Redo the id for the variable with 'id1'
VariableUtil.updateVariableIdentifiers(target1.blocks.getAllVariableAndListReferences().id1, 'renamed id');
const varField = target1.blocks.getBlock('a block').fields.VARIABLE;
t.equals(varField.id, 'renamed id');
t.equals(varField.value, 'foo');
t.end();
});
test('merge variable ids but with new name too', t => {
// Redo the id for the variable with 'id1'
VariableUtil.updateVariableIdentifiers(target1.blocks.getAllVariableAndListReferences().id1, 'renamed id', 'baz');
const varField = target1.blocks.getBlock('a block').fields.VARIABLE;
t.equals(varField.id, 'renamed id');
t.equals(varField.value, 'baz');
t.end();
});

View File

@@ -0,0 +1,52 @@
const test = require('tap').test;
const xml = require('../../src/util/xml-escape');
test('escape', t => {
const input = '<foo bar="he & llo \'"></foo>';
const output = '&lt;foo bar=&quot;he &amp; llo &apos;&quot;&gt;&lt;/foo&gt;';
t.strictEqual(xml(input), output);
t.end();
});
test('xmlEscape (more)', t => {
const empty = '';
t.equal(xml(empty), empty);
const safe = 'hello';
t.equal(xml(safe), safe);
const unsafe = '< > & \' "';
t.equal(xml(unsafe), '&lt; &gt; &amp; &apos; &quot;');
const single = '&';
t.equal(xml(single), '&amp;');
const mix = '<a>b& c\'def_-"';
t.equal(xml(mix), '&lt;a&gt;b&amp; c&apos;def_-&quot;');
const dupes = '<<&_"_"_&>>';
t.equal(xml(dupes), '&lt;&lt;&amp;_&quot;_&quot;_&amp;&gt;&gt;');
const emoji = '(>^_^)>';
t.equal(xml(emoji), '(&gt;^_^)&gt;');
t.end();
});
test('xmlEscape should handle non strings', t => {
const array = ['hello', 'world'];
t.equal(xml(array), String(array));
const arrayWithSpecialChar = ['hello', '<world>'];
t.equal(xml(arrayWithSpecialChar), 'hello,&lt;world&gt;');
const arrayWithNumbers = [1, 2, 3];
t.equal(xml(arrayWithNumbers), '1,2,3');
// Objects shouldn't get provided to replaceUnsafeChars, but in the event
// they do, it should just return the object (and log an error)
const object = {hello: 'world'};
t.equal(xml(object), object);
t.end();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
const test = require('tap').test;
const VirtualMachine = require('../../src/virtual-machine');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
const Variable = require('../../src/engine/variable');
test('emitTargetsUpdate targetList is lazy', t => {
const vm = new VirtualMachine();
let calledToJSON = false;
vm.runtime.targets = [{
toJSON () {
calledToJSON = true;
return {};
}
}];
let targetsUpdateEvent;
vm.on('targetsUpdate', e => {
targetsUpdateEvent = e;
});
vm.emitTargetsUpdate();
t.equal(calledToJSON, false);
void targetsUpdateEvent.targetList; // should trigger lazy compute
t.equal(calledToJSON, true);
t.end();
});
test('non-primitive values in lists and variables converted to strings', t => {
const vm = new VirtualMachine();
const sprite = new Sprite();
const target = new RenderedTarget(sprite, vm.runtime);
target.variables.var1 = new Variable('var', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var1.value = null;
target.variables.var2 = new Variable('var2', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var2.value = undefined;
target.variables.var3 = new Variable('var3', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var3.value = {};
target.variables.var4 = new Variable('var4', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var4.value = 1;
target.variables.var5 = new Variable('var5', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var5.value = 'abc';
target.variables.var6 = new Variable('var6', 'test var', Variable.SCALAR_TYPE, false);
target.variables.var6.value = false;
target.variables.list = new Variable('list', 'test list', Variable.LIST_TYPE, false);
target.variables.list.value = ['abc', false, 1, null, undefined, {}];
vm.runtime.addTarget(target);
const json = JSON.parse(vm.toJSON());
t.deepEqual(json.targets[0].variables.var1[1], 'null');
t.deepEqual(json.targets[0].variables.var2[1], 'undefined');
t.deepEqual(json.targets[0].variables.var3[1], '[object Object]');
t.deepEqual(json.targets[0].variables.var4[1], 1);
t.deepEqual(json.targets[0].variables.var5[1], 'abc');
t.deepEqual(json.targets[0].variables.var6[1], false);
t.deepEqual(json.targets[0].lists.list[1], ['abc', false, 1, 'null', 'undefined', '[object Object]']);
t.end();
});
test('addSound error handling when sprite does not exist', async t => {
t.plan(1);
const vm = new VirtualMachine();
const id = 'Inva1id5pri731D$!';
try {
await vm.addSound({
thisObjectDoesNotMatter: true
}, id);
} catch (e) {
if (e && e.message === `No target with ID: ${id}`) {
t.pass();
}
}
t.end();
});
test('convertToPackagedRuntime forwards to runtime', t => {
t.plan(1);
const vm = new VirtualMachine();
vm.runtime.convertToPackagedRuntime = () => {
t.pass();
};
vm.convertToPackagedRuntime();
t.end();
});

View File

@@ -0,0 +1,24 @@
const test = require('tap').test;
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
const VirtualMachine = require('../../src/virtual-machine');
test('collectAssets', t => {
const vm = new VirtualMachine();
const sprite = new Sprite(null, vm.runtime);
const target = new RenderedTarget(sprite, vm.runtime);
vm.runtime.targets = [target];
const [
soundAsset1,
soundAsset2,
costumeAsset1
] = [{assetId: 1}, {assetId: 2}, {assetId: 3}];
sprite.sounds = [{id: 1, asset: soundAsset1}, {id: 2, asset: soundAsset2}];
sprite.costumes = [{id: 1, asset: costumeAsset1}];
const assets = vm.assets;
t.type(assets.length, 'number');
t.equal(assets.length, 3);
t.deepEqual(assets, [soundAsset1, soundAsset2, costumeAsset1]);
t.end();
});