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,11 @@
module.exports = {
extends: ['scratch/react', 'scratch/es6', 'plugin:jest/recommended'],
env: {
browser: true,
jest: true
},
plugins: ['jest'],
rules: {
'react/prop-types': 0
}
};

View File

@@ -0,0 +1,15 @@
export default class MockAudioBufferPlayer {
constructor (samples, sampleRate) {
this.samples = samples;
this.sampleRate = sampleRate;
this.buffer = {
getChannelData: jest.fn(() => samples),
sampleRate: sampleRate
};
this.play = jest.fn((trimStart, trimEnd, onUpdate) => {
this.onUpdate = onUpdate;
});
this.stop = jest.fn();
MockAudioBufferPlayer.instance = this;
}
}

View File

@@ -0,0 +1,24 @@
export default class MockAudioEffects {
static get effectTypes () { // @todo can this be imported from the real file?
return {
ROBOT: 'robot',
REVERSE: 'reverse',
LOUDER: 'higher',
SOFTER: 'lower',
FASTER: 'faster',
SLOWER: 'slower',
ECHO: 'echo'
};
}
constructor (buffer, name) {
this.buffer = buffer;
this.name = name;
this.process = jest.fn(done => {
this._finishProcessing = renderedBuffer => {
done(renderedBuffer, 0, 1);
return new Promise(resolve => setTimeout(resolve));
};
});
MockAudioEffects.instance = this;
}
}

View File

@@ -0,0 +1,3 @@
export default {
en: {}
};

View File

@@ -0,0 +1,3 @@
// __mocks__/fileMock.js
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,3 @@
// __mocks__/styleMock.js
module.exports = {};

20
scratch-gui/test/fixtures/100-100.svg vendored Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
<title>Artboard</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M63.1539006,65.6857098 C62.40142,65.6857098 61.6278419,65.4877384 60.9245891,65.0705843 C58.8359282,63.8261925 58.1397079,61.1182262 59.3774329,59.018315 C61.7333299,55.0023232 61.7333299,50.0247559 59.3774329,46.0158346 C58.1397079,43.9088529 58.8359282,41.2008866 60.9245891,39.9564948 C63.0343476,38.7262438 65.7207734,39.4191438 66.9444333,41.5119846 C70.9318768,48.2995764 70.9318768,56.7275028 66.9444333,63.5150946 C66.12866,64.9150354 64.6588616,65.6857098 63.1539006,65.6857098 Z M76.2831813,72.7766221 C75.5307008,72.7766221 74.7500901,72.5786507 74.0538698,72.1614966 C71.965209,70.9171048 71.2689887,68.2091385 72.5067136,66.1092273 C77.4365159,57.7237233 77.4365159,47.3019418 72.5067136,38.9235082 C71.2689887,36.823597 71.965209,34.1085603 74.0538698,32.8641685 C76.1495632,31.6409879 78.835989,32.3268175 80.073714,34.4196583 C86.6420953,45.5767622 86.6420953,59.4489029 80.073714,70.6060068 C79.2509082,72.0059476 77.7881423,72.7766221 76.2831813,72.7766221 Z M51.892643,30.8929387 L51.892643,74.1002025 C51.892643,79.219178 45.8587338,81.8988626 42.0963312,78.4485035 L33.9737611,70.9892229 C31.0974571,68.3519607 27.3491195,66.8883863 23.4530989,66.8883863 L22.0325282,66.8883863 C18.1505726,66.8883863 15,63.7279138 15,59.8179782 L15,45.2529375 C15,41.3500723 18.1505726,38.1825294 22.0325282,38.1825294 L23.3757411,38.1825294 C27.2717617,38.1825294 31.0200993,36.718955 33.8964033,34.0816927 L42.0963312,26.5517081 C45.8587338,23.101349 51.892643,25.7810337 51.892643,30.8929387 Z" id="path-1"></path>
</defs>
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Editor-Tabs/Sounds" transform="translate(50.000000, 50.000000) scale(-1, 1) translate(-50.000000, -50.000000) ">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="sound" fill="#4C97FF" fill-rule="evenodd" xlink:href="#path-1"></use>
<g id="Color/Gray" mask="url(#mask-2)" fill="#575E75" fill-rule="evenodd">
<rect id="Color" x="0" y="0" width="100" height="100"></rect>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
scratch-gui/test/fixtures/bmpfile.bmp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="208" height="159" viewBox="0,0,208,159">
<here is some nonsense that will make this costume not valid svg>
<g transform="translate(-149.51562,-117.5)">
<g data-paper-data="{&quot;isPaintingLayer&quot;:true}" fill-rule="nonzero" stroke="#000000" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" style="mix-blend-mode: normal">
<path d="M150.51563,275.5v-157h206v157z" fill="#66c1ff"/>
<path d="M231.51563,173.5c0,11.04569 -9.62588,20 -21.5,20c-11.87412,0 -21.5,-8.95431 -21.5,-20c0,-11.04569 9.62588,-20 21.5,-20c11.87412,0 21.5,8.95431 21.5,20z" fill="#ffffff"/>
<path d="M325.51563,179.5c0,8.83656 -11.6406,16 -26,16c-14.3594,0 -26,-7.16344 -26,-16c0,-8.83656 11.6406,-16 26,-16c14.3594,0 26,7.16344 26,16z" fill="#ffffff"/>
<path d="M211.51563,185.5c0,3.31371 -3.35786,6 -7.5,6c-4.14214,0 -7.5,-2.68629 -7.5,-6c0,-3.31371 3.35786,-6 7.5,-6c4.14214,0 7.5,2.68629 7.5,6z" fill="#000000"/>
<path d="M295.51563,187c0,3.58985 -3.13401,6.5 -7,6.5c-3.86599,0 -7,-2.91015 -7,-6.5c0,-3.58985 3.13401,-6.5 7,-6.5c3.86599,0 7,2.91015 7,6.5z" fill="#000000"/>
<path d="M250.51563,245c0,6.35127 -4.70101,11.5 -10.5,11.5c-5.79899,0 -10.5,-5.14873 -10.5,-11.5c0,-6.35127 4.70101,-11.5 10.5,-11.5c5.79899,0 10.5,5.14873 10.5,11.5z" fill="#000000"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
scratch-gui/test/fixtures/movie.wav vendored Normal file

Binary file not shown.

BIN
scratch-gui/test/fixtures/paddleball.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
scratch-gui/test/fixtures/project1.sb3 vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
<svg version="1.1" width="112" height="82" viewBox="-1 -1 112 82" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Exported by Scratch - http://scratch.mit.edu/ -->
<Here is something that will make this svg not valid>
<path id="ID0.2747190184891224" fill="#003FFF" stroke="none" d="M 187 140 L 297 140 L 297 220 L 187 220 L 187 140 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
<path id="ID0.8901655622757971" fill="#FFFFFF" stroke="none" d="M 228 164 C 230.759 164 233.259 165.121 235.069 166.931 C 236.879 168.741 238 171.241 238 174 C 238 176.759 236.879 179.259 235.069 181.069 C 233.259 182.879 230.759 184 228 184 C 225.241 184 222.741 182.879 220.931 181.069 C 219.121 179.259 218 176.759 218 174 C 218 171.241 219.121 168.741 220.931 166.931 C 222.741 165.121 225.241 164 228 164 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
<path id="ID0.8060284000821412" fill="#FFFFFF" stroke="none" d="M 267 170 C 270.311 170 273.311 170.784 275.483 172.052 C 277.655 173.319 279 175.069 279 177 C 279 178.931 277.655 180.681 275.483 181.948 C 273.311 183.216 270.311 184 267 184 C 263.689 184 260.689 183.216 258.517 181.948 C 256.345 180.681 255 178.931 255 177 C 255 175.069 256.345 173.319 258.517 172.052 C 260.689 170.784 263.689 170 267 170 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
<path id="ID0.5429155449382961" fill="#000000" stroke="none" d="M 232 173 C 233.379 173 234.629 173.560 235.535 174.465 C 236.440 175.371 237 176.621 237 178 C 237 179.379 236.440 180.629 235.535 181.535 C 234.629 182.440 233.379 183 232 183 C 230.621 183 229.371 182.440 228.465 181.535 C 227.560 180.629 227 179.379 227 178 C 227 176.621 227.560 175.371 228.465 174.465 C 229.371 173.560 230.621 173 232 173 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
<path id="ID0.10347355296835303" fill="#000000" stroke="none" d="M 272.500 176 C 273.742 176 274.867 176.448 275.681 177.172 C 276.496 177.896 277 178.896 277 180 C 277 181.104 276.496 182.104 275.681 182.828 C 274.867 183.552 273.742 184 272.500 184 C 271.258 184 270.133 183.552 269.319 182.828 C 268.504 182.104 268 181.104 268 180 C 268 178.896 268.504 177.896 269.319 177.172 C 270.133 176.448 271.258 176 272.500 176 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
<path id="ID0.0358476871624589" fill="#000000" stroke="none" stroke-linecap="round" d="M 249.500 202.350 C 254.446 202.182 258.918 198.475 263.845 198.465 C 266.211 198.462 270 200.621 270 202 C 270 203.379 267.647 204.629 263.845 205.535 C 260.044 206.440 254.794 207 249 207 C 243.206 207 237.956 206.440 234.155 205.535 C 230.353 204.629 228.623 203.791 228 202 C 227.588 200.799 230.381 199.602 231.850 199.650 C 237.831 199.723 243.629 202.549 249.500 202.350 Z " transform="matrix(1, 0, 0, 1, -187, -140)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
scratch-gui/test/fixtures/sneaker.wav vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,4 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({adapter: new Adapter()});

View File

@@ -0,0 +1,39 @@
/*
* Helpers for using enzyme and react-test-renderer with react-intl
* Directly from https://github.com/yahoo/react-intl/wiki/Testing-with-React-Intl
*/
import React from 'react';
import renderer from 'react-test-renderer';
import {IntlProvider, intlShape} from 'react-intl';
import {mount, shallow} from 'enzyme';
const intlProvider = new IntlProvider({locale: 'en'}, {});
const {intl} = intlProvider.getChildContext();
const nodeWithIntlProp = node => React.cloneElement(node, {intl});
const shallowWithIntl = (node, {context} = {}) => shallow(
nodeWithIntlProp(node),
{
context: Object.assign({}, context, {intl})
}
);
const mountWithIntl = (node, {context, childContextTypes} = {}) => mount(
nodeWithIntlProp(node),
{
context: Object.assign({}, context, {intl}),
childContextTypes: Object.assign({}, {intl: intlShape}, childContextTypes)
}
);
// react-test-renderer component for use with snapshot testing
const componentWithIntl = (children, props = {locale: 'en'}) => renderer.create(
<IntlProvider {...props}>{children}</IntlProvider>
);
export {
componentWithIntl,
shallowWithIntl,
mountWithIntl
};

View File

@@ -0,0 +1,378 @@
jest.setTimeout(30000); // eslint-disable-line no-undef
import bindAll from 'lodash.bindall';
import 'chromedriver'; // register path
import webdriver from 'selenium-webdriver';
const {Button, By, until} = webdriver;
const USE_HEADLESS = process.env.USE_HEADLESS !== 'no';
// The main reason for this timeout is so that we can control the timeout message and report details;
// if we hit the Jasmine default timeout then we get a terse message that we can't control.
// The Jasmine default timeout is 30 seconds so make sure this is lower.
const DEFAULT_TIMEOUT_MILLISECONDS = 20 * 1000;
/**
* Add more debug information to an error:
* - Merge a causal error into an outer error with valuable stack information
* - Add the causal error's message to the outer error's message.
* - Add debug information from the web driver, if available.
* The outerError compensates for the loss of context caused by `regenerator-runtime`.
* @param {Error} outerError The error to embed the cause into.
* @param {Error} cause The "inner" error to embed.
* @param {webdriver.ThenableWebDriver} [driver] Optional driver to capture debug info from.
* @returns {Promise<Error>} The outerError, with the cause embedded.
*/
const enhanceError = async (outerError, cause, driver) => {
if (cause) {
// This is the official way to nest errors in modern Node.js, but Jest ignores this field.
// It's here in case a future version uses it, or in case the caller does.
outerError.cause = cause;
}
if (cause && cause.message) {
outerError.message += `\n${['Cause:', ...cause.message.split('\n')].join('\n ')}`;
} else {
outerError.message += '\nCause: unknown';
}
if (driver) {
const url = await driver.getCurrentUrl();
const title = await driver.getTitle();
const pageSource = await driver.getPageSource();
const browserLogEntries = await driver.manage()
.logs()
.get('browser');
const browserLogText = browserLogEntries.map(entry => entry.message).join('\n');
outerError.message += `\nBrowser URL: ${url}`;
outerError.message += `\nBrowser title: ${title}`;
outerError.message += `\nBrowser logs:\n*****\n${browserLogText}\n*****\n`;
outerError.message += `\nBrowser page source:\n*****\n${pageSource}\n*****\n`;
}
return outerError;
};
class SeleniumHelper {
constructor () {
bindAll(this, [
'clickText',
'clickButton',
'clickXpath',
'clickBlocksCategory',
'elementIsVisible',
'findByText',
'textToXpath',
'findByXpath',
'textExists',
'getDriver',
'getSauceDriver',
'getLogs',
'loadUri',
'rightClickText'
]);
this.Key = webdriver.Key; // map Key constants, for sending special keys
// this type declaration suppresses IDE type warnings throughout this file
/** @type {webdriver.ThenableWebDriver} */
this.driver = null;
}
/**
* Set the browser window title. Useful for debugging.
* @param {string} title The title to set.
* @returns {Promise<void>} A promise that resolves when the title is set.
*/
async setTitle (title) {
await this.driver.executeScript(`document.title = arguments[0];`, title);
}
/**
* Wait for an element to be visible.
* @param {webdriver.WebElement} element The element to wait for.
* @returns {Promise<void>} A promise that resolves when the element is visible.
*/
async elementIsVisible (element) {
const outerError = new Error('elementIsVisible failed');
try {
await this.setTitle(`elementIsVisible ${await element.getId()}`);
await this.driver.wait(until.elementIsVisible(element), DEFAULT_TIMEOUT_MILLISECONDS);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* List of useful xpath scopes for finding elements.
* @returns {object} An object mapping names to xpath strings.
*/
get scope () {
return {
blocksTab: "*[@id='react-tabs-1']",
costumesTab: "*[@id='react-tabs-3']",
modal: '*[@class="ReactModalPortal"]',
reportedValue: '*[@class="blocklyDropDownContent"]',
soundsTab: "*[@id='react-tabs-5']",
spriteTile: '*[starts-with(@class,"react-contextmenu-wrapper")]',
menuBar: '*[contains(@class,"menu-bar_menu-bar_")]',
monitors: '*[starts-with(@class,"stage_monitor-wrapper")]',
contextMenu: '*[starts-with(@class,"react-contextmenu")]'
};
}
/**
* Instantiate a new Selenium driver.
* @returns {webdriver.ThenableWebDriver} The new driver.
*/
getDriver () {
const chromeCapabilities = webdriver.Capabilities.chrome();
const args = [];
if (USE_HEADLESS) {
args.push('--headless');
}
// Stub getUserMedia to always not allow access
args.push('--use-fake-ui-for-media-stream=deny');
// Suppress complaints about AudioContext starting before a user gesture
// This is especially important on Windows, where Selenium directs JS console messages to stdout
args.push('--autoplay-policy=no-user-gesture-required');
chromeCapabilities.set('chromeOptions', {args});
chromeCapabilities.setLoggingPrefs({
performance: 'ALL'
});
this.driver = new webdriver.Builder()
.forBrowser('chrome')
.withCapabilities(chromeCapabilities)
.build();
return this.driver;
}
/**
* Instantiate a new Selenium driver for Sauce Labs.
* @param {string} username The Sauce Labs username.
* @param {string} accessKey The Sauce Labs access key.
* @param {object} configs The Sauce Labs configuration.
* @param {string} configs.browserName The name of the desired browser.
* @param {string} configs.platform The name of the desired platform.
* @param {string} configs.version The desired browser version.
* @returns {webdriver.ThenableWebDriver} The new driver.
*/
getSauceDriver (username, accessKey, configs) {
this.driver = new webdriver.Builder()
.withCapabilities({
browserName: configs.browserName,
platform: configs.platform,
version: configs.version,
username: username,
accessKey: accessKey
})
.usingServer(`http://${username}:${accessKey}@ondemand.saucelabs.com:80/wd/hub`)
.build();
return this.driver;
}
/**
* Find an element by xpath.
* @param {string} xpath The xpath to search for.
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
*/
async findByXpath (xpath) {
const outerError = new Error(`findByXpath failed with arguments:\n\txpath: ${xpath}`);
try {
await this.setTitle(`findByXpath ${xpath}`);
const el = await this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS);
// await this.driver.wait(() => el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS);
return el;
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Generate an xpath that finds an element by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {string} The xpath.
*/
textToXpath (text, scope) {
return `//body//${scope || '*'}//*[contains(text(), '${text}')]`;
}
/**
* Find an element by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<webdriver.WebElement>} A promise that resolves to the element.
*/
findByText (text, scope) {
return this.findByXpath(this.textToXpath(text, scope));
}
/**
* Check if an element exists by its text.
* @param {string} text The text to search for.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<boolean>} A promise that resolves to true if the element exists.
*/
async textExists (text, scope) {
const outerError = new Error(`textExists failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`textExists ${text}`);
const elements = await this.driver.findElements(By.xpath(this.textToXpath(text, scope)));
return elements.length > 0;
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Load a URI in the driver.
* @param {string} uri The URI to load.
* @returns {Promise} A promise that resolves when the URI is loaded.
*/
async loadUri (uri) {
const outerError = new Error(`loadUri failed with arguments:\n\turi: ${uri}`);
try {
await this.setTitle(`loadUri ${uri}`);
const WINDOW_WIDTH = 1024;
const WINDOW_HEIGHT = 768;
await this.driver
.get(`file://${uri}`);
await this.driver
.executeScript('window.onbeforeunload = undefined;');
await this.driver.manage().window()
.setSize(WINDOW_WIDTH, WINDOW_HEIGHT);
await this.driver.wait(
async () => await this.driver.executeScript('return document.readyState;') === 'complete',
DEFAULT_TIMEOUT_MILLISECONDS
);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click an element by xpath.
* @param {string} xpath The xpath to click.
* @returns {Promise<void>} A promise that resolves when the element is clicked.
*/
async clickXpath (xpath) {
const outerError = new Error(`clickXpath failed with arguments:\n\txpath: ${xpath}`);
try {
await this.setTitle(`clickXpath ${xpath}`);
const el = await this.findByXpath(xpath);
return el.click();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click an element by its text.
* @param {string} text The text to click.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<void>} A promise that resolves when the element is clicked.
*/
async clickText (text, scope) {
const outerError = new Error(`clickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`clickText ${text}`);
const el = await this.findByText(text, scope);
return el.click();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click a category in the blocks pane.
* @param {string} categoryText The text of the category to click.
* @returns {Promise<void>} A promise that resolves when the category is clicked.
*/
async clickBlocksCategory (categoryText) {
const outerError = new Error(`clickBlocksCategory failed with arguments:\n\tcategoryText: ${categoryText}`);
// The toolbox is destroyed and recreated several times, so avoid clicking on a nonexistent element and erroring
// out. First we wait for the block pane itself to appear, then wait 100ms for the toolbox to finish refreshing,
// then finally click the toolbox text.
try {
await this.setTitle(`clickBlocksCategory ${categoryText}`);
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
await this.driver.sleep(100);
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
await this.driver.sleep(500); // Wait for scroll to finish
} catch (cause) {
throw await enhanceError(outerError, cause);
}
}
/**
* Right click an element by its text.
* @param {string} text The text to right click.
* @param {string} [scope] An optional xpath scope to search within.
* @returns {Promise<void>} A promise that resolves when the element is right clicked.
*/
async rightClickText (text, scope) {
const outerError = new Error(`rightClickText failed with arguments:\n\ttext: ${text}\n\tscope: ${scope}`);
try {
await this.setTitle(`rightClickText ${text}`);
const el = await this.findByText(text, scope);
return this.driver.actions()
.click(el, Button.RIGHT)
.perform();
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Click a button by its text.
* @param {string} text The text to click.
* @returns {Promise<void>} A promise that resolves when the button is clicked.
*/
async clickButton (text) {
const outerError = new Error(`clickButton failed with arguments:\n\ttext: ${text}`);
try {
await this.setTitle(`clickButton ${text}`);
await this.clickXpath(`//button//*[contains(text(), '${text}')]`);
} catch (cause) {
throw await enhanceError(outerError, cause, this.driver);
}
}
/**
* Get selected browser log entries.
* @param {Array.<string>} [whitelist] An optional list of log strings to allow. Default: see implementation.
* @returns {Promise<Array.<webdriver.logging.Entry>>} A promise that resolves to the log entries.
*/
async getLogs (whitelist) {
const outerError = new Error(`getLogs failed with arguments:\n\twhitelist: ${whitelist}`);
try {
await this.setTitle(`getLogs ${whitelist}`);
if (!whitelist) {
// Default whitelist
whitelist = [
'The play() request was interrupted by a call to pause()'
];
}
const entries = await this.driver.manage()
.logs()
.get('browser');
return entries.filter(entry => {
const message = entry.message;
for (const element of whitelist) {
if (message.indexOf(element) !== -1) {
return false;
} else if (entry.level !== 'SEVERE') { // WARNING: this doesn't do what it looks like it does!
return false;
}
}
return true;
});
} catch (cause) {
throw await enhanceError(outerError, cause);
}
}
}
export default SeleniumHelper;

View File

@@ -0,0 +1,117 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with backdrops', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Adding a backdrop from the library should not switch to stage', async () => {
await loadUri(uri);
// Start on the sounds tab of sprite1 to test switching behavior
await clickText('Sounds');
// Add a backdrop without selecting the stage first to test switching
await clickXpath('//button[@aria-label="Choose a Backdrop"]');
const el = await findByXpath("//input[@placeholder='Search']");
await el.sendKeys('blue');
await clickText('Blue Sky'); // Adds the backdrop
// Make sure the sprite is still selected, and that the tab has not changed
await clickText('Meow', scope.soundsTab);
// Make sure the backdrop was actually added by going to the backdrops tab
await clickXpath('//span[text()="Stage"]');
await clickText('Backdrops');
await clickText('Blue Sky', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding backdrop via paint should switch to stage', async () => {
await loadUri(uri);
const buttonXpath = '//button[@aria-label="Choose a Backdrop"]';
const paintXpath = `${buttonXpath}/following-sibling::div//button[@aria-label="Paint"]`;
const el = await findByXpath(buttonXpath);
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath(paintXpath);
// Stage should become selected and costume tab activated
await findByText('backdrop2', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding backdrop via surprise should not switch to stage', async () => {
await loadUri(uri);
// Start on the sounds tab of sprite1 to test switching behavior
await clickText('Sounds');
const buttonXpath = '//button[@aria-label="Choose a Backdrop"]';
const surpriseXpath = `${buttonXpath}/following-sibling::div//button[@aria-label="Surprise"]`;
const el = await findByXpath(buttonXpath);
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath(surpriseXpath);
// Make sure the sprite is still selected, and that the tab has not changed
await clickText('Meow', scope.soundsTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding multiple backdrops from file should switch to stage', async () => {
const files = [
path.resolve(__dirname, '../fixtures/gh-3582-png.png'),
path.resolve(__dirname, '../fixtures/100-100.svg')
];
await loadUri(uri);
const buttonXpath = '//button[@aria-label="Choose a Backdrop"]';
const fileXpath = `${buttonXpath}/following-sibling::div//input[@type="file"]`;
const el = await findByXpath(buttonXpath);
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath(fileXpath);
await input.sendKeys(files.join('\n'));
// Should have been switched to stage/costume tab already
await findByText('gh-3582-png', scope.costumesTab);
await findByText('100-100', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,44 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
getDriver,
getLogs,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with the how-to library', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Backpack is "Coming Soon" without backpack host param', async () => {
await loadUri(uri);
// Check that the backpack header is visible and wrapped in a coming soon tooltip
await clickText('Backpack', '*[@data-for="backpack-tooltip"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Backpack can be expanded with backpack host param', async () => {
await loadUri(`${uri}?backpack_host=https://backpack.scratch.mit.edu`);
// Try activating the backpack from the costumes tab to make sure it isn't pushed off
await clickText('Costumes');
// Check that the backpack header is visible and wrapped in a coming soon tooltip
await clickText('Backpack'); // Not wrapped in tooltip
await clickText('Backpack is empty'); // Make sure it can expand, is empty
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,339 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickBlocksCategory,
clickButton,
clickXpath,
findByText,
findByXpath,
textExists,
getDriver,
getLogs,
Key,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
describe('Working with the blocks', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Blocks report when clicked in the toolbox', async () => {
await loadUri(uri);
await clickText('Code');
await clickBlocksCategory('Operators');
await clickText('join', scope.blocksTab); // Click "join <hello> <world>" block
await findByText('apple banana', scope.reportedValue); // Tooltip with result
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Switching sprites updates the block menus', async () => {
await loadUri(uri);
await clickBlocksCategory('Sound');
// "Meow" sound block should be visible
await findByText('Meow', scope.blocksTab);
await clickText('Backdrops'); // Switch to the backdrop
// Now "pop" sound block should be visible and motion blocks hidden
await findByText('pop', scope.blocksTab);
await clickBlocksCategory('Motion');
await findByText('Stage selected: no motion blocks');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Creating variables', async () => {
await loadUri(uri);
await clickText('Code');
await clickBlocksCategory('Variables');
// Expect a default variable "my variable" to be visible
await clickText('my\u00A0variable', scope.blocksTab);
await findByText('0', scope.reportedValue);
await clickText('Make a Variable');
let el = await findByXpath("//input[@name='New variable name:']");
await el.sendKeys('score');
await clickButton('OK');
await clickText('Make a Variable');
el = await findByXpath("//input[@name='New variable name:']");
await el.sendKeys('second variable');
await clickButton('OK');
// Make sure reporting works on a new variable
await clickBlocksCategory('Variables');
await clickText('score', scope.blocksTab);
await findByText('0', scope.reportedValue); // Tooltip with result
// And there should be a monitor visible
await rightClickText('score', scope.monitors);
await clickText('slider');
await findByXpath("//input[@step='1']");
// Changing the slider to a decimal should make it have a step size of 0.01
await rightClickText('score', scope.monitors);
await clickText('change slider range');
el = await findByXpath("//input[@name='Maximum value']");
await el.sendKeys('.1');
await clickButton('OK');
await findByXpath("//input[@step='0.01'][@max='100.1']");
// Hiding the monitor via context menu should work
await rightClickText('score', scope.monitors);
await clickText('hide', scope.contextMenu);
await driver.sleep(100);
const monitorExists = await textExists('score', scope.monitors);
await expect(monitorExists).toBeFalsy();
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Creating a list', async () => {
await loadUri(uri);
await clickText('Code');
await clickBlocksCategory('Variables');
await clickText('Make a List');
let el = await findByXpath("//input[@name='New list name:']");
await el.sendKeys('list1');
await clickButton('OK');
// Click the "add <thing> to list" block 3 times
await clickText('add', scope.blocksTab);
await clickText('add', scope.blocksTab);
await clickText('add', scope.blocksTab);
await clickText('list1', scope.blocksTab);
await findByText('thing thing thing', scope.reportedValue); // Tooltip with result
// Interact with the monitor, adding an item
await findByText('list1', scope.monitors); // Just to be sure it is there
await clickText('+', scope.monitors);
el = await findByXpath(`//body//${scope.monitors}//input`);
await el.sendKeys('thing2');
await el.click(); // Regression for "clicking active input erases value" bug.
await clickText('list1', scope.monitors); // Blur the input to submit
// Check that the list value has been propagated.
await clickText('list1', scope.blocksTab);
await findByText('thing thing thing thing2', scope.reportedValue); // Tooltip with result
// Hiding the monitor via context menu should work
await rightClickText('list1', scope.monitors);
await clickText('hide', scope.contextMenu);
await driver.sleep(100);
const monitorExists = await textExists('list1', scope.monitors);
await expect(monitorExists).toBeFalsy();
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Custom procedures', async () => {
await loadUri(uri);
await clickBlocksCategory('My Blocks');
await clickText('Make a Block');
// Click on the "add an input" buttons
await clickText('number or text', scope.modal);
await clickText('boolean', scope.modal);
await clickText('Add a label', scope.modal);
await clickText('OK', scope.modal);
// Make sure a "define" block has been added to the workspace
await findByText('define', scope.blocksTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding an extension', async () => {
await loadUri(uri);
await clickXpath('//button[@title="Add Extension"]');
await clickText('Pen');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
// Make sure toolbox has been scrolled to the pen extension
await findByText('stamp', scope.blocksTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Record option from sound block menu opens sound recorder', async () => {
await loadUri(uri);
await clickText('Code');
await clickBlocksCategory('Sound');
await clickText('Meow', scope.blocksTab); // Click "play sound <Meow> until done" block
await clickText('record'); // Click "record..." option in the block's sound menu
// Access has been force denied, so close the alert that comes up
await driver.sleep(1000); // getUserMedia requests are very slow to fail for some reason
await driver.switchTo().alert()
.accept();
await findByText('Record Sound'); // Sound recorder is open
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Renaming costume changes the default costume name in the toolbox', async () => {
await loadUri(uri);
// Rename the costume
await clickText('Costumes');
await clickText('costume2', scope.costumesTab);
const el = await findByXpath("//input[@value='costume2']");
await el.sendKeys('newname');
await el.sendKeys(Key.ENTER);
// wait until the updated costume appears in costume item list panel
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
"//div[contains(text(), 'newname')]");
// Make sure it is updated in the block menu
await clickText('Code');
await clickBlocksCategory('Looks');
await clickText('newname', scope.blocksTab);
});
test('Renaming costume with a special character should not break toolbox', async () => {
await loadUri(uri);
// Rename the costume
await clickText('Costumes');
await clickText('costume2', scope.costumesTab);
const el = await findByXpath("//input[@value='costume2']");
await el.sendKeys('<NewCostume>');
await el.sendKeys(Key.ENTER);
// wait until the updated costume appears in costume item list panel
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
"//div[contains(text(), '<NewCostume>')]");
// Make sure it is updated in the block menu
await clickText('Code');
await clickBlocksCategory('Looks');
await clickText('<NewCostume>', scope.blocksTab);
await clickBlocksCategory('Sound');
});
test('Adding costumes DOES update the default costume name in the toolbox', async () => {
await loadUri(uri);
// By default, costume2 is in the costume tab
await clickBlocksCategory('Looks');
await clickText('costume2', scope.blocksTab);
// Also check that adding a new costume does update the list
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Paint"]');
// wait until the new costume appears in costume item list panel
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
"//div[contains(text(), 'costume3')]");
await clickText('costume3', scope.costumesTab);
// Check that the menu has been updated
await clickText('Code');
await clickText('costume3', scope.blocksTab);
});
// Skipped because it was flakey on travis, but seems to run locally ok
test('Adding a sound DOES update the default sound name in the toolbox', async () => {
await loadUri(uri);
await clickText('Sounds');
await clickXpath('//button[@aria-label="Choose a Sound"]');
await clickText('A Bass', scope.modal); // Should close the modal
// wait until the selected sound appears in sounds item list panel
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
"//div[contains(text(), 'A Bass')]");
await clickText('Code');
await clickBlocksCategory('Sound');
await clickText('A\u00A0Bass', scope.blocksTab); // Need &nbsp; for block text
});
// Regression test for switching between editor/player causing toolbox to stop updating
test('"See inside" after being on project page re-initializing variables', async () => {
const playerUri = path.resolve(__dirname, '../../build/player.html');
await loadUri(playerUri);
await clickText('See inside');
await clickBlocksCategory('Variables');
await clickText('my\u00A0variable');
await clickText('See Project Page');
await clickText('See inside');
await clickBlocksCategory('Variables');
await clickText('my\u00A0variable');
});
// Regression test for switching editor tabs causing toolbox to stop updating
test('Creating variables after adding extensions updates the toolbox', async () => {
await loadUri(uri);
await clickText('Costumes');
await clickText('Code');
await clickBlocksCategory('Variables');
await clickText('Make a List');
const el = await findByXpath("//input[@name='New list name:']");
await el.sendKeys('list1');
await clickButton('OK');
await clickText('list1', scope.blocksTab);
});
test('Use variable blocks after switching languages', async () => {
const myVariable = 'my\u00A0variable';
const changeVariableByScope = "*[@data-id='data_changevariableby']";
await loadUri(uri);
await clickText('Code');
await clickBlocksCategory('Variables');
// change "my variable" by 1
await clickText('change', changeVariableByScope);
// check reported value 1
await clickText(myVariable, scope.blocksTab);
await findByText('1', scope.reportedValue);
// change language
await clickXpath(SETTINGS_MENU_XPATH);
await clickText('Language', scope.menuBar);
await clickText('Deutsch');
await clickText('Skripte');
await clickBlocksCategory('Variablen');
// make sure "my variable" is still 1
await clickText(myVariable);
await findByText('1', scope.reportedValue);
// change step from 1 to 10
await clickText('1', changeVariableByScope);
await driver.actions()
.sendKeys('10')
.perform();
// change "my variable" by 10
await clickText('ändere', changeVariableByScope);
// check it is turned up to 11
await clickText(myVariable);
await findByText('11', scope.reportedValue);
});
});

View File

@@ -0,0 +1,67 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
getDriver,
getLogs,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
// The tests below require Scratch Link to be unavailable, so we can trigger
// an error modal. To make sure this is always true, we came up with the idea of
// injecting javascript that overwrites the global Websocket object with one that
// attempts to connect to a fake socket address.
const websocketFakeoutJs = `var RealWebSocket = WebSocket;
WebSocket = function () {
return new RealWebSocket("wss://fake.fake");
}`;
describe('Hardware extension connection modal', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Message saying Scratch Link is unavailable (BLE)', async () => {
await driver.quit();
driver = getDriver();
await loadUri(uri);
await driver.executeScript(websocketFakeoutJs);
await clickXpath('//button[@title="Add Extension"]');
await clickText('micro:bit');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for modal to open
findByText('Scratch Link'); // Scratch Link is mentioned in the error modal
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Message saying Scratch Link is unavailable (BT)', async () => {
await loadUri(uri);
await driver.executeScript(websocketFakeoutJs);
await clickXpath('//button[@title="Add Extension"]');
await clickText('EV3');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for modal to open
findByText('Scratch Link'); // Scratch Link is mentioned in the error modal
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,262 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
// The costumes library is slow to load. Increase the timeout for these tests.
jest.setTimeout(60_000);
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with costumes', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Adding a costume through the library', async () => {
await loadUri(uri);
await driver.sleep(500);
await clickText('Costumes');
await clickXpath('//button[@aria-label="Choose a Costume"]');
const el = await findByXpath("//input[@placeholder='Search']");
await el.sendKeys('abb');
await clickText('Abby-a'); // Should close the modal, then click the costumes in the selector
await findByXpath("//input[@value='Abby-a']"); // Should show editor for new costume
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a costume by surprise button', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Surprise"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a costume by paint button', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Paint"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Duplicating a costume', async () => {
await loadUri(uri);
await clickText('Costumes');
await rightClickText('costume1', scope.costumesTab);
await clickText('duplicate', scope.costumesTab);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for duplication to finish
// Make sure the duplicated costume is named correctly.
await clickText('costume3', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Converting bitmap/vector in paint editor', async () => {
await loadUri(uri);
await clickText('Costumes');
// Convert the first costume to bitmap.
await clickText('costume1', scope.costumesTab);
await clickText('Convert to Bitmap', scope.costumesTab);
// Make sure mode switches back to vector for vector costume.
await clickText('costume2', scope.costumesTab);
await clickText('Convert to Bitmap', scope.costumesTab);
// Make sure bitmap is saved by switching back and converting to vector.
await clickText('Sounds');
await clickText('Costumes');
await clickText('Convert to Vector', scope.costumesTab); // costume2
await clickText('costume1', scope.costumesTab);
await clickText('Convert to Vector', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Undo/redo in the paint editor', async () => {
await loadUri(uri);
await clickText('Costumes');
await clickText('costume1', scope.costumesTab);
await clickText('Convert to Bitmap', scope.costumesTab);
await clickXpath('//img[@alt="Undo"]');
await clickText('Convert to Bitmap', scope.costumesTab);
await clickXpath('//img[@alt="Undo"]');
await clickXpath('//img[@alt="Redo"]');
await clickText('Convert to Vector', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding an svg from file', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/100-100.svg'));
await clickText('100-100', scope.costumesTab); // Name from filename
await clickText('100 x 100', scope.costumesTab); // Size is right
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a png from file (gh-3582)', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/gh-3582-png.png'));
await clickText('gh-3582-png', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a bmp from file', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/bmpfile.bmp'));
await clickText('bmpfile', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding several costumes with a gif', async () => {
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/paddleball.gif'));
await findByText('paddleball', scope.costumesTab);
await findByText('paddleball2', scope.costumesTab);
await findByText('paddleball3', scope.costumesTab);
await findByText('paddleball4', scope.costumesTab);
await findByText('paddleball5', scope.costumesTab);
await findByText('paddleball6', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a letter costume through the Letters filter in the library', async () => {
await loadUri(uri);
await driver.manage()
.window()
.setSize(1244, 768); // Letters filter not visible at 1024 width
await clickText('Costumes');
await clickXpath('//button[@aria-label="Choose a Costume"]');
await clickText('Letters');
await clickText('Block-a', scope.modal); // Closes modal
await rightClickText('Block-a', scope.costumesTab); // Make sure it is there
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Costumes animate on mouseover', async () => {
await loadUri(uri);
await clickXpath('//button[@aria-label="Choose a Sprite"]');
const searchElement = await findByXpath("//input[@placeholder='Search']");
await searchElement.sendKeys('abb');
const abbyElement = await findByXpath('//*[span[text()="Abby"]]');
driver.actions()
.mouseMove(abbyElement)
.perform();
// wait for one of Abby's alternate costumes to appear
await findByXpath('//img[@src="https://cdn.assets.scratch.mit.edu/internalapi/asset/45de34b47a2ce22f6f5d28bb35a44ff5.svg/get/"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding multiple costumes at the same time', async () => {
const files = [
path.resolve(__dirname, '../fixtures/gh-3582-png.png'),
path.resolve(__dirname, '../fixtures/100-100.svg')
];
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(files.join('\n'));
await findByText('gh-3582-png', scope.costumesTab);
await findByText('100-100', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Load an invalid svg from scratch3 as costume', async () => { // eslint-disable-line no-disabled-tests
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-from-scratch3.svg'));
const costumeTile = await findByText('corrupt-from-scratch3', scope.costumesTab); // Name from filename
const tileVisible = await costumeTile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load an invalid svg from scratch2 as costume', async () => { // eslint-disable-line no-disabled-tests
await loadUri(uri);
await clickText('Costumes');
const el = await findByXpath('//button[@aria-label="Choose a Costume"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/scratch2-corrupted.svg'));
const costumeTile = await findByText('scratch2-corrupted', scope.costumesTab); // Name from filename
const tileVisible = await costumeTile.isDisplayed();
await expect(tileVisible).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
/* globals Promise */
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickButton,
clickText,
clickXpath,
findByXpath,
getDriver,
getLogs,
loadUri
} = new SeleniumHelper();
let driver;
describe('player example', () => {
const uri = path.resolve(__dirname, '../../build/player.html');
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test.skip('Player: load a project by ID', async () => {
const projectId = '96708228';
await loadUri(`${uri}#${projectId}`);
await clickXpath('//img[@title="Go"]');
await new Promise(resolve => setTimeout(resolve, 2000));
await clickXpath('//img[@title="Stop"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
const projectRequests = await driver.manage().logs()
.get('performance')
.then(pLogs => pLogs.map(log => JSON.parse(log.message).message)
.filter(m => m.method === 'Network.requestWillBeSent')
.map(m => m.params.request.url)
.filter(url => url === 'https://projects.scratch.mit.edu/96708228')
);
await expect(projectRequests).toEqual(['https://projects.scratch.mit.edu/96708228']);
});
});
describe('blocks example', () => {
const uri = path.resolve(__dirname, '../../build/blocks-only.html');
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test.skip('Blocks: load a project by ID', async () => {
const projectId = '96708228';
await loadUri(`${uri}#${projectId}`);
await new Promise(resolve => setTimeout(resolve, 2000));
await clickXpath('//img[@title="Go"]');
await new Promise(resolve => setTimeout(resolve, 2000));
await clickXpath('//img[@title="Stop"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
const projectRequests = await driver.manage().logs()
.get('performance')
.then(pLogs => pLogs.map(log => JSON.parse(log.message).message)
.filter(m => m.method === 'Network.requestWillBeSent')
.map(m => m.params.request.url)
.filter(url => url === 'https://projects.scratch.mit.edu/96708228')
);
await expect(projectRequests).toEqual(['https://projects.scratch.mit.edu/96708228']);
});
// skipping per https://github.com/LLK/scratch-gui/issues/4902 until we have better approach
test.skip('Change categories', async () => {
await loadUri(`${uri}`);
await clickText('Looks');
await clickText('Sound');
await clickText('Events');
await clickText('Control');
await clickText('Sensing');
await clickText('Operators');
await clickText('Variables');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
await clickText('Make a Variable');
let el = await findByXpath("//input[@name='New variable name:']");
await el.sendKeys('score');
await clickButton('OK');
await clickText('Make a Variable');
el = await findByXpath("//input[@name='New variable name:']");
await el.sendKeys('second variable');
await clickButton('OK');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByXpath,
getDriver,
getLogs,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with the how-to library', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Choosing a how-to', async () => {
await loadUri(uri);
await clickText('Costumes');
await clickXpath('//*[@aria-label="Tutorials"]');
await clickText('Getting Started'); // Modal should close
// Make sure YouTube video on first card appears
await findByXpath('//div[contains(@class, "step-video")]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// @todo navigating cards, etc.
});

View File

@@ -0,0 +1,109 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
describe('Localization', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Switching languages', async () => {
await loadUri(uri);
// Add a sprite to make sure it stays when switching languages
await clickXpath('//button[@aria-label="Choose a Sprite"]');
await clickText('Apple', scope.modal); // Closes modal
await clickXpath(SETTINGS_MENU_XPATH);
await clickText('Language', scope.menuBar);
await clickText('Deutsch');
await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks refresh
// Make sure the blocks are translating
await clickText('Fühlen'); // Sensing category in German
await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks to scroll
await clickText('Antwort'); // Find the "answer" block in German
// Change to the costumes tab to confirm other parts of the GUI are translating
await clickText('Kostüme');
// After switching languages, make sure Apple sprite still exists
await rightClickText('Apple', scope.spriteTile); // Make sure it is there
// Remounting re-attaches the beforeunload callback. Make sure to remove it
driver.executeScript('window.onbeforeunload = undefined;');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// Regression test for #4476, blocks in wrong language when loaded with locale
test('Loading with locale shows correct blocks', async () => {
await loadUri(`${uri}?locale=de`);
await clickText('Fühlen'); // Sensing category in German
await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks to scroll
await clickText('Antwort'); // Find the "answer" block in German
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// test for #5445
test('Loading with locale shows correct translation for string length block parameter', async () => {
await loadUri(`${uri}?locale=ja`);
await clickText('演算'); // Operators category in Japanese
await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks to scroll
await clickText('の長さ', scope.blocksTab); // Click "length <apple>" block
await findByText('3', scope.reportedValue); // Tooltip with result
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// Regression test for ENA-142, monitor can lag behind language selection
test('Monitor labels update on locale change', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/monitor-variable.sb3'));
// Monitors are present
await findByText('username', scope.monitors);
await findByText('language', scope.monitors);
// Change locale to ja
await clickXpath(SETTINGS_MENU_XPATH);
await clickText('Language', scope.menuBar);
await clickText('日本語');
// Monitor labels updated
await findByText('ユーザー名', scope.monitors);
await findByText('言語', scope.monitors);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,172 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
describe('Menu bar settings', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('File->New should be enabled', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[span[text()="New"]] and not(@data-tip="tooltip")]');
});
test('File->Load should be enabled', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[text()="Load from your computer"] and not(@data-tip="tooltip")]');
});
test('File->Save should be enabled', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[span[text()="Save to your computer"]] and not(@data-tip="tooltip")]');
});
test('Share button should NOT be enabled', async () => {
await loadUri(uri);
await findByXpath('//div[span[div[span[text()="Share"]]] and @data-tip="tooltip"]');
});
test('Logo should be clickable', async () => {
await loadUri(uri);
await clickXpath('//img[@alt="Scratch"]');
const currentUrl = await driver.getCurrentUrl();
await expect(currentUrl).toEqual('https://scratch.mit.edu/');
});
test('(GH#4064) Project name should be editable', async () => {
await loadUri(uri);
const el = await findByXpath('//input[@value="Scratch Project"]');
await el.sendKeys(' - Personalized');
await clickText('Costumes'); // just to blur the input
await clickXpath('//input[@value="Scratch Project - Personalized"]');
});
test('User is not warned before uploading project file over a fresh project', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
// No replace alert since no changes were made
await findByText('project1-sprite');
});
test('User is warned before uploading project file over an edited project', async () => {
await loadUri(uri);
// Change the project by deleting a sprite
await rightClickText('Sprite1', scope.spriteTile);
await clickText('delete', scope.spriteTile);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
await driver.switchTo().alert()
.accept();
await findByText('project1-sprite');
});
test('Theme picker shows themes', async () => {
await loadUri(uri);
await clickXpath(SETTINGS_MENU_XPATH);
await clickText('Color Mode', scope.menuBar);
expect(await (await findByText('Original', scope.menuBar)).isDisplayed()).toBe(true);
expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(true);
});
test('Theme picker switches to high contrast', async () => {
await loadUri(uri);
await clickXpath(SETTINGS_MENU_XPATH);
await clickText('Color Mode', scope.menuBar);
await clickText('High Contrast', scope.menuBar);
// There is a tiny delay for the color theme to be applied to the categories.
await driver.wait(async () => {
const motionCategoryDiv = await findByXpath(
'//div[contains(@class, "scratchCategoryMenuItem") and ' +
'contains(@class, "scratchCategoryId-motion")]/*[1]');
const color = await motionCategoryDiv.getCssValue('background-color');
// Documentation for getCssValue says it depends on how the browser
// returns the value. Locally I am seeing 'rgba(128, 181, 255, 1)',
// but this is a bit flexible just in case.
return /128,\s?181,\s?255/.test(color) || color.includes('80B5FF');
}, 5000, 'Motion category color does not match high contrast theme');
});
test('Settings menu switches between submenus', async () => {
await loadUri(uri);
await clickXpath(SETTINGS_MENU_XPATH);
// Language and theme options not visible yet
expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(false);
expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(false);
await clickText('Color Mode', scope.menuBar);
// Only theme options visible
expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(true);
expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(false);
await clickText('Language', scope.menuBar);
// Only language options visible
expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(false);
expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(true);
});
test('Menu labels hidden when width is equal to 1024', async () => {
await loadUri(uri);
await driver.manage()
.window()
.setSize(1024, 768);
const collapsibleMenus = ['Settings', 'File', 'Edit', 'Tutorials'];
for (const menu of collapsibleMenus) {
const settingsMenu = await findByText(menu, scope.menuBar);
expect(await settingsMenu.isDisplayed()).toBe(false);
}
});
test('Menu labels shown when width is greater than 1024', async () => {
await loadUri(uri);
await driver.manage()
.window()
.setSize(1200, 768);
const collapsibleMenus = ['Settings', 'File', 'Edit', 'Tutorials'];
for (const menu of collapsibleMenus) {
const settingsMenu = await findByText(menu, scope.menuBar);
expect(await settingsMenu.isDisplayed()).toBe(true);
}
});
});

View File

@@ -0,0 +1,117 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
describe('Loading scratch gui', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
describe('Loading projects by ID', () => {
test('Nonexistent projects show error screen', async () => {
await loadUri(`${uri}#999999999999999999999`);
await clickText('Oops! Something went wrong.');
});
// skipping because it relies on network speed, and tests a method
// of loading projects that we are not actively using anymore
test.skip('Load a project by ID directly through url', async () => {
await driver.quit(); // Reset driver to test hitting # url directly
driver = getDriver();
const projectId = '96708228';
await loadUri(`${uri}#${projectId}`);
await clickXpath('//img[@title="Go"]');
await new Promise(resolve => setTimeout(resolve, 2000));
await clickXpath('//img[@title="Stop"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// skipping because it relies on network speed, and tests a method
// of loading projects that we are not actively using anymore
test.skip('Load a project by ID (fullscreen)', async () => {
await driver.quit(); // Reset driver to test hitting # url directly
driver = getDriver();
const prevSize = driver.manage()
.window()
.getSize();
await new Promise(resolve => setTimeout(resolve, 2000));
driver.manage()
.window()
.setSize(1920, 1080);
const projectId = '96708228';
await loadUri(`${uri}#${projectId}`);
await clickXpath('//img[@title="Full Screen Control"]');
await new Promise(resolve => setTimeout(resolve, 500));
await clickXpath('//img[@title="Go"]');
await new Promise(resolve => setTimeout(resolve, 1000));
await clickXpath('//img[@title="Stop"]');
prevSize.then(value => {
driver.manage()
.window()
.setSize(value.width, value.height);
});
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// skipping because this test fails frequently on CI; might need "wait(until.elementLocated" or similar
// error message is "stale element reference: element is not attached to the page document"
test.skip('Creating new project resets active tab to Code tab', async () => {
await loadUri(uri);
await findByXpath('//*[span[text()="Costumes"]]');
await clickText('Costumes');
await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
await findByXpath('//div[@class="scratchCategoryMenu"]');
await clickText('Operators', scope.blocksTab);
});
test('Not logged in->made no changes to project->create new project should not show alert', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
await findByXpath('//*[div[@class="scratchCategoryMenu"]]');
await clickText('Operators', scope.blocksTab);
});
test.skip('Not logged in->made a change to project->create new project should show alert', async () => {
await loadUri(uri);
await clickText('Sounds');
await clickXpath('//button[@aria-label="Choose a Sound"]');
await clickText('A Bass', scope.modal); // Should close the modal
await findByText('1.28'); // length of A Bass sound
await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
driver.switchTo()
.alert()
.accept();
await findByXpath('//*[div[@class="scratchCategoryMenu"]]');
await clickText('Operators', scope.blocksTab);
});
});
});

View File

@@ -0,0 +1,45 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByXpath,
getDriver,
Key,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
describe('Project state', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('File->New resets project title', async () => {
const defaultProjectTitle = 'Scratch Project';
await loadUri(uri);
const inputEl = await findByXpath(`//input[@value="${defaultProjectTitle}"]`);
for (let i = 0; i < defaultProjectTitle.length; i++) {
inputEl.sendKeys(Key.BACK_SPACE);
}
inputEl.sendKeys('Changed title of project');
await clickText('Costumes'); // just to blur the input
// verify that project title has changed
await clickXpath('//input[@value="Changed title of project"]');
await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
// project title should be default again
await clickXpath(`//input[@value="${defaultProjectTitle}"]`);
});
});

View File

@@ -0,0 +1,114 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
'[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
describe('Loading scratch gui', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Loading project file from computer succeeds, without opening failure alert', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
await findByText('project1-sprite');
// this test will fail if an alert appears, e.g. in SBFileUploaderHOC's onload() function
});
test('Loading project file from computer gives project the filename from file', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
await findByText('project1-sprite');
await clickXpath('//input[@value="project1"]');
});
test('Load sb3 project with a missing svg costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-sprite-svg.sb3'));
const spriteTile = await findByText('Blue Square Guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
test('Load sb3 project with an invalid svg costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-svg.sb3'));
const spriteTile = await findByText('Blue Square Guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
test('Load sb2 project with a missing svg costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-svg.sb2'));
const spriteTile = await findByText('Blue Guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
test('Load sb2 project with an invalid svg costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-svg.sb2'));
const spriteTile = await findByText('Blue Guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
test('Load sb3 project with a missing bmp costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-bmp.sb3'));
const spriteTile = await findByText('green-bmp-guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
test('Load sb3 project with an invalid bmp costume', async () => {
await loadUri(uri);
await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-bmp.sb3'));
const spriteTile = await findByText('green-bmp-guy');
const tileVisible = await spriteTile.isDisplayed();
expect(tileVisible).toBe(true);
});
});

View File

@@ -0,0 +1,189 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
import {Key} from 'selenium-webdriver';
const {
clickText,
clickXpath,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with sounds', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Adding a sound through the library', async () => {
await loadUri(uri);
await clickText('Sounds');
// Delete the sound
await rightClickText('Meow', scope.soundsTab);
await driver.sleep(500); // Wait a moment for context menu; only needed for local testing
await clickText('delete', scope.soundsTab);
// Add it back
await clickXpath('//button[@aria-label="Choose a Sound"]');
let el = await findByXpath("//input[@placeholder='Search']");
await el.sendKeys('meow');
await clickText('Meow', scope.modal); // Should close the modal
// Add a new sound
await clickXpath('//button[@aria-label="Choose a Sound"]');
el = await findByXpath("//input[@placeholder='Search']");
await el.sendKeys('chom');
await clickText('Chomp'); // Should close the modal, then click the sounds in the selector
await findByXpath("//input[@value='Chomp']"); // Should show editor for new sound
await clickXpath('//button[@title="Play"]');
await clickText('Louder');
await clickText('Softer');
await clickText('Faster');
await clickText('Slower');
await clickText('Robot');
await clickText('Reverse');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a sound by surprise button', async () => {
await loadUri(uri);
await clickText('Sounds');
const el = await findByXpath('//button[@aria-label="Choose a Sound"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Surprise"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Duplicating a sound', async () => {
await loadUri(uri);
await clickText('Sounds');
await rightClickText('Meow', scope.soundsTab);
await clickText('duplicate', scope.soundsTab);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error
// Make sure the duplicated sound is named correctly.
await clickText('Meow2', scope.soundsTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// Regression test for gui issue #1320
test('Switching sprites with different numbers of sounds', async () => {
await loadUri(uri);
// Add a sound so this sprite has 2 sounds.
await clickText('Sounds');
await clickXpath('//button[@aria-label="Choose a Sound"]');
await clickText('A Bass'); // Closes the modal
// Now add a sprite with only one sound.
await clickXpath('//button[@aria-label="Choose a Sprite"]');
await clickText('Abby'); // Doing this used to crash the editor.
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error
// Make sure the 'Oops' screen is not visible
const content = await driver.getPageSource();
expect(content.indexOf('Oops')).toEqual(-1);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding multiple sounds at the same time', async () => {
const files = [
path.resolve(__dirname, '../fixtures/movie.wav'),
path.resolve(__dirname, '../fixtures/sneaker.wav')
];
await loadUri(uri);
await clickText('Sounds');
const el = await findByXpath('//button[@aria-label="Choose a Sound"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(files.join('\n'));
await findByText('movie', scope.soundsTab);
await findByText('sneaker', scope.soundsTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Copy to new button adds a new sound', async () => {
await loadUri(uri);
await clickText('Sounds');
await clickText('Copy to New', scope.soundsTab);
await clickText('Meow2', scope.soundsTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Copy and pasting within a sound changes its duration', async () => {
await loadUri(uri);
await clickText('Sounds');
await findByText('0.85', scope.soundsTab); // Original meow sound duration
await clickText('Copy', scope.soundsTab);
await clickText('Paste', scope.soundsTab);
await findByText('1.70', scope.soundsTab); // Sound has doubled in duration
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Can copy a sound from a sprite and paste into a sound on the stage', async () => {
await loadUri(uri);
await clickText('Sounds');
await clickText('Copy', scope.soundsTab); // Copy the meow sound
await clickXpath('//span[text()="Stage"]');
await findByText('0.02', scope.soundsTab); // Original pop sound duration
await clickText('Paste', scope.soundsTab);
await findByText('0.87', scope.soundsTab); // Duration of pop + meow sound
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Keyboard shortcuts', async () => {
const cmdCtrl = process.platform.includes('darwin') ? Key.COMMAND : Key.CONTROL;
await loadUri(uri);
await clickText('Sounds');
const el = await findByXpath('//button[@aria-label="Choose a Sound"]');
await el.sendKeys(Key.chord(cmdCtrl, 'a')); // Select all
await findByText('0.85', scope.soundsTab); // Meow sound duration
await el.sendKeys(Key.DELETE);
await findByText('0.00', scope.soundsTab); // Sound is now empty
await el.sendKeys(Key.chord(cmdCtrl, 'z')); // undo
await findByText('0.85', scope.soundsTab); // Meow sound is back
await el.sendKeys(Key.chord(cmdCtrl, Key.SHIFT, 'z')); // redo
await findByText('0.00', scope.soundsTab); // Sound is empty again
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,299 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
import {StaleElementReferenceError} from 'selenium-webdriver/lib/error';
import until from 'selenium-webdriver/lib/until';
const {
clickText,
clickXpath,
elementIsVisible,
findByText,
findByXpath,
getDriver,
getLogs,
loadUri,
rightClickText,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Working with sprites', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Adding a sprite through the library', async () => {
await loadUri(uri);
await clickText('Costumes');
await clickXpath('//button[@aria-label="Choose a Sprite"]');
await clickText('Apple', scope.modal); // Closes modal
await rightClickText('Apple', scope.spriteTile); // Make sure it is there
await clickText('Motion'); // Make sure we are back to the code tab
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a sprite by surprise button', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Surprise"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a sprite by paint button', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
await clickXpath('//button[@aria-label="Paint"]');
await findByText('Convert to Bitmap'); // Make sure we are on the paint editor
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Deleting only sprite does not crash', async () => {
await loadUri(uri);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
await rightClickText('Sprite1', scope.spriteTile);
await clickText('delete', scope.spriteTile);
// Confirm that the stage has been switched to
await findByText('Stage selected: no motion blocks');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Deleting by x button on sprite tile', async () => {
await loadUri(uri);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
await clickXpath('//*[@aria-label="Delete"]'); // Only visible close button is on the sprite
// Confirm that the stage has been switched to
await findByText('Stage selected: no motion blocks');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a sprite by uploading a png', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/gh-3582-png.png'));
await clickText('gh-3582-png', scope.spriteTile);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
// This test fails because uploading an SVG as a sprite changes the scaling
// Enable when this is fixed issues/3608
test('Adding a sprite by uploading an svg (gh-3608)', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/100-100.svg'));
await clickText('100-100', scope.spriteTile); // Sprite is named for costume filename
// Check to make sure the size is right
await clickText('Costumes');
await clickText('100-100', scope.costumesTab); // The name of the costume
await clickText('100 x 100', scope.costumesTab); // The size of the costume
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a sprite by uploading a gif', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/paddleball.gif'));
await clickText('paddleball', scope.spriteTile); // Sprite is named for costume filename
await clickText('Costumes');
await findByText('paddleball', scope.costumesTab);
await findByText('paddleball2', scope.costumesTab);
await findByText('paddleball3', scope.costumesTab);
await findByText('paddleball4', scope.costumesTab);
await findByText('paddleball5', scope.costumesTab);
await findByText('paddleball6', scope.costumesTab);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding a letter sprite through the Letters filter in the library', async () => {
await loadUri(uri);
await driver.manage()
.window()
.setSize(1244, 768); // Letters filter not visible at 1024 width
await clickText('Costumes');
await clickXpath('//button[@aria-label="Choose a Sprite"]');
await clickText('Letters');
await clickText('Block-B', scope.modal); // Closes modal
await rightClickText('Block-B', scope.spriteTile); // Make sure it is there
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Use browser back button to close library', async () => {
await loadUri(uri);
await clickText('Costumes');
await clickXpath('//button[@aria-label="Choose a Sprite"]');
const abbyElement = await findByText('Abby'); // Should show editor for new costume
await elementIsVisible(abbyElement);
await driver.navigate().back();
// should throw error because library is no longer present
await expect(driver.wait(until.elementIsVisible(abbyElement)))
.rejects
.toBeInstanceOf(StaleElementReferenceError);
const costumesElement = await findByText('Costumes'); // Should show editor for new costume
await elementIsVisible(costumesElement);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Adding multiple sprites at the same time', async () => {
const files = [
path.resolve(__dirname, '../fixtures/gh-3582-png.png'),
path.resolve(__dirname, '../fixtures/100-100.svg')
];
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(files.join('\n'));
await findByText('gh-3582-png', scope.spriteTile);
await findByText('100-100', scope.spriteTile);
const logs = await getLogs();
await expect(logs).toEqual([]);
});
test('Load a sprite3 with a missing svg costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-svg.sprite3'));
const tile = await findByText('Blue Square Guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a sprite3 with a currupt svg costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-svg.sprite3'));
const tile = await findByText('Blue Square Guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a scratch3 corrupt svg as a sprite', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-from-scratch3.svg'));
const tile = await findByText('corrupt-from-scratch3', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a sprite2 with a missing svg costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-svg.sprite2'));
const tile = await findByText('Blue Guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a sprite2 with a currupt svg costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupted-svg.sprite2'));
const tile = await findByText('Blue Guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a corrupt scratch2 svg as a sprite', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/scratch2-corrupted.svg'));
const tile = await findByText('scratch2-corrupted', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a sprite3 with a missing bmp costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-bmp.sprite3'));
const tile = await findByText('green-bmp-guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
test('Load a sprite3 with a currupt bmp costume', async () => {
await loadUri(uri);
const el = await findByXpath('//button[@aria-label="Choose a Sprite"]');
await driver.actions().mouseMove(el)
.perform();
await driver.sleep(500); // Wait for thermometer menu to come up
const input = await findByXpath('//input[@type="file"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-bmp.sprite3'));
const tile = await findByText('green-bmp-guy', scope.spriteTile);
const tileVisible = await tile.isDisplayed();
await expect(tileVisible).toBe(true);
});
// TODO: uploading a corrupt bmp as a sprite should throw an error and not add a gray question mark
});

View File

@@ -0,0 +1,46 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
clickXpath,
rightClickText,
getDriver,
getLogs,
loadUri,
scope
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
describe('Loading scratch gui', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('Switching small/large stage after highlighting and deleting sprite', async () => {
await loadUri(uri);
// Highlight the sprite
await clickText('Sprite1', scope.spriteTile);
// Delete it
await rightClickText('Sprite1', scope.spriteTile);
await clickText('delete', scope.spriteTile);
// Go to small stage mode
await clickXpath('//button[@title="Switch to small stage"]');
// Confirm app still working
await clickXpath('//button[@title="Switch to large stage"]');
const logs = await getLogs();
await expect(logs).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import path from 'path';
import SeleniumHelper from '../helpers/selenium-helper';
const {
clickText,
findByXpath,
getDriver,
loadUri
} = new SeleniumHelper();
const uri = path.resolve(__dirname, '../../build/index.html?tutorial=all');
const uriPrefix = path.resolve(__dirname, '../../build/index.html?tutorial=');
let driver;
describe('Working with shortcut to Tutorials library', () => {
beforeAll(() => {
driver = getDriver();
});
afterAll(async () => {
await driver.quit();
});
test('opens with the Tutorial Library showing', async () => {
await loadUri(uri);
// make sure there is a tutorial visible that doesn't have a shortcut
await clickText('Make It Spin');
await findByXpath('//div[contains(@class, "step-video")]');
});
test('can open hidden tutorials', async () => {
await loadUri(`${uriPrefix}whatsnew`);
// should open the tutorial video immediately
await findByXpath('//div[contains(@class, "step-video")]');
});
// @todo navigating cards, etc.
});

View File

@@ -0,0 +1,74 @@
import SeleniumHelper from '../helpers/selenium-helper';
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY, SMOKE_URL} = process.env;
const {
getSauceDriver,
findByText
} = new SeleniumHelper();
// Make the default timeout longer, Sauce tests take ~30s
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 1000; // eslint-disable-line
const SUPPORTED_MESSAGE = 'Welcome to the Scratch 3.0 Beta';
const UNSUPPORTED_MESSAGE = 'Scratch 3.0 does not support Internet Explorer';
// Driver configs can be generated with the Sauce Platform Configurator
// https://wiki.saucelabs.com/display/DOCS/Platform+Configurator
describe('Smoke tests on older browsers', () => {
let driver;
afterEach(async () => {
if (driver) await driver.quit();
});
test('Credentials should be provided', () => {
expect(SAUCE_USERNAME && SAUCE_ACCESS_KEY && SMOKE_URL).toBeTruthy();
});
test('IE 11 should be unsupported', async () => {
const driverConfig = {
browserName: 'internet explorer',
platform: 'Windows 10',
version: '11.103'
};
driver = await getSauceDriver(
process.env.SAUCE_USERNAME,
process.env.SAUCE_ACCESS_KEY,
driverConfig);
await driver.get(process.env.SMOKE_URL);
const el = await findByText(UNSUPPORTED_MESSAGE);
const isDisplayed = await el.isDisplayed();
return expect(isDisplayed).toEqual(true);
});
test('Safari 9 should be supported', async () => {
const driverConfig = {
browserName: 'safari',
platform: 'OS X 10.11',
version: '9.0'
};
driver = await getSauceDriver(
process.env.SAUCE_USERNAME,
process.env.SAUCE_ACCESS_KEY,
driverConfig);
await driver.get(process.env.SMOKE_URL);
const el = await findByText(SUPPORTED_MESSAGE);
const isDisplayed = await el.isDisplayed();
return expect(isDisplayed).toEqual(true);
});
test('Safari 10 should be supported', async () => {
const driverConfig = {
browserName: 'safari',
platform: 'OS X 10.11',
version: '10.0'
};
driver = await getSauceDriver(
process.env.SAUCE_USERNAME,
process.env.SAUCE_ACCESS_KEY,
driverConfig);
await driver.get(process.env.SMOKE_URL);
const el = await findByText(SUPPORTED_MESSAGE);
const isDisplayed = await el.isDisplayed();
return expect(isDisplayed).toEqual(true);
});
});

View File

@@ -0,0 +1,705 @@
import SettingStore from '../../../src/addons/settings-store';
import upstreamMeta from '../../../src/addons/generated/upstream-meta.json';
class LocalStorageShim {
constructor () {
this.storage = Object.create(null);
}
getItem (key) {
return this.storage[key];
}
setItem (key, value) {
this.storage[key] = value.toString();
}
}
beforeEach(() => {
global.localStorage = new LocalStorageShim();
});
const lightTheme = {
isDark: () => false
};
const darkTheme = {
isDark: () => true
};
test('enabled, event', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(store.getAddonEnabled('editor-devtools')).toBe(true);
expect('enabled' in store.store['editor-devtools']).toBe(false);
store.setAddonEnabled('editor-devtools', false);
expect(store.getAddonEnabled('editor-devtools')).toBe(false);
expect('enabled' in store.store['editor-devtools']).toBe(true);
store.setAddonEnabled('editor-devtools', true);
store.setAddonEnabled('cat-blocks', true);
expect('enabled' in store.store['cat-blocks']).toBe(true);
store.setAddonEnabled('cat-blocks', null);
expect('enabled' in store.store['cat-blocks']).toBe(false);
expect(fn).toHaveBeenCalledTimes(4);
expect(fn.mock.calls[0][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[1][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[1][0].detail.value).toBe(true);
expect(fn.mock.calls[2][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[2][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[2][0].detail.value).toBe(true);
expect(fn.mock.calls[3][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[3][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[3][0].detail.value).toBe(false);
});
test('settings, event, default values', () => {
const store = new SettingStore();
const fn = jest.fn();
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect('default' in store.store['onion-skinning']).toBe(false);
store.addEventListener('setting-changed', fn);
store.setAddonSetting('onion-skinning', 'default', true);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(true);
expect('default' in store.store['onion-skinning']).toBe(true);
store.setAddonSetting('onion-skinning', 'default', null);
expect('default' in store.store['onion-skinning']).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('default');
expect(fn.mock.calls[0][0].detail.value).toBe(true);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('default');
expect(fn.mock.calls[1][0].detail.value).toBe(false);
});
test('no actual change emits no event', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
for (let i = 0; i < 5; i++) store.setAddonEnabled('cat-blocks', true);
expect(fn).toHaveBeenCalledTimes(1);
for (let i = 0; i < 5; i++) store.setAddonEnabled('cat-blocks', false);
expect(fn).toHaveBeenCalledTimes(2);
for (let i = 0; i < 5; i++) store.setAddonSetting('onion-skinning', 'default', true);
expect(fn).toHaveBeenCalledTimes(3);
for (let i = 0; i < 5; i++) store.setAddonSetting('onion-skinning', 'default', false);
expect(fn).toHaveBeenCalledTimes(4);
});
test('changing enabled throws on unknown addons', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonEnabled('egriohergoijergijregojiergdfoijre', true)).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing settings throws on unknown settings', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonSetting('onion-skinning', 'sdlkjfslkdjfljksd', true)).toThrow();
expect(() => store.setAddonSetting('ergfoijgi', 'sdflkjsfdlkj', true)).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing enabled throws on invalid values', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonEnabled('cat-blocks', 'sdfjlksdflk')).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', 0)).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', [])).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', {})).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing settings checks value validity and throws', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
// boolean
expect(() => store.setAddonSetting('onion-skinning', 'default', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', 1)).toThrow();
// integer
expect(() => store.setAddonSetting('onion-skinning', 'next', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', '3')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', false)).toThrow();
// select
expect(() => store.setAddonSetting('onion-skinning', 'mode', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', false)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 1)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 'tint')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 'merge')).not.toThrow();
// color
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '#abcdef')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '#abcDE1')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', false)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', 1)).toThrow();
expect(fn).toHaveBeenCalledTimes(4);
});
test('colors with alpha channel', () => {
const store = new SettingStore();
store.setAddonSetting('onion-skinning', 'beforeTint', '#123456');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#123456');
store.setAddonSetting('onion-skinning', 'beforeTint', '#234567ff');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#234567');
store.setAddonSetting('onion-skinning', 'beforeTint', '#abc67800');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#abc678');
store.import({
addons: {
'onion-skinning': {
settings: {
beforeTint: '#56789aff'
}
}
}
});
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#56789a');
});
test('reset does not change enabled', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.resetAddon('cat-blocks');
expect(store.getAddonEnabled('cat-blocks')).toBe(true);
});
test('reset settings, event', () => {
const store = new SettingStore();
store.setAddonSetting('onion-skinning', 'default', true);
store.setAddonSetting('onion-skinning', 'next', 3);
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.resetAddon('onion-skinning');
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'next')).toBe(0);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('default');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('next');
expect(fn.mock.calls[1][0].detail.value).toBe(0);
});
test('reset all addons', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.setAddonSetting('onion-skinning', 'default', true);
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.resetAllAddons();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('default');
expect(fn.mock.calls[1][0].detail.value).toBe(false);
});
test('apply preset', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.setAddonSetting('editor-theme3', 'motion-color', '#abcdef');
store.applyAddonPreset('editor-theme3', 'original');
expect(fn.mock.calls.length).toBeGreaterThan(5);
expect(store.getAddonSetting('editor-theme3', 'motion-color')).toBe('#4a6cd4');
// TODO: test that settings not specified in the preset don't change
});
test('unknown preset throws', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.applyAddonPreset('alksdfjlksdf', 'jksdflkjsdf')).toThrow();
expect(() => store.applyAddonPreset('editor-theme3', 'jksdflkjsdf')).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('export core', () => {
const store = new SettingStore();
const exported = store.export({theme: lightTheme});
expect(exported.core.version).toMatch(/tw/);
expect(exported.core.lightTheme).toBe(true);
const dark = store.export({theme: darkTheme});
expect(dark.core.lightTheme).toBe(false);
});
test('export settings', () => {
const store = new SettingStore();
let exported = store.export({theme: lightTheme});
expect(exported.addons['remove-sprite-confirm'].enabled).toBe(false);
expect(exported.addons['remove-sprite-confirm'].settings).toEqual({});
expect(exported.addons['onion-skinning'].enabled).toBe(true);
expect(exported.addons['onion-skinning'].settings.default).toEqual(false);
store.setAddonEnabled('remove-sprite-confirm', true);
store.setAddonSetting('onion-skinning', 'default', true);
exported = store.export({theme: lightTheme});
expect(exported.addons['remove-sprite-confirm'].enabled).toBe(true);
expect(exported.addons['remove-sprite-confirm'].settings).toEqual({});
expect(exported.addons['onion-skinning'].enabled).toBe(true);
expect(exported.addons['onion-skinning'].settings.default).toEqual(true);
});
test('export theme', () => {
const store = new SettingStore();
const exported = store.export({theme: lightTheme});
expect(exported.core.lightTheme).toBe(true);
const exported2 = store.export({theme: darkTheme});
expect(exported2.core.lightTheme).toBe(false);
});
test('import, event', () => {
const store = new SettingStore();
store.setAddonEnabled('onion-skinning', false);
store.setAddonSetting('onion-skinning', 'next', 5);
const newStore = new SettingStore();
newStore.setAddonSetting('onion-skinning', 'next', 10);
const fn = jest.fn();
newStore.addEventListener('setting-changed', fn);
newStore.import(store.export({theme: lightTheme}));
expect(newStore.getAddonEnabled('onion-skinning')).toBe(false);
expect(newStore.getAddonSetting('onion-skinning', 'next')).toBe(5);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('next');
expect(fn.mock.calls[1][0].detail.value).toBe(5);
});
test('export is identical after import', () => {
const store = new SettingStore();
const fn = jest.fn();
const exported = store.export({theme: lightTheme});
store.import(exported);
expect(fn).toHaveBeenCalledTimes(0);
expect(store.export({theme: lightTheme})).toEqual(exported);
});
test('import format', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.import({
core: {
version: 'lksd',
lightTheme: false
},
addons: {
'onion-skinning': {
enabled: false,
settings: {
next: 7
}
}
}
});
expect(store.getAddonEnabled('onion-skinning')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'next')).toBe(7);
expect(store.getAddonEnabled('cat-blocks')).toBe(true);
});
test('invalid imports', () => {
const store = new SettingStore();
expect(() => store.import({
addons: {}
})).not.toThrow();
expect(() => store.import({
addons: {
'onion-skinning': {
enabled: false,
settings: {
dsjfokosdfj: 5
}
}
}
})).not.toThrow();
expect(() => store.import({
addons: {
grfdjiklk: {
enabled: true,
settings: {}
}
}
})).not.toThrow();
expect(() => store.import({
addons: {
'onion-skinning': {
enabled: '4',
settings: {
default: '3'
}
}
}
})).not.toThrow();
expect(store.getAddonEnabled('onion-skinning')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
});
test('local storage', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.setAddonSetting('onion-skinning', 'default', true);
const newStore = new SettingStore();
newStore.readLocalStorage();
expect(newStore.store).toEqual(store.store);
});
test('local storage is resistent to errors', () => {
global.localStorage = new LocalStorageShim();
const store = new SettingStore();
localStorage.getItem = () => {
throw new Error(':(');
};
store.readLocalStorage();
localStorage.getItem = () => 'eoiru4jtg)(R(';
store.readLocalStorage();
localStorage.setItem = () => {
throw new Error(':(');
};
store.setAddonEnabled('cat-blocks', true);
// eslint-disable-next-line no-undefined
global.localStorage = undefined;
store.readLocalStorage();
store.setAddonEnabled('cat-blocks', false);
});
test('setStore diffing', () => {
const settingsStore = new SettingStore();
const pageStore = new SettingStore();
settingsStore.setAddonEnabled('editor-devtools', false);
pageStore.setAddonEnabled('editor-devtools', false);
const fn = jest.fn();
pageStore.addEventListener('addon-changed', fn);
pageStore.setStore(settingsStore.store);
expect(fn).toHaveBeenCalledTimes(0);
settingsStore.setAddonEnabled('editor-devtools', true);
settingsStore.setAddonSetting('onion-skinning', 'next', 10);
pageStore.setStore(settingsStore.store);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
});
test('setStore dynamic enable/disable', () => {
const settingsStore = new SettingStore();
const pageStore = new SettingStore();
settingsStore.setAddonEnabled('block-palette-icons', false);
pageStore.setStore(settingsStore.store);
const fn = jest.fn();
pageStore.addEventListener('addon-changed', fn);
settingsStore.setAddonEnabled('block-palette-icons', true);
pageStore.setStore(settingsStore.store);
expect(fn.mock.calls[0][0].detail.addonId).toBe('block-palette-icons');
expect(fn.mock.calls[0][0].detail.dynamicEnable).toBe(true);
expect(fn.mock.calls[0][0].detail.dynamicDisable).toBe(false);
settingsStore.setAddonEnabled('block-palette-icons', false);
pageStore.setStore(settingsStore.store);
expect(fn.mock.calls[1][0].detail.addonId).toBe('block-palette-icons');
expect(fn.mock.calls[1][0].detail.dynamicEnable).toBe(false);
expect(fn.mock.calls[1][0].detail.dynamicDisable).toBe(true);
});
test('setStore weird values', () => {
const settingsStore = new SettingStore();
expect(settingsStore.getAddonEnabled('pause')).toBe(true);
settingsStore.setAddonEnabled('pause', false);
settingsStore.setAddonEnabled('clones', true);
settingsStore.setStore({
invalid0: {},
invalid1: null,
pause: null
});
expect(settingsStore.getAddonEnabled('pause')).toBe(false);
});
test('resetting an addon through setStore', () => {
const store = new SettingStore();
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(100);
store.setAddonSetting('custom-block-shape', 'paddingSize', 50);
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(50);
const store2 = new SettingStore();
store.setStore(store2.store);
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(100);
});
test('setStoreWithVersionCheck', () => {
const store = new SettingStore();
store.setStore = jest.fn();
store.setStoreWithVersionCheck({
store: '1234',
version: upstreamMeta.commit
});
expect(store.setStore).toHaveBeenCalledTimes(1);
expect(store.setStore).toHaveBeenCalledWith('1234');
store.setStore = jest.fn();
store.setStoreWithVersionCheck({
store: '1234',
version: 'something invalid'
});
expect(store.setStore).toHaveBeenCalledTimes(0);
});
test('parseUrlParameter', () => {
const store = new SettingStore();
expect(store.getAddonEnabled('pause')).toBe(true);
expect(store.getAddonEnabled('mute-project')).toBe(true);
expect(store.getAddonEnabled('remove-curved-stage-border')).toBe(false);
expect(store.remote).toBe(false);
store.parseUrlParameter('pause,remove-curved-stage-border,,invalid addon??43t987(*&$');
expect(store.getAddonEnabled('pause')).toBe(true);
expect(store.getAddonEnabled('mute-project')).toBe(false);
expect(store.getAddonEnabled('remove-curved-stage-border')).toBe(true);
expect(store.remote).toBe(true);
});
test('Settings migration 1 -> 2', () => {
const store = new SettingStore();
// eslint-disable-next-line max-len
global.localStorage.getItem = () => `{"_":1,"tw-project-info":{"enabled":false},"tw-interface-customization":{"enabled":false,"removeFeedback":true,"removeBackpack":true}}`;
store.readLocalStorage();
expect(store.getAddonEnabled('block-count')).toBe(true);
expect(store.getAddonEnabled('tw-remove-backpack')).toBe(true);
expect(store.getAddonEnabled('tw-remove-feedback')).toBe(true);
// eslint-disable-next-line max-len
global.localStorage.getItem = () => `{"_":1,"tw-project-info":{"enabled":true},"tw-interface-customization":{"enabled":true,"removeFeedback":true,"removeBackpack":true}}`;
store.readLocalStorage();
expect(store.getAddonEnabled('block-count')).toBe(true);
expect(store.getAddonEnabled('tw-remove-backpack')).toBe(true);
expect(store.getAddonEnabled('tw-remove-feedback')).toBe(true);
});
test('Settings migration 2 -> 3', () => {
const store = new SettingStore();
global.localStorage.getItem = () => JSON.stringify({
'_': 2,
'hide-flyout': {
enabled: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('hide-flyout', 'toggle')).toBe('hover');
});
test('Settings migration 3 -> 4', () => {
const store = new SettingStore();
global.localStorage.getItem = () => JSON.stringify({
_: 3
});
store.readLocalStorage();
expect(store.getAddonEnabled('editor-devtools')).toBe(true);
expect(store.getAddonEnabled('find-bar')).toBe(true);
expect(store.getAddonEnabled('middle-click-popup')).toBe(true);
global.localStorage.getItem = () => JSON.stringify({
'_': 3,
'editor-devtools': {
enabled: false
}
});
store.readLocalStorage();
expect(store.getAddonEnabled('editor-devtools')).toBe(false);
expect(store.getAddonEnabled('find-bar')).toBe(false);
expect(store.getAddonEnabled('middle-click-popup')).toBe(false);
});
test('if', () => {
const store = new SettingStore();
store.setAddonEnabled('editor-devtools', true);
store.setAddonEnabled('onion-skinning', false);
store.setAddonSetting('editor-theme3', 'motion-color', '#000000');
store.setAddonSetting('editor-theme3', 'looks-color', '#FFFFFF');
// eslint-disable-next-line no-undefined
expect(store.evaluateCondition('editor-theme3', undefined)).toBe(true);
expect(store.evaluateCondition('editor-theme3', null)).toBe(true);
expect(store.evaluateCondition('editor-theme3', {})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning']
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: 'onion-skinning'
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools']
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: 'editor-devtools'
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools', 'onion-skinning']
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000000'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': '#FFFFFF'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': '#FFFFFE'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000000',
'looks-color': '#FFFFFF'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000001',
'looks-color': '#FFFFFF'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFF']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE', '#FFFFFF']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFF', '#FFFFFE']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE']
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE', '#FFFFFD']
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': []
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools'],
settings: {
'motion-color': '#000000'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning'],
settings: {
'motion-color': '#000000'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools'],
settings: {
'motion-color': '#000001'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning'],
settings: {
'motion-color': '#000001'
}
})).toBe(false);
});
test('Settings migration 4 -> 5', () => {
const store = new SettingStore();
// implied default settings
global.localStorage.getItem = () => JSON.stringify({
_: 4
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// also implied default settings
global.localStorage.getItem = () => JSON.stringify({
_: 4,
fullscreen: {}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// explicit default settings
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: false
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// explicit hide, implied default hover setting
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hover');
// explicit hide and default hover
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true,
hoverToolbar: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hover');
// explicit hide, no hover
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true,
hoverToolbar: false
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hide');
});

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ButtonComponent matches snapshot 1`] = `
<span
className=""
onClick={[Function]}
role="button"
>
<div
className={undefined}
/>
</span>
`;

View File

@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IconButtonComponent matches snapshot 1`] = `
<div
className="custom-class-name"
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="imgSrc"
/>
<div
className={undefined}
>
<div>
Text
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,565 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sound Editor Component matches snapshot 1`] = `
<div
className={undefined}
onMouseDown={undefined}
>
<div
className={undefined}
>
<div
className={undefined}
>
<label
className={undefined}
>
<span
className={undefined}
>
Sound
</span>
<input
className=""
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
onSubmit={[Function]}
tabIndex="1"
type="text"
value="sound name"
/>
</label>
<div
className={undefined}
>
<button
className={undefined}
disabled={false}
onClick={[Function]}
title="Undo"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
</button>
<button
className={undefined}
disabled={true}
onClick={[Function]}
title="Redo"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
</button>
</div>
</div>
<div
className={undefined}
>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Copy
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Paste
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Copy to New
</div>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Delete
</div>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
<svg
className={undefined}
viewBox="0 0 600 160"
>
<g
transform="scale(1, -1) translate(0, -80)"
>
<path
className={undefined}
d="M0 0Q0 80 150 120 Q300 160 450 200 Q600 240 600 0 Q600 -240 450 -200 Q300 -160 150 -120 Q0 -80 0 0Z"
strokeLinejoin="round"
strokeWidth={1}
/>
</g>
</svg>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"left": "20%",
"width": "60.00000000000001%",
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
/>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
</div>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
</div>
</div>
<div
className={undefined}
>
<div
className=""
style={
Object {
"transform": "translateX(50%)",
}
}
/>
</div>
</div>
</div>
</div>
<div
className=""
>
<div
className=""
>
<button
className=""
onClick={[Function]}
title="Stop"
>
<img
draggable={false}
src="test-file-stub"
/>
</button>
</div>
<div
className={undefined}
>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Faster
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Slower
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Louder
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Softer
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Mute
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Fade in
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Fade out
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Reverse
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Robot
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Echo
</span>
</div>
</div>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
00:00.30 / 00:00.60
</div>
<div
className={undefined}
>
Hz
<span>
Mono
</span>
(10.51KB)
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,179 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SpriteSelectorItemComponent matches snapshot when given a number and details to show 1`] = `
<div
className="react-contextmenu-wrapper ponies undefined"
onClick={[Function]}
onContextMenu={[Function]}
onMouseDown={[Function]}
onMouseEnter={undefined}
onMouseLeave={undefined}
onMouseOut={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className={undefined}
>
5
</div>
<div
className={undefined}
>
<div
className={undefined}
>
<img
className={undefined}
draggable={false}
src="https://scratch.mit.edu/foo/bar/pony"
/>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
Pony sprite
</div>
<div
className={undefined}
>
480 x 360
</div>
</div>
<div
aria-label="Delete"
className=""
onClick={[Function]}
role="button"
tabIndex={0}
>
<div
className={undefined}
>
<img
className={undefined}
src="test-file-stub"
/>
</div>
</div>
<nav
className="react-contextmenu"
onContextMenu={[Function]}
onMouseLeave={[Function]}
role="menu"
style={
Object {
"opacity": 0,
"pointerEvents": "none",
"position": "fixed",
}
}
tabIndex="-1"
>
<div
aria-disabled="false"
aria-orientation={null}
className="react-contextmenu-item"
onClick={[Function]}
onMouseLeave={[Function]}
onMouseMove={[Function]}
onTouchEnd={[Function]}
role="menuitem"
tabIndex="-1"
>
<span>
delete
</span>
</div>
</nav>
</div>
`;
exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = `
<div
className="react-contextmenu-wrapper ponies undefined"
onClick={[Function]}
onContextMenu={[Function]}
onMouseDown={[Function]}
onMouseEnter={undefined}
onMouseLeave={undefined}
onMouseOut={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className={undefined}
>
<div
className={undefined}
>
<img
className={undefined}
draggable={false}
src="https://scratch.mit.edu/foo/bar/pony"
/>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
Pony sprite
</div>
</div>
<div
aria-label="Delete"
className=""
onClick={[Function]}
role="button"
tabIndex={0}
>
<div
className={undefined}
>
<img
className={undefined}
src="test-file-stub"
/>
</div>
</div>
<nav
className="react-contextmenu"
onContextMenu={[Function]}
onMouseLeave={[Function]}
role="menu"
style={
Object {
"opacity": 0,
"pointerEvents": "none",
"position": "fixed",
}
}
tabIndex="-1"
>
<div
aria-disabled="false"
aria-orientation={null}
className="react-contextmenu-item"
onClick={[Function]}
onMouseLeave={[Function]}
onMouseMove={[Function]}
onTouchEnd={[Function]}
role="menuitem"
tabIndex="-1"
>
<span>
delete
</span>
</div>
</nav>
</div>
`;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import {shallow} from 'enzyme';
import ButtonComponent from '../../../src/components/button/button';
import renderer from 'react-test-renderer';
describe('ButtonComponent', () => {
test('matches snapshot', () => {
const onClick = jest.fn();
const component = renderer.create(
<ButtonComponent onClick={onClick} />
);
expect(component.toJSON()).toMatchSnapshot();
});
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const componentShallowWrapper = shallow(
<ButtonComponent onClick={onClick} />
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,62 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
// Mock this utility because it uses dynamic imports that do not work with jest
jest.mock('../../../src/lib/libraries/decks/translate-image.js', () => {});
import Cards, {ImageStep, VideoStep} from '../../../src/components/cards/cards.jsx';
describe('Cards component', () => {
const defaultProps = () => ({
activeDeckId: 'id1',
content: {
id1: {
name: 'id1 - name',
img: 'id1 - img',
steps: [{video: 'videoUrl'}]
}
},
dragging: false,
expanded: true,
isRtl: false,
locale: 'en',
onActivateDeckFactory: jest.fn(),
onCloseCards: jest.fn(),
onDrag: jest.fn(),
onEndDrag: jest.fn(),
onNextStep: jest.fn(),
onPrevStep: jest.fn(),
onShowAll: jest.fn(),
onShrinkExpandCards: jest.fn(),
onStartDrag: jest.fn(),
showVideos: true,
step: 0,
x: 0,
y: 0
});
test('showVideos=true shows the video step', () => {
const component = mountWithIntl(
<Cards
{...defaultProps()}
showVideos
/>
);
expect(component.find(ImageStep).exists()).toEqual(false);
expect(component.find(VideoStep).exists()).toEqual(true);
});
test('showVideos=false shows the title image/name instead of video step', () => {
const component = mountWithIntl(
<Cards
{...defaultProps()}
showVideos={false}
/>
);
expect(component.find(VideoStep).exists()).toEqual(false);
const imageStep = component.find(ImageStep);
expect(imageStep.props().image).toEqual('id1 - img');
expect(imageStep.props().title).toEqual('id1 - name');
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import Controls from '../../../src/components/controls/controls';
import TurboMode from '../../../src/components/turbo-mode/turbo-mode';
import GreenFlag from '../../../src/components/green-flag/green-flag';
import StopAll from '../../../src/components/stop-all/stop-all';
describe('Controls component', () => {
const defaultProps = () => ({
active: false,
onGreenFlagClick: jest.fn(),
onStopAllClick: jest.fn(),
turbo: false
});
test('shows turbo mode when in turbo mode', () => {
const component = mountWithIntl(
<Controls
{...defaultProps()}
/>
);
expect(component.find(TurboMode).exists()).toEqual(false);
component.setProps({turbo: true});
expect(component.find(TurboMode).exists()).toEqual(true);
});
test('triggers the right callbacks when clicked', () => {
const props = defaultProps();
const component = mountWithIntl(
<Controls
{...props}
/>
);
component.find(GreenFlag).simulate('click');
expect(props.onGreenFlagClick).toHaveBeenCalled();
component.find(StopAll).simulate('click');
expect(props.onStopAllClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import {Provider} from 'react-redux';
const {mountWithIntl} = require('../../helpers/intl-helpers.jsx');
import configureStore from 'redux-mock-store';
import CrashMessageComponent from '../../../src/components/crash-message/crash-message.jsx';
import ErrorBoundary from '../../../src/containers/error-boundary.jsx';
const ChildComponent = () => <div>hello</div>;
describe('ErrorBoundary', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
locales: {
isRtl: false,
locale: 'en-US'
}
});
});
test('ErrorBoundary shows children before error and CrashMessageComponent after', () => {
const child = <ChildComponent />;
const wrapper = mountWithIntl(
<Provider store={store}><ErrorBoundary action="test">{child}</ErrorBoundary></Provider>
);
const errorSite = wrapper.childAt(0).childAt(0);
// @ts-ignore: 'onReload' prop is absent because this component will only be used for pattern matching
const crashMessagePattern = <CrashMessageComponent />;
expect(wrapper.containsMatchingElement(child)).toBeTruthy();
expect(wrapper.containsMatchingElement(crashMessagePattern)).toBeFalsy();
errorSite.simulateError(new Error('fake error for testing purposes'));
expect(wrapper.containsMatchingElement(child)).toBeFalsy();
expect(wrapper.containsMatchingElement(crashMessagePattern)).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import {shallow} from 'enzyme';
import IconButton from '../../../src/components/icon-button/icon-button';
import renderer from 'react-test-renderer';
describe('IconButtonComponent', () => {
test('matches snapshot', () => {
const onClick = jest.fn();
const title = <div>Text</div>;
const imgSrc = 'imgSrc';
const className = 'custom-class-name';
const component = renderer.create(
<IconButton
className={className}
img={imgSrc}
title={title}
onClick={onClick}
/>
);
expect(component.toJSON()).toMatchSnapshot();
});
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const title = <div>Text</div>;
const imgSrc = 'imgSrc';
const componentShallowWrapper = shallow(
<IconButton
img={imgSrc}
title={title}
onClick={onClick}
/>
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
describe('no-op', () => {
test('no-op', () => {});
});
// tw: these seem to be hopelessly broken to the increasing scope of changes we make to the menu bar, disable for now...
/*
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers';
import MenuBar from '../../../src/components/menu-bar/menu-bar';
import {menuInitialState} from '../../../src/reducers/menus';
import {LoadingState} from '../../../src/reducers/project-state';
import {DEFAULT_THEME} from '../../../src/lib/themes';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import VM from 'scratch-vm';
describe('MenuBar Component', () => {
const store = configureStore()({
locales: {
isRtl: false,
locale: 'en-US'
},
scratchGui: {
menus: menuInitialState,
projectState: {
loadingState: LoadingState.NOT_LOADED
},
theme: {
theme: DEFAULT_THEME
},
timeTravel: {
year: 'NOW'
},
vm: new VM()
}
});
const getComponent = function (props = {}) {
return <Provider store={store}><MenuBar {...props} /></Provider>;
};
test('menu bar with no About handler has no About button', () => {
const menuBar = mountWithIntl(getComponent());
const button = menuBar.find('AboutButton');
expect(button.exists()).toBe(false);
});
test('menu bar with an About handler has an About button', () => {
const onClickAbout = jest.fn();
const menuBar = mountWithIntl(getComponent({onClickAbout}));
const button = menuBar.find('AboutButton');
expect(button.exists()).toBe(true);
});
test('clicking on About button calls the handler', () => {
const onClickAbout = jest.fn();
const menuBar = mountWithIntl(getComponent({onClickAbout}));
const button = menuBar.find('AboutButton');
expect(onClickAbout).toHaveBeenCalledTimes(0);
button.simulate('click');
expect(onClickAbout).toHaveBeenCalledTimes(1);
});
});
*/

View File

@@ -0,0 +1,78 @@
import React from 'react';
import {OrderedMap} from 'immutable';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import MonitorList from '../../../src/components/monitor-list/monitor-list.jsx';
import {DEFAULT_THEME} from '../../../src/lib/themes';
describe('MonitorListComponent', () => {
const store = configureStore()({scratchGui: {
monitorLayout: {
monitors: {},
savedMonitorPositions: {}
},
theme: {
theme: DEFAULT_THEME
},
toolbox: {
toolboxXML: ''
},
vm: {
runtime: {
requestUpdateMonitor: () => {},
getLabelForOpcode: () => ''
}
}
}});
const draggable = false;
const onMonitorChange = jest.fn();
const stageSize = {
width: 100,
height: 100,
widthDefault: 100,
heightDefault: 100
};
let monitors = OrderedMap({});
// Wrap this in a function so it gets test specific states and can be reused.
const getComponent = function () {
return (
<Provider store={store}>
<MonitorList
draggable={draggable}
monitors={monitors}
stageSize={stageSize}
onMonitorChange={onMonitorChange}
/>
</Provider>
);
};
test('it renders the correct step size for discrete sliders', () => {
monitors = OrderedMap({
id1: {
visible: true,
mode: 'slider',
isDiscrete: true
}
});
const wrapper = mountWithIntl(getComponent());
const input = wrapper.find('input');
expect(input.props().step).toBe(1);
});
test('it renders the correct step size for non-discrete sliders', () => {
monitors = OrderedMap({
id1: {
visible: true,
mode: 'slider',
isDiscrete: false
}
});
const wrapper = mountWithIntl(getComponent());
const input = wrapper.find('input');
expect(input.props().step).toBe(0.01);
});
});

View File

@@ -0,0 +1,56 @@
import React from 'react';
import {shallow} from 'enzyme';
import DefaultMonitor from '../../../src/components/monitor/default-monitor';
import Monitor from '../../../src/components/monitor/monitor';
import {DARK_THEME, DEFAULT_THEME} from '../../../src/lib/themes';
jest.mock('../../../src/lib/themes/default');
jest.mock('../../../src/lib/themes/dark');
describe('Monitor Component', () => {
test('it selects the correct colors based on default theme', () => {
const noop = () => {};
const wrapper = shallow(<Monitor
category="motion"
// eslint-disable-next-line react/jsx-no-bind
componentRef={noop}
draggable={false}
label="My label"
mode="default"
// eslint-disable-next-line react/jsx-no-bind
onDragEnd={noop}
// eslint-disable-next-line react/jsx-no-bind
onNextMode={noop}
theme={DEFAULT_THEME}
/>);
const defaultMonitor = wrapper.find(DefaultMonitor);
// selects colors from mock value in src/lib/themes/__mocks__/default-colors.js
expect(defaultMonitor.props().categoryColor).toEqual({background: '#111111', text: '#444444'});
});
test('it selects the correct colors based on dark mode theme', () => {
const noop = () => {};
const wrapper = shallow(<Monitor
category="motion"
// eslint-disable-next-line react/jsx-no-bind
componentRef={noop}
draggable={false}
label="My label"
mode="default"
// eslint-disable-next-line react/jsx-no-bind
onDragEnd={noop}
// eslint-disable-next-line react/jsx-no-bind
onNextMode={noop}
theme={DARK_THEME}
/>);
const defaultMonitor = wrapper.find(DefaultMonitor);
// selects colors from mock value in src/lib/themes/__mocks__/dark-mode.js
expect(defaultMonitor.props().categoryColor).toEqual({background: '#AAAAAA', text: '#BBBBBB'});
});
});

View File

@@ -0,0 +1,146 @@
import React from 'react';
import {mountWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx';
import SoundEditor from '../../../src/components/sound-editor/sound-editor';
describe('Sound Editor Component', () => {
let props;
beforeEach(() => {
props = {
isStereo: false,
duration: 1,
size: 10507,
canUndo: true,
canRedo: false,
chunkLevels: [1, 2, 3],
name: 'sound name',
playhead: 0.5,
trimStart: 0.2,
trimEnd: 0.8,
onChangeName: jest.fn(),
onDelete: jest.fn(),
onPlay: jest.fn(),
onRedo: jest.fn(),
onReverse: jest.fn(),
onSofter: jest.fn(),
onLouder: jest.fn(),
onRobot: jest.fn(),
onEcho: jest.fn(),
onFaster: jest.fn(),
onSlower: jest.fn(),
onSetTrimEnd: jest.fn(),
onSetTrimStart: jest.fn(),
onStop: jest.fn(),
onUndo: jest.fn()
};
});
test('matches snapshot', () => {
const component = componentWithIntl(<SoundEditor {...props} />);
expect(component.toJSON()).toMatchSnapshot();
});
test('delete button appears when selection is not null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
trimEnd={0.75}
trimStart={0.25}
/>
);
wrapper.find('[children="Delete"]').simulate('click');
expect(props.onDelete).toHaveBeenCalled();
});
test('play button appears when playhead is null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
playhead={null}
/>
);
wrapper.find('button[title="Play"]').simulate('click');
expect(props.onPlay).toHaveBeenCalled();
});
test('stop button appears when playhead is not null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
playhead={0.5}
/>
);
wrapper.find('button[title="Stop"]').simulate('click');
expect(props.onStop).toHaveBeenCalled();
});
test('submitting name calls the callback', () => {
const wrapper = mountWithIntl(
<SoundEditor {...props} />
);
wrapper.find('input')
.simulate('change', {target: {value: 'hello'}})
.simulate('blur');
expect(props.onChangeName).toHaveBeenCalled();
});
test('effect buttons call the correct callbacks', () => {
const wrapper = mountWithIntl(
<SoundEditor {...props} />
);
wrapper.find('[children="Reverse"]').simulate('click');
expect(props.onReverse).toHaveBeenCalled();
wrapper.find('[children="Robot"]').simulate('click');
expect(props.onRobot).toHaveBeenCalled();
wrapper.find('[children="Faster"]').simulate('click');
expect(props.onFaster).toHaveBeenCalled();
wrapper.find('[children="Slower"]').simulate('click');
expect(props.onSlower).toHaveBeenCalled();
wrapper.find('[children="Louder"]').simulate('click');
expect(props.onLouder).toHaveBeenCalled();
wrapper.find('[children="Softer"]').simulate('click');
expect(props.onSofter).toHaveBeenCalled();
});
test('undo and redo buttons can be disabled by canUndo/canRedo', () => {
let wrapper = mountWithIntl(
<SoundEditor
{...props}
canUndo
canRedo={false}
/>
);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true);
wrapper = mountWithIntl(
<SoundEditor
{...props}
canRedo
canUndo={false}
/>
);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false);
});
test.skip('undo/redo buttons call the correct callback', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
canRedo
canUndo
/>
);
wrapper.find('button[title="Undo"]').simulate('click');
expect(props.onUndo).toHaveBeenCalled();
wrapper.find('button[title="Redo"]').simulate('click');
expect(props.onRedo).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import React from 'react';
import {mountWithIntl, shallowWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx';
import SpriteSelectorItemComponent from '../../../src/components/sprite-selector-item/sprite-selector-item';
import DeleteButton from '../../../src/components/delete-button/delete-button';
describe('SpriteSelectorItemComponent', () => {
let className;
let costumeURL;
let name;
let onClick;
let onDeleteButtonClick;
let selected;
let number;
let details;
// Wrap this in a function so it gets test specific states and can be reused.
const getComponent = function () {
return (
<SpriteSelectorItemComponent
className={className}
costumeURL={costumeURL}
details={details}
name={name}
number={number}
selected={selected}
onClick={onClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
);
};
beforeEach(() => {
className = 'ponies';
costumeURL = 'https://scratch.mit.edu/foo/bar/pony';
name = 'Pony sprite';
onClick = jest.fn();
onDeleteButtonClick = jest.fn();
selected = true;
// Reset to undefined since they are optional props
number = undefined; // eslint-disable-line no-undefined
details = undefined; // eslint-disable-line no-undefined
});
test('matches snapshot when selected', () => {
const component = componentWithIntl(getComponent());
expect(component.toJSON()).toMatchSnapshot();
});
test('matches snapshot when given a number and details to show', () => {
number = 5;
details = '480 x 360';
const component = componentWithIntl(getComponent());
expect(component.toJSON()).toMatchSnapshot();
});
test('does not have a close box when not selected', () => {
selected = false;
const wrapper = shallowWithIntl(getComponent());
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
test('triggers callback when Box component is clicked', () => {
// Use `mount` here because of the way ContextMenuTrigger consumes onClick
const wrapper = mountWithIntl(getComponent());
wrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
test('triggers callback when CloseButton component is clicked', () => {
const wrapper = shallowWithIntl(getComponent());
wrapper.find(DeleteButton).simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalled();
});
test('it has a context menu with delete menu item and callback', () => {
const wrapper = mountWithIntl(getComponent());
const contextMenu = wrapper.find('ContextMenu');
expect(contextMenu.exists()).toBe(true);
contextMenu.find('[children="delete"]').simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {shallow} from 'enzyme';
import ToggleButtons from '../../../src/components/toggle-buttons/toggle-buttons';
describe('ToggleButtons', () => {
test('renders multiple buttons', () => {
const component = shallow(<ToggleButtons
buttons={[
{
title: 'Button 1',
handleClick: () => {},
icon: 'Button 1 icon'
},
{
title: 'Button 2',
handleClick: () => {},
icon: 'Button 2 icon'
}
]}
/>);
const buttons = component.find('button');
expect(buttons).toHaveLength(2);
expect(buttons.get(0).props.title).toBe('Button 1');
expect(buttons.get(1).props.title).toBe('Button 2');
});
test('calls correct click handler', () => {
const onClick1 = jest.fn();
const onClick2 = jest.fn();
const component = shallow(<ToggleButtons
buttons={[
{
title: 'Button 1',
handleClick: onClick1,
icon: 'Button 1 icon'
},
{
title: 'Button 2',
handleClick: onClick2,
icon: 'Button 2 icon'
}
]}
/>);
const button2 = component.find('button[title="Button 2"]');
button2.simulate('click');
expect(onClick2).toHaveBeenCalled();
expect(onClick1).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,170 @@
describe('no-op', () => {
test('no-op', () => {});
});
// tw: these seem to be hopelessly broken to the increasing scope of changes we make to the menu bar, disable for now...
/*
import React from 'react';
import {mount} from 'enzyme';
import configureStore from 'redux-mock-store';
import MenuBarHOC from '../../../src/containers/menu-bar-hoc.jsx';
describe('Menu Bar HOC', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectChanged: true
}
});
});
test('Logged in user who IS owner and HAS changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
canSave
projectChanged
// assume the user will click "cancel" on the confirm dialog
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who IS owner and has NOT changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
canSave
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who is NOT owner and HAS changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
projectChanged
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged OUT user who HAS changed project WILL be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
projectChanged
canCreateNew={false}
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(false);
});
test('Logged OUT user who has NOT changed project WILL NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew={false}
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who IS owner and HAS changed project SHOULD save before transition to project page', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave
projectChanged
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(true);
});
test('Logged in user who IS owner and has NOT changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
test('Logged in user who is NOT owner and HAS changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
projectChanged
canSave={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
test('Logged in user who is NOT owner and has NOT changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave={false}
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
});
*/

View File

@@ -0,0 +1,76 @@
import React from 'react';
import {Provider} from 'react-redux';
import configureStore from 'redux-mock-store';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import SaveStatus from '../../../src/components/menu-bar/save-status.jsx';
import InlineMessages from '../../../src/containers/inline-messages.jsx';
import {AlertTypes} from '../../../src/lib/alerts/index.jsx';
// Stub the manualUpdateProject action creator for later testing
jest.mock('../../../src/reducers/project-state', () => ({
manualUpdateProject: jest.fn(() => ({type: 'stubbed'}))
}));
describe('SaveStatus container', () => {
const mockStore = configureStore();
test('if there are inline messages, they are shown instead of save now', () => {
const store = mockStore({
scratchGui: {
projectChanged: true,
alerts: {
alertsList: [
{alertId: 'saveSuccess', alertType: AlertTypes.INLINE}
]
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).toBe(true);
expect(wrapper.contains('Save Now')).not.toBe(true);
});
test('save now is shown if there are project changes and no inline messages', () => {
const store = mockStore({
scratchGui: {
projectChanged: true,
alerts: {
alertsList: []
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).not.toBe(true);
expect(wrapper.contains('Save Now')).toBe(true);
// Clicking save now should dispatch the manualUpdateProject action (stubbed above)
wrapper.find('[children="Save Now"]').simulate('click');
expect(store.getActions()[0].type).toEqual('stubbed');
});
test('neither is shown if there are no project changes or inline messages', () => {
const store = mockStore({
scratchGui: {
projectChanged: false,
alerts: {
alertsList: []
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).not.toBe(true);
expect(wrapper.contains('Save Now')).not.toBe(true);
});
});

View File

@@ -0,0 +1,111 @@
import React from 'react';
import {shallow} from 'enzyme';
import SliderPrompt from '../../../src/containers/slider-prompt.jsx';
import SliderPromptComponent from '../../../src/components/slider-prompt/slider-prompt.jsx';
describe('Slider Prompt Container', () => {
let onCancel;
let onOk;
beforeEach(() => {
onCancel = jest.fn();
onOk = jest.fn();
});
test('Min/max are shown with decimal when isDiscrete is false', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
expect(componentProps.minValue).toBe('0.00');
expect(componentProps.maxValue).toBe('100.00');
});
test('Min/max are NOT shown with decimal when isDiscrete is true', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
expect(componentProps.minValue).toBe('0');
expect(componentProps.maxValue).toBe('100');
});
test('Entering a number with a decimal submits with isDiscrete=false', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1.0'}});
componentProps.onOk();
expect(onOk).toHaveBeenCalledWith(1, 100, false);
});
test('Entering integers submits with isDiscrete=true', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1'}});
componentProps.onChangeMax({target: {value: '2'}});
componentProps.onOk();
expect(onOk).toHaveBeenCalledWith(1, 2, true);
});
test('Enter button submits the form', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1'}});
componentProps.onChangeMax({target: {value: '2'}});
componentProps.onKeyPress({key: 'Enter'});
expect(onOk).toHaveBeenCalledWith(1, 2, true);
});
test('Validates number-ness before submitting', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: 'hello'}});
componentProps.onOk();
expect(onOk).not.toHaveBeenCalled();
expect(onCancel).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,306 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import configureStore from 'redux-mock-store';
import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js';
import mockAudioEffects from '../../__mocks__/audio-effects.js';
import SoundEditor from '../../../src/containers/sound-editor';
import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor';
jest.mock('react-ga');
jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer);
jest.mock('../../../src/lib/audio/audio-effects', () => mockAudioEffects);
describe('Sound Editor Container', () => {
const mockStore = configureStore();
let store;
let soundIndex;
let soundBuffer;
const samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef
let vm;
beforeEach(() => {
soundIndex = 0;
soundBuffer = {
numberOfChannels: 1,
sampleRate: 0,
getChannelData: jest.fn(() => samples)
};
vm = {
getSoundBuffer: jest.fn(() => soundBuffer),
renameSound: jest.fn(),
updateSoundBuffer: jest.fn(),
editingTarget: {
sprite: {
sounds: [{name: 'first name', id: 'first id'}]
}
}
};
store = mockStore({scratchGui: {vm: vm, mode: {isFullScreen: false}}});
});
test('should pass the correct data to the component from the store', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const componentProps = wrapper.find(SoundEditorComponent).props();
// Data retreived and processed by the `connect` with the store
expect(componentProps.name).toEqual('first name');
expect(componentProps.chunkLevels).toEqual([0]);
expect(mockAudioBufferPlayer.instance.samples).toEqual(samples);
// Initial data
expect(componentProps.playhead).toEqual(null);
expect(componentProps.trimStart).toEqual(null);
expect(componentProps.trimEnd).toEqual(null);
});
test('it plays when clicked and stops when clicked again', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Ensure rendering doesn't start playing any sounds
expect(mockAudioBufferPlayer.instance.play.mock.calls).toEqual([]);
expect(mockAudioBufferPlayer.instance.stop.mock.calls).toEqual([]);
component.props().onPlay();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
// Mock the audio buffer player calling onUpdate
mockAudioBufferPlayer.instance.onUpdate(0.5);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.props().playhead).toEqual(0.5);
component.props().onStop();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(mockAudioBufferPlayer.instance.stop).toHaveBeenCalled();
expect(component.props().playhead).toEqual(null);
});
test('it submits name changes to the vm', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onChangeName('hello');
expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
});
test('it handles an effect by submitting the result and playing', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onReverse(); // Could be any of the effects, just testing the end result
await mockAudioEffects.instance._finishProcessing(soundBuffer);
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
});
test('it handles reverse effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onReverse();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.REVERSE);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles louder effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onLouder();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.LOUDER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles softer effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onSofter();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SOFTER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles faster effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onFaster();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.FASTER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles slower effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onSlower();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SLOWER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles echo effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onEcho();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ECHO);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles robot effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onRobot();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('undo/redo stack state', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Undo and redo should be disabled initially
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(false);
// Submitting new samples should make it possible to undo
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// Undoing should make it possible to redo and not possible to undo again
await component.props().onUndo();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(true);
// Redoing should make it possible to undo and not possible to redo again
await component.props().onRedo();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// New submission should clear the redo stack
await component.props().onUndo(); // Undo to go back to a state where redo is enabled
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canRedo')).toEqual(true);
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canRedo')).toEqual(false);
});
test('undo and redo submit new samples and play the sound', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Set up an undoable state
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
// Undo should update the sound buffer and play the new samples
await component.props().onUndo();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
// Clear the mocks call history to assert again for redo.
vm.updateSoundBuffer.mockClear();
mockAudioBufferPlayer.instance.play.mockClear();
// Undo should update the sound buffer and play the new samples
await component.props().onRedo();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
});
test('isStereo numberOfChannels=1', () => {
soundBuffer.numberOfChannels = 1;
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
expect(component.props().isStereo).toEqual(false);
});
test('isStereo numberOfChannels=2', () => {
soundBuffer.numberOfChannels = 2;
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
expect(component.props().isStereo).toEqual(true);
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import SpriteSelectorItem from '../../../src/containers/sprite-selector-item';
import DeleteButton from '../../../src/components/delete-button/delete-button';
describe('SpriteSelectorItem Container', () => {
const mockStore = configureStore();
let className;
let costumeURL;
let name;
let onClick;
let dispatchSetHoveredSprite;
let onDeleteButtonClick;
let selected;
let id;
let store;
// Wrap this in a function so it gets test specific states and can be reused.
const getContainer = function () {
return (
<Provider store={store}>
<SpriteSelectorItem
className={className}
costumeURL={costumeURL}
dispatchSetHoveredSprite={dispatchSetHoveredSprite}
id={id}
name={name}
selected={selected}
onClick={onClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
</Provider>
);
};
beforeEach(() => {
store = mockStore({scratchGui: {
hoveredTarget: {receivedBlocks: false, sprite: null},
assetDrag: {dragging: false}
}});
className = 'ponies';
costumeURL = 'https://scratch.mit.edu/foo/bar/pony';
id = 1337;
name = 'Pony sprite';
onClick = jest.fn();
onDeleteButtonClick = jest.fn();
dispatchSetHoveredSprite = jest.fn();
selected = true;
});
test('should delete the sprite', () => {
const wrapper = mountWithIntl(getContainer());
wrapper.find(DeleteButton).simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalledWith(1337);
});
});

View File

@@ -0,0 +1,208 @@
// TODO: add tests of extension alerts
/* eslint-env jest */
import {AlertTypes, AlertLevels} from '../../../src/lib/alerts/index.jsx';
import alertsReducer, {
closeAlert,
closeAlertWithId,
filterInlineAlerts,
filterPopupAlerts,
showStandardAlert
} from '../../../src/reducers/alerts';
test('initialState', () => {
let defaultState;
/* alertsReducer(state, action) */
expect(alertsReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(alertsReducer(defaultState, {type: 'anything'}).visible).toBe(true);
expect(alertsReducer(defaultState, {type: 'anything'}).alertsList).toEqual([]);
});
test('create one standard alert', () => {
let defaultState;
const action = showStandardAlert('creating');
const resultState = alertsReducer(defaultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('creating');
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.STANDARD);
expect(resultState.alertsList[0].level).toBe(AlertLevels.SUCCESS);
});
test('add several standard alerts', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const action = showStandardAlert('creating');
let resultState = alertsReducer(initialState, action);
resultState = alertsReducer(resultState, action);
resultState = alertsReducer(resultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.STANDARD);
expect(resultState.alertsList[0].iconURL).not.toBe('/no_image_here.jpg');
expect(resultState.alertsList[0].alertId).toBe('creating');
});
test('create one inline alert message', () => {
let defaultState;
const action = showStandardAlert('saving');
const resultState = alertsReducer(defaultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('saving');
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.INLINE);
expect(resultState.alertsList[0].level).toBe(AlertLevels.INFO);
});
test('can close alerts by index', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const closeAction = closeAlert(0);
let resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.length).toBe(0);
const createAction = showStandardAlert('creating');
resultState = alertsReducer(resultState, createAction);
expect(resultState.alertsList.length).toBe(1);
resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.length).toBe(0);
resultState = alertsReducer(resultState, createAction);
});
test('can close a single alert by id', () => {
const initialState = {
visible: true,
alertsList: [
{alertId: 'saving'},
{alertId: 'creating'},
{alertId: 'saving'},
{alertId: 'saving'}
]
};
const closeAction = closeAlertWithId('saving');
let resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating', 'saving', 'saving'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating', 'saving'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating'
]);
});
test('related alerts can clear each other', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
},
{
alertId: 'creating',
alertType: AlertTypes.STANDARD,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const action = showStandardAlert('saveSuccess');
const resultState = alertsReducer(initialState, action);
expect(resultState.alertsList.length).toBe(2);
expect(resultState.alertsList[0].alertId).toBe('creating');
expect(resultState.alertsList[1].alertId).toBe('saveSuccess');
});
test('several related alerts can be cleared at once', () => {
const initialState = {
visible: true,
alertsList: []
};
const createAction = showStandardAlert('creating');
let resultState = alertsReducer(initialState, createAction);
resultState = alertsReducer(resultState, createAction);
resultState = alertsReducer(resultState, createAction);
const createSuccessAction = showStandardAlert('createSuccess');
resultState = alertsReducer(resultState, createSuccessAction);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('createSuccess');
});
test('filterInlineAlerts only returns inline type alerts', () => {
const alerts = [
{
alertId: 'extension',
alertType: AlertTypes.EXTENSION
},
{
alertId: 'inline',
alertType: AlertTypes.INLINE
},
{
alertId: 'standard',
alertType: AlertTypes.STANDARD
},
{
alertId: 'non-existent type',
alertType: 'wirly-burly'
}
];
const filtered = filterInlineAlerts(alerts);
expect(filtered.length).toEqual(1);
expect(filtered[0].alertId).toEqual('inline');
});
test('filterPopupAlerts returns standard and extension type alerts', () => {
const alerts = [
{
alertId: 'extension',
alertType: AlertTypes.EXTENSION
},
{
alertId: 'inline',
alertType: AlertTypes.INLINE
},
{
alertId: 'standard',
alertType: AlertTypes.STANDARD
},
{
alertId: 'non-existent type',
alertType: 'wirly-burly'
}
];
const filtered = filterPopupAlerts(alerts);
expect(filtered.length).toEqual(2);
expect(filtered[0].alertId).toEqual('extension');
expect(filtered[1].alertId).toEqual('standard');
});

View File

@@ -0,0 +1,53 @@
/* eslint-env jest */
import modeReducer from '../../../src/reducers/mode';
const SET_FULL_SCREEN = 'scratch-gui/mode/SET_FULL_SCREEN';
const SET_PLAYER = 'scratch-gui/mode/SET_PLAYER';
test('initialState', () => {
let defaultState;
/* modeReducer(state, action) */
expect(modeReducer(defaultState, {type: 'anything'})).toBeDefined();
});
test('set full screen mode', () => {
const previousState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
const action = {
type: SET_FULL_SCREEN,
isFullScreen: true
};
const newState = {
showBranding: false,
isFullScreen: true,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
/* modeReducer(state, action) */
expect(modeReducer(previousState, action)).toEqual(newState);
});
test('set player mode', () => {
const previousState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
const action = {
type: SET_PLAYER,
isPlayerOnly: true
};
const newState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: true,
hasEverEnteredEditor: true
};
/* modeReducer(state, action) */
expect(modeReducer(previousState, action)).toEqual(newState);
});

View File

@@ -0,0 +1,302 @@
/* eslint-env jest */
import monitorLayoutReducer, {
addMonitorRect, moveMonitorRect,
resizeMonitorRect, removeMonitorRect,
getInitialPosition, PADDING, SCREEN_WIDTH, SCREEN_HEIGHT
} from '../../../src/reducers/monitor-layout';
test('initialState', () => {
let defaultState;
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined();
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).monitors).toBeDefined();
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).savedMonitorPositions)
.toBeDefined();
});
test('addMonitorRect', () => {
let defaultState;
const monitorId = 1;
const monitorId2 = 2;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
expect(reduxState.monitors[monitorId]).toBeDefined();
expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd);
// Add monitor rect doesn't save position
expect(reduxState.savedMonitorPositions[monitorId]).toBeUndefined();
const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0));
// Add a second monitor rect
const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd));
expect(reduxState3.monitors[monitorId]).toBeDefined();
expect(reduxState3.monitors[monitorId2]).toBeDefined();
expect(reduxState3.monitors[monitorId2].upperStart).toEqual(upperStart);
expect(reduxState3.monitors[monitorId2].lowerEnd).toEqual(lowerEnd);
// Saved positions aren't changed by adding monitor
expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions);
});
test('addMonitorRectWithSavedPosition', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState,
addMonitorRect(monitorId, upperStart, lowerEnd, true /* savePosition */));
expect(reduxState.monitors[monitorId]).toBeDefined();
expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd);
// Save position
expect(reduxState.savedMonitorPositions[monitorId].x).toEqual(upperStart.x);
expect(reduxState.savedMonitorPositions[monitorId].y).toEqual(upperStart.y);
});
test('invalidRect', () => {
let defaultState;
const reduxState = monitorLayoutReducer(defaultState /* state */, {type: 'initialize'} /* action */);
// Problem: x end is before x start
expect(
monitorLayoutReducer(reduxState,
addMonitorRect(1, {x: 100, y: 100}, {x: 10, y: 200})))
.toEqual(reduxState);
// Problem: y end is before y start
expect(
monitorLayoutReducer(reduxState,
addMonitorRect(1, {x: 100, y: 100}, {x: 200, y: 10})))
.toEqual(reduxState);
});
test('invalidAddMonitorRect', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
// Try to add the same one
expect(monitorLayoutReducer(reduxState, addMonitorRect(monitorId, upperStart, lowerEnd)))
.toEqual(reduxState);
});
test('moveMonitorRect', () => {
let defaultState;
const monitorId = 1;
const monitorId2 = 2;
const width = 102;
const height = 101;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: upperStart.x + width, y: upperStart.y + height};
const movedToPosition = {x: 0, y: 0};
const movedToPosition2 = {x: 543, y: 2};
// Add a monitor rect and move it. Expect it to be in monitors state and saved positions.
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
const reduxState2 = monitorLayoutReducer(reduxState,
moveMonitorRect(monitorId, movedToPosition.x, movedToPosition.y));
expect(reduxState2.monitors[monitorId]).toBeDefined();
expect(reduxState2.monitors[monitorId].upperStart).toEqual(movedToPosition);
expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(movedToPosition.x + width);
expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(movedToPosition.y + height);
expect(reduxState2.savedMonitorPositions[monitorId]).toBeDefined();
expect(reduxState2.savedMonitorPositions[monitorId].x).toEqual(movedToPosition.x);
expect(reduxState2.savedMonitorPositions[monitorId].y).toEqual(movedToPosition.y);
// Add a second monitor rect and move it. Expect there to now be 2 saved positions.
const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd));
const reduxState4 = monitorLayoutReducer(reduxState3,
moveMonitorRect(monitorId2, movedToPosition2.x, movedToPosition2.y));
expect(reduxState4.savedMonitorPositions[monitorId]).toEqual(reduxState2.savedMonitorPositions[monitorId]);
expect(reduxState4.savedMonitorPositions[monitorId2].x).toEqual(movedToPosition2.x);
expect(reduxState4.savedMonitorPositions[monitorId2].y).toEqual(movedToPosition2.y);
});
test('invalidMoveMonitorRect', () => {
let defaultState;
let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
const monitorId = 1;
// Try to move a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */, 1 /* newY */)))
.toEqual(reduxState);
// Add the monitor to move
reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200}));
// Invalid newX
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 'Oregon' /* newX */, 1 /* newY */)))
.toEqual(reduxState);
// Invalid newY
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */)))
.toEqual(reduxState);
});
test('resizeMonitorRect', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const newWidth = 10;
const newHeight = 20;
// Add a monitor rect and resize it
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, {x: 200, y: 200}));
const reduxState2 = monitorLayoutReducer(reduxState,
resizeMonitorRect(monitorId, newWidth, newHeight));
expect(reduxState2.monitors[monitorId]).toBeDefined();
expect(reduxState2.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(upperStart.x + newWidth);
expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(upperStart.y + newHeight);
// Saved positions aren't changed by resizing monitor
expect(reduxState2.savedMonitorPositions).toEqual(reduxState.savedMonitorPositions);
});
test('invalidResizeMonitorRect', () => {
let defaultState;
let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
const monitorId = 1;
// Try to resize a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// Add the monitor to resize
reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200}));
// Invalid newWidth
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 'Oregon' /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// Invalid newHeight
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newWidth */)))
.toEqual(reduxState);
// newWidth < 0
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, -1 /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// newHeight < 0
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, -1 /* newHeight */)))
.toEqual(reduxState);
});
test('removeMonitorRect', () => {
let defaultState;
const monitorId = 1;
// Add a monitor rect, move it, and remove it
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId,
{x: 100, y: 100},
{x: 200, y: 200}
));
const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0));
const reduxState3 = monitorLayoutReducer(reduxState2, removeMonitorRect(monitorId));
expect(reduxState3.monitors[monitorId]).toBeUndefined();
// Check that saved positions aren't changed by removing monitor
expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions);
});
test('invalidRemoveMonitorRect', () => {
let defaultState;
const reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
// Try to remove a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(1)))
.toEqual(reduxState);
});
test('getInitialPosition_lineUpTopLeft', () => {
let defaultState;
const width = 100;
const height = 200;
// Add monitors to right and bottom, but there is a space in the top left
let reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: width + PADDING, y: 0},
{x: 100, y: height}
));
reduxState = monitorLayoutReducer(defaultState, addMonitorRect(2,
{x: 0, y: height + PADDING},
{x: width, y: 100}
));
// Check that the added monitor appears in the space
const rect = getInitialPosition(reduxState, 3, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
expect(rect.upperStart.x).toEqual(PADDING);
expect(rect.upperStart.y).toEqual(PADDING);
});
test('getInitialPosition_savedPosition', () => {
const monitorId = 1;
const savedX = 100;
const savedY = 200;
const width = 7;
const height = 8;
const reduxState = {
monitors: {},
savedMonitorPositions: {[monitorId]: {x: savedX, y: savedY}}
};
// Check that initial position uses saved state
const rect = getInitialPosition(reduxState, monitorId, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
expect(rect.upperStart.x).toEqual(savedX);
expect(rect.upperStart.y).toEqual(savedY);
});
test('getInitialPosition_lineUpLeft', () => {
let defaultState;
const monitor1EndY = 60;
// Add a monitor that takes up the upper left corner
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, {x: 0, y: 0}, {x: 100, y: monitor1EndY}));
// Check that added monitor is under it and lines up left
const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */);
expect(rect.upperStart.y >= monitor1EndY + PADDING).toBeTruthy();
});
test('getInitialPosition_lineUpTop', () => {
let defaultState;
const monitor1EndX = 100;
// Add a monitor that takes up the whole left side
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: 0, y: 0},
{x: monitor1EndX, y: SCREEN_HEIGHT}
));
// Check that added monitor is to the right of it and lines up top
const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */);
expect(rect.upperStart.y).toEqual(PADDING);
expect(rect.upperStart.x >= monitor1EndX + PADDING).toBeTruthy();
});
test('getInitialPosition_noRoom', () => {
let defaultState;
const width = 7;
const height = 8;
// Add a monitor that takes up the whole screen
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: 0, y: 0},
{x: SCREEN_WIDTH, y: SCREEN_HEIGHT}
));
// Check that added monitor exists somewhere (we don't care where)
const rect = getInitialPosition(reduxState, 2, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
});

View File

@@ -0,0 +1,508 @@
/* eslint-env jest */
import projectStateReducer, {
LoadingState,
autoUpdateProject,
doneCreatingProject,
doneUpdatingProject,
manualUpdateProject,
onFetchedProjectData,
onLoadedProject,
projectError,
remixProject,
requestNewProject,
requestProjectUpload,
saveProjectAsCopy,
setProjectId
} from '../../../src/reducers/project-state';
test('initialState', () => {
let defaultState;
/* projectStateReducer(state, action) */
expect(projectStateReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(projectStateReducer(defaultState, {type: 'anything'}).error).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).projectData).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).projectId).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).loadingState).toBe(LoadingState.NOT_LOADED);
});
test('doneCreatingProject for new project with projectId type string shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_NEW
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneCreatingProject for new project with projectId type number shows project with id of type number', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_NEW
};
const action = doneCreatingProject(100, initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe(100);
});
test('doneCreatingProject for remix shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.REMIXING
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneCreatingProject for save as copy shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_COPY
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onFetchedProjectData with id loads project data into vm', () => {
const initialState = {
projectData: null,
loadingState: LoadingState.FETCHING_WITH_ID
};
const action = onFetchedProjectData('1010101', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_WITH_ID);
expect(resultState.projectData).toBe('1010101');
});
test('onFetchedProjectData new loads project data into vm', () => {
const initialState = {
projectData: null,
loadingState: LoadingState.FETCHING_NEW_DEFAULT
};
const action = onFetchedProjectData('1010101', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_NEW_DEFAULT);
expect(resultState.projectData).toBe('1010101');
});
// onLoadedProject: LOADING_VM_WITH_ID
test('onLoadedProject(LOADING_VM_WITH_ID, true, true) results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_WITH_ID, false, true) results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
// case where we started out viewing a project with a projectId, then
// started to load another project; but loading fails. We go back to
// showing the original project.
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with project id, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
// case where we started out viewing a project with default projectId, then
// started to load one with an id, such as in standalone mode when user adds
// '#PROJECT_ID_NUMBER' to the URI; but loading fails. We go back to
// showing the original project.
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with no project id, ' +
'results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
// onLoadedProject: LOADING_VM_FILE_UPLOAD
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, true, true) prepares to save', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we know project id, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we ' +
'don\'t know project id, results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
// onLoadedProject: LOADING_VM_NEW_DEFAULT
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, true, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, false) results in ERROR state', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe('0');
});
// doneUpdatingProject
test('doneUpdatingProject with id results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.MANUAL_UPDATING
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneUpdatingProject with id, before copy occurs, results in state CREATING_COPY', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.UPDATING_BEFORE_COPY
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
expect(resultState.projectId).toBe('100');
});
test('doneUpdatingProject with id, before new, results in state FETCHING_NEW_DEFAULT', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.UPDATING_BEFORE_NEW
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
expect(resultState.projectId).toBe('0'); // resets id
});
test('calling setProjectId, using with same id as already showing, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId('100');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('calling setProjectId, using different id from project already showing, ' +
'results in state FETCHING_WITH_ID', () => {
const initialState = {
projectId: 99,
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId('100');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('setProjectId, with same id as before, but not same type, ' +
'results in FETCHING_WITH_ID because the two projectIds are not strictly equal', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId(100);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_WITH_ID);
expect(resultState.projectId).toBe(100);
});
test('requestNewProject, when can\'t create/save, results in FETCHING_NEW_DEFAULT', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.SHOWING_WITHOUT_ID
};
const action = requestNewProject(false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
expect(resultState.projectId).toBe('0');
});
test('requestNewProject, when can create/save, results in UPDATING_BEFORE_NEW ' +
'(in order to save before fetching default project)', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = requestNewProject(true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_NEW);
expect(resultState.projectId).toBe('100');
});
test('requestProjectUpload when project not loaded results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.NOT_LOADED
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe(null);
});
test('requestProjectUpload when showing project with id results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe('100');
});
test('requestProjectUpload when showing project without id results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.SHOWING_WITHOUT_ID
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe(null);
});
test('manualUpdateProject should prepare to update', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = manualUpdateProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.MANUAL_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('autoUpdateProject should prepare to update', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = autoUpdateProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('saveProjectAsCopy should save, before preparing to save as a copy', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = saveProjectAsCopy();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY);
expect(resultState.projectId).toBe('100');
});
test('remixProject should prepare to remix', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = remixProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.REMIXING);
expect(resultState.projectId).toBe('100');
});
test('projectError from various states should show error', () => {
const startStates = [
LoadingState.AUTO_UPDATING,
LoadingState.CREATING_NEW,
LoadingState.FETCHING_NEW_DEFAULT,
LoadingState.FETCHING_WITH_ID,
LoadingState.LOADING_VM_NEW_DEFAULT,
LoadingState.LOADING_VM_WITH_ID,
LoadingState.MANUAL_UPDATING,
LoadingState.REMIXING,
LoadingState.CREATING_COPY,
LoadingState.UPDATING_BEFORE_NEW
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.error).toEqual('Error string');
expect(resultState.projectId).toBe('100');
}
});
test('fatal projectError should show error state', () => {
const startStates = [
LoadingState.FETCHING_NEW_DEFAULT,
LoadingState.FETCHING_WITH_ID,
LoadingState.LOADING_VM_NEW_DEFAULT,
LoadingState.LOADING_VM_WITH_ID
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe('100');
}
});
test('non-fatal projectError should show normal state', () => {
const startStates = [
LoadingState.AUTO_UPDATING,
LoadingState.CREATING_COPY,
LoadingState.MANUAL_UPDATING,
LoadingState.REMIXING,
LoadingState.UPDATING_BEFORE_NEW
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
}
});
test('projectError when creating new while viewing project with id should ' +
'go back to state SHOWING_WITH_ID', () => {
const initialState = {
error: null,
loadingState: LoadingState.CREATING_NEW,
projectId: '12345'
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('12345');
});
test('projectError when creating new while logged out, looking at default project ' +
'should go back to state SHOWING_WITHOUT_ID', () => {
const initialState = {
error: null,
loadingState: LoadingState.CREATING_NEW,
projectId: '0'
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('projectError encountered while in state FETCHING_WITH_ID results in ' +
'ERROR state', () => {
const initialState = {
error: null,
projectId: null,
loadingState: LoadingState.FETCHING_WITH_ID
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe(null);
expect(resultState.error).toEqual('Error string');
});

View File

@@ -0,0 +1,25 @@
/* eslint-env jest */
import workspaceMetricsReducer, {updateMetrics} from '../../../src/reducers/workspace-metrics';
test('initialState', () => {
let defaultState;
/* workspaceMetricsReducer(state, action) */
expect(workspaceMetricsReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(workspaceMetricsReducer(defaultState, {type: 'anything'})).toEqual({targets: {}});
});
test('updateMetrics action creator', () => {
let defaultState;
const action = updateMetrics({
targetID: 'abcde',
scrollX: 225,
scrollY: 315,
scale: 1.25
});
const resultState = workspaceMetricsReducer(defaultState, action);
expect(Object.keys(resultState.targets).length).toBe(1);
expect(resultState.targets.abcde).toBeDefined();
expect(resultState.targets.abcde.scrollX).toBe(225);
expect(resultState.targets.abcde.scrollY).toBe(315);
expect(resultState.targets.abcde.scale).toBe(1.25);
});

View File

@@ -0,0 +1,30 @@
/* global WebAudioTestAPI */
import 'web-audio-test-api';
WebAudioTestAPI.setState({
'AudioContext#resume': 'enabled'
});
import SharedAudioContext from '../../../src/lib/audio/shared-audio-context';
describe('Shared Audio Context', () => {
const audioContext = new AudioContext();
test('returns empty object without user gesture', () => {
const sharedAudioContext = new SharedAudioContext();
expect(sharedAudioContext).toMatchObject({});
});
test('returns AudioContext when mousedown is triggered', () => {
const sharedAudioContext = new SharedAudioContext();
const event = new Event('mousedown');
document.dispatchEvent(event);
expect(sharedAudioContext).toMatchObject(audioContext);
});
test('returns AudioContext when touchstart is triggered', () => {
const sharedAudioContext = new SharedAudioContext();
const event = new Event('touchstart');
document.dispatchEvent(event);
expect(sharedAudioContext).toMatchObject(audioContext);
});
});

View File

@@ -0,0 +1,91 @@
/* global WebAudioTestAPI */
import 'web-audio-test-api';
WebAudioTestAPI.setState({
'OfflineAudioContext#startRendering': 'promise'
});
import AudioEffects from '../../../src/lib/audio/audio-effects';
import RobotEffect from '../../../src/lib/audio/effects/robot-effect';
import EchoEffect from '../../../src/lib/audio/effects/echo-effect';
import VolumeEffect from '../../../src/lib/audio/effects/volume-effect';
describe('Audio Effects manager', () => {
const audioContext = new AudioContext();
const audioBuffer = audioContext.createBuffer(1, 400, 44100);
test('changes buffer length and playback rate for faster effect', () => {
const audioEffects = new AudioEffects(audioBuffer, 'faster', 0, 1);
expect(audioEffects.audioContext._.length).toBeLessThan(400);
});
test('changes buffer length and playback rate for slower effect', () => {
const audioEffects = new AudioEffects(audioBuffer, 'slower', 0, 1);
expect(audioEffects.audioContext._.length).toBeGreaterThan(400);
});
test('changes buffer length for echo effect', () => {
const audioEffects = new AudioEffects(audioBuffer, 'echo', 0, 1);
expect(audioEffects.audioContext._.length).toBeGreaterThan(400);
});
test('updates the trim positions after an effect has changed the length of selection', () => {
const slowerEffect = new AudioEffects(audioBuffer, 'slower', 0.25, 0.75);
expect(slowerEffect.adjustedTrimStartSeconds).toEqual(slowerEffect.trimStartSeconds);
expect(slowerEffect.adjustedTrimEndSeconds).toBeGreaterThan(slowerEffect.trimEndSeconds);
const fasterEffect = new AudioEffects(audioBuffer, 'faster', 0.25, 0.75);
expect(fasterEffect.adjustedTrimStartSeconds).toEqual(fasterEffect.trimStartSeconds);
expect(fasterEffect.adjustedTrimEndSeconds).toBeLessThan(fasterEffect.trimEndSeconds);
// Some effects do not change the length of the selection
const fadeEffect = new AudioEffects(audioBuffer, 'fade in', 0.25, 0.75);
expect(fadeEffect.adjustedTrimStartSeconds).toEqual(fadeEffect.trimStartSeconds);
// Should be within one millisecond (flooring can change the duration by one sample)
expect(fadeEffect.adjustedTrimEndSeconds).toBeCloseTo(fadeEffect.trimEndSeconds, 3);
});
test.skip('process starts the offline rendering context and returns a promise', () => {
// @todo haven't been able to get web audio test api to actually run render
});
test('reverse effect strictly reverses the samples', () => {
const fakeSound = [1, 2, 3, 4, 5, 6, 7, 8];
const fakeBuffer = audioContext.createBuffer(1, 8, 44100);
const bufferData = fakeBuffer.getChannelData(0);
fakeSound.forEach((sample, index) => {
bufferData[index] = sample;
});
// Reverse the entire sound
const reverseAll = new AudioEffects(fakeBuffer, 'reverse', 0, 1);
expect(Array.from(reverseAll.buffer.getChannelData(0))).toEqual(fakeSound.reverse());
// Reverse part of the sound
const reverseSelection = new AudioEffects(fakeBuffer, 'reverse', 0.25, 0.75);
const selectionReversed = [1, 2, 6, 5, 4, 3, 7, 8];
expect(Array.from(reverseSelection.buffer.getChannelData(0))).toEqual(selectionReversed);
});
});
describe('Effects', () => {
let audioContext;
beforeEach(() => {
audioContext = new AudioContext();
});
test('all effects provide an input and output that are connected', () => {
const robotEffect = new RobotEffect(audioContext, 0, 1);
expect(robotEffect.input).toBeInstanceOf(AudioNode);
expect(robotEffect.output).toBeInstanceOf(AudioNode);
const echoEffect = new EchoEffect(audioContext, 0, 1);
expect(echoEffect.input).toBeInstanceOf(AudioNode);
expect(echoEffect.output).toBeInstanceOf(AudioNode);
const volumeEffect = new VolumeEffect(audioContext, 0.5, 0, 1);
expect(volumeEffect.input).toBeInstanceOf(AudioNode);
expect(volumeEffect.output).toBeInstanceOf(AudioNode);
});
});

View File

@@ -0,0 +1,102 @@
import {
computeRMS,
computeChunkedRMS,
downsampleIfNeeded,
dropEveryOtherSample
} from '../../../src/lib/audio/audio-util';
describe('computeRMS', () => {
test('returns 0 when given no samples', () => {
expect(computeRMS([])).toEqual(0);
});
test('returns the RMS scaled by the given unity value and square rooted', () => {
const unity = 0.5;
const samples = [3, 2, 1];
expect(computeRMS(samples, unity)).toEqual(
Math.sqrt(Math.sqrt(((3 * 3) + (2 * 2) + (1 * 1)) / 3) / 0.5)
);
});
test('uses a default unity value of 0.55', () => {
const samples = [1, 1, 1];
// raw rms is 1, scaled to (1 / 0.55) and square rooted
expect(computeRMS(samples)).toEqual(Math.sqrt(1 / 0.55));
});
});
describe('computeChunkedRMS', () => {
test('computes the rms for each chunk based on chunk size', () => {
const samples = [2, 1, 3, 2, 5];
const chunkedLevels = computeChunkedRMS(samples, 2);
// chunked to [2, 0], [3, 0], [5]
// rms scaled with default unity of 0.55
expect(chunkedLevels.length).toEqual(3);
expect(chunkedLevels).toEqual([
Math.sqrt(Math.sqrt(((2 * 2) + (1 * 1)) / 2) / 0.55),
Math.sqrt(Math.sqrt(((3 * 3) + (2 * 2)) / 2) / 0.55),
Math.sqrt(Math.sqrt((5 * 5) / 1) / 0.55)
]);
});
test('chunk size larger than sample size creates single chunk', () => {
const samples = [1, 1, 1];
const chunkedLevels = computeChunkedRMS(samples, 7);
// chunked to [1, 1, 1]
// rms scaled with default unity of 0.55
expect(chunkedLevels.length).toEqual(1);
expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55)]);
});
test('chunk size as multiple is handled correctly', () => {
const samples = [1, 1, 1, 1, 1, 1];
const chunkedLevels = computeChunkedRMS(samples, 3);
// chunked to [1, 1, 1], [1, 1, 1]
// rms scaled with default unity of 0.55
expect(chunkedLevels.length).toEqual(2);
expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55), Math.sqrt(1 / 0.55)]);
});
});
describe('downsampleIfNeeded', () => {
const samples = {length: 1};
const sampleRate = 44100;
test('returns given data when no downsampling needed', async () => {
samples.length = 1;
const res = await downsampleIfNeeded({samples, sampleRate}, null);
expect(res.samples).toEqual(samples);
expect(res.sampleRate).toEqual(sampleRate);
});
test('downsamples to 22050 if that puts it under the limit', async () => {
samples.length = 44100 * 3 * 60;
const resampler = jest.fn(() => 'TEST');
const res = await downsampleIfNeeded({samples, sampleRate}, resampler);
expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 22050);
expect(res).toEqual('TEST');
});
// TW: We allow resampling even if it would exceed the limit because our GUI handles this better.
test.skip('fails if resampling would not put it under the limit', async () => {
samples.length = 44100 * 4 * 60;
try {
await downsampleIfNeeded({samples, sampleRate}, null);
} catch (e) {
expect(e.message).toEqual('Sound too large to save, refusing to edit');
}
});
});
describe('dropEveryOtherSample', () => {
const buffer = {
samples: [1, 0, 2, 0, 3, 0],
sampleRate: 2
};
test('result is half the length', () => {
const {samples} = dropEveryOtherSample(buffer);
expect(samples.length).toEqual(Math.floor(buffer.samples.length / 2));
});
test('result contains only even-index items', () => {
const {samples} = dropEveryOtherSample(buffer);
expect(samples).toEqual(new Float32Array([1, 2, 3]));
});
test('result sampleRate is given sampleRate / 2', () => {
const {sampleRate} = dropEveryOtherSample(buffer);
expect(sampleRate).toEqual(buffer.sampleRate / 2);
});
});

View File

@@ -0,0 +1,401 @@
import 'web-audio-test-api';
import React from 'react';
import configureStore from 'redux-mock-store';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import VM from 'scratch-vm';
import {LoadingState} from '../../../src/reducers/project-state';
import CloudProvider from '../../../src/lib/cloud-provider';
const mockCloudProviderInstance = {
connection: true,
requestCloseConnection: jest.fn()
};
jest.mock('../../../src/lib/cloud-provider', () =>
jest.fn().mockImplementation(() => mockCloudProviderInstance)
);
import cloudManagerHOC from '../../../src/lib/cloud-manager-hoc.jsx';
describe.skip('CloudManagerHOC', () => {
const mockStore = configureStore();
let store;
let vm;
let stillLoadingStore;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectState: {
projectId: '1234',
loadingState: LoadingState.SHOWING_WITH_ID
},
mode: {
hasEverEnteredEditor: false
},
tw: {}
}
});
stillLoadingStore = mockStore({
scratchGui: {
projectState: {
projectId: '1234',
loadingState: LoadingState.LOADING_WITH_ID
},
mode: {
hasEverEnteredEditor: false
}
}
});
vm = new VM();
vm.setCloudProvider = jest.fn();
vm.runtime = {
hasCloudData: jest.fn(() => true)
};
vm.extensionManager = {
isExtensionLoaded: jest.fn(() => false)
};
CloudProvider.mockClear();
mockCloudProviderInstance.requestCloseConnection.mockClear();
});
test('when it mounts, the cloud provider is set on the vm', () => {
const Component = () => (<div />);
const WrappedComponent = cloudManagerHOC(Component);
const onShowCloudInfo = jest.fn();
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
onShowCloudInfo={onShowCloudInfo}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
expect(CloudProvider).toHaveBeenCalledTimes(1);
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
expect(onShowCloudInfo).not.toHaveBeenCalled();
});
test('when cloudHost is missing, the cloud provider is not set on the vm', () => {
const Component = () => (<div />);
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
store={store}
username="user"
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('when projectID is missing, the cloud provider is not set on the vm', () => {
const Component = () => (<div />);
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('when project is not showingWithId, the cloud provider is not set on the vm', () => {
const Component = () => (<div />);
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={stillLoadingStore}
username="user"
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('when hasCloudPermission is false, the cloud provider is not set on the vm', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
cloudHost="nonEmpty"
hasCloudPermission={false}
store={store}
username="user"
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('when videoSensing extension is active, the cloud provider is not set on the vm', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
vm.extensionManager.isExtensionLoaded = jest.fn(extension => extension === 'videoSensing');
mount(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('if the isShowingWithId prop becomes true, it sets the cloud provider on the vm', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
const onShowCloudInfo = jest.fn();
vm.runtime.hasCloudData = jest.fn(() => false);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={stillLoadingStore}
username="user"
vm={vm}
onShowCloudInfo={onShowCloudInfo}
/>
);
expect(onShowCloudInfo).not.toHaveBeenCalled();
vm.runtime.hasCloudData = jest.fn(() => true);
vm.emit('HAS_CLOUD_DATA_UPDATE', true);
mounted.setProps({
isShowingWithId: true,
loadingState: LoadingState.SHOWING_WITH_ID
});
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
expect(CloudProvider).toHaveBeenCalledTimes(1);
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
expect(onShowCloudInfo).not.toHaveBeenCalled();
});
test('projectId change should not trigger cloudProvider connection unless isShowingWithId becomes true', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={stillLoadingStore}
username="user"
vm={vm}
/>
);
mounted.setProps({
projectId: 'a different id'
});
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
mounted.setProps({
isShowingWithId: true,
loadingState: LoadingState.SHOWING_WITH_ID
});
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
expect(CloudProvider).toHaveBeenCalledTimes(1);
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
});
test('when it unmounts, the cloud provider is reset to null on the vm', () => {
const Component = () => (<div />);
const WrappedComponent = cloudManagerHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(CloudProvider).toHaveBeenCalled();
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
mounted.unmount();
// vm.setCloudProvider is called twice,
// once during mount and once during unmount
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
});
test('projectId changing should trigger cloudProvider disconnection', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(CloudProvider).toHaveBeenCalled();
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
mounted.setProps({
projectId: 'a different id'
});
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
});
test('username changing should trigger cloudProvider disconnection', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(CloudProvider).toHaveBeenCalled();
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
mounted.setProps({
username: 'a different user'
});
expect(vm.setCloudProvider.mock.calls.length).toBe(3); // tw: the test is wrong.
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
});
test('project without cloud data should not trigger cloud connection', () => {
// Mock the vm runtime function so that has cloud data is not
// initially true
vm.runtime.hasCloudData = jest.fn(() => false);
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
});
test('projectHasCloudData becoming true should trigger a cloud connection', () => {
// Mock the vm runtime function so that has cloud data is not
// initially true
vm.runtime.hasCloudData = jest.fn(() => false);
const onShowCloudInfo = jest.fn();
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
onShowCloudInfo={onShowCloudInfo}
/>
);
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
expect(CloudProvider).not.toHaveBeenCalled();
expect(onShowCloudInfo).not.toHaveBeenCalled();
// Mock VM hasCloudData becoming true and emitting an update
vm.runtime.hasCloudData = jest.fn(() => true);
vm.emit('HAS_CLOUD_DATA_UPDATE', true);
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
expect(CloudProvider).toHaveBeenCalledTimes(1);
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
expect(onShowCloudInfo).toHaveBeenCalled();
});
test('projectHasCloudDataUpdate becoming false should trigger cloudProvider disconnection', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(CloudProvider).toHaveBeenCalled();
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
vm.runtime.hasCloudData = jest.fn(() => false);
vm.emit('HAS_CLOUD_DATA_UPDATE', false);
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
});
// Editor Mode Connection/Disconnection Tests
test('Entering editor mode and can\'t save project should disconnect cloud provider', () => {
const Component = () => <div />;
const WrappedComponent = cloudManagerHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
hasCloudPermission
cloudHost="nonEmpty"
store={store}
username="user"
vm={vm}
/>
);
expect(CloudProvider).toHaveBeenCalled();
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
mounted.setProps({
canModifyCloudData: false
});
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,211 @@
import CloudProvider from '../../../src/lib/cloud-provider';
let websocketConstructorCount = 0;
// Stub the global websocket so we can call open/close/error/send on it
global.WebSocket = function (url) {
this._url = url;
this._sentMessages = [];
// These are not real websocket methods, but used to trigger callbacks
this._open = () => this.onopen();
this._error = e => this.onerror(e);
this._receive = msg => this.onmessage(msg);
// Stub the real websocket.send to store sent messages
this.send = msg => this._sentMessages.push(msg);
this.close = () => this.onclose();
websocketConstructorCount++;
};
global.WebSocket.CLOSING = 'CLOSING';
global.WebSocket.CLOSED = 'CLOSED';
describe('CloudProvider', () => {
let cloudProvider = null;
let vmIOData = [];
let timeout = 0;
beforeEach(() => {
vmIOData = [];
cloudProvider = new CloudProvider();
// Stub vm
cloudProvider.vm = {
postIOData: (_namespace, data) => {
vmIOData.push(data);
}
};
// Stub setTimeout so this can run instantly.
cloudProvider.setTimeout = (fn, after) => {
timeout = after;
fn();
};
// Stub randomize to make it consistent for testing.
cloudProvider.randomizeDuration = t => t;
});
test('createVariable', () => {
cloudProvider.createVariable('hello', 1);
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('create');
expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(1);
});
test('updateVariable', () => {
cloudProvider.updateVariable('hello', 1);
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(1);
});
test('updateVariable with falsey value', () => {
cloudProvider.updateVariable('hello', 0);
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(0);
});
test('renameVariable', () => {
cloudProvider.renameVariable('oldName', 'newName');
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('rename');
expect(obj.name).toEqual('oldName');
expect(typeof obj.value).toEqual('undefined');
expect(obj.new_name).toEqual('newName');
});
test('deleteVariable', () => {
cloudProvider.deleteVariable('hello');
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('delete');
expect(obj.name).toEqual('hello');
expect(typeof obj.value).toEqual('undefined');
});
test('onMessage set', () => {
const msg = JSON.stringify({
method: 'set',
name: 'name',
value: 'value'
});
cloudProvider.connection._receive({data: msg});
expect(vmIOData[0].varUpdate.name).toEqual('name');
expect(vmIOData[0].varUpdate.value).toEqual('value');
});
test('onMessage with newline at the end', () => {
const msg1 = JSON.stringify({
method: 'set',
name: 'name1',
value: 'value'
});
cloudProvider.onMessage({data: `${msg1}\n`});
expect(vmIOData[0].varUpdate.name).toEqual('name1');
});
test('onMessage with multiple commands', () => {
const msg1 = JSON.stringify({
method: 'set',
name: 'name1',
value: 'value'
});
const msg2 = JSON.stringify({
method: 'set',
name: 'name2',
value: 'value2'
});
cloudProvider.connection._receive({data: `${msg1}\n${msg2}`});
expect(vmIOData[0].varUpdate.name).toEqual('name1');
expect(vmIOData[1].varUpdate.name).toEqual('name2');
});
test('connnection attempts set back to 1 when socket is opened', () => {
cloudProvider.connectionAttempts = 100;
cloudProvider.connection._open();
expect(cloudProvider.connectionAttempts).toBe(1);
});
test('disconnect waits for a period equal to 2^k-1 before trying again', () => {
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
// Constructor attempts to open connection, so attempts is initially 1
expect(cloudProvider.connectionAttempts).toBe(1);
// Make sure a close without a previous OPEN still waits 1s before reconnecting
cloudProvider.connection.close();
expect(timeout).toEqual(1 * 1000); // 2^1 - 1
expect(websocketConstructorCount).toBe(2);
expect(cloudProvider.connectionAttempts).toBe(2);
cloudProvider.connection.close();
expect(timeout).toEqual(3 * 1000); // 2^2 - 1
expect(websocketConstructorCount).toBe(3);
expect(cloudProvider.connectionAttempts).toBe(3);
cloudProvider.connection.close();
expect(timeout).toEqual(7 * 1000); // 2^3 - 1
expect(websocketConstructorCount).toBe(4);
expect(cloudProvider.connectionAttempts).toBe(4);
cloudProvider.connection.close();
expect(timeout).toEqual(15 * 1000); // 2^4 - 1
expect(websocketConstructorCount).toBe(5);
expect(cloudProvider.connectionAttempts).toBe(5);
cloudProvider.connection.close();
expect(timeout).toEqual(31 * 1000); // 2^5 - 1
expect(websocketConstructorCount).toBe(6);
expect(cloudProvider.connectionAttempts).toBe(6);
cloudProvider.connection.close();
expect(timeout).toEqual(31 * 1000); // maxed out at 2^5 - 1
expect(websocketConstructorCount).toBe(7);
expect(cloudProvider.connectionAttempts).toBe(7);
});
test('close after connection is opened waits 1s before reconnecting', () => {
// This test is basically to check that opening the connection does not impact
// the time until reconnection for the first reconnect.
// It is easy to introduce a bug that causes reconnection time to be different
// based on whether an initial connection was made.
websocketConstructorCount = 1;
cloudProvider.connection._open();
cloudProvider.connection.close();
expect(timeout).toEqual(1 * 1000); // 2^1 - 1
expect(websocketConstructorCount).toBe(2);
expect(cloudProvider.connectionAttempts).toBe(2);
});
test('exponentialTimeout caps connection attempt number', () => {
cloudProvider.connectionAttempts = 1000;
expect(cloudProvider.exponentialTimeout()).toEqual(31 * 1000);
});
test('requestCloseConnection does not try to reconnect', () => {
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
cloudProvider.requestCloseConnection();
expect(websocketConstructorCount).toBe(1); // No reconnection attempts
});
test('close with code 4002 triggers invalid username', () => {
cloudProvider.onInvalidUsername = jest.fn();
cloudProvider.onClose({code: 4002});
expect(cloudProvider.onInvalidUsername).toHaveBeenCalledTimes(1);
});
test('close with normal code does not trigger invalid username', () => {
cloudProvider.username = 'aaa';
cloudProvider.onInvalidUsername = jest.fn();
cloudProvider.onClose({code: 1000});
expect(cloudProvider.onInvalidUsername).not.toHaveBeenCalled();
});
});
test('username anonymization', () => {
const anonymized = new CloudProvider('', null, 'player1234', '');
expect(anonymized.username).toBe('player');
const verbatim = new CloudProvider('', null, 'abcdef', '');
expect(verbatim.username).toBe('abcdef');
});

View File

@@ -0,0 +1,19 @@
jest.mock('../../../src/lib/backpack/block-to-image', () => () => Promise.resolve('block-image'));
jest.mock('../../../src/lib/backpack/thumbnail', () => () => Promise.resolve('thumbnail'));
import codePayload from '../../../src/lib/backpack/code-payload';
import {Base64} from 'js-base64';
describe('codePayload', () => {
test('base64 encodes the blocks as json', () => {
const blocks = '☁︎❤️🐻';
const payload = codePayload({
blockObjects: blocks
});
return payload.then(p => {
expect(
JSON.parse(Base64.decode(p.body))
).toEqual(blocks);
});
});
});

View File

@@ -0,0 +1,21 @@
import defaultProjectGenerator from '../../../src/lib/default-project/index.js';
describe('defaultProject', () => {
// This test ensures that the assets referenced in the default project JSON
// do not get out of sync with the raw assets that are included alongside.
// see https://github.com/LLK/scratch-gui/issues/4844
test('assets referenced by the project are included', () => {
const translatorFn = () => '';
const defaultProject = defaultProjectGenerator(translatorFn);
const includedAssetIds = defaultProject.map(obj => obj.id);
const projectData = JSON.parse(defaultProject[0].data);
projectData.targets.forEach(target => {
target.costumes.forEach(costume => {
expect(includedAssetIds.includes(costume.assetId)).toBe(true);
});
target.sounds.forEach(sound => {
expect(includedAssetIds.includes(sound.assetId)).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,199 @@
import defineDynamicBlock from '../../../src/lib/define-dynamic-block';
import BlockType from 'scratch-vm/src/extension-support/block-type';
const MockScratchBlocks = {
OUTPUT_SHAPE_HEXAGONAL: 1,
OUTPUT_SHAPE_ROUND: 2,
OUTPUT_SHAPE_SQUARE: 3
};
const categoryInfo = {
name: 'test category',
color1: '#111',
color2: '#222',
color3: '#333'
};
const penIconURI = 'data:image/svg+xml;base64,fake_pen_icon_svg_base64_data';
const testBlockInfo = {
commandWithIcon: {
blockType: BlockType.COMMAND,
blockIconURI: penIconURI,
text: 'command with icon'
},
commandWithoutIcon: {
blockType: BlockType.COMMAND,
text: 'command without icon'
},
terminalCommand: {
blockType: BlockType.COMMAND,
isTerminal: true,
text: 'terminal command'
},
reporter: {
blockType: BlockType.REPORTER,
text: 'reporter'
},
boolean: {
blockType: BlockType.BOOLEAN,
text: 'Boolean'
},
hat: {
blockType: BlockType.HAT,
text: 'hat'
}
};
// similar to goog.mixin from the Closure library
const mixin = function (target, source) {
for (const x in source) {
target[x] = source[x];
}
};
class MockBlock {
constructor (blockInfo, extendedOpcode) {
// mimic Closure-style inheritance by mixing in `defineDynamicBlock` output as this instance's prototype
// see also the `Blockly.Block` constructor
const prototype = defineDynamicBlock(MockScratchBlocks, categoryInfo, blockInfo, extendedOpcode);
mixin(this, prototype);
this.init();
// bootstrap the mutation<->DOM cycle
this.blockInfoText = JSON.stringify(blockInfo);
const xmlElement = this.mutationToDom();
// parse blockInfo from XML to fill dynamic properties
this.domToMutation(xmlElement);
}
jsonInit (json) {
this.result = Object.assign({}, json);
}
interpolate_ () {
// TODO: add tests for this?
}
setCheckboxInFlyout (isEnabled) {
this.result.checkboxInFlyout_ = isEnabled;
}
setOutput (isEnabled) {
this.result.outputConnection = isEnabled; // Blockly calls `makeConnection_` here
}
setOutputShape (outputShape) {
this.result.outputShape_ = outputShape;
}
setNextStatement (isEnabled) {
this.result.nextConnection = isEnabled; // Blockly calls `makeConnection_` here
}
setPreviousStatement (isEnabled) {
this.result.previousConnection = isEnabled; // Blockly calls `makeConnection_` here
}
}
describe('defineDynamicBlock', () => {
test('is a function', () => {
expect(typeof defineDynamicBlock).toBe('function');
});
test('can define a command block with an icon', () => {
const extendedOpcode = 'test.commandWithIcon';
const block = new MockBlock(testBlockInfo.commandWithIcon, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
extensions: ['scratch_extension'],
inputsInline: true,
nextConnection: true,
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
previousConnection: true,
type: extendedOpcode
});
});
test('can define a command block without an icon', () => {
const extendedOpcode = 'test.commandWithoutIcon';
const block = new MockBlock(testBlockInfo.commandWithoutIcon, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
// extensions: undefined, // no icon means no extension
inputsInline: true,
nextConnection: true,
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
previousConnection: true,
type: extendedOpcode
});
});
test('can define a terminal command', () => {
const extendedOpcode = 'test.terminal';
const block = new MockBlock(testBlockInfo.terminalCommand, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
// extensions: undefined, // no icon means no extension
inputsInline: true,
nextConnection: false, // terminal
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
previousConnection: true,
type: extendedOpcode
});
});
test('can define a reporter', () => {
const extendedOpcode = 'test.reporter';
const block = new MockBlock(testBlockInfo.reporter, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
checkboxInFlyout_: true,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
// extensions: undefined, // no icon means no extension
inputsInline: true,
// nextConnection: undefined, // reporter
outputConnection: true, // reporter
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_ROUND, // reporter
// previousConnection: undefined, // reporter
type: extendedOpcode
});
});
test('can define a Boolean', () => {
const extendedOpcode = 'test.boolean';
const block = new MockBlock(testBlockInfo.boolean, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
// checkboxInFlyout_: undefined,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
// extensions: undefined, // no icon means no extension
inputsInline: true,
// nextConnection: undefined, // reporter
outputConnection: true, // reporter
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_HEXAGONAL, // Boolean
// previousConnection: undefined, // reporter
type: extendedOpcode
});
});
test('can define a hat', () => {
const extendedOpcode = 'test.hat';
const block = new MockBlock(testBlockInfo.hat, extendedOpcode);
expect(block.result).toEqual({
category: categoryInfo.name,
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
// extensions: undefined, // no icon means no extension
inputsInline: true,
nextConnection: true,
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
// previousConnection: undefined, // hat
type: extendedOpcode
});
});
});

View File

@@ -0,0 +1,86 @@
import {detectLocale} from '../../../src/lib/detect-locale.js';
const supportedLocales = ['en', 'es', 'pt-br', 'de', 'it'];
Object.defineProperty(window.location,
'search',
{value: '?name=val', configurable: true}
);
Object.defineProperty(window.navigator,
'language',
{value: 'en-US', configurable: true}
);
describe('detectLocale', () => {
test('uses locale from the URL when present', () => {
Object.defineProperty(window.location,
'search',
{value: '?locale=pt-br'}
);
expect(detectLocale(supportedLocales)).toEqual('pt-br');
});
test('is case insensitive', () => {
Object.defineProperty(window.location,
'search',
{value: '?locale=pt-BR'}
);
expect(detectLocale(supportedLocales)).toEqual('pt-br');
});
test('also accepts lang from the URL when present', () => {
Object.defineProperty(window.location,
'search',
{value: '?lang=it'}
);
expect(detectLocale(supportedLocales)).toEqual('it');
});
test('ignores unsupported locales', () => {
Object.defineProperty(window.location,
'search',
{value: '?lang=sv'}
);
expect(detectLocale(supportedLocales)).toEqual('en');
});
test('ignores other parameters', () => {
Object.defineProperty(window.location,
'search',
{value: '?enable=language'}
);
expect(detectLocale(supportedLocales)).toEqual('en');
});
test('uses navigator language property for default if supported', () => {
Object.defineProperty(window.navigator,
'language',
{value: 'pt-BR'}
);
expect(detectLocale(supportedLocales)).toEqual('pt-br');
});
test('ignores navigator language property if unsupported', () => {
Object.defineProperty(window.navigator,
'language',
{value: 'da'}
);
expect(detectLocale(supportedLocales)).toEqual('en');
});
test('works with an empty locale', () => {
Object.defineProperty(window.location,
'search',
{value: '?locale='}
);
expect(detectLocale(supportedLocales)).toEqual('en');
});
test('if multiple, uses the first locale', () => {
Object.defineProperty(window.location,
'search',
{value: '?locale=de&locale=en'}
);
expect(detectLocale(supportedLocales)).toEqual('de');
});
});

View File

@@ -0,0 +1,120 @@
import DragRecognizer from '../../../src/lib/drag-recognizer';
describe('DragRecognizer', () => {
let onDrag;
let onDragEnd;
let dragRecognizer;
beforeEach(() => {
onDrag = jest.fn();
onDragEnd = jest.fn();
dragRecognizer = new DragRecognizer({onDrag, onDragEnd});
});
afterEach(() => {
dragRecognizer.reset();
});
test('start -> small drag', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 101, clientY: 101}));
expect(onDrag).not.toHaveBeenCalled();
});
test('start -> large vertical touch move -> scroll, not drag', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 106, clientY: 150}));
expect(onDrag).not.toHaveBeenCalled();
});
test('start -> large vertical mouse move -> mouse moves always drag)', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 100, clientY: 150}));
expect(onDrag).toHaveBeenCalled();
});
test('start -> large horizontal touch move -> drag', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
expect(onDrag).toHaveBeenCalled();
});
test('after starting a scroll, it cannot become a drag', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 110}));
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 100}));
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 110, clientY: 100}));
expect(onDrag).not.toHaveBeenCalled();
});
test('start -> end unbinds', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
expect(onDrag).toHaveBeenCalledTimes(1);
window.dispatchEvent(new MouseEvent('touchend', {clientX: 150, clientY: 106}));
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
});
test('start -> end calls dragEnd callback after resetting internal state', done => {
onDragEnd = () => {
expect(dragRecognizer.gestureInProgress()).toBe(false);
done();
};
dragRecognizer = new DragRecognizer({onDrag, onDragEnd});
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
window.dispatchEvent(new MouseEvent('touchend', {clientX: 150, clientY: 106}));
});
test('start -> reset unbinds', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
expect(onDrag).toHaveBeenCalledTimes(1);
dragRecognizer.reset();
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
});
test('scrolls do not call prevent default', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
const event = new MouseEvent('touchmove', {clientX: 100, clientY: 110});
event.preventDefault = jest.fn();
window.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalledTimes(0);
});
test('confirmed drags have preventDefault called on them', () => {
dragRecognizer.start({clientX: 100, clientY: 100});
const event = new MouseEvent('touchmove', {clientX: 150, clientY: 106});
event.preventDefault = jest.fn();
window.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
test('multiple horizontal drag angles', () => {
// +45 from horizontal => drag
dragRecognizer.start({clientX: 0, clientY: 0});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: 10}));
expect(onDrag).toHaveBeenCalledTimes(1);
dragRecognizer.reset();
// -45 from horizontal => drag
dragRecognizer.start({clientX: 0, clientY: 0});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: -10}));
expect(onDrag).toHaveBeenCalledTimes(2);
dragRecognizer.reset();
// +135 from horizontal => drag
dragRecognizer.start({clientX: 0, clientY: 0});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: 10}));
expect(onDrag).toHaveBeenCalledTimes(3);
dragRecognizer.reset();
// -135 from horizontal => drag
dragRecognizer.start({clientX: 0, clientY: 0});
window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: -10}));
expect(onDrag).toHaveBeenCalledTimes(4);
dragRecognizer.reset();
});
});

View File

@@ -0,0 +1,74 @@
import {indexForPositionOnList} from '../../../src/lib/drag-utils';
const box = (top, right, bottom, left) => ({top, right, bottom, left});
describe('indexForPositionOnList', () => {
test('returns null when not given any boxes', () => {
expect(indexForPositionOnList({x: 0, y: 0}, [])).toEqual(null);
});
test('wrapped list with incomplete last row LTR', () => {
const boxes = [
box(0, 100, 100, 0), // index: 0
box(0, 200, 100, 100), // index: 1
box(0, 300, 100, 200), // index: 2
box(100, 100, 200, 0), // index: 3 (second row)
box(100, 200, 200, 100) // index: 4 (second row, left incomplete intentionally)
];
// Inside the second box.
expect(indexForPositionOnList({x: 150, y: 50}, boxes, false)).toEqual(1);
// On the border edge of the first and second box. Given to the first box.
expect(indexForPositionOnList({x: 100, y: 50}, boxes, false)).toEqual(0);
// Off the top/left edge.
expect(indexForPositionOnList({x: -100, y: -100}, boxes, false)).toEqual(0);
// Off the left edge, in the second row.
expect(indexForPositionOnList({x: -100, y: 175}, boxes, false)).toEqual(3);
// Off the right edge, in the first row.
expect(indexForPositionOnList({x: 400, y: 75}, boxes, false)).toEqual(2);
// Off the top edge, middle of second item.
expect(indexForPositionOnList({x: 150, y: -75}, boxes, false)).toEqual(1);
// Within the right edge bounds, but on the second (incomplete) row.
// This tests that wrapped lists with incomplete final rows work correctly.
expect(indexForPositionOnList({x: 375, y: 175}, boxes, false)).toEqual(4);
});
test('wrapped list with incomplete last row RTL', () => {
const boxes = [
box(0, 0, 100, -100), // index: 0
box(0, -100, 100, -200), // index: 1
box(0, -200, 100, -300), // index: 2
box(100, 0, 200, -100), // index: 3 (second row)
box(100, -100, 200, -200) // index: 4 (second row, left incomplete intentionally)
];
// Inside the second box.
expect(indexForPositionOnList({x: -150, y: 50}, boxes, true)).toEqual(1);
// On the border edge of the first and second box. Given to the first box.
expect(indexForPositionOnList({x: -100, y: 50}, boxes, true)).toEqual(0);
// Off the top/right edge.
expect(indexForPositionOnList({x: 100, y: -100}, boxes, true)).toEqual(0);
// Off the right edge, in the second row.
expect(indexForPositionOnList({x: 100, y: 175}, boxes, true)).toEqual(3);
// Off the left edge, in the first row.
expect(indexForPositionOnList({x: -400, y: 75}, boxes, true)).toEqual(2);
// Off the top edge, middle of second item.
expect(indexForPositionOnList({x: -150, y: -75}, boxes, true)).toEqual(1);
// Within the left edge bounds, but on the second (incomplete) row.
// This tests that wrapped lists with incomplete final rows work correctly.
expect(indexForPositionOnList({x: -375, y: 175}, boxes, true)).toEqual(4);
});
});

View File

@@ -0,0 +1,11 @@
import {HAS_FONT_REGEXP} from '../../../src/lib/get-costume-url';
describe('SVG Font Parsing', () => {
test('Has font regexp works', () => {
expect('font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
expect('font-family="none" font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
expect('font-family = "Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
expect('font-family="none"'.match(HAS_FONT_REGEXP)).toBeFalsy();
});
});

View File

@@ -0,0 +1,81 @@
import React from 'react';
import configureStore from 'redux-mock-store';
import {mount} from 'enzyme';
import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx';
jest.mock('react-ga');
describe('HashParserHOC', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectState: {}
}
});
});
test('when there is a hash, it passes the hash as projectId', () => {
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = HashParserHOC(Component);
window.location.hash = '#1234567';
const mockSetProjectIdFunc = jest.fn();
mount(
<WrappedComponent
setProjectId={mockSetProjectIdFunc}
store={store}
/>
);
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('1234567');
});
test('when there is no hash, it passes 0 as the projectId', () => {
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = HashParserHOC(Component);
window.location.hash = '';
const mockSetProjectIdFunc = jest.fn();
mount(
<WrappedComponent
setProjectId={mockSetProjectIdFunc}
store={store}
/>
);
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('0');
});
test('when the hash is not a number, it passes 0 as projectId', () => {
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = HashParserHOC(Component);
window.location.hash = '#winning';
const mockSetProjectIdFunc = jest.fn();
mount(
<WrappedComponent
setProjectId={mockSetProjectIdFunc}
store={store}
/>
);
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('0');
});
test('when hash change happens, the projectId state is changed', () => {
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = HashParserHOC(Component);
window.location.hash = '';
const mockSetProjectIdFunc = jest.fn();
const mounted = mount(
<WrappedComponent
setProjectId={mockSetProjectIdFunc}
store={store}
/>
);
window.location.hash = '#1234567';
mounted
.childAt(0)
.instance()
.handleHashChange();
expect(mockSetProjectIdFunc.mock.calls.length).toBe(2);
});
});

View File

@@ -0,0 +1,15 @@
import opcodeLabels from '../../../src/lib/opcode-labels';
describe('Opcode Labels', () => {
test('day of week label', () => {
const labelFun = opcodeLabels.getLabel('sensing_current').labelFn;
expect(labelFun({CURRENTMENU: 'dayofweek'})).toBe('day of week');
expect(labelFun({CURRENTMENU: 'DAYOFWEEK'})).toBe('day of week');
});
test('unspecified opcodes default to extension category and opcode as label', () => {
const labelInfo = opcodeLabels.getLabel('music_getTempo');
expect(labelInfo.label).toBe('music_getTempo');
expect(labelInfo.category).toBe('extension');
});
});

View File

@@ -0,0 +1,68 @@
import React from 'react';
import configureStore from 'redux-mock-store';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import ProjectFetcherHOC from '../../../src/lib/project-fetcher-hoc.jsx';
import storage from '../../../src/lib/storage';
import {LoadingState} from '../../../src/reducers/project-state';
jest.mock('react-ga');
describe('ProjectFetcherHOC', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectState: {},
vm: {
clear: () => {},
stop: () => {}
}
}
});
});
test.skip('when there is an id, it tries to update the store with that id', () => {
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = ProjectFetcherHOC(Component);
const mockSetProjectIdFunc = jest.fn();
mountWithIntl(
<WrappedComponent
projectId="100"
setProjectId={mockSetProjectIdFunc}
store={store}
/>
);
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('100');
});
test.skip('when there is a reduxProjectId and isFetchingWithProjectId is true, it loads the project', () => {
const mockedOnFetchedProject = jest.fn();
const originalLoad = storage.load;
storage.load = jest.fn((type, id) => Promise.resolve({data: id}));
const Component = ({projectId}) => <div>{projectId}</div>;
const WrappedComponent = ProjectFetcherHOC(Component);
const mounted = mountWithIntl(
<WrappedComponent
store={store}
onFetchedProjectData={mockedOnFetchedProject}
/>
);
mounted.setProps({
reduxProjectId: '100',
isFetchingWithId: true,
loadingState: LoadingState.FETCHING_WITH_ID
});
expect(storage.load).toHaveBeenLastCalledWith(
storage.AssetType.Project, '100', storage.DataFormat.JSON
);
storage.load = originalLoad;
// nextTick needed since storage.load is async, and onFetchedProject is called in its then()
process.nextTick(
() => expect(mockedOnFetchedProject)
.toHaveBeenLastCalledWith('100', LoadingState.FETCHING_WITH_ID)
);
});
});

View File

@@ -0,0 +1,491 @@
import 'web-audio-test-api';
import React from 'react';
import configureStore from 'redux-mock-store';
import {mount} from 'enzyme';
import {LoadingState} from '../../../src/reducers/project-state';
import VM from 'scratch-vm';
import projectSaverHOC from '../../../src/lib/project-saver-hoc.jsx';
describe('projectSaverHOC', () => {
const mockStore = configureStore();
let store;
let vm;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectChanged: false,
projectState: {},
projectTitle: 'Scratch Project',
timeout: {
autoSaveTimeoutId: null
}
},
locales: {
locale: 'en'
}
});
vm = new VM();
jest.useFakeTimers();
});
test('if canSave becomes true when showing a project with an id, project will be saved', () => {
const mockedUpdateProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
isShowingWithId
canSave={false}
isCreatingNew={false}
isShowingSaveable={false} // set explicitly because it relies on ownProps.canSave
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.SHOWING_WITH_ID}
store={store}
vm={vm}
onAutoUpdateProject={mockedUpdateProject}
/>
);
mounted.setProps({
canSave: true,
isShowingSaveable: true
});
expect(mockedUpdateProject).toHaveBeenCalled();
});
test('if canSave is already true and we show a project with an id, project will NOT be saved', () => {
const mockedSaveProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
canSave
isCreatingNew={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.LOADING_VM_WITH_ID}
store={store}
vm={vm}
onAutoUpdateProject={mockedSaveProject}
/>
);
mounted.setProps({
canSave: true,
isShowingWithId: true,
loadingState: LoadingState.SHOWING_WITH_ID
});
expect(mockedSaveProject).not.toHaveBeenCalled();
});
test('if canSave is false when showing a project without an id, project will NOT be created', () => {
const mockedCreateProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
isShowingWithoutId
canSave={false}
isCreatingNew={false}
isShowingWithId={false}
isUpdating={false}
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
store={store}
vm={vm}
onCreateProject={mockedCreateProject}
/>
);
mounted.setProps({
isShowingWithoutId: true,
loadingState: LoadingState.SHOWING_WITHOUT_ID
});
expect(mockedCreateProject).not.toHaveBeenCalled();
});
test('if canCreateNew becomes true when showing a project without an id, project will be created', () => {
const mockedCreateProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
isShowingWithoutId
canCreateNew={false}
isCreatingNew={false}
isShowingWithId={false}
isUpdating={false}
loadingState={LoadingState.SHOWING_WITHOUT_ID}
store={store}
vm={vm}
onCreateProject={mockedCreateProject}
/>
);
mounted.setProps({
canCreateNew: true
});
expect(mockedCreateProject).toHaveBeenCalled();
});
test('if canCreateNew is true and we transition to showing new project, project will be created', () => {
const mockedCreateProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
canCreateNew
isCreatingNew={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
store={store}
vm={vm}
onCreateProject={mockedCreateProject}
/>
);
mounted.setProps({
isShowingWithoutId: true,
loadingState: LoadingState.SHOWING_WITHOUT_ID
});
expect(mockedCreateProject).toHaveBeenCalled();
});
test('if we enter creating new state, vm project should be requested', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedStoreProject = jest.fn(() => Promise.resolve());
// The first wrapper is redux's Connect HOC
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
canSave
isCreatingCopy={false}
isCreatingNew={false}
isRemixing={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
reduxProjectId={'100'}
store={store}
vm={vm}
/>
);
mounted.setProps({
isCreatingNew: true,
loadingState: LoadingState.CREATING_NEW
});
expect(mockedStoreProject).toHaveBeenCalled();
});
test('if we enter remixing state, vm project should be requested, and alert should show', () => {
const mockedShowCreatingRemixAlert = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedStoreProject = jest.fn(() => Promise.resolve());
// The first wrapper is redux's Connect HOC
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
canSave
isCreatingCopy={false}
isCreatingNew={false}
isRemixing={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.SHOWING_WITH_ID}
reduxProjectId={'100'}
store={store}
vm={vm}
onShowCreatingRemixAlert={mockedShowCreatingRemixAlert}
/>
);
mounted.setProps({
isRemixing: true,
loadingState: LoadingState.REMIXING
});
expect(mockedStoreProject).toHaveBeenCalled();
expect(mockedShowCreatingRemixAlert).toHaveBeenCalled();
});
test('if we enter creating copy state, vm project should be requested, and alert should show', () => {
const mockedShowCreatingCopyAlert = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedStoreProject = jest.fn(() => Promise.resolve());
// The first wrapper is redux's Connect HOC
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
canSave
isCreatingCopy={false}
isCreatingNew={false}
isRemixing={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.SHOWING_WITH_ID}
reduxProjectId={'100'}
store={store}
vm={vm}
onShowCreatingCopyAlert={mockedShowCreatingCopyAlert}
/>
);
mounted.setProps({
isCreatingCopy: true,
loadingState: LoadingState.CREATING_COPY
});
expect(mockedStoreProject).toHaveBeenCalled();
expect(mockedShowCreatingCopyAlert).toHaveBeenCalled();
});
test('if we enter updating/saving state, vm project should be requested', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedStoreProject = jest.fn(() => Promise.resolve());
// The first wrapper is redux's Connect HOC
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
canSave
isCreatingNew={false}
isShowingWithId={false}
isShowingWithoutId={false}
isUpdating={false}
loadingState={LoadingState.LOADING_VM_WITH_ID}
reduxProjectId={'100'}
store={store}
vm={vm}
/>
);
mounted.setProps({
isUpdating: true,
loadingState: LoadingState.MANUAL_UPDATING
});
expect(mockedStoreProject).toHaveBeenCalled();
});
test('if we are already in updating/saving state, vm project ' +
'should NOT requested, alert should NOT show', () => {
const mockedShowCreatingAlert = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedStoreProject = jest.fn(() => Promise.resolve());
// The first wrapper is redux's Connect HOC
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
canSave
isUpdating
isCreatingNew={false}
isShowingWithId={false}
isShowingWithoutId={false}
loadingState={LoadingState.MANUAL_UPDATING}
reduxProjectId={'100'}
store={store}
vm={vm}
onShowCreatingAlert={mockedShowCreatingAlert}
/>
);
mounted.setProps({
isUpdating: true,
loadingState: LoadingState.AUTO_UPDATING,
reduxProjectId: '99' // random change to force a re-render and componentDidUpdate
});
expect(mockedStoreProject).not.toHaveBeenCalled();
expect(mockedShowCreatingAlert).not.toHaveBeenCalled();
});
test('if user saves, inline saving alert should show', () => {
const mockedShowSavingAlert = jest.fn();
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mounted = mount(
<WrappedComponent
canSave
isShowingWithoutId
canCreateNew={false}
isCreatingNew={false}
isManualUpdating={false}
isShowingWithId={false}
isUpdating={false}
loadingState={LoadingState.SHOWING_WITH_ID}
store={store}
vm={vm}
onShowSavingAlert={mockedShowSavingAlert}
/>
);
mounted.setProps({
isManualUpdating: true,
isUpdating: true
});
expect(mockedShowSavingAlert).toHaveBeenCalled();
});
test('if project is changed, it should autosave after interval', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
const mounted = mount(
<WrappedComponent
canSave
isShowingSaveable
isShowingWithId
loadingState={LoadingState.SHOWING_WITH_ID}
store={store}
vm={vm}
onAutoUpdateProject={mockedAutoUpdate}
/>
);
mounted.setProps({
projectChanged: true
});
// Fast-forward until all timers have been executed
jest.runAllTimers();
expect(mockedAutoUpdate).toHaveBeenCalled();
});
test('if project is changed several times in a row, it should only autosave once', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
const mounted = mount(
<WrappedComponent
canSave
isShowingSaveable
isShowingWithId
loadingState={LoadingState.SHOWING_WITH_ID}
store={store}
vm={vm}
onAutoUpdateProject={mockedAutoUpdate}
/>
);
mounted.setProps({
projectChanged: true,
reduxProjectTitle: 'a'
});
mounted.setProps({
projectChanged: true,
reduxProjectTitle: 'b'
});
mounted.setProps({
projectChanged: true,
reduxProjectTitle: 'c'
});
// Fast-forward until all timers have been executed
jest.runAllTimers();
expect(mockedAutoUpdate).toHaveBeenCalledTimes(1);
});
test('if project is not changed, it should not autosave after interval', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
const mounted = mount(
<WrappedComponent
canSave
isShowingSaveable
isShowingWithId
loadingState={LoadingState.SHOWING_WITH_ID}
store={store}
vm={vm}
onAutoUpdateProject={mockedAutoUpdate}
/>
);
mounted.setProps({
projectChanged: false
});
// Fast-forward until all timers have been executed
jest.runAllTimers();
expect(mockedAutoUpdate).not.toHaveBeenCalled();
});
test('when starting to remix, onRemixing should be called with param true', () => {
const mockedOnRemixing = jest.fn();
const mockedStoreProject = jest.fn(() => Promise.resolve());
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
isRemixing={false}
store={store}
vm={vm}
onRemixing={mockedOnRemixing}
/>
);
mounted.setProps({
isRemixing: true
});
expect(mockedOnRemixing).toHaveBeenCalledWith(true);
});
test('when starting to remix, onRemixing should be called with param false', () => {
const mockedOnRemixing = jest.fn();
const mockedStoreProject = jest.fn(() => Promise.resolve());
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
const mounted = mount(
<WrappedComponent
isRemixing
store={store}
vm={vm}
onRemixing={mockedOnRemixing}
/>
);
mounted.setProps({
isRemixing: false
});
expect(mockedOnRemixing).toHaveBeenCalledWith(false);
});
test('uses onSetProjectThumbnailer on mount/unmount', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const setThumb = jest.fn();
const mounted = mount(
<WrappedComponent
store={store}
vm={vm}
onSetProjectThumbnailer={setThumb}
/>
);
// Set project thumbnailer should be called on mount
expect(setThumb).toHaveBeenCalledTimes(1);
// And it should not pass that function on to wrapped element
expect(mounted.find(Component).props().onSetProjectThumbnailer).toBeUndefined();
// Unmounting should call it again with null
mounted.unmount();
expect(setThumb).toHaveBeenCalledTimes(2);
expect(setThumb.mock.calls[1][0]).toBe(null);
});
test('uses onSetProjectSaver on mount/unmount', () => {
const Component = () => <div />;
const WrappedComponent = projectSaverHOC(Component);
const setSaver = jest.fn();
const mounted = mount(
<WrappedComponent
store={store}
vm={vm}
onSetProjectSaver={setSaver}
/>
);
// Set project saver should be called on mount
expect(setSaver).toHaveBeenCalledTimes(1);
// And it should not pass that function on to wrapped element
expect(mounted.find(Component).props().onSetProjectSaver).toBeUndefined();
// Unmounting should call it again with null
mounted.unmount();
expect(setSaver).toHaveBeenCalledTimes(2);
expect(setSaver.mock.calls[1][0]).toBe(null);
});
});

View File

@@ -0,0 +1,110 @@
import 'web-audio-test-api';
import React from 'react';
import configureStore from 'redux-mock-store';
import {mountWithIntl, shallowWithIntl} from '../../helpers/intl-helpers.jsx';
import {LoadingState} from '../../../src/reducers/project-state';
import VM from 'scratch-vm';
import SBFileUploaderHOC from '../../../src/lib/sb-file-uploader-hoc.jsx';
describe('SBFileUploaderHOC', () => {
const mockStore = configureStore();
let store;
let vm;
// Wrap this in a function so it gets test specific states and can be reused.
const getContainer = function () {
const Component = () => <div />;
return SBFileUploaderHOC(Component);
};
const shallowMountWithContext = component => (
shallowWithIntl(component, {context: {store}})
);
const unwrappedInstance = () => {
const WrappedComponent = getContainer();
// default starting state: looking at a project you created, not logged in
const wrapper = shallowMountWithContext(
<WrappedComponent
projectChanged
canSave={false}
cancelFileUpload={jest.fn()}
closeFileMenu={jest.fn()}
requestProjectUpload={jest.fn()}
userOwnsProject={false}
vm={vm}
onLoadingFinished={jest.fn()}
onLoadingStarted={jest.fn()}
onUpdateProjectTitle={jest.fn()}
/>
);
return wrapper
.dive() // unwrap intl
.dive() // unwrap redux Connect(SBFileUploaderComponent)
.instance(); // SBFileUploaderComponent
};
beforeEach(() => {
vm = new VM();
store = mockStore({
scratchGui: {
projectState: {
loadingState: LoadingState.SHOWING_WITHOUT_ID
},
vm: {}
},
locales: {
locale: 'en'
}
});
});
test('correctly sets title with .sb3 filename', () => {
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb3');
expect(projectName).toBe('my project is great');
});
test('correctly sets title with .sb2 filename', () => {
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb2');
expect(projectName).toBe('my project is great');
});
test('correctly sets title with .sb filename', () => {
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb');
expect(projectName).toBe('my project is great');
});
test('sets blank title with filename with no extension', () => {
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great');
expect(projectName).toBe('');
});
/* tw: test is broken by flag required to fix issues with multiple instances
test('if isLoadingUpload becomes true, without fileToUpload set, will call cancelFileUpload', () => {
const mockedCancelFileUpload = jest.fn();
const WrappedComponent = getContainer();
const mounted = mountWithIntl(
<WrappedComponent
projectChanged
canSave={false}
cancelFileUpload={mockedCancelFileUpload}
closeFileMenu={jest.fn()}
isLoadingUpload={false}
requestProjectUpload={jest.fn()}
store={store}
userOwnsProject={false}
vm={vm}
onLoadingFinished={jest.fn()}
onLoadingStarted={jest.fn()}
onUpdateProjectTitle={jest.fn()}
/>
);
mounted.setProps({
isLoadingUpload: true
});
expect(mockedCancelFileUpload).toHaveBeenCalled();
});
*/
});

View File

@@ -0,0 +1,165 @@
import {
DARK_THEME,
defaultColors,
DEFAULT_THEME,
getColorsForTheme,
HIGH_CONTRAST_THEME
} from '../../../src/lib/themes';
import {injectExtensionBlockTheme, injectExtensionCategoryTheme} from '../../../src/lib/themes/blockHelpers';
import {detectTheme, persistTheme} from '../../../src/lib/themes/themePersistance';
jest.mock('../../../src/lib/themes/default');
jest.mock('../../../src/lib/themes/dark');
describe('themes', () => {
let serializeToString;
describe('core functionality', () => {
test('provides the default theme colors', () => {
expect(defaultColors.motion.primary).toEqual('#111111');
});
test('returns the dark mode', () => {
const colors = getColorsForTheme(DARK_THEME);
expect(colors.motion.primary).toEqual('#AAAAAA');
});
test('uses default theme colors when not specified', () => {
const colors = getColorsForTheme(DARK_THEME);
expect(colors.motion.secondary).toEqual('#222222');
});
});
describe('block helpers', () => {
beforeEach(() => {
serializeToString = jest.fn(() => 'mocked xml');
global.XMLSerializer = () => ({
serializeToString
});
});
test('updates extension block colors based on theme', () => {
const blockInfoJson = {
type: 'dummy_block',
colour: '#0FBD8C',
colourSecondary: '#0DA57A',
colourTertiary: '#0B8E69'
};
const updated = injectExtensionBlockTheme(blockInfoJson, DARK_THEME);
expect(updated).toEqual({
type: 'dummy_block',
colour: '#FFFFFF',
colourSecondary: '#EEEEEE',
colourTertiary: '#DDDDDD'
});
// The original value was not modified
expect(blockInfoJson.colour).toBe('#0FBD8C');
});
test('updates extension block icon based on theme', () => {
const blockInfoJson = {
type: 'pen_block',
args0: [
{
type: 'field_image',
src: 'original'
}
],
colour: '#0FBD8C',
colourSecondary: '#0DA57A',
colourTertiary: '#0B8E69'
};
const updated = injectExtensionBlockTheme(blockInfoJson, DARK_THEME);
expect(updated).toEqual({
type: 'pen_block',
args0: [
{
type: 'field_image',
src: 'darkPenIcon'
}
],
colour: '#FFFFFF',
colourSecondary: '#EEEEEE',
colourTertiary: '#DDDDDD'
});
// The original value was not modified
expect(blockInfoJson.args0[0].src).toBe('original');
});
test('bypasses updates if using the default theme', () => {
const blockInfoJson = {
type: 'dummy_block',
colour: '#0FBD8C',
colourSecondary: '#0DA57A',
colourTertiary: '#0B8E69'
};
const updated = injectExtensionBlockTheme(blockInfoJson, DEFAULT_THEME);
expect(updated).toEqual({
type: 'dummy_block',
colour: '#0FBD8C',
colourSecondary: '#0DA57A',
colourTertiary: '#0B8E69'
});
});
test('updates extension category based on theme', () => {
const dynamicBlockXML = [
{
id: 'pen',
xml: '<category name="Pen" id="pen" colour="#0FBD8C" secondaryColour="#0DA57A"></category>'
}
];
injectExtensionCategoryTheme(dynamicBlockXML, DARK_THEME);
// XMLSerializer is not available outside the browser.
// Verify the mocked XMLSerializer.serializeToString is called with updated colors.
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('colour')).toBe('#FFFFFF');
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('secondaryColour')).toBe('#DDDDDD');
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('iconURI')).toBe('darkPenIcon');
});
});
describe('theme persistance', () => {
test('returns the theme stored in a cookie', () => {
window.document.cookie = `scratchtheme=${HIGH_CONTRAST_THEME}`;
const theme = detectTheme();
expect(theme).toEqual(HIGH_CONTRAST_THEME);
});
test('returns the system theme when no cookie', () => {
window.document.cookie = 'scratchtheme=';
const theme = detectTheme();
expect(theme).toEqual(DEFAULT_THEME);
});
test('persists theme to cookie', () => {
window.document.cookie = 'scratchtheme=';
persistTheme(HIGH_CONTRAST_THEME);
expect(window.document.cookie).toEqual(`scratchtheme=${HIGH_CONTRAST_THEME}`);
});
test('clears theme when matching system preferences', () => {
window.document.cookie = `scratchtheme=${HIGH_CONTRAST_THEME}`;
persistTheme(DEFAULT_THEME);
expect(window.document.cookie).toEqual('scratchtheme=');
});
});
});

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {mount} from 'enzyme';
import ThrottledPropertyHOC from '../../../src/lib/throttled-property-hoc.jsx';
describe('VMListenerHOC', () => {
let mounted;
const throttleTime = 500;
beforeEach(() => {
const Component = ({propToThrottle, doNotThrottle}) => (
<input
name={doNotThrottle}
value={propToThrottle}
/>
);
const WrappedComponent = ThrottledPropertyHOC('propToThrottle', throttleTime)(Component);
global.Date.now = () => 0;
mounted = mount(
<WrappedComponent
doNotThrottle="oldvalue"
propToThrottle={0}
/>
);
});
test('it passes the props on initial render ', () => {
expect(mounted.find('[value=0]').exists()).toEqual(true);
expect(mounted.find('[name="oldvalue"]').exists()).toEqual(true);
});
test('it does not rerender if throttled prop is updated too soon', () => {
global.Date.now = () => throttleTime / 2;
mounted.setProps({propToThrottle: 1});
mounted.update();
expect(mounted.find('[value=0]').exists()).toEqual(true);
});
test('it does rerender if throttled prop is updated after throttle timeout', () => {
global.Date.now = () => throttleTime * 2;
mounted.setProps({propToThrottle: 1});
mounted.update();
expect(mounted.find('[value=1]').exists()).toEqual(true);
});
test('it does rerender if a non-throttled prop is changed', () => {
global.Date.now = () => throttleTime / 2;
mounted.setProps({doNotThrottle: 'newvalue', propToThrottle: 2});
mounted.update();
expect(mounted.find('[name="newvalue"]').exists()).toEqual(true);
expect(mounted.find('[value=2]').exists()).toEqual(true);
});
});

View File

@@ -0,0 +1,25 @@
describe('no-op', () => {
test('no-op', () => {});
});
// tw: we intentionally break this test
/*
import {translateVideo} from '../../../src/lib/libraries/decks/translate-video.js';
describe('translateVideo', () => {
test('returns the id if it is not found', () => {
expect(translateVideo('not-a-key', 'en')).toEqual('not-a-key');
});
test('returns the expected id for Japanese', () => {
expect(translateVideo('intro-move-sayhello', 'ja')).toEqual('v2c2f3y2sc');
});
test('returns the expected id for English', () => {
expect(translateVideo('intro-move-sayhello', 'en')).toEqual('rpjvs3v9gj');
});
test('returns the English id for non-existent locales', () => {
expect(translateVideo('intro-move-sayhello', 'yum')).toEqual('rpjvs3v9gj');
});
});
*/

View File

@@ -0,0 +1,47 @@
jest.mock('../../../src/lib/analytics.js', () => ({
event: () => {}
}));
jest.mock('../../../src/lib/libraries/decks/index.jsx', () => ({
noUrlId: {},
foo: {urlId: 'one'},
noUrlIdSandwich: {}
}));
import queryString from 'query-string';
import {detectTutorialId} from '../../../src/lib/tutorial-from-url.js';
test('returns the tutorial ID if the urlId matches', () => {
const queryParams = queryString.parse('?tutorial=one');
expect(detectTutorialId(queryParams)).toBe('foo');
});
test('returns null if no matching urlId', () => {
const queryParams = queryString.parse('?tutorial=10');
expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if empty template', () => {
const queryParams = queryString.parse('?tutorial=');
expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if no query param', () => {
const queryParams = queryString.parse('');
expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if unrecognized template', () => {
const queryParams = queryString.parse('?tutorial=asdf');
expect(detectTutorialId(queryParams)).toBe(null);
});
test('takes the first of multiple', () => {
const queryParams = queryString.parse('?tutorial=one&tutorial=two');
expect(detectTutorialId(queryParams)).toBe('foo');
});
test('returns all for the tutorial library shortcut', () => {
const queryParams = queryString.parse('?tutorial=all');
expect(detectTutorialId(queryParams)).toBe('all');
});

View File

@@ -0,0 +1,186 @@
import React from 'react';
import configureStore from 'redux-mock-store';
import {mount} from 'enzyme';
import VM from 'scratch-vm';
import vmListenerHOC from '../../../src/lib/vm-listener-hoc.jsx';
describe('VMListenerHOC', () => {
const mockStore = configureStore();
let store;
let vm;
beforeEach(() => {
vm = new VM();
store = mockStore({
scratchGui: {
mode: {},
modals: {},
vm: vm,
tw: {hasCloudVariables: false}
}
});
});
test('vm green flag event is bound to the passed in prop callback', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
const onGreenFlag = jest.fn();
mount(
<WrappedComponent
store={store}
vm={vm}
onGreenFlag={onGreenFlag}
/>
);
expect(onGreenFlag).not.toHaveBeenCalled();
vm.emit('PROJECT_START');
expect(onGreenFlag).toHaveBeenCalled();
});
test('onGreenFlag is not passed to the children', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
const wrapper = mount(
<WrappedComponent
store={store}
vm={vm}
onGreenFlag={jest.fn()}
/>
);
const child = wrapper.find(Component);
expect(child.props().onGreenFlag).toBeUndefined();
});
test('targetsUpdate event from vm triggers targets update action', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
mount(
<WrappedComponent
store={store}
vm={vm}
/>
);
const targetList = [];
const editingTarget = 'id';
vm.emit('targetsUpdate', {targetList, editingTarget});
const actions = store.getActions();
expect(actions[0].type).toEqual('scratch-gui/targets/UPDATE_TARGET_LIST');
expect(actions[0].targets).toEqual(targetList);
expect(actions[0].editingTarget).toEqual(editingTarget);
});
test('targetsUpdate does not dispatch if the sound recorder is visible', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
store = mockStore({
scratchGui: {
mode: {},
modals: {soundRecorder: true},
vm: vm,
tw: {hasCloudVariables: false}
}
});
mount(
<WrappedComponent
store={store}
vm={vm}
/>
);
const targetList = [];
const editingTarget = 'id';
vm.emit('targetsUpdate', {targetList, editingTarget});
const actions = store.getActions();
expect(actions.length).toEqual(0);
});
test('PROJECT_CHANGED does dispatch if the sound recorder is visible', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
store = mockStore({
scratchGui: {
mode: {},
modals: {soundRecorder: true},
vm: vm,
tw: {hasCloudVariables: false}
}
});
mount(
<WrappedComponent
store={store}
vm={vm}
/>
);
vm.emit('PROJECT_CHANGED');
const actions = store.getActions();
expect(actions.length).toEqual(1);
});
test('PROJECT_CHANGED does not dispatch if in fullscreen mode', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
store = mockStore({
scratchGui: {
mode: {isFullScreen: true},
modals: {soundRecorder: true},
vm: vm,
tw: {hasCloudVariables: false}
}
});
mount(
<WrappedComponent
store={store}
vm={vm}
/>
);
vm.emit('PROJECT_CHANGED');
const actions = store.getActions();
expect(actions.length).toEqual(0);
});
test('keypresses go to the vm', () => {
const Component = () => (<div />);
const WrappedComponent = vmListenerHOC(Component);
// Mock document.addEventListener so we can trigger keypresses manually
// Cannot use the enzyme simulate method because that only works on synthetic events
const eventTriggers = {};
document.addEventListener = jest.fn((event, cb) => {
eventTriggers[event] = cb;
});
vm.postIOData = jest.fn();
store = mockStore({
scratchGui: {
mode: {isFullScreen: true},
modals: {soundRecorder: true},
vm: vm,
tw: {hasCloudVariables: false}
}
});
mount(
<WrappedComponent
attachKeyboardEvents
store={store}
vm={vm}
/>
);
// keyboard events that do not target the document or body are ignored
eventTriggers.keydown({key: 'A', target: null});
expect(vm.postIOData).not.toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
// keydown/up with target as the document are sent to the vm via postIOData
eventTriggers.keydown({key: 'A', target: document});
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
eventTriggers.keyup({key: 'A', target: document});
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: false});
// When key is 'Dead' e.g. bluetooth keyboards on iOS, it sends keyCode instead
// because the VM can process both named keys or keyCodes as the `key` property
eventTriggers.keyup({key: 'Dead', keyCode: 10, target: document});
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 10, isDown: false, keyCode: 10});
});
});

View File

@@ -0,0 +1,199 @@
/* global WebAudioTestAPI */
import 'web-audio-test-api';
WebAudioTestAPI.setState({
'AudioContext#resume': 'enabled'
});
import React from 'react';
import configureStore from 'redux-mock-store';
import {mount} from 'enzyme';
import VM from 'scratch-vm';
import {LoadingState} from '../../../src/reducers/project-state';
import vmManagerHOC from '../../../src/lib/vm-manager-hoc.jsx';
describe('VMManagerHOC', () => {
const mockStore = configureStore();
let store;
let vm;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectState: {},
mode: {},
vmStatus: {}
},
locales: {
locale: '',
messages: {}
}
});
vm = new VM();
vm.attachAudioEngine = jest.fn();
vm.setCompatibilityMode = jest.fn();
vm.setLocale = jest.fn();
vm.start = jest.fn();
});
test('when it mounts in player mode, the vm is initialized but not started', () => {
const Component = () => (<div />);
const WrappedComponent = vmManagerHOC(Component);
mount(
<WrappedComponent
isPlayerOnly
isStarted={false}
store={store}
vm={vm}
/>
);
expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
expect(vm.setLocale.mock.calls.length).toBe(1);
expect(vm.initialized).toBe(true);
// But vm should not be started automatically
expect(vm.start).not.toHaveBeenCalled();
});
test('when it mounts in editor mode, the vm is initialized and started', () => {
const Component = () => (<div />);
const WrappedComponent = vmManagerHOC(Component);
mount(
<WrappedComponent
isPlayerOnly={false}
isStarted={false}
store={store}
vm={vm}
/>
);
expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
expect(vm.setLocale.mock.calls.length).toBe(1);
expect(vm.initialized).toBe(true);
expect(vm.start).toHaveBeenCalled();
});
test('if it mounts with an initialized vm, it does not reinitialize the vm but will start it', () => {
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
vm.initialized = true;
mount(
<WrappedComponent
isPlayerOnly={false}
isStarted={false}
store={store}
vm={vm}
/>
);
expect(vm.attachAudioEngine.mock.calls.length).toBe(0);
expect(vm.setLocale.mock.calls.length).toBe(0);
expect(vm.initialized).toBe(true);
expect(vm.start).toHaveBeenCalled();
});
test('if it mounts without starting the VM, it can be started by switching to editor mode', () => {
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
vm.initialized = true;
const mounted = mount(
<WrappedComponent
isPlayerOnly
isStarted={false}
store={store}
vm={vm}
/>
);
expect(vm.start).not.toHaveBeenCalled();
mounted.setProps({
isPlayerOnly: false
});
expect(vm.start).toHaveBeenCalled();
});
test('if it mounts with an initialized and started VM, it does not start again', () => {
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
vm.initialized = true;
const mounted = mount(
<WrappedComponent
isPlayerOnly
isStarted
store={store}
vm={vm}
/>
);
expect(vm.start).not.toHaveBeenCalled();
mounted.setProps({
isPlayerOnly: false
});
expect(vm.start).not.toHaveBeenCalled();
});
test('if the isLoadingWithId prop becomes true, it loads project data into the vm', () => {
vm.loadProject = jest.fn(() => Promise.resolve());
const mockedOnLoadedProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
const mounted = mount(
<WrappedComponent
fontsLoaded
isLoadingWithId={false}
store={store}
vm={vm}
onLoadedProject={mockedOnLoadedProject}
/>
);
mounted.setProps({
canSave: true,
isLoadingWithId: true,
loadingState: LoadingState.LOADING_VM_WITH_ID,
projectData: '100'
});
expect(vm.loadProject).toHaveBeenLastCalledWith('100');
// nextTick needed since vm.loadProject is async, and we have to wait for it :/
process.nextTick(() => (
expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, true)
));
});
test('if the fontsLoaded prop becomes true, it loads project data into the vm', () => {
vm.loadProject = jest.fn(() => Promise.resolve());
const mockedOnLoadedProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
const mounted = mount(
<WrappedComponent
isLoadingWithId
store={store}
vm={vm}
onLoadedProject={mockedOnLoadedProject}
/>
);
mounted.setProps({
canSave: false,
fontsLoaded: true,
loadingState: LoadingState.LOADING_VM_WITH_ID,
projectData: '100'
});
expect(vm.loadProject).toHaveBeenLastCalledWith('100');
// nextTick needed since vm.loadProject is async, and we have to wait for it :/
process.nextTick(() => (
expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, false)
));
});
test('if the fontsLoaded prop is false, project data is never loaded', () => {
vm.loadProject = jest.fn(() => Promise.resolve());
const mockedOnLoadedProject = jest.fn();
const Component = () => <div />;
const WrappedComponent = vmManagerHOC(Component);
const mounted = mount(
<WrappedComponent
isLoadingWithId
store={store}
vm={vm}
onLoadedProject={mockedOnLoadedProject}
/>
);
mounted.setProps({
loadingState: LoadingState.LOADING_VM_WITH_ID,
projectData: '100'
});
expect(vm.loadProject).toHaveBeenCalledTimes(0);
process.nextTick(() => expect(mockedOnLoadedProject).toHaveBeenCalledTimes(0));
});
});