diff --git a/.babelrc b/.babelrc index 97242d46ff06d0c89e2652bd3bd629e722def056..0cb0bd16047bb69cd9786166ba7709906320358e 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,7 @@ { "plugins": [ "syntax-dynamic-import", + "transform-async-to-generator", "transform-object-rest-spread", ["react-intl", { "messagesDir": "./translations/messages/" diff --git a/.travis.yml b/.travis.yml index cc8a5822a40f0588d27d8e81a5c613b940fcde2d..ec6ccee6c4196c66b195391b41637c38633ca256 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,11 @@ language: node_js sudo: false dist: trusty +addons: + chrome: stable +before_script: + - "export DISPLAY=:99.0" + - "sh -e /etc/init.d/xvfb start" node_js: - 6 cache: diff --git a/README.md b/README.md index b478b1e47504962bd77f2cc115da6e57c2dabea1..c036a170930285319aff1fe3f5b348883df9ec65 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Then go to [http://localhost:8601/](http://localhost:8601/) - the playground out ## Testing NOTE: If you're a windows user, please run these scripts in Windows `cmd.exe` instead of Git Bash/MINGW64. -Run linter, unit tests, and build. +Run linter, unit tests, build, and integration tests. ```bash npm test ``` @@ -46,6 +46,11 @@ Run unit tests in watch mode (watches for code changes and continuously runs tes npm run unit-test -- --watch ``` +Run integration tests in isolation. +```bash +npm run integration-test +``` + You may want to review the documentation for [Jest](https://facebook.github.io/jest/docs/en/api.html) and [Enzyme](http://airbnb.io/enzyme/docs/api/) as you write your tests. ## Publishing to GitHub Pages diff --git a/package.json b/package.json index 1c766e8b49309cde678121dfd18bb76add111d64..9f7b08cf6fd298fc3bd5900da8bcd44c92a78187 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "i18n:src": "babel src > tmp.js && rimraf tmp.js && ./scripts/build-i18n-source.js ./translations/messages/ ./translations/", "lint": "eslint . --ext .js,.jsx", "start": "npm run i18n:msgs && webpack-dev-server", - "unit-test": "jest", - "test": "npm run lint && npm run unit-test && npm run build", + "unit-test": "jest test/unit", + "integration-test": "npm run build && jest test/integration", + "test": "npm run lint && npm run unit-test && npm run integration-test", "watch": "webpack --progress --colors --watch" }, "author": "Massachusetts Institute of Technology", @@ -34,9 +35,11 @@ "babel-loader": "^7.0.0", "babel-plugin-react-intl": "2.3.1", "babel-plugin-syntax-dynamic-import": "6.18.0", + "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", + "chromedriver": "^2.31.0", "classnames": "2.2.5", "copy-webpack-plugin": "4.0.1", "css-loader": "0.28.3", @@ -46,6 +49,7 @@ "eslint-config-scratch": "^3.0.0", "eslint-plugin-import": "^2.7.0", "eslint-plugin-react": "^7.0.1", + "get-float-time-domain-data": "0.1.0", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", "html-webpack-plugin": "2.30.0", "immutable": "3.8.1", @@ -67,8 +71,9 @@ "react-draggable": "2.2.6", "react-intl": "2.3.0", "react-intl-redux": "0.6.0", - "react-modal": "2.2.2", - "react-redux": "5.0.5", + "react-modal": "2.2.3", + "react-redux": "5.0.6", + "react-responsive": "1.3.4", "react-style-proptype": "3.0.0", "react-tabs": "1.1.0", "react-test-renderer": "^15.5.4", @@ -81,10 +86,11 @@ "scratch-render": "latest", "scratch-storage": "^0.2.0", "scratch-vm": "latest", + "selenium-webdriver": "^3.5.0", "style-loader": "^0.18.0", "svg-to-image": "1.1.3", "svg-url-loader": "2.1.0", - "wav-encoder": "1.1.0", + "wav-encoder": "1.2.0", "web-audio-test-api": "^0.5.2", "webpack": "^2.4.1", "webpack-dev-server": "^2.4.1", diff --git a/src/components/forms/input.css b/src/components/forms/input.css index 6747e2c215a38356104d977b29b9e6008f8b42f8..91b42d4cc59e69dc505975a90d22db22891b5253 100644 --- a/src/components/forms/input.css +++ b/src/components/forms/input.css @@ -35,6 +35,6 @@ } .input-small { - width: 3.5rem; + width: 3rem; text-align: center; } diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index de9c7f9ec3918a67cfdb2b65661f9277b5dd5676..5146c84f16e5340ab0be74a4340f40c3533ebbd6 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -112,13 +112,6 @@ */ display: flex; flex-direction: column; - - /* - Calc the minimum width for this pane, accounting for left + right spacing. - If and when the width doesn't need to be fixed, we can move the spacing out - of this calc, and into padding instead - */ - flex-basis: calc(480px + ($space * 2)); } .stage-wrapper { diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index f15a5c9a933f1d1814bd54ef8537b6589cdb6645..1e7fe027e54d69c1caead489e3bb5f9f6dde6fdf 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import {Tab, Tabs, TabList, TabPanel} from 'react-tabs'; +import MediaQuery from 'react-responsive'; import tabStyles from 'react-tabs/style/react-tabs.css'; import VM from 'scratch-vm'; @@ -16,9 +17,9 @@ import StopAll from '../../containers/stop-all.jsx'; import Box from '../box/box.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; +import layout from '../../lib/layout-constants.js'; import styles from './gui.css'; - const GUIComponent = props => { const { basePath, @@ -87,20 +88,22 @@ const GUIComponent = props => { </Tabs> </Box> - <Box className={styles.stageAndTargetWrapper} > - <Box className={styles.stageMenuWrapper} > + <Box className={styles.stageAndTargetWrapper}> + <Box className={styles.stageMenuWrapper}> <GreenFlag vm={vm} /> <StopAll vm={vm} /> </Box> - - <Box className={styles.stageWrapper} > - <Stage - shrink={0} - vm={vm} - /> + <Box className={styles.stageWrapper}> + <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => ( + <Stage + height={isFullSize ? layout.fullStageHeight : layout.smallerStageHeight} + shrink={0} + vm={vm} + width={isFullSize ? layout.fullStageWidth : layout.smallerStageWidth} + /> + )}</MediaQuery> </Box> - - <Box className={styles.targetWrapper} > + <Box className={styles.targetWrapper}> <TargetPane vm={vm} /> diff --git a/src/components/sound-editor/icon--redo.svg b/src/components/sound-editor/icon--redo.svg new file mode 100644 index 0000000000000000000000000000000000000000..960785c7b6707bda17c4b2365f3b08abda42cd7c Binary files /dev/null and b/src/components/sound-editor/icon--redo.svg differ diff --git a/src/components/sound-editor/icon--undo.svg b/src/components/sound-editor/icon--undo.svg new file mode 100644 index 0000000000000000000000000000000000000000..af3d7ba4239d630cdc6c5a21344887285354b8bc Binary files /dev/null and b/src/components/sound-editor/icon--undo.svg differ diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index c209d507a64955fd60733301458a008af5609f00..88fb5773f14dc0589be7a2796f5a3d9b6a900fe5 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -35,12 +35,14 @@ padding: 3px; } +$border-radius: 0.25rem; + .button { height: 2rem; padding: 0.25rem; outline: none; background: white; - border-radius: 0.25rem; + border-radius: $border-radius; border: 1px solid #ddd; cursor: pointer; font-size: 0.85rem; @@ -83,3 +85,27 @@ width: 60px; height: 60px; } + +.button-group { + margin: 0 1rem; +} + +.button-group .button { + border-radius: 0; + border-left: none; +} + +.button-group .button:last-of-type { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; +} + +.button-group .button:first-of-type { + border-left: 1px solid #ddd; + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; +} + +.button:disabled > img { + opacity: 0.25; +} diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index 240e427194aba238e7eddd5f5c254307046ff860..21c2613bfa21511fc4e0aa0f3f1e616c0efe41e5 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -16,6 +16,8 @@ import styles from './sound-editor.css'; import playIcon from '../record-modal/icon--play.svg'; import stopIcon from '../record-modal/icon--stop-playback.svg'; import trimIcon from './icon--trim.svg'; +import redoIcon from './icon--redo.svg'; +import undoIcon from './icon--undo.svg'; import echoIcon from './icon--echo.svg'; import higherIcon from './icon--higher.svg'; import lowerIcon from './icon--lower.svg'; @@ -52,6 +54,16 @@ const messages = defineMessages({ description: 'Title of the button to save trimmed sound', defaultMessage: 'Save' }, + undo: { + id: 'soundEditor.undo', + description: 'Title of the button to undo', + defaultMessage: 'Undo' + }, + redo: { + id: 'soundEditor.redo', + description: 'Title of the button to redo', + defaultMessage: 'Redo' + }, faster: { id: 'soundEditor.faster', description: 'Title of the button to apply the faster effect', @@ -140,6 +152,24 @@ const SoundEditor = props => ( <FormattedMessage {...messages.save} /> )} </button> + <div className={styles.buttonGroup}> + <button + className={styles.button} + disabled={!props.canUndo} + title={props.intl.formatMessage(messages.undo)} + onClick={props.onUndo} + > + <img src={undoIcon} /> + </button> + <button + className={styles.button} + disabled={!props.canRedo} + title={props.intl.formatMessage(messages.redo)} + onClick={props.onRedo} + > + <img src={redoIcon} /> + </button> + </div> </div> </div> <div className={styles.row}> @@ -206,6 +236,8 @@ const SoundEditor = props => ( ); SoundEditor.propTypes = { + canRedo: PropTypes.bool.isRequired, + canUndo: PropTypes.bool.isRequired, chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, intl: intlShape, name: PropTypes.string.isRequired, @@ -215,6 +247,7 @@ SoundEditor.propTypes = { onFaster: PropTypes.func.isRequired, onLouder: PropTypes.func.isRequired, onPlay: PropTypes.func.isRequired, + onRedo: PropTypes.func.isRequired, onReverse: PropTypes.func.isRequired, onRobot: PropTypes.func.isRequired, onSetTrimEnd: PropTypes.func, @@ -222,6 +255,7 @@ SoundEditor.propTypes = { onSlower: PropTypes.func.isRequired, onSofter: PropTypes.func.isRequired, onStop: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, playhead: PropTypes.number, trimEnd: PropTypes.number, trimStart: PropTypes.number diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index fc68c9121a44f6d587b46a15d1654e372b4688f4..8b3f212d99dfa870db88c6338f4efd09cfe0a643 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -1,12 +1,14 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import MediaQuery from 'react-responsive'; import Box from '../box/box.jsx'; import Label from '../forms/label.jsx'; import Input from '../forms/input.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; +import layout from '../../lib/layout-constants.js'; import styles from './sprite-info.css'; import xIcon from './icon--x.svg'; @@ -49,12 +51,14 @@ class SpriteInfo extends React.Component { </div> <div className={styles.group}> - <div className={styles.iconWrapper}> - <img - className={classNames(styles.xIcon, styles.icon)} - src={xIcon} - /> - </div> + <MediaQuery minWidth={layout.fullSizeMinWidth}> + <div className={styles.iconWrapper}> + <img + className={classNames(styles.xIcon, styles.icon)} + src={xIcon} + /> + </div> + </MediaQuery> <Label text="x"> <BufferedInput small @@ -69,12 +73,14 @@ class SpriteInfo extends React.Component { </div> <div className={styles.group}> - <div className={styles.iconWrapper}> - <img - className={classNames(styles.yIcon, styles.icon)} - src={yIcon} - /> - </div> + <MediaQuery minWidth={layout.fullSizeMinWidth}> + <div className={styles.iconWrapper}> + <img + className={classNames(styles.yIcon, styles.icon)} + src={yIcon} + /> + </div> + </MediaQuery> <Label text="y"> <BufferedInput small diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 1328e88fc8c28a31e7af3cb2566c1aafe0a04030..67c4b1b5fdab808d0920decf7a2becd240a8d43f 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -21,7 +21,10 @@ class SoundEditor extends React.Component { 'handleActivateTrim', 'handleUpdateTrimEnd', 'handleUpdateTrimStart', - 'handleEffect' + 'handleEffect', + 'handleUndo', + 'handleRedo', + 'submitNewSamples' ]); this.state = { chunkLevels: computeChunkedRMS(this.props.samples), @@ -29,12 +32,17 @@ class SoundEditor extends React.Component { trimStart: null, trimEnd: null }; + + this.redoStack = []; + this.undoStack = []; } componentDidMount () { this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate); } componentWillReceiveProps (newProps) { if (newProps.soundId !== this.props.soundId) { // A different sound has been selected + this.redoStack = []; + this.undoStack = []; this.resetState(newProps.samples, newProps.sampleRate); } } @@ -51,7 +59,11 @@ class SoundEditor extends React.Component { trimEnd: null }); } - submitNewSamples (samples, sampleRate) { + submitNewSamples (samples, sampleRate, skipUndo) { + if (!skipUndo) { + this.redoStack = []; + this.undoStack.push(this.props.samples.slice(0)); + } this.resetState(samples, sampleRate); this.props.onUpdateSoundBuffer( this.props.soundIndex, @@ -107,10 +119,26 @@ class SoundEditor extends React.Component { this.handlePlay(); }); } + handleUndo () { + this.redoStack.push(this.props.samples.slice(0)); + const samples = this.undoStack.pop(); + if (samples) { + this.submitNewSamples(samples, this.props.sampleRate, true); + } + } + handleRedo () { + const samples = this.redoStack.pop(); + if (samples) { + this.undoStack.push(this.props.samples.slice(0)); + this.submitNewSamples(samples, this.props.sampleRate, true); + } + } render () { const {effectTypes} = AudioEffects; return ( <SoundEditorComponent + canRedo={this.redoStack.length > 0} + canUndo={this.undoStack.length > 0} chunkLevels={this.state.chunkLevels} name={this.props.name} playhead={this.state.playhead} @@ -122,6 +150,7 @@ class SoundEditor extends React.Component { onFaster={this.effectFactory(effectTypes.FASTER)} onLouder={this.effectFactory(effectTypes.LOUDER)} onPlay={this.handlePlay} + onRedo={this.handleRedo} onReverse={this.effectFactory(effectTypes.REVERSE)} onRobot={this.effectFactory(effectTypes.ROBOT)} onSetTrimEnd={this.handleUpdateTrimEnd} @@ -129,6 +158,7 @@ class SoundEditor extends React.Component { onSlower={this.effectFactory(effectTypes.SLOWER)} onSofter={this.effectFactory(effectTypes.SOFTER)} onStop={this.handleStopPlaying} + onUndo={this.handleUndo} /> ); } diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index f0545c088dc422b39b61ea7aa8ee17eb342c0853..d9ab05f12966d701d898142b9f7a5a4855af9d15 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -40,8 +40,8 @@ class Stage extends React.Component { this.audioEngine = new AudioEngine(); this.props.vm.attachAudioEngine(this.audioEngine); } - shouldComponentUpdate () { - return false; + shouldComponentUpdate (nextProps) { + return this.props.width !== nextProps.width || this.props.height !== nextProps.height; } componentWillUnmount () { this.detachMouseEvents(this.canvas); @@ -69,9 +69,10 @@ class Stage extends React.Component { this.rect = this.canvas.getBoundingClientRect(); } getScratchCoords (x, y) { + const nativeSize = this.renderer.getNativeSize(); return [ - x - (this.rect.width / 2), - y - (this.rect.height / 2) + (nativeSize[0] / this.rect.width) * (x - (this.rect.width / 2)), + (nativeSize[1] / this.rect.height) * (y - (this.rect.height / 2)) ]; } handleDoubleClick (e) { @@ -127,6 +128,7 @@ class Stage extends React.Component { } } onMouseDown (e) { + this.updateRect(); const mousePosition = [e.clientX - this.rect.left, e.clientY - this.rect.top]; this.setState({ mouseDown: true, @@ -192,7 +194,9 @@ class Stage extends React.Component { } Stage.propTypes = { - vm: PropTypes.instanceOf(VM).isRequired + height: PropTypes.number, + vm: PropTypes.instanceOf(VM).isRequired, + width: PropTypes.number }; export default Stage; diff --git a/src/lib/audio/audio-recorder.js b/src/lib/audio/audio-recorder.js index 576021a2ef01ae606c17dfe329b0d800e978d099..528bb1eec28e9a020d37e7caa2ebb1cf11c97c58 100644 --- a/src/lib/audio/audio-recorder.js +++ b/src/lib/audio/audio-recorder.js @@ -1,3 +1,4 @@ +import 'get-float-time-domain-data'; import SharedAudioContext from './shared-audio-context.js'; import {computeRMS} from './audio-util.js'; diff --git a/src/lib/layout-constants.js b/src/lib/layout-constants.js new file mode 100644 index 0000000000000000000000000000000000000000..672c8013f58d477ae767a159a3d2af28784d7139 --- /dev/null +++ b/src/lib/layout-constants.js @@ -0,0 +1,7 @@ +export default { + fullStageWidth: 480, + fullStageHeight: 360, + smallerStageWidth: 480 * 0.85, + smallerStageHeight: 360 * 0.85, + fullSizeMinWidth: 1096 +}; diff --git a/src/lib/libraries/costumes.json b/src/lib/libraries/costumes.json index 370f2d8b95b60b31f319982dd5bf547f6e1b4595..f359a3f16670dd17f324c32b6e4585d2d6395678 100644 --- a/src/lib/libraries/costumes.json +++ b/src/lib/libraries/costumes.json @@ -7717,7 +7717,7 @@ 109, 32 ], - "md5": "4cddf35440090d30d4de188625d609c9.svg", + "md5": "0a54c19714962c197532f68c56fde123.svg", "type": "costume", "name": "text awesome", "tags": [ @@ -7731,7 +7731,7 @@ 135, 24 ], - "md5": "6f54c5de1985d7f23a24341e1688c4b0.svg", + "md5": "97c9e6ef78d4f1987fd8c6e5042963cb.svg", "type": "costume", "name": "text keep scratching", "tags": [ @@ -7745,7 +7745,7 @@ 172, 30 ], - "md5": "e634628e4b948573710f7e4a4f50659b.svg", + "md5": "9fde2e190a9d01c08737dff291bdd4a8.svg", "type": "costume", "name": "text valentine's day", "tags": [ @@ -7759,7 +7759,7 @@ 165, 34 ], - "md5": "7ebe62c6f2b7c8b644d2abe036b8f69e.svg", + "md5": "0176a402a133f7b833da61b890e0d73e.svg", "type": "costume", "name": "text Halloween", "tags": [ @@ -10629,4 +10629,4 @@ "vector" ] } -] \ No newline at end of file +] diff --git a/test/helpers/selenium-helpers.js b/test/helpers/selenium-helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..5ee0af01e65a379c5dee5f92f26536e3aa98dab4 --- /dev/null +++ b/test/helpers/selenium-helpers.js @@ -0,0 +1,58 @@ +/* eslint-env jest */ +jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; // eslint-disable-line no-undef + +import webdriver from 'selenium-webdriver'; + +const {By, until} = webdriver; + +const driver = new webdriver.Builder() + .forBrowser('chrome') + .build(); + +const findByXpath = (xpath) => { + return driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000)); +}; + +const clickXpath = (xpath) => { + return findByXpath(xpath).then(el => el.click()); +}; + +const clickText = (text) => { + return clickXpath(`//*[contains(text(), '${text}')]`); +}; + +const clickButton = (text) => { + return clickXpath(`//button[contains(text(), '${text}')]`); +}; + +const getLogs = (whitelist) => { + return driver.manage() + .logs() + .get('browser') + .then((entries) => { + return entries.filter((entry) => { + const message = entry.message; + for (let i = 0; i < whitelist.length; i++) { + if (message.indexOf(whitelist[i]) !== -1) { + // eslint-disable-next-line no-console + console.warn('Ignoring whitelisted error: ' + whitelist[i]); + return false; + } else if (entry.level !== 'SEVERE') { + // eslint-disable-next-line no-console + console.warn('Ignoring non-SEVERE entry: ' + message); + return false; + } + return true; + } + }); + }); +}; + +export { + clickText, + clickButton, + clickXpath, + driver, + findByXpath, + getLogs +}; diff --git a/test/integration/test.js b/test/integration/test.js new file mode 100644 index 0000000000000000000000000000000000000000..7e3e60a651a6e7d64f06ad56c561058449432f20 --- /dev/null +++ b/test/integration/test.js @@ -0,0 +1,89 @@ +/* eslint-env jest */ +/* globals Promise */ + +import path from 'path'; +import { + clickText, + clickButton, + clickXpath, + driver, + findByXpath, + getLogs +} from '../helpers/selenium-helpers'; + +const uri = path.resolve(__dirname, '../../build/index.html'); + +const errorWhitelist = [ + 'The play() request was interrupted by a call to pause()' +]; + +describe('costumes, sounds and variables', () => { + afterAll(async () => { + await driver.quit(); + }); + + test('Adding a costume', async () => { + await driver.get('file://' + uri); + await clickText('Costumes'); + await clickText('Add Costume'); + const el = await findByXpath("//input[@placeholder='what are you looking for?']"); + await el.sendKeys('abb'); + await clickText('abby-a'); // Should close the modal, then click the costumes in the selector + await clickText('costume1'); + await clickText('abby-a'); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Adding a sound', async () => { + await driver.get('file://' + uri); + await clickText('Sounds'); + await clickText('Add Sound'); + const el = await findByXpath("//input[@placeholder='what are you looking for?']"); + await el.sendKeys('chom'); + await clickText('chomp'); // Should close the modal, then click the sounds in the selector + await clickText('meow'); + await clickText('chomp'); + await clickXpath('//button[@title="Play"]'); + await clickText('meow'); + await clickXpath('//button[@title="Play"]'); + + await clickText('Louder'); + await clickText('Softer'); + await clickText('Faster'); + await clickText('Slower'); + await clickText('Robot'); + await clickText('Echo'); + await clickText('Reverse'); + + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Load a project by ID', async () => { + const projectId = '96708228'; + await driver.get('file://' + 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(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Creating variables', async () => { + await driver.get('file://' + uri); + await clickText('Blocks'); + await clickText('Data'); + await clickText('Create variable...'); + let el = await findByXpath("//input[@placeholder='']"); + await el.sendKeys('score'); + await clickButton('OK'); + await clickText('Create variable...'); + el = await findByXpath("//input[@placeholder='']"); + await el.sendKeys('second variable'); + await clickButton('OK'); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); +}); diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap index 076dd860b6b9a08d9b5536f93739a274947526a2..644d330d0e5a099f59cfe4c527d93b5aa5913535 100644 --- a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap +++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap @@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = ` Save </span> </button> + <div + className={undefined} + > + <button + className={undefined} + disabled={false} + onClick={[Function]} + title="Undo" + > + <img + src="test-file-stub" + /> + </button> + <button + className={undefined} + disabled={true} + onClick={[Function]} + title="Redo" + > + <img + src="test-file-stub" + /> + </button> + </div> </div> </div> <div diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx index b3b744235e827ff35654dbe4150b6647e4888189..6c12f473060ed5b7167fe2b353563345b349c92d 100644 --- a/test/unit/components/sound-editor.test.jsx +++ b/test/unit/components/sound-editor.test.jsx @@ -7,6 +7,8 @@ describe('Sound Editor Component', () => { let props; beforeEach(() => { props = { + canUndo: true, + canRedo: false, chunkLevels: [1, 2, 3], name: 'sound name', playhead: 0.5, @@ -15,6 +17,7 @@ describe('Sound Editor Component', () => { onActivateTrim: jest.fn(), onChangeName: jest.fn(), onPlay: jest.fn(), + onRedo: jest.fn(), onReverse: jest.fn(), onSofter: jest.fn(), onLouder: jest.fn(), @@ -24,7 +27,8 @@ describe('Sound Editor Component', () => { onSlower: jest.fn(), onSetTrimEnd: jest.fn(), onSetTrimStart: jest.fn(), - onStop: jest.fn() + onStop: jest.fn(), + onUndo: jest.fn() }; }); @@ -64,6 +68,7 @@ describe('Sound Editor Component', () => { .simulate('blur'); expect(props.onChangeName).toHaveBeenCalled(); }); + test('effect buttons call the correct callbacks', () => { const wrapper = mountWithIntl(<SoundEditor {...props} />); @@ -88,4 +93,23 @@ describe('Sound Editor Component', () => { 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={true} 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} canUndo={false} canRedo={true} />); + 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', () => { + let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={true} />); + wrapper.find('button[title="Undo"]').simulate('click'); + expect(props.onUndo).toHaveBeenCalled(); + + wrapper.find('button[title="Redo"]').simulate('click'); + expect(props.onRedo).toHaveBeenCalled(); + }); }); diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx index b84a1233ff753d30baac63bb25fc4c543b178201..5a5618ea96cd88491b5a7de40cc0d321632ae1b8 100644 --- a/test/unit/containers/sound-editor.test.jsx +++ b/test/unit/containers/sound-editor.test.jsx @@ -160,4 +160,39 @@ describe('Sound Editor Container', () => { expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT); expect(mockAudioEffects.instance.process).toHaveBeenCalled(); }); + + test('undo/redo functionality', () => { + const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const 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().onActivateTrim(); // Activate trimming + component.props().onActivateTrim(); // Submit new samples by calling again + wrapper.update(); + 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 + component.props().onUndo(); + wrapper.update(); + 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 + component.props().onRedo(); + wrapper.update(); + expect(component.prop('canUndo')).toEqual(true); + expect(component.prop('canRedo')).toEqual(false); + + // New submission should clear the redo stack + component.props().onUndo(); // Undo to go back to a state where redo is enabled + wrapper.update(); + expect(component.prop('canRedo')).toEqual(true); + component.props().onActivateTrim(); // Activate trimming + component.props().onActivateTrim(); // Submit new samples by calling again + expect(component.prop('canRedo')).toEqual(false); + }); });