-
+ {mainMessages[this.props.messageId]}
{thumbnailUrl ? (
-

) : null}
diff --git a/src/components/menu-bar/author-info.css b/src/components/menu-bar/author-info.css
new file mode 100644
index 000000000..dcec5986c
--- /dev/null
+++ b/src/components/menu-bar/author-info.css
@@ -0,0 +1,39 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.author-info {
+ color: $ui-white;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ display: flex;
+ justify-content: start;
+ align-items: center;
+ cursor: default;
+}
+
+.avatar {
+ margin-right: .5625rem;
+}
+
+.project-title {
+ max-width: $menu-bar-item-max-width;
+ display: block;
+ overflow: hidden;
+ font-size: $menu-bar-large-font-size;
+ font-weight: bold;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.username-line {
+ max-width: $menu-bar-item-max-width;
+ font-size: $menu-bar-standard-font-size;
+ display: block;
+ overflow: hidden;
+ font-weight: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.username {
+ font-weight: bold;
+}
diff --git a/src/components/menu-bar/author-info.jsx b/src/components/menu-bar/author-info.jsx
new file mode 100644
index 000000000..1b8c9a747
--- /dev/null
+++ b/src/components/menu-bar/author-info.jsx
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import {FormattedMessage, injectIntl} from 'react-intl';
+import UserAvatar from './user-avatar.jsx';
+
+import styles from './author-info.css';
+
+const AuthorInfo = ({
+ className,
+ imageUrl,
+ projectTitle,
+ // TODO: use userId to link to user's profile
+ userId, // eslint-disable-line no-unused-vars
+ username
+}) => (
+
+
+
+
+ {projectTitle}
+
+
+
+ {username}
+ }}
+ />
+
+
+
+
+);
+
+AuthorInfo.propTypes = {
+ className: PropTypes.string,
+ imageUrl: PropTypes.string,
+ projectTitle: PropTypes.string,
+ userId: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ username: PropTypes.oneOfType([PropTypes.string, PropTypes.bool])
+};
+
+export default injectIntl(AuthorInfo);
diff --git a/src/components/menu-bar/community-button.css b/src/components/menu-bar/community-button.css
new file mode 100644
index 000000000..9facb4bc4
--- /dev/null
+++ b/src/components/menu-bar/community-button.css
@@ -0,0 +1,9 @@
+@import "../../css/colors.css";
+
+.community-button {
+ box-shadow: 0 0 0 1px $ui-black-transparent;
+}
+
+.community-button-icon {
+ height: 1.25rem;
+}
diff --git a/src/components/menu-bar/community-button.jsx b/src/components/menu-bar/community-button.jsx
new file mode 100644
index 000000000..62bf22e40
--- /dev/null
+++ b/src/components/menu-bar/community-button.jsx
@@ -0,0 +1,40 @@
+import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from '../button/button.jsx';
+
+import communityIcon from './icon--see-community.svg';
+import styles from './community-button.css';
+
+const CommunityButton = ({
+ className,
+ onClick
+}) => (
+
+);
+
+CommunityButton.propTypes = {
+ className: PropTypes.string,
+ onClick: PropTypes.func
+};
+
+CommunityButton.defaultProps = {
+ onClick: () => {}
+};
+
+export default CommunityButton;
diff --git a/src/components/menu-bar/login-dropdown.jsx b/src/components/menu-bar/login-dropdown.jsx
index 0116d7342..fcfcd8117 100644
--- a/src/components/menu-bar/login-dropdown.jsx
+++ b/src/components/menu-bar/login-dropdown.jsx
@@ -35,6 +35,11 @@ const LoginDropdownMessages = defineMessages({ // eslint-disable-line no-unused-
defaultMessage: 'Need Help?',
description: 'Button text for user to indicate that they need help',
id: 'login.needHelp'
+ },
+ validationRequired: {
+ defaultMessage: 'This field is required',
+ description: 'Message to tell user they must enter text in a form field',
+ id: 'form.validationRequired'
}
});
diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css
index 29902eabb..aa893a097 100644
--- a/src/components/menu-bar/menu-bar.css
+++ b/src/components/menu-bar/menu-bar.css
@@ -25,7 +25,7 @@
width: 100%;
*/
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: .75rem;
+ font-size: $menu-bar-standard-font-size;
font-weight: bold;
background-color: $motion-primary;
color: $ui-white;
@@ -45,6 +45,10 @@
vertical-align: middle;
}
+.scratch-logo.clickable {
+ cursor: pointer;
+}
+
.language-icon {
height: 1.5rem;
vertical-align: middle;
@@ -83,7 +87,7 @@
}
.menu-bar-item.growable {
- max-width: 12rem;
+ max-width: $menu-bar-item-max-width;
flex: 1;
}
@@ -126,24 +130,13 @@
height: 34px;
}
+.author-info {
+ margin-left: .25rem;
+ margin-right: .6875rem;
+}
+
.menu-bar-button {
- height: 2rem;
-}
-
-.share-button {
- background: $data-primary;
-}
-
-.share-button-is-shared {
- background: $ui-black-transparent;
-}
-
-.community-button {
- box-shadow: 0 0 0 1px $ui-black-transparent;
-}
-
-.community-button-icon {
- height: 1.25rem;
+ height: $menu-bar-button-size;
}
.remix-button {
@@ -186,8 +179,8 @@
.profile-icon {
margin: 0 .25rem;
- width: 2rem;
- border-radius: 0.25rem;
+ width: $menu-bar-button-size;
+ border-radius: $form-radius;
}
.dropdown-caret-icon {
@@ -207,19 +200,6 @@
opacity: 0.5;
}
-.save-in-progress {
- animation: hue-rotate 3s linear infinite;
-}
-
-@keyframes hue-rotate {
- from {
- filter: hue-rotate();
- }
- to {
- filter: hue-rotate(360deg);
- }
-}
-
.mystuff > a {
background-repeat: no-repeat;
background-position: center center;
diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index 87af1e683..825f6096f 100644
--- a/src/components/menu-bar/menu-bar.jsx
+++ b/src/components/menu-bar/menu-bar.jsx
@@ -7,13 +7,18 @@ import React from 'react';
import Box from '../box/box.jsx';
import Button from '../button/button.jsx';
+import CommunityButton from './community-button.jsx';
+import ShareButton from './share-button.jsx';
import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
import Divider from '../divider/divider.jsx';
import LanguageSelector from '../../containers/language-selector.jsx';
+import SaveStatus from './save-status.jsx';
import SBFileUploader from '../../containers/sb-file-uploader.jsx';
+import ProjectWatcher from '../../containers/project-watcher.jsx';
import MenuBarMenu from './menu-bar-menu.jsx';
import {MenuItem, MenuSection} from '../menu/menu.jsx';
import ProjectTitleInput from './project-title-input.jsx';
+import AuthorInfo from './author-info.jsx';
import AccountNav from '../../containers/account-nav.jsx';
import LoginDropdown from './login-dropdown.jsx';
import SB3Downloader from '../../containers/sb3-downloader.jsx';
@@ -23,11 +28,12 @@ import TurboMode from '../../containers/turbo-mode.jsx';
import {openTipsLibrary} from '../../reducers/modals';
import {setPlayer} from '../../reducers/mode';
import {
+ autoUpdateProject,
getIsUpdating,
getIsShowingProject,
+ manualUpdateProject,
requestNewProject,
remixProject,
- updateProject,
saveProjectAsCopy
} from '../../reducers/project-state';
import {
@@ -54,13 +60,19 @@ import helpIcon from '../../lib/assets/icon--tutorials.svg';
import mystuffIcon from './icon--mystuff.png';
import feedbackIcon from './icon--feedback.svg';
import profileIcon from './icon--profile.png';
-import communityIcon from './icon--see-community.svg';
import remixIcon from './icon--remix.svg';
import dropdownCaret from './dropdown-caret.svg';
import languageIcon from '../language-selector/language-icon.svg';
import scratchLogo from './scratch-logo.svg';
+const messages = defineMessages({
+ confirmNav: {
+ id: 'gui.menuBar.confirmNewWithoutSaving',
+ defaultMessage: 'Replace contents of the current project?',
+ description: 'message for prompting user to confirm that they want to create new project without saving'
+ }
+});
const ariaMessages = defineMessages({
language: {
id: 'gui.menuBar.LanguageSelector',
@@ -136,37 +148,64 @@ class MenuBar extends React.Component {
'handleClickRemix',
'handleClickSave',
'handleClickSaveAsCopy',
+ 'handleClickSeeCommunity',
+ 'handleClickShare',
'handleCloseFileMenuAndThen',
'handleLanguageMouseUp',
'handleRestoreOption',
'restoreOptionMessage'
]);
}
- componentDidUpdate (prevProps) {
- // if we're no longer showing the project (loading, or whatever), close menus
- if (this.props.isShowingProject && !prevProps.isShowingProject) {
- this.props.onRequestCloseFile();
- this.props.onRequestCloseEdit();
- }
- }
handleClickNew () {
- // if canSave===true and canCreateNew===true, it's safe to replace current project,
- // since we will auto-save first. Else, confirm first.
- const readyToReplaceProject = (this.props.canSave && this.props.canCreateNew) ||
- confirm('Replace contents of the current project?'); // eslint-disable-line no-alert
+ let readyToReplaceProject = true;
+ // if the project is dirty, and user owns the project, we will autosave.
+ // but if they are not logged in and can't save, user should consider
+ // downloading or logging in first.
+ // Note that if user is logged in and editing someone else's project,
+ // they'll lose their work.
+ if (this.props.projectChanged && !this.props.canCreateNew) {
+ readyToReplaceProject = confirm( // eslint-disable-line no-alert
+ this.props.intl.formatMessage(messages.confirmNav)
+ );
+ }
this.props.onRequestCloseFile();
if (readyToReplaceProject) {
this.props.onClickNew(this.props.canSave && this.props.canCreateNew);
}
+ this.props.onRequestCloseFile();
}
handleClickRemix () {
this.props.onClickRemix();
+ this.props.onRequestCloseFile();
}
handleClickSave () {
this.props.onClickSave();
+ this.props.onRequestCloseFile();
}
handleClickSaveAsCopy () {
this.props.onClickSaveAsCopy();
+ this.props.onRequestCloseFile();
+ }
+ handleClickSeeCommunity (waitForUpdate) {
+ if (this.props.canSave) { // save before transitioning to project page
+ this.props.autoUpdateProject();
+ waitForUpdate(true); // queue the transition to project page
+ } else {
+ waitForUpdate(false); // immediately transition to project page
+ }
+ }
+ handleClickShare (waitForUpdate) {
+ if (!this.props.isShared) {
+ if (this.props.canShare) { // save before transitioning to project page
+ this.props.onShare();
+ }
+ if (this.props.canSave) { // save before transitioning to project page
+ this.props.autoUpdateProject();
+ waitForUpdate(true); // queue the transition to project page
+ } else {
+ waitForUpdate(false); // immediately transition to project page
+ }
+ }
}
handleRestoreOption (restoreFun) {
return () => {
@@ -243,32 +282,6 @@ class MenuBar extends React.Component {
id="gui.menuBar.new"
/>
);
- const shareMessage = (
-
- );
- const isSharedMessage = (
-
- );
- const shareButton = (
-
- );
const remixButton = (
+
+

+
-

-
-`;
-
-exports[`GreenFlag Container renders inactive state 1`] = `
-

-`;
diff --git a/test/unit/containers/green-flag.test.jsx b/test/unit/containers/green-flag.test.jsx
deleted file mode 100644
index 4744ebaab..000000000
--- a/test/unit/containers/green-flag.test.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import {shallow} from 'enzyme';
-import GreenFlag from '../../../src/containers/green-flag';
-import renderer from 'react-test-renderer';
-import VM from 'scratch-vm';
-
-describe('GreenFlag Container', () => {
- let vm;
- beforeEach(() => {
- vm = new VM();
- });
-
- test('renders active state', () => {
- const component = renderer.create(
-
- );
- expect(component.toJSON()).toMatchSnapshot();
- });
-
- test('renders inactive state', () => {
- const component = renderer.create(
-
- );
- expect(component.toJSON()).toMatchSnapshot();
- });
-
- test('triggers onClick when active', () => {
- const onClick = jest.fn();
- const componentShallowWrapper = shallow(
-
- );
- componentShallowWrapper.simulate('click');
- expect(onClick).toHaveBeenCalled();
- });
-
- // @todo: Test for handles key events.
- // @todo: Test project run start.
- // @todo: Test project run stop.
-});
diff --git a/test/unit/containers/save-status.test.jsx b/test/unit/containers/save-status.test.jsx
new file mode 100644
index 000000000..c426864d4
--- /dev/null
+++ b/test/unit/containers/save-status.test.jsx
@@ -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(
+
+
+
+ );
+ 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(
+
+
+
+ );
+ 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(
+
+
+
+ );
+ expect(wrapper.find(InlineMessages).exists()).not.toBe(true);
+ expect(wrapper.contains('Save Now')).not.toBe(true);
+ });
+});
diff --git a/test/unit/containers/sb-file-uploader.test.jsx b/test/unit/containers/sb-file-uploader.test.jsx
index 856b8f058..083dbfa2d 100644
--- a/test/unit/containers/sb-file-uploader.test.jsx
+++ b/test/unit/containers/sb-file-uploader.test.jsx
@@ -65,14 +65,14 @@ describe('SBFileUploader Container', () => {
expect(projectName).toBe('my project is great');
});
- test('sets blank title with .sb filename', () => {
+ test('correctly sets title with .sb filename', () => {
const wrapper = shallowWithIntl(getContainer(), {context: {store}});
const instance = wrapper
.dive() // unwrap redux Connect(InjectIntl(SBFileUploader))
.dive() // unwrap InjectIntl(SBFileUploader)
.instance(); // SBFileUploader
const projectName = instance.getProjectTitleFromFilename('my project is great.sb');
- expect(projectName).toBe('');
+ expect(projectName).toBe('my project is great');
});
test('sets blank title with filename with no extension', () => {
diff --git a/test/unit/reducers/alerts-reducer.test.js b/test/unit/reducers/alerts-reducer.test.js
new file mode 100644
index 000000000..1a9db5138
--- /dev/null
+++ b/test/unit/reducers/alerts-reducer.test.js
@@ -0,0 +1,179 @@
+// TODO: add tests of extension alerts
+
+/* eslint-env jest */
+import {AlertTypes, AlertLevels} from '../../../src/lib/alerts/index.jsx';
+import alertsReducer from '../../../src/reducers/alerts';
+import {
+ closeAlert,
+ 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('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');
+});
diff --git a/test/unit/reducers/project-state-reducer.test.js b/test/unit/reducers/project-state-reducer.test.js
index c4d300bab..412287d44 100644
--- a/test/unit/reducers/project-state-reducer.test.js
+++ b/test/unit/reducers/project-state-reducer.test.js
@@ -2,17 +2,18 @@
import projectStateReducer from '../../../src/reducers/project-state';
import {
LoadingState,
+ autoUpdateProject,
doneCreatingProject,
doneUpdatingProject,
+ manualUpdateProject,
onFetchedProjectData,
onLoadedProject,
- onProjectUploadStarted,
projectError,
remixProject,
requestNewProject,
+ requestProjectUpload,
saveProjectAsCopy,
- setProjectId,
- updateProject
+ setProjectId
} from '../../../src/reducers/project-state';
test('initialState', () => {
@@ -95,7 +96,7 @@ test('onLoadedProject upload, with canSave false, shows without id', () => {
const initialState = {
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
- const action = onLoadedProject(initialState.loadingState, false);
+ const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
});
@@ -104,16 +105,16 @@ test('onLoadedProject upload, with canSave true, prepares to save', () => {
const initialState = {
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
- const action = onLoadedProject(initialState.loadingState, true);
+ const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
- expect(resultState.loadingState).toBe(LoadingState.UPDATING);
+ expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
});
test('onLoadedProject with id shows with id', () => {
const initialState = {
loadingState: LoadingState.LOADING_VM_WITH_ID
};
- const action = onLoadedProject(initialState.loadingState);
+ const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
});
@@ -122,7 +123,7 @@ test('onLoadedProject new shows without id', () => {
const initialState = {
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
- const action = onLoadedProject(initialState.loadingState);
+ const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
});
@@ -131,20 +132,49 @@ test('onLoadedProject new, to save shows without id', () => {
const initialState = {
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
- const action = onLoadedProject(initialState.loadingState);
+ const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
});
+test('onLoadedProject with success false, no project id, shows without id', () => {
+ const initialState = {
+ loadingState: LoadingState.LOADING_VM_WITH_ID,
+ projectId: null
+ };
+ const action = onLoadedProject(initialState.loadingState, false, false);
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
+});
+
+test('onLoadedProject with success false, valid project id, shows with id', () => {
+ const initialState = {
+ loadingState: LoadingState.LOADING_VM_WITH_ID,
+ projectId: '12345'
+ };
+ const action = onLoadedProject(initialState.loadingState, false, false);
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
+});
+
test('doneUpdatingProject with id shows with id', () => {
const initialState = {
- loadingState: LoadingState.UPDATING
+ loadingState: LoadingState.MANUAL_UPDATING
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
});
+test('doneUpdatingProject with id, before copy, creates copy', () => {
+ const initialState = {
+ loadingState: LoadingState.UPDATING_BEFORE_COPY
+ };
+ const action = doneUpdatingProject(initialState.loadingState);
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
+});
+
test('doneUpdatingProject with id, before new, fetches default project', () => {
const initialState = {
loadingState: LoadingState.UPDATING_BEFORE_NEW
@@ -205,49 +235,58 @@ test('requestNewProject, when can create new, should save and prepare to fetch d
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_NEW);
});
-test('onProjectUploadStarted when project not loaded should load', () => {
+test('requestProjectUpload when project not loaded should load', () => {
const initialState = {
loadingState: LoadingState.NOT_LOADED
};
- const action = onProjectUploadStarted();
+ const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
});
-test('onProjectUploadStarted when showing project with id should load', () => {
+test('requestProjectUpload when showing project with id should load', () => {
const initialState = {
loadingState: LoadingState.SHOWING_WITH_ID
};
- const action = onProjectUploadStarted();
+ const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
- expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
+ expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_FILE_UPLOAD);
});
-test('onProjectUploadStarted when showing project without id should load', () => {
+test('requestProjectUpload when showing project without id should load', () => {
const initialState = {
loadingState: LoadingState.SHOWING_WITHOUT_ID
};
- const action = onProjectUploadStarted();
+ const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
});
-test('updateProject should prepare to update', () => {
+test('manualUpdateProject should prepare to update', () => {
const initialState = {
loadingState: LoadingState.SHOWING_WITH_ID
};
- const action = updateProject();
+ const action = manualUpdateProject();
const resultState = projectStateReducer(initialState, action);
- expect(resultState.loadingState).toBe(LoadingState.UPDATING);
+ expect(resultState.loadingState).toBe(LoadingState.MANUAL_UPDATING);
});
-test('saveProjectAsCopy should prepare to save as a copy', () => {
+test('autoUpdateProject should prepare to update', () => {
+ const initialState = {
+ loadingState: LoadingState.SHOWING_WITH_ID
+ };
+ const action = autoUpdateProject();
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
+});
+
+test('saveProjectAsCopy should save, before preparing to save as a copy', () => {
const initialState = {
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = saveProjectAsCopy();
const resultState = projectStateReducer(initialState, action);
- expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
+ expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY);
});
test('remixProject should prepare to remix', () => {
@@ -261,35 +300,94 @@ test('remixProject should prepare to remix', () => {
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,
- LoadingState.UPDATING
+ LoadingState.UPDATING_BEFORE_NEW
];
for (const startState of startStates) {
const initialState = {
error: null,
loadingState: startState
};
- const action = projectError({message: 'Error string'});
+ const action = projectError('Error string');
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.error).toEqual('Error string');
+ }
+});
+
+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,
+ loadingState: startState
+ };
+ const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
- expect(resultState.error).toEqual({message: 'Error string'});
}
});
+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,
+ loadingState: startState
+ };
+ const action = projectError('Error string');
+ const resultState = projectStateReducer(initialState, action);
+ expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
+ }
+});
+
+test('projectError when creating new while viewing project with id should show project 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);
+});
+
+test('projectError when creating new while logged out, looking at default project should show default project', () => {
+ 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);
+});
+
test('projectError from showing project should show error', () => {
const initialState = {
error: null,
loadingState: LoadingState.FETCHING_WITH_ID
};
- const action = projectError({message: 'Error string'});
+ const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
- expect(resultState.error).toEqual({message: 'Error string'});
+ expect(resultState.error).toEqual('Error string');
});
diff --git a/test/unit/util/cloud-manager-hoc.test.jsx b/test/unit/util/cloud-manager-hoc.test.jsx
new file mode 100644
index 000000000..d372e1ff5
--- /dev/null
+++ b/test/unit/util/cloud-manager-hoc.test.jsx
@@ -0,0 +1,419 @@
+import 'web-audio-test-api';
+
+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 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('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
+ }
+ }
+ });
+ 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)
+ };
+ CloudProvider.mockClear();
+ mockCloudProviderInstance.requestCloseConnection.mockClear();
+ });
+ test('when it mounts, the cloud provider is set on the vm', () => {
+ const Component = () => (
);
+ const WrappedComponent = cloudManagerHOC(Component);
+ const onShowCloudInfo = jest.fn();
+
+ mount(
+
+ );
+ 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 = () => (
);
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+ 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 = () => (
);
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+ 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 = () => (
);
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const onShowCloudInfo = jest.fn();
+ vm.runtime.hasCloudData = jest.fn(() => false);
+
+ const mounted = mount(
+
+ );
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+ 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 = () => (
);
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+
+ expect(CloudProvider).toHaveBeenCalled();
+ const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
+
+ mounted.setProps({
+ username: 'a different user'
+ });
+
+ expect(vm.setCloudProvider.mock.calls.length).toBe(2);
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+ 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 = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ mount(
+
+ );
+
+ 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 # 1', () => {
+ const Component = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+
+ expect(CloudProvider).toHaveBeenCalled();
+ const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
+
+ mounted.setProps({
+ canSave: false,
+ hasEverEnteredEditor: true
+ });
+
+ expect(vm.setCloudProvider.mock.calls.length).toBe(2);
+ expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
+ expect(requestCloseConnection).toHaveBeenCalledTimes(1);
+ });
+
+ test('Entering editor mode and can\'t save project should disconnect cloud provider # 2', () => {
+ const Component = () =>
;
+ const WrappedComponent = cloudManagerHOC(Component);
+ const mounted = mount(
+
+ );
+
+ expect(CloudProvider).toHaveBeenCalled();
+ const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
+
+ mounted.setProps({
+ hasEverEnteredEditor: true
+ });
+
+ expect(vm.setCloudProvider.mock.calls.length).toBe(2);
+ expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
+ expect(requestCloseConnection).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/unit/util/cloud-provider.test.js b/test/unit/util/cloud-provider.test.js
index 65ebbc06e..220215915 100644
--- a/test/unit/util/cloud-provider.test.js
+++ b/test/unit/util/cloud-provider.test.js
@@ -1,25 +1,59 @@
import CloudProvider from '../../../src/lib/cloud-provider';
-// Disable window.WebSocket
-global.WebSocket = null;
+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 sentMessage = null;
-
+ let vmIOData = [];
+ let timeout = 0;
beforeEach(() => {
+ vmIOData = [];
cloudProvider = new CloudProvider();
- // Stub connection
- cloudProvider.connection = {
- send: msg => {
- sentMessage = msg;
+ // 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(sentMessage);
+ const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(1);
@@ -27,18 +61,131 @@ describe('CloudProvider', () => {
test('updateVariable with falsey value', () => {
cloudProvider.updateVariable('hello', 0);
- const obj = JSON.parse(sentMessage);
+ const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
expect(obj.method).toEqual('set');
expect(obj.name).toEqual('hello');
expect(obj.value).toEqual(0);
});
- test('writeToServer with falsey index value', () => {
- cloudProvider.writeToServer('method', 'name', 5, 0);
- const obj = JSON.parse(sentMessage);
- expect(obj.method).toEqual('method');
- expect(obj.name).toEqual('name');
- expect(obj.value).toEqual(5);
- expect(obj.index).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
});
});
diff --git a/test/unit/util/opcode-labels.test.js b/test/unit/util/opcode-labels.test.js
new file mode 100644
index 000000000..78bdfeaa7
--- /dev/null
+++ b/test/unit/util/opcode-labels.test.js
@@ -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');
+ });
+});
diff --git a/test/unit/util/project-saver-hoc.test.jsx b/test/unit/util/project-saver-hoc.test.jsx
index a750f2f01..e9d6e3b95 100644
--- a/test/unit/util/project-saver-hoc.test.jsx
+++ b/test/unit/util/project-saver-hoc.test.jsx
@@ -16,10 +16,16 @@ describe('projectSaverHOC', () => {
beforeEach(() => {
store = mockStore({
scratchGui: {
- projectState: {}
+ projectChanged: false,
+ projectState: {},
+ projectTitle: 'Scratch Project',
+ timeout: {
+ autoSaveTimeoutId: null
+ }
}
});
vm = new VM();
+ jest.useFakeTimers();
});
test('if canSave becomes true when showing a project with an id, project will be saved', () => {
@@ -31,16 +37,18 @@ describe('projectSaverHOC', () => {
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}
- onUpdateProject={mockedUpdateProject}
+ onAutoUpdateProject={mockedUpdateProject}
/>
);
mounted.setProps({
- canSave: true
+ canSave: true,
+ isShowingSaveable: true
});
expect(mockedUpdateProject).toHaveBeenCalled();
});
@@ -59,7 +67,7 @@ describe('projectSaverHOC', () => {
loadingState={LoadingState.LOADING_VM_WITH_ID}
store={store}
vm={vm}
- onUpdateProject={mockedSaveProject}
+ onAutoUpdateProject={mockedSaveProject}
/>
);
mounted.setProps({
@@ -142,9 +150,11 @@ describe('projectSaverHOC', () => {
});
test('if we enter creating new state, vm project should be requested', () => {
- vm.saveProjectSb3 = jest.fn(() => Promise.resolve());
const Component = () =>
;
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(
{
isCreatingNew: true,
loadingState: LoadingState.CREATING_NEW
});
- expect(vm.saveProjectSb3).toHaveBeenCalled();
+ expect(mockedStoreProject).toHaveBeenCalled();
});
- test('if we enter remixing state, vm project should be requested', () => {
- vm.saveProjectSb3 = jest.fn(() => Promise.resolve());
+ test('if we enter remixing state, vm project should be requested, and alert should show', () => {
+ const mockedShowCreatingRemixAlert = jest.fn();
const Component = () => ;
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(
{
reduxProjectId={'100'}
store={store}
vm={vm}
+ onShowCreatingRemixAlert={mockedShowCreatingRemixAlert}
/>
);
mounted.setProps({
isRemixing: true,
loadingState: LoadingState.REMIXING
});
- expect(vm.saveProjectSb3).toHaveBeenCalled();
+ expect(mockedStoreProject).toHaveBeenCalled();
+ expect(mockedShowCreatingRemixAlert).toHaveBeenCalled();
});
-
- test('if we enter creating copy state, vm project should be requested', () => {
- vm.saveProjectSb3 = jest.fn(() => Promise.resolve());
+ test('if we enter creating copy state, vm project should be requested, and alert should show', () => {
+ const mockedShowCreatingCopyAlert = jest.fn();
const Component = () => ;
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(
{
reduxProjectId={'100'}
store={store}
vm={vm}
+ onShowCreatingCopyAlert={mockedShowCreatingCopyAlert}
/>
);
mounted.setProps({
isCreatingCopy: true,
loadingState: LoadingState.CREATING_COPY
});
- expect(vm.saveProjectSb3).toHaveBeenCalled();
+ expect(mockedStoreProject).toHaveBeenCalled();
+ expect(mockedShowCreatingCopyAlert).toHaveBeenCalled();
});
- test('if we enter updating/saving state, vm project shold be requested', () => {
- vm.saveProjectSb3 = jest.fn(() => Promise.resolve());
+ test('if we enter updating/saving state, vm project should be requested', () => {
const Component = () => ;
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(
{
);
mounted.setProps({
isUpdating: true,
- loadingState: LoadingState.UPDATING
+ loadingState: LoadingState.MANUAL_UPDATING
});
- expect(vm.saveProjectSb3).toHaveBeenCalled();
+ expect(mockedStoreProject).toHaveBeenCalled();
});
- test('if we are already in updating/saving state, vm project shold be NOT requested', () => {
- vm.saveProjectSb3 = jest.fn(() => Promise.resolve());
+ test('if we are already in updating/saving state, vm project ' +
+ 'should NOT requested, alert should NOT show', () => {
+ const mockedShowCreatingAlert = jest.fn();
const Component = () => ;
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(
{
isCreatingNew={false}
isShowingWithId={false}
isShowingWithoutId={false}
- loadingState={LoadingState.UPDATING}
+ loadingState={LoadingState.MANUAL_UPDATING}
reduxProjectId={'100'}
store={store}
vm={vm}
+ onShowCreatingAlert={mockedShowCreatingAlert}
/>
);
mounted.setProps({
isUpdating: true,
- loadingState: LoadingState.UPDATING,
+ loadingState: LoadingState.AUTO_UPDATING,
reduxProjectId: '99' // random change to force a re-render and componentDidUpdate
});
- expect(vm.saveProjectSb3).not.toHaveBeenCalled();
+ expect(mockedStoreProject).not.toHaveBeenCalled();
+ expect(mockedShowCreatingAlert).not.toHaveBeenCalled();
+ });
+
+ test('if user saves, inline saving alert should show', () => {
+ const mockedShowSavingAlert = jest.fn();
+ const Component = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ const mounted = mount(
+
+ );
+ mounted.setProps({
+ isManualUpdating: true,
+ isUpdating: true
+ });
+ expect(mockedShowSavingAlert).toHaveBeenCalled();
+ });
+
+ test('if project is changed, it should autosave after interval', () => {
+ const Component = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ const mockedAutoUpdate = jest.fn(() => Promise.resolve());
+ const mounted = mount(
+
+ );
+ 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 = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ const mockedAutoUpdate = jest.fn(() => Promise.resolve());
+ const mounted = mount(
+
+ );
+ 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 = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ const mockedAutoUpdate = jest.fn(() => Promise.resolve());
+ const mounted = mount(
+
+ );
+ 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 = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
+ const mounted = mount(
+
+ );
+ mounted.setProps({
+ isRemixing: true
+ });
+ expect(mockedOnRemixing).toHaveBeenCalledWith(true);
+ });
+
+ test('when starting to remix, onRemixing should be called with param true', () => {
+ const mockedOnRemixing = jest.fn();
+ const mockedStoreProject = jest.fn(() => Promise.resolve());
+ const Component = () => ;
+ const WrappedComponent = projectSaverHOC(Component);
+ WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
+ const mounted = mount(
+
+ );
+ mounted.setProps({
+ isRemixing: false
+ });
+ expect(mockedOnRemixing).toHaveBeenCalledWith(false);
});
});
diff --git a/test/unit/util/throttled-property-hoc.test.jsx b/test/unit/util/throttled-property-hoc.test.jsx
new file mode 100644
index 000000000..c24f937a3
--- /dev/null
+++ b/test/unit/util/throttled-property-hoc.test.jsx
@@ -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}) => (
+
+ );
+ const WrappedComponent = ThrottledPropertyHOC('propToThrottle', throttleTime)(Component);
+
+ global.Date.now = () => 0;
+
+ mounted = mount(
+
+ );
+ });
+
+ 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);
+ });
+});
diff --git a/test/unit/util/tutorial-from-url.test.js b/test/unit/util/tutorial-from-url.test.js
index cfe2c14fe..454bc8725 100644
--- a/test/unit/util/tutorial-from-url.test.js
+++ b/test/unit/util/tutorial-from-url.test.js
@@ -8,45 +8,40 @@ jest.mock('../../../src/lib/libraries/decks/index.jsx', () => ({
noUrlIdSandwich: {}
}));
+import queryString from 'query-string';
import {detectTutorialId} from '../../../src/lib/tutorial-from-url.js';
-Object.defineProperty(
- window.location,
- 'search',
- {value: '', writable: true}
-);
-
test('returns the tutorial ID if the urlId matches', () => {
- window.location.search = '?tutorial=one';
- expect(detectTutorialId()).toBe('foo');
+ const queryParams = queryString.parse('?tutorial=one');
+ expect(detectTutorialId(queryParams)).toBe('foo');
});
test('returns null if no matching urlId', () => {
- window.location.search = '?tutorial=10';
- expect(detectTutorialId()).toBe(null);
+ const queryParams = queryString.parse('?tutorial=10');
+ expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if empty template', () => {
- window.location.search = '?tutorial=';
- expect(detectTutorialId()).toBe(null);
+ const queryParams = queryString.parse('?tutorial=');
+ expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if no query param', () => {
- window.location.search = '';
- expect(detectTutorialId()).toBe(null);
+ const queryParams = queryString.parse('');
+ expect(detectTutorialId(queryParams)).toBe(null);
});
test('returns null if unrecognized template', () => {
- window.location.search = '?tutorial=asdf';
- expect(detectTutorialId()).toBe(null);
+ const queryParams = queryString.parse('?tutorial=asdf');
+ expect(detectTutorialId(queryParams)).toBe(null);
});
test('takes the first of multiple', () => {
- window.location.search = '?tutorial=one&tutorial=two';
- expect(detectTutorialId()).toBe('foo');
+ const queryParams = queryString.parse('?tutorial=one&tutorial=two');
+ expect(detectTutorialId(queryParams)).toBe('foo');
});
test('returns all for the tutorial library shortcut', () => {
- window.location.search = '?tutorial=all';
- expect(detectTutorialId()).toBe('all');
+ const queryParams = queryString.parse('?tutorial=all');
+ expect(detectTutorialId(queryParams)).toBe('all');
});
diff --git a/test/unit/util/vm-listener-hoc.test.jsx b/test/unit/util/vm-listener-hoc.test.jsx
new file mode 100644
index 000000000..33753cf8e
--- /dev/null
+++ b/test/unit/util/vm-listener-hoc.test.jsx
@@ -0,0 +1,94 @@
+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
+ }
+ });
+ });
+
+ test('vm green flag event is bound to the passed in prop callback', () => {
+ const Component = () => ();
+ const WrappedComponent = vmListenerHOC(Component);
+ const onGreenFlag = jest.fn();
+ mount(
+
+ );
+ expect(onGreenFlag).not.toHaveBeenCalled();
+ vm.emit('PROJECT_START');
+ expect(onGreenFlag).toHaveBeenCalled();
+ });
+
+ test('onGreenFlag is not passed to the children', () => {
+ const Component = () => ();
+ const WrappedComponent = vmListenerHOC(Component);
+ const wrapper = mount(
+
+ );
+ const child = wrapper.find(Component);
+ expect(child.props().onGreenFlag).toBeUndefined();
+ });
+
+ test('targetsUpdate event from vm triggers targets update action', () => {
+ const Component = () => ();
+ const WrappedComponent = vmListenerHOC(Component);
+ mount(
+
+ );
+ 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 = () => ();
+ const WrappedComponent = vmListenerHOC(Component);
+ store = mockStore({
+ scratchGui: {
+ mode: {},
+ modals: {soundRecorder: true},
+ vm: vm
+ }
+ });
+ mount(
+
+ );
+ const targetList = [];
+ const editingTarget = 'id';
+ vm.emit('targetsUpdate', {targetList, editingTarget});
+ const actions = store.getActions();
+ expect(actions.length).toEqual(0);
+ });
+});
diff --git a/test/unit/util/vm-manager-hoc.test.jsx b/test/unit/util/vm-manager-hoc.test.jsx
index 8c065d83d..798e3a7e4 100644
--- a/test/unit/util/vm-manager-hoc.test.jsx
+++ b/test/unit/util/vm-manager-hoc.test.jsx
@@ -16,7 +16,9 @@ describe('VMManagerHOC', () => {
beforeEach(() => {
store = mockStore({
scratchGui: {
- projectState: {}
+ projectState: {},
+ mode: {},
+ vmStatus: {}
}
});
vm = new VM();
@@ -24,34 +26,95 @@ describe('VMManagerHOC', () => {
vm.setCompatibilityMode = jest.fn();
vm.start = jest.fn();
});
- test('when it mounts, the vm is initialized', () => {
+ test('when it mounts in player mode, the vm is initialized but not started', () => {
const Component = () => ();
const WrappedComponent = vmManagerHOC(Component);
mount(
);
expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
expect(vm.setCompatibilityMode.mock.calls.length).toBe(1);
- expect(vm.start.mock.calls.length).toBe(1);
expect(vm.initialized).toBe(true);
+
+ // But vm should not be started automatically
+ expect(vm.start).not.toHaveBeenCalled();
});
- test('if it mounts with an initialized vm, it does not reinitialize the vm', () => {
+ test('when it mounts in editor mode, the vm is initialized and started', () => {
+ const Component = () => ();
+ const WrappedComponent = vmManagerHOC(Component);
+ mount(
+
+ );
+ expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
+ expect(vm.setCompatibilityMode.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 = () => ;
const WrappedComponent = vmManagerHOC(Component);
vm.initialized = true;
mount(
);
expect(vm.attachAudioEngine.mock.calls.length).toBe(0);
expect(vm.setCompatibilityMode.mock.calls.length).toBe(0);
- expect(vm.start.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 = () => ;
+ const WrappedComponent = vmManagerHOC(Component);
+ vm.initialized = true;
+ const mounted = mount(
+
+ );
+ 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 = () => ;
+ const WrappedComponent = vmManagerHOC(Component);
+ vm.initialized = true;
+ const mounted = mount(
+
+ );
+ 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());