From b9c6f3fb44200e134fee8c545a6cff47625d2d1b Mon Sep 17 00:00:00 2001 From: Paul Kaplan <pkaplan@media.mit.edu> Date: Mon, 31 Jul 2017 15:22:17 -0400 Subject: [PATCH] Add trimming and tests to the sound editor --- .../audio-trimmer/audio-trimmer.jsx | 70 ++-- src/components/sound-editor/icon--trim.svg | Bin 0 -> 989 bytes src/components/sound-editor/sound-editor.css | 21 +- src/components/sound-editor/sound-editor.jsx | 55 +++- src/containers/audio-trimmer.jsx | 2 + src/containers/sound-editor.jsx | 61 +++- test/__mocks__/audio-buffer-player.js | 12 + .../__snapshots__/sound-editor.test.jsx.snap | 311 ++++++++++++++++++ test/unit/components/sound-editor.test.jsx | 63 ++++ test/unit/containers/sound-editor.test.jsx | 93 ++++++ 10 files changed, 630 insertions(+), 58 deletions(-) create mode 100644 src/components/sound-editor/icon--trim.svg create mode 100644 test/__mocks__/audio-buffer-player.js create mode 100644 test/unit/components/__snapshots__/sound-editor.test.jsx.snap create mode 100644 test/unit/components/sound-editor.test.jsx create mode 100644 test/unit/containers/sound-editor.test.jsx diff --git a/src/components/audio-trimmer/audio-trimmer.jsx b/src/components/audio-trimmer/audio-trimmer.jsx index d414f3ae2..fc216063a 100644 --- a/src/components/audio-trimmer/audio-trimmer.jsx +++ b/src/components/audio-trimmer/audio-trimmer.jsx @@ -10,23 +10,25 @@ const AudioTrimmer = props => ( className={styles.absolute} ref={props.containerRef} > - <Box - className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)} - style={{ - width: `${100 * props.trimStart}%` - }} - onMouseDown={props.onTrimStartMouseDown} - > - <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> - <Box className={classNames(styles.trimLine, styles.startTrimLine)}> - <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.startTrimHandle)}> - <img src={handleIcon} /> - </Box> - <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.startTrimHandle)}> - <img src={handleIcon} /> + {props.trimStart !== null ? ( + <Box + className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)} + style={{ + width: `${100 * props.trimStart}%` + }} + onMouseDown={props.onTrimStartMouseDown} + > + <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> + <Box className={classNames(styles.trimLine, styles.startTrimLine)}> + <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.startTrimHandle)}> + <img src={handleIcon} /> + </Box> + <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.startTrimHandle)}> + <img src={handleIcon} /> + </Box> </Box> </Box> - </Box> + ) : null} {props.playhead ? ( <Box @@ -37,24 +39,26 @@ const AudioTrimmer = props => ( /> ) : null} - <Box - className={classNames(styles.absolute, styles.trimBackground, styles.endTrimBackground)} - style={{ - left: `${100 * props.trimEnd}%`, - width: `${100 - 100 * props.trimEnd}%` - }} - onMouseDown={props.onTrimEndMouseDown} - > - <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> - <Box className={classNames(styles.trimLine, styles.endTrimLine)}> - <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.endTrimHandle)}> - <img src={handleIcon} /> - </Box> - <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.endTrimHandle)}> - <img src={handleIcon} /> + {props.trimEnd !== null ? ( + <Box + className={classNames(styles.absolute, styles.trimBackground, styles.endTrimBackground)} + style={{ + left: `${100 * props.trimEnd}%`, + width: `${100 - 100 * props.trimEnd}%` + }} + onMouseDown={props.onTrimEndMouseDown} + > + <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> + <Box className={classNames(styles.trimLine, styles.endTrimLine)}> + <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.endTrimHandle)}> + <img src={handleIcon} /> + </Box> + <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.endTrimHandle)}> + <img src={handleIcon} /> + </Box> </Box> </Box> - </Box> + ) : null } </div> ); @@ -63,8 +67,8 @@ AudioTrimmer.propTypes = { onTrimEndMouseDown: PropTypes.func.isRequired, onTrimStartMouseDown: PropTypes.func.isRequired, playhead: PropTypes.number, - trimEnd: PropTypes.number.isRequired, - trimStart: PropTypes.number.isRequired + trimEnd: PropTypes.number, + trimStart: PropTypes.number }; export default AudioTrimmer; diff --git a/src/components/sound-editor/icon--trim.svg b/src/components/sound-editor/icon--trim.svg new file mode 100644 index 0000000000000000000000000000000000000000..cf64941cab62829199fa7d895442c4c17f506c57 GIT binary patch literal 989 zcmc&zO>f&U488AH2;SK=DN(XiBsnxdPw9^sk}M72G)3&iMSguB<sEw8VSsH><m2O~ zSL6NU?&stDeEnQEt-4m6Bs`7#ybSN-x_O(XkEgDi=b7d}*W0P{%-q4R*{r7XG#oeM zc^t2|an&u?)ye%n55v0osoT=OyfpRN-u?{7b#uHQ->-)Qd_R6oZ|<;eUL)mRoiGXe zeM=g8$1U~6Q%K91IXQNu8Yx7;xvyqBE85{2TEP7xz+e=>Lm5E=0%HOO(uEyx61Pej zaXn?}9R#70>|`qNS63*<>?)|ZCCRcE;-E-IkTi4=y)p^k!nx`>S{U`FlGdz-De`LK zf2s3ZnMk~riE}BFmog1fYAHDXrN~g5mr0||(xYG-M8P1u$EQk>tJb4|-nSJ0YJsj( z#^8a4(o-~r&*qE^s}Tef9+#F_eH$o+0U|5jyn|<-DajTB2CD&+X6XqdL-!TyB0%>7 z*p?!og^@!EutqFb6yU`VM&lB3+7p~LDcNQj2WNN(sKD#Z-Ez}hEf_m&12CoxJ!UT{ zB1Mt>z?R3PAPpt$l+*>pNLVWcx$F@O-H}Wli|P~l2R12rSd!ht84hSdMw%JXleeH6 T((W7aqv<xQ?t~Y==jO*3g|pL& literal 0 HcmV?d00001 diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index 0bb44f115..1d67886da 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -36,17 +36,36 @@ } .button { - width: 2rem; height: 2rem; padding: 0.25rem; outline: none; background: white; border-radius: 0.25rem; border: 1px solid #ddd; + cursor: pointer; + font-size: 0.85rem; + transition: 0.2s; } .button img { flex-grow: 1; max-width: 100%; max-height: 100%; + min-width: 1.5rem; +} + +.trim-button { + display: flex; + align-items: center; + padding-right: 10px; /* To equalize with empty whitespace from image on left */ +} + +.trim-button-active { + filter: hue-rotate(155deg); /* @todo replace blue -> red with real submit icon */ +} + +.input-group-right { + flex-grow: 1; + display: flex; + flex-direction: row-reverse; } diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index 3e379b3f5..2168ee651 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -1,26 +1,28 @@ import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; -import Box from '../box/box.jsx'; import Waveform from '../waveform/waveform.jsx'; import Label from '../forms/label.jsx'; import Input from '../forms/input.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; +import AudioTrimmer from '../../containers/audio-trimmer.jsx'; 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'; const BufferedInput = BufferedInputHOC(Input); const SoundEditor = props => ( - <Box className={styles.editorContainer}> - <Box className={styles.row}> - <Box className={styles.inputGroup}> + <div className={styles.editorContainer}> + <div className={styles.row}> + <div className={styles.inputGroup}> {props.playhead ? ( <button className={classNames(styles.button, styles.stopButtonn)} + title={'Stop'} onClick={props.onStop} > <img src={stopIcon} /> @@ -28,13 +30,14 @@ const SoundEditor = props => ( ) : ( <button className={classNames(styles.button, styles.playButton)} + title={'Play'} onClick={props.onPlay} > <img src={playIcon} /> </button> )} - </Box> - <Box className={styles.inputGroup}> + </div> + <div className={styles.inputGroup}> <Label text="Sound"> <BufferedInput tabIndex="1" @@ -43,27 +46,51 @@ const SoundEditor = props => ( onSubmit={props.onChangeName} /> </Label> - </Box> - </Box> - <Box className={styles.row}> - <Box className={styles.waveformContainer}> + </div> + <div className={styles.inputGroupRight}> + <button + className={classNames(styles.button, styles.trimButton, { + [styles.trimButtonActive]: props.trimStart !== null + })} + title={props.trimStart === null ? 'Trim' : 'Save'} + onClick={props.onActivateTrim} + > + <img src={trimIcon} /> + {props.trimStart === null ? 'Trim' : 'Save'} + </button> + </div> + </div> + <div className={styles.row}> + <div className={styles.waveformContainer}> <Waveform data={props.chunkLevels} height={180} width={600} /> - </Box> - </Box> - </Box> + <AudioTrimmer + playhead={props.playhead} + trimEnd={props.trimEnd} + trimStart={props.trimStart} + onSetTrimEnd={props.onSetTrimEnd} + onSetTrimStart={props.onSetTrimStart} + /> + </div> + </div> + </div> ); SoundEditor.propTypes = { chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, name: PropTypes.string.isRequired, + onActivateTrim: PropTypes.func, onChangeName: PropTypes.func.isRequired, onPlay: PropTypes.func.isRequired, + onSetTrimEnd: PropTypes.func, + onSetTrimStart: PropTypes.func, onStop: PropTypes.func.isRequired, - playhead: PropTypes.number + playhead: PropTypes.number, + trimEnd: PropTypes.number, + trimStart: PropTypes.number }; export default SoundEditor; diff --git a/src/containers/audio-trimmer.jsx b/src/containers/audio-trimmer.jsx index 07ba2154d..03e91443e 100644 --- a/src/containers/audio-trimmer.jsx +++ b/src/containers/audio-trimmer.jsx @@ -20,12 +20,14 @@ class AudioTrimmer extends React.Component { const dx = (e.clientX - this.initialX) / containerSize; const newTrim = Math.max(0, Math.min(this.props.trimEnd, this.initialTrim + dx)); this.props.onSetTrimStart(newTrim); + e.preventDefault(); } handleTrimEndMouseMove (e) { const containerSize = this.containerElement.getBoundingClientRect().width; const dx = (e.clientX - this.initialX) / containerSize; const newTrim = Math.min(1, Math.max(this.props.trimStart, this.initialTrim + dx)); this.props.onSetTrimEnd(newTrim); + e.preventDefault(); } handleTrimStartMouseUp () { window.removeEventListener('mousemove', this.handleTrimStartMouseMove); diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 7706b1e98..d18678273 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -17,30 +17,50 @@ class SoundEditor extends React.Component { 'handleChangeName', 'handlePlay', 'handleStopPlaying', - 'handleUpdatePlayhead' + 'handleUpdatePlayhead', + 'handleActivateTrim', + 'handleUpdateTrimEnd', + 'handleUpdateTrimStart' ]); this.state = { + chunkLevels: computeChunkedRMS(this.props.samples), playhead: null, // null is not playing, [0 -> 1] is playing percent - chunkLevels: computeChunkedRMS(this.props.samples) + trimStart: null, + trimEnd: null }; } componentDidMount () { this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate); } componentWillReceiveProps (newProps) { - if (newProps.soundIndex !== this.props.soundIndex) { - this.audioBufferPlayer.stop(); - this.audioBufferPlayer = new AudioBufferPlayer(newProps.samples, newProps.sampleRate); - this.setState({chunkLevels: computeChunkedRMS(newProps.samples)}); + if (newProps.soundId !== this.props.soundId) { // A different sound has been selected + this.resetState(newProps.samples, newProps.sampleRate); } } componentWillUnmount () { this.audioBufferPlayer.stop(); } + resetState (samples, sampleRate) { + this.audioBufferPlayer.stop(); + this.audioBufferPlayer = new AudioBufferPlayer(samples, sampleRate); + this.setState({ + chunkLevels: computeChunkedRMS(samples), + playhead: null, + trimStart: null, + trimEnd: null + }); + } + submitNewSamples (samples, sampleRate) { + this.resetState(samples, sampleRate); + this.props.onUpdateSoundBuffer( + this.props.soundIndex, + this.audioBufferPlayer.buffer + ); + } handlePlay () { this.audioBufferPlayer.play( - 0, - 1, + this.state.trimStart || 0, + this.state.trimEnd || 1, this.handleUpdatePlayhead, this.handleStoppedPlaying); } @@ -57,6 +77,23 @@ class SoundEditor extends React.Component { handleChangeName (name) { this.props.onRenameSound(this.props.soundIndex, name); } + handleActivateTrim () { + if (this.state.trimStart === null && this.state.trimEnd === null) { + this.setState({trimEnd: 0.95, trimStart: 0.05}); + } else { + const sampleCount = this.props.samples.length; + const startIndex = Math.floor(this.state.trimStart * sampleCount); + const endIndex = Math.floor(this.state.trimEnd * sampleCount); + const clippedSamples = this.props.samples.slice(startIndex, endIndex); + this.submitNewSamples(clippedSamples, this.props.sampleRate); + } + } + handleUpdateTrimEnd (trimEnd) { + this.setState({trimEnd}); + } + handleUpdateTrimStart (trimStart) { + this.setState({trimStart}); + } render () { return ( <SoundEditorComponent @@ -65,12 +102,12 @@ class SoundEditor extends React.Component { playhead={this.state.playhead} trimEnd={this.state.trimEnd} trimStart={this.state.trimStart} + onActivateTrim={this.handleActivateTrim} onChangeName={this.handleChangeName} onPlay={this.handlePlay} onSetTrimEnd={this.handleUpdateTrimEnd} onSetTrimStart={this.handleUpdateTrimStart} onStop={this.handleStopPlaying} - onTrim={this.handleActivateTrim} /> ); } @@ -79,8 +116,10 @@ class SoundEditor extends React.Component { SoundEditor.propTypes = { name: PropTypes.string.isRequired, onRenameSound: PropTypes.func.isRequired, + onUpdateSoundBuffer: PropTypes.func.isRequired, sampleRate: PropTypes.number, samples: PropTypes.instanceOf(Float32Array), + soundId: PropTypes.string, soundIndex: PropTypes.number }; @@ -88,10 +127,12 @@ const mapStateToProps = (state, {soundIndex}) => { const sound = state.vm.editingTarget.sprite.sounds[soundIndex]; const audioBuffer = state.vm.getSoundBuffer(soundIndex); return { + soundId: sound.soundId, sampleRate: audioBuffer.sampleRate, samples: audioBuffer.getChannelData(0), name: sound.name, - onRenameSound: state.vm.renameSound.bind(state.vm) + onRenameSound: state.vm.renameSound.bind(state.vm), + onUpdateSoundBuffer: state.vm.updateSoundBuffer.bind(state.vm) }; }; diff --git a/test/__mocks__/audio-buffer-player.js b/test/__mocks__/audio-buffer-player.js new file mode 100644 index 000000000..c36092be3 --- /dev/null +++ b/test/__mocks__/audio-buffer-player.js @@ -0,0 +1,12 @@ +/* eslint-env jest */ +export default class MockAudioBufferPlayer { + constructor (samples, sampleRate) { + this.samples = samples; + this.sampleRate = sampleRate; + this.play = jest.fn((trimStart, trimEnd, onUpdate) => { + this.onUpdate = onUpdate; + }); + this.stop = jest.fn(); + MockAudioBufferPlayer.instance = this; + } +} diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap new file mode 100644 index 000000000..b32ad42e3 --- /dev/null +++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap @@ -0,0 +1,311 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sound Editor Component matches snapshot 1`] = ` +<div + className={undefined} +> + <div + className={undefined} + > + <div + className={undefined} + > + <button + className="" + onClick={[Function]} + title="Stop" + > + <img + src="test-file-stub" + /> + </button> + </div> + <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> + <div + className={undefined} + > + <button + className="undefined" + onClick={[Function]} + title="Save" + > + <img + src="test-file-stub" + /> + Save + </button> + </div> + </div> + <div + className={undefined} + > + <div + className={undefined} + > + <svg + className={undefined} + viewBox="0 0 600 180" + > + <g + transform="scale(1, -1) translate(0, -90)" + > + <path + className={undefined} + d="M0 0Q0 0 60 45,Q120 90 180 135,Q240 180 300 225,Q360 270 420 135,Q480 0 480 0,Q480 0 420 -135,Q360 -270 300 -225,Q240 -180 180 -135,Q120 -90 60 -45,Q0 0 0 0Z" + strokeLinejoin="round" + strokeWidth={2} + /> + </g> + </svg> + <div + className={undefined} + > + <div + className="" + onMouseDown={[Function]} + style={ + Object { + "alignContent": undefined, + "alignItems": undefined, + "alignSelf": undefined, + "flexBasis": undefined, + "flexDirection": undefined, + "flexGrow": undefined, + "flexShrink": undefined, + "flexWrap": undefined, + "height": undefined, + "justifyContent": undefined, + "width": "20%", + } + } + > + <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="" + 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 + 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 + src="test-file-stub" + /> + </div> + </div> + </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, + "left": "50%", + "width": undefined, + } + } + /> + <div + className="" + onMouseDown={[Function]} + style={ + Object { + "alignContent": undefined, + "alignItems": undefined, + "alignSelf": undefined, + "flexBasis": undefined, + "flexDirection": undefined, + "flexGrow": undefined, + "flexShrink": undefined, + "flexWrap": undefined, + "height": undefined, + "justifyContent": undefined, + "left": "80%", + "width": "20%", + } + } + > + <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="" + 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 + 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 + src="test-file-stub" + /> + </div> + </div> + </div> + </div> + </div> + </div> +</div> +`; diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx new file mode 100644 index 000000000..f22609cdb --- /dev/null +++ b/test/unit/components/sound-editor.test.jsx @@ -0,0 +1,63 @@ +/* eslint-env jest */ +import React from 'react'; // eslint-disable-line no-unused-vars +import {mount} from 'enzyme'; +import SoundEditor from '../../../src/components/sound-editor/sound-editor'; // eslint-disable-line no-unused-vars +import renderer from 'react-test-renderer'; + +describe('Sound Editor Component', () => { + let props; + beforeEach(() => { + props = { + chunkLevels: [1, 2, 3], + name: 'sound name', + playhead: 0.5, + trimStart: 0.2, + trimEnd: 0.8, + onActivateTrim: jest.fn(), + onChangeName: jest.fn(), + onPlay: jest.fn(), + onSetTrimEnd: jest.fn(), + onSetTrimStart: jest.fn(), + onStop: jest.fn() + }; + }); + + test('matches snapshot', () => { + const component = renderer.create( + <SoundEditor {...props} /> + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('trim button appears when trims are null', () => { + const wrapper = mount(<SoundEditor {...props} trimStart={null} trimEnd={null} />); + wrapper.find('button[title="Trim"]').simulate('click'); + expect(props.onActivateTrim).toHaveBeenCalled(); + }); + + test('save button appears when trims are not null', () => { + const wrapper = mount(<SoundEditor {...props} trimStart={0.25} trimEnd={0.75} />); + wrapper.find('button[title="Save"]').simulate('click'); + expect(props.onActivateTrim).toHaveBeenCalled(); + }); + + test('play button appears when playhead is null', () => { + const wrapper = mount(<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 = mount(<SoundEditor {...props} playhead={0.5} />); + wrapper.find('button[title="Stop"]').simulate('click'); + expect(props.onStop).toHaveBeenCalled(); + }); + + test('submitting name calls the callback', () => { + const wrapper = mount(<SoundEditor {...props} />); + wrapper.find('input') + .simulate('change', {target: {value: 'hello'}}) + .simulate('blur'); + expect(props.onChangeName).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx new file mode 100644 index 000000000..a0838689f --- /dev/null +++ b/test/unit/containers/sound-editor.test.jsx @@ -0,0 +1,93 @@ +/* eslint-env jest */ +import React from 'react'; // eslint-disable-line no-unused-vars +import {mount} from 'enzyme'; +import configureStore from 'redux-mock-store'; +import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js'; + +import SoundEditor from '../../../src/containers/sound-editor'; // eslint-disable-line no-unused-vars +// eslint-disable-next-line no-unused-vars +import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor'; + +jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer); + +describe('Sound Editor Container', () => { + const mockStore = configureStore(); + let store; + let soundIndex; + let soundBuffer; + let samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef + let vm; + + beforeEach(() => { + soundIndex = 0; + soundBuffer = { + 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({vm}); + }); + + test('should pass the correct data to the component from the store', () => { + const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />); + 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 = mount(<SoundEditor store={store} soundIndex={soundIndex} />); + const 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); + expect(component.props().playhead).toEqual(0.5); + + component.props().onStop(); + expect(mockAudioBufferPlayer.instance.stop).toHaveBeenCalled(); + expect(component.props().playhead).toEqual(null); + }); + + test('it sets the component props for trimming and submits to the vm', () => { + const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + + component.props().onActivateTrim(); + expect(component.props().trimStart).not.toEqual(null); + expect(component.props().trimEnd).not.toEqual(null); + + component.props().onActivateTrim(); + expect(vm.updateSoundBuffer).toHaveBeenCalled(); + expect(component.props().trimStart).toEqual(null); + expect(component.props().trimEnd).toEqual(null); + }); + + test('it submits name changes to the vm', () => { + const wrapper = mount(<SoundEditor store={store} soundIndex={soundIndex} />); + const component = wrapper.find(SoundEditorComponent); + component.props().onChangeName('hello'); + expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello'); + }); +}); -- GitLab