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:
11
scratch-gui/test/.eslintrc.js
Normal file
11
scratch-gui/test/.eslintrc.js
Normal 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
|
||||
}
|
||||
};
|
||||
15
scratch-gui/test/__mocks__/audio-buffer-player.js
Normal file
15
scratch-gui/test/__mocks__/audio-buffer-player.js
Normal 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;
|
||||
}
|
||||
}
|
||||
24
scratch-gui/test/__mocks__/audio-effects.js
Normal file
24
scratch-gui/test/__mocks__/audio-effects.js
Normal 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;
|
||||
}
|
||||
}
|
||||
3
scratch-gui/test/__mocks__/editor-msgs-mock.js
Normal file
3
scratch-gui/test/__mocks__/editor-msgs-mock.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
en: {}
|
||||
};
|
||||
3
scratch-gui/test/__mocks__/fileMock.js
Normal file
3
scratch-gui/test/__mocks__/fileMock.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// __mocks__/fileMock.js
|
||||
|
||||
module.exports = 'test-file-stub';
|
||||
3
scratch-gui/test/__mocks__/styleMock.js
Normal file
3
scratch-gui/test/__mocks__/styleMock.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// __mocks__/styleMock.js
|
||||
|
||||
module.exports = {};
|
||||
20
scratch-gui/test/fixtures/100-100.svg
vendored
Normal file
20
scratch-gui/test/fixtures/100-100.svg
vendored
Normal 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
BIN
scratch-gui/test/fixtures/bmpfile.bmp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
scratch-gui/test/fixtures/corrupt-bmp.sb3
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupt-bmp.sb3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/corrupt-bmp.sprite3
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupt-bmp.sprite3
vendored
Normal file
Binary file not shown.
14
scratch-gui/test/fixtures/corrupt-from-scratch3.svg
vendored
Normal file
14
scratch-gui/test/fixtures/corrupt-from-scratch3.svg
vendored
Normal 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="{"isPaintingLayer":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 |
BIN
scratch-gui/test/fixtures/corrupt-svg.sb2
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupt-svg.sb2
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/corrupt-svg.sb3
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupt-svg.sb3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/corrupt-svg.sprite3
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupt-svg.sprite3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/corrupted-svg.sprite2
vendored
Normal file
BIN
scratch-gui/test/fixtures/corrupted-svg.sprite2
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/gh-3582-png.png
vendored
Normal file
BIN
scratch-gui/test/fixtures/gh-3582-png.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
scratch-gui/test/fixtures/missing-bmp.sb3
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-bmp.sb3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/missing-bmp.sprite3
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-bmp.sprite3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/missing-sprite-svg.sb3
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-sprite-svg.sb3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/missing-svg.sb2
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-svg.sb2
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/missing-svg.sprite2
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-svg.sprite2
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/missing-svg.sprite3
vendored
Normal file
BIN
scratch-gui/test/fixtures/missing-svg.sprite3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/monitor-variable.sb3
vendored
Normal file
BIN
scratch-gui/test/fixtures/monitor-variable.sb3
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/movie.wav
vendored
Normal file
BIN
scratch-gui/test/fixtures/movie.wav
vendored
Normal file
Binary file not shown.
BIN
scratch-gui/test/fixtures/paddleball.gif
vendored
Normal file
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
BIN
scratch-gui/test/fixtures/project1.sb3
vendored
Normal file
Binary file not shown.
10
scratch-gui/test/fixtures/scratch2-corrupted.svg
vendored
Normal file
10
scratch-gui/test/fixtures/scratch2-corrupted.svg
vendored
Normal 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
BIN
scratch-gui/test/fixtures/sneaker.wav
vendored
Normal file
Binary file not shown.
4
scratch-gui/test/helpers/enzyme-setup.js
Normal file
4
scratch-gui/test/helpers/enzyme-setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
Enzyme.configure({adapter: new Adapter()});
|
||||
39
scratch-gui/test/helpers/intl-helpers.jsx
Normal file
39
scratch-gui/test/helpers/intl-helpers.jsx
Normal 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
|
||||
};
|
||||
378
scratch-gui/test/helpers/selenium-helper.js
Normal file
378
scratch-gui/test/helpers/selenium-helper.js
Normal 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;
|
||||
117
scratch-gui/test/integration/backdrops.test.js
Normal file
117
scratch-gui/test/integration/backdrops.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
44
scratch-gui/test/integration/backpack.test.js
Normal file
44
scratch-gui/test/integration/backpack.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
339
scratch-gui/test/integration/blocks.test.js
Normal file
339
scratch-gui/test/integration/blocks.test.js
Normal 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 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);
|
||||
});
|
||||
});
|
||||
67
scratch-gui/test/integration/connection-modal.test.js
Normal file
67
scratch-gui/test/integration/connection-modal.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
262
scratch-gui/test/integration/costumes.test.js
Normal file
262
scratch-gui/test/integration/costumes.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
100
scratch-gui/test/integration/examples.test.js
Normal file
100
scratch-gui/test/integration/examples.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
38
scratch-gui/test/integration/how-tos.test.js
Normal file
38
scratch-gui/test/integration/how-tos.test.js
Normal 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.
|
||||
});
|
||||
109
scratch-gui/test/integration/localization.test.js
Normal file
109
scratch-gui/test/integration/localization.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
172
scratch-gui/test/integration/menu-bar.test.js
Normal file
172
scratch-gui/test/integration/menu-bar.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
117
scratch-gui/test/integration/project-loading.test.js
Normal file
117
scratch-gui/test/integration/project-loading.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
scratch-gui/test/integration/project-state.test.js
Normal file
45
scratch-gui/test/integration/project-state.test.js
Normal 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}"]`);
|
||||
});
|
||||
});
|
||||
114
scratch-gui/test/integration/sb-file-uploader-hoc.test.js
Normal file
114
scratch-gui/test/integration/sb-file-uploader-hoc.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
189
scratch-gui/test/integration/sounds.test.js
Normal file
189
scratch-gui/test/integration/sounds.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
299
scratch-gui/test/integration/sprites.test.js
Normal file
299
scratch-gui/test/integration/sprites.test.js
Normal 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
|
||||
|
||||
});
|
||||
46
scratch-gui/test/integration/stage-size.test.js
Normal file
46
scratch-gui/test/integration/stage-size.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
38
scratch-gui/test/integration/tutorials-shortcut.test.js
Normal file
38
scratch-gui/test/integration/tutorials-shortcut.test.js
Normal 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.
|
||||
});
|
||||
74
scratch-gui/test/smoke/browser.test.js
Normal file
74
scratch-gui/test/smoke/browser.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
705
scratch-gui/test/unit/addons/settings.test.js
Normal file
705
scratch-gui/test/unit/addons/settings.test.js
Normal 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');
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
23
scratch-gui/test/unit/components/button.test.jsx
Normal file
23
scratch-gui/test/unit/components/button.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
62
scratch-gui/test/unit/components/cards.test.jsx
Normal file
62
scratch-gui/test/unit/components/cards.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
40
scratch-gui/test/unit/components/controls.test.jsx
Normal file
40
scratch-gui/test/unit/components/controls.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
43
scratch-gui/test/unit/components/error-boundary-hoc.test.jsx
Normal file
43
scratch-gui/test/unit/components/error-boundary-hoc.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
37
scratch-gui/test/unit/components/icon-button.test.jsx
Normal file
37
scratch-gui/test/unit/components/icon-button.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
64
scratch-gui/test/unit/components/menu-bar.test.jsx
Normal file
64
scratch-gui/test/unit/components/menu-bar.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
*/
|
||||
78
scratch-gui/test/unit/components/monitor-list.test.jsx
Normal file
78
scratch-gui/test/unit/components/monitor-list.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
56
scratch-gui/test/unit/components/monitor.test.jsx
Normal file
56
scratch-gui/test/unit/components/monitor.test.jsx
Normal 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'});
|
||||
});
|
||||
});
|
||||
146
scratch-gui/test/unit/components/sound-editor.test.jsx
Normal file
146
scratch-gui/test/unit/components/sound-editor.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
52
scratch-gui/test/unit/components/toggle-buttons.test.jsx
Normal file
52
scratch-gui/test/unit/components/toggle-buttons.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
170
scratch-gui/test/unit/containers/menu-bar-hoc.test.jsx
Normal file
170
scratch-gui/test/unit/containers/menu-bar-hoc.test.jsx
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
*/
|
||||
76
scratch-gui/test/unit/containers/save-status.test.jsx
Normal file
76
scratch-gui/test/unit/containers/save-status.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
111
scratch-gui/test/unit/containers/slider-prompt.test.jsx
Normal file
111
scratch-gui/test/unit/containers/slider-prompt.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
306
scratch-gui/test/unit/containers/sound-editor.test.jsx
Normal file
306
scratch-gui/test/unit/containers/sound-editor.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
208
scratch-gui/test/unit/reducers/alerts-reducer.test.js
Normal file
208
scratch-gui/test/unit/reducers/alerts-reducer.test.js
Normal 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');
|
||||
});
|
||||
53
scratch-gui/test/unit/reducers/mode-reducer.test.js
Normal file
53
scratch-gui/test/unit/reducers/mode-reducer.test.js
Normal 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);
|
||||
});
|
||||
302
scratch-gui/test/unit/reducers/monitor-layout-reducer.test.js
Normal file
302
scratch-gui/test/unit/reducers/monitor-layout-reducer.test.js
Normal 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);
|
||||
});
|
||||
508
scratch-gui/test/unit/reducers/project-state-reducer.test.js
Normal file
508
scratch-gui/test/unit/reducers/project-state-reducer.test.js
Normal 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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
30
scratch-gui/test/unit/util/audio-context.test.js
Normal file
30
scratch-gui/test/unit/util/audio-context.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
91
scratch-gui/test/unit/util/audio-effects.test.js
Normal file
91
scratch-gui/test/unit/util/audio-effects.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
102
scratch-gui/test/unit/util/audio-util.test.js
Normal file
102
scratch-gui/test/unit/util/audio-util.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
401
scratch-gui/test/unit/util/cloud-manager-hoc.test.jsx
Normal file
401
scratch-gui/test/unit/util/cloud-manager-hoc.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
211
scratch-gui/test/unit/util/cloud-provider.test.js
Normal file
211
scratch-gui/test/unit/util/cloud-provider.test.js
Normal 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');
|
||||
});
|
||||
19
scratch-gui/test/unit/util/code-payload.test.js
Normal file
19
scratch-gui/test/unit/util/code-payload.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
scratch-gui/test/unit/util/default-project.test.js
Normal file
21
scratch-gui/test/unit/util/default-project.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
199
scratch-gui/test/unit/util/define-dynamic-block.test.js
Normal file
199
scratch-gui/test/unit/util/define-dynamic-block.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
86
scratch-gui/test/unit/util/detect-locale.test.js
Normal file
86
scratch-gui/test/unit/util/detect-locale.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
120
scratch-gui/test/unit/util/drag-recognizer.test.js
Normal file
120
scratch-gui/test/unit/util/drag-recognizer.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
74
scratch-gui/test/unit/util/drag-utils.test.js
Normal file
74
scratch-gui/test/unit/util/drag-utils.test.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
11
scratch-gui/test/unit/util/get-costume-url.test.js
Normal file
11
scratch-gui/test/unit/util/get-costume-url.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
81
scratch-gui/test/unit/util/hash-project-loader-hoc.test.jsx
Normal file
81
scratch-gui/test/unit/util/hash-project-loader-hoc.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
15
scratch-gui/test/unit/util/opcode-labels.test.js
Normal file
15
scratch-gui/test/unit/util/opcode-labels.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
68
scratch-gui/test/unit/util/project-fetcher-hoc.test.jsx
Normal file
68
scratch-gui/test/unit/util/project-fetcher-hoc.test.jsx
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
491
scratch-gui/test/unit/util/project-saver-hoc.test.jsx
Normal file
491
scratch-gui/test/unit/util/project-saver-hoc.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
110
scratch-gui/test/unit/util/sb-file-uploader-hoc.test.jsx
Normal file
110
scratch-gui/test/unit/util/sb-file-uploader-hoc.test.jsx
Normal 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();
|
||||
});
|
||||
*/
|
||||
});
|
||||
165
scratch-gui/test/unit/util/themes.test.js
Normal file
165
scratch-gui/test/unit/util/themes.test.js
Normal 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=');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
scratch-gui/test/unit/util/throttled-property-hoc.test.jsx
Normal file
54
scratch-gui/test/unit/util/throttled-property-hoc.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
25
scratch-gui/test/unit/util/translate-video.test.js
Normal file
25
scratch-gui/test/unit/util/translate-video.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
*/
|
||||
47
scratch-gui/test/unit/util/tutorial-from-url.test.js
Normal file
47
scratch-gui/test/unit/util/tutorial-from-url.test.js
Normal 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');
|
||||
});
|
||||
186
scratch-gui/test/unit/util/vm-listener-hoc.test.jsx
Normal file
186
scratch-gui/test/unit/util/vm-listener-hoc.test.jsx
Normal 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});
|
||||
});
|
||||
});
|
||||
199
scratch-gui/test/unit/util/vm-manager-hoc.test.jsx
Normal file
199
scratch-gui/test/unit/util/vm-manager-hoc.test.jsx
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user