Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
30
scratch-gui/test/unit/util/audio-context.test.js
Normal file
30
scratch-gui/test/unit/util/audio-context.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/* global WebAudioTestAPI */
|
||||
import 'web-audio-test-api';
|
||||
WebAudioTestAPI.setState({
|
||||
'AudioContext#resume': 'enabled'
|
||||
});
|
||||
|
||||
import SharedAudioContext from '../../../src/lib/audio/shared-audio-context';
|
||||
|
||||
describe('Shared Audio Context', () => {
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
test('returns empty object without user gesture', () => {
|
||||
const sharedAudioContext = new SharedAudioContext();
|
||||
expect(sharedAudioContext).toMatchObject({});
|
||||
});
|
||||
|
||||
test('returns AudioContext when mousedown is triggered', () => {
|
||||
const sharedAudioContext = new SharedAudioContext();
|
||||
const event = new Event('mousedown');
|
||||
document.dispatchEvent(event);
|
||||
expect(sharedAudioContext).toMatchObject(audioContext);
|
||||
});
|
||||
|
||||
test('returns AudioContext when touchstart is triggered', () => {
|
||||
const sharedAudioContext = new SharedAudioContext();
|
||||
const event = new Event('touchstart');
|
||||
document.dispatchEvent(event);
|
||||
expect(sharedAudioContext).toMatchObject(audioContext);
|
||||
});
|
||||
});
|
||||
91
scratch-gui/test/unit/util/audio-effects.test.js
Normal file
91
scratch-gui/test/unit/util/audio-effects.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/* global WebAudioTestAPI */
|
||||
import 'web-audio-test-api';
|
||||
WebAudioTestAPI.setState({
|
||||
'OfflineAudioContext#startRendering': 'promise'
|
||||
});
|
||||
|
||||
import AudioEffects from '../../../src/lib/audio/audio-effects';
|
||||
import RobotEffect from '../../../src/lib/audio/effects/robot-effect';
|
||||
import EchoEffect from '../../../src/lib/audio/effects/echo-effect';
|
||||
import VolumeEffect from '../../../src/lib/audio/effects/volume-effect';
|
||||
|
||||
describe('Audio Effects manager', () => {
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = audioContext.createBuffer(1, 400, 44100);
|
||||
|
||||
test('changes buffer length and playback rate for faster effect', () => {
|
||||
const audioEffects = new AudioEffects(audioBuffer, 'faster', 0, 1);
|
||||
expect(audioEffects.audioContext._.length).toBeLessThan(400);
|
||||
});
|
||||
|
||||
test('changes buffer length and playback rate for slower effect', () => {
|
||||
const audioEffects = new AudioEffects(audioBuffer, 'slower', 0, 1);
|
||||
expect(audioEffects.audioContext._.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test('changes buffer length for echo effect', () => {
|
||||
const audioEffects = new AudioEffects(audioBuffer, 'echo', 0, 1);
|
||||
expect(audioEffects.audioContext._.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test('updates the trim positions after an effect has changed the length of selection', () => {
|
||||
const slowerEffect = new AudioEffects(audioBuffer, 'slower', 0.25, 0.75);
|
||||
expect(slowerEffect.adjustedTrimStartSeconds).toEqual(slowerEffect.trimStartSeconds);
|
||||
expect(slowerEffect.adjustedTrimEndSeconds).toBeGreaterThan(slowerEffect.trimEndSeconds);
|
||||
|
||||
const fasterEffect = new AudioEffects(audioBuffer, 'faster', 0.25, 0.75);
|
||||
expect(fasterEffect.adjustedTrimStartSeconds).toEqual(fasterEffect.trimStartSeconds);
|
||||
expect(fasterEffect.adjustedTrimEndSeconds).toBeLessThan(fasterEffect.trimEndSeconds);
|
||||
|
||||
// Some effects do not change the length of the selection
|
||||
const fadeEffect = new AudioEffects(audioBuffer, 'fade in', 0.25, 0.75);
|
||||
expect(fadeEffect.adjustedTrimStartSeconds).toEqual(fadeEffect.trimStartSeconds);
|
||||
// Should be within one millisecond (flooring can change the duration by one sample)
|
||||
expect(fadeEffect.adjustedTrimEndSeconds).toBeCloseTo(fadeEffect.trimEndSeconds, 3);
|
||||
});
|
||||
|
||||
test.skip('process starts the offline rendering context and returns a promise', () => {
|
||||
// @todo haven't been able to get web audio test api to actually run render
|
||||
});
|
||||
|
||||
test('reverse effect strictly reverses the samples', () => {
|
||||
const fakeSound = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
const fakeBuffer = audioContext.createBuffer(1, 8, 44100);
|
||||
const bufferData = fakeBuffer.getChannelData(0);
|
||||
fakeSound.forEach((sample, index) => {
|
||||
bufferData[index] = sample;
|
||||
});
|
||||
|
||||
// Reverse the entire sound
|
||||
const reverseAll = new AudioEffects(fakeBuffer, 'reverse', 0, 1);
|
||||
expect(Array.from(reverseAll.buffer.getChannelData(0))).toEqual(fakeSound.reverse());
|
||||
|
||||
// Reverse part of the sound
|
||||
const reverseSelection = new AudioEffects(fakeBuffer, 'reverse', 0.25, 0.75);
|
||||
const selectionReversed = [1, 2, 6, 5, 4, 3, 7, 8];
|
||||
expect(Array.from(reverseSelection.buffer.getChannelData(0))).toEqual(selectionReversed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effects', () => {
|
||||
let audioContext;
|
||||
|
||||
beforeEach(() => {
|
||||
audioContext = new AudioContext();
|
||||
});
|
||||
|
||||
test('all effects provide an input and output that are connected', () => {
|
||||
const robotEffect = new RobotEffect(audioContext, 0, 1);
|
||||
expect(robotEffect.input).toBeInstanceOf(AudioNode);
|
||||
expect(robotEffect.output).toBeInstanceOf(AudioNode);
|
||||
|
||||
const echoEffect = new EchoEffect(audioContext, 0, 1);
|
||||
expect(echoEffect.input).toBeInstanceOf(AudioNode);
|
||||
expect(echoEffect.output).toBeInstanceOf(AudioNode);
|
||||
|
||||
const volumeEffect = new VolumeEffect(audioContext, 0.5, 0, 1);
|
||||
expect(volumeEffect.input).toBeInstanceOf(AudioNode);
|
||||
expect(volumeEffect.output).toBeInstanceOf(AudioNode);
|
||||
});
|
||||
});
|
||||
102
scratch-gui/test/unit/util/audio-util.test.js
Normal file
102
scratch-gui/test/unit/util/audio-util.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
computeRMS,
|
||||
computeChunkedRMS,
|
||||
downsampleIfNeeded,
|
||||
dropEveryOtherSample
|
||||
} from '../../../src/lib/audio/audio-util';
|
||||
|
||||
describe('computeRMS', () => {
|
||||
test('returns 0 when given no samples', () => {
|
||||
expect(computeRMS([])).toEqual(0);
|
||||
});
|
||||
test('returns the RMS scaled by the given unity value and square rooted', () => {
|
||||
const unity = 0.5;
|
||||
const samples = [3, 2, 1];
|
||||
expect(computeRMS(samples, unity)).toEqual(
|
||||
Math.sqrt(Math.sqrt(((3 * 3) + (2 * 2) + (1 * 1)) / 3) / 0.5)
|
||||
);
|
||||
});
|
||||
test('uses a default unity value of 0.55', () => {
|
||||
const samples = [1, 1, 1];
|
||||
// raw rms is 1, scaled to (1 / 0.55) and square rooted
|
||||
expect(computeRMS(samples)).toEqual(Math.sqrt(1 / 0.55));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('computeChunkedRMS', () => {
|
||||
test('computes the rms for each chunk based on chunk size', () => {
|
||||
const samples = [2, 1, 3, 2, 5];
|
||||
const chunkedLevels = computeChunkedRMS(samples, 2);
|
||||
// chunked to [2, 0], [3, 0], [5]
|
||||
// rms scaled with default unity of 0.55
|
||||
expect(chunkedLevels.length).toEqual(3);
|
||||
expect(chunkedLevels).toEqual([
|
||||
Math.sqrt(Math.sqrt(((2 * 2) + (1 * 1)) / 2) / 0.55),
|
||||
Math.sqrt(Math.sqrt(((3 * 3) + (2 * 2)) / 2) / 0.55),
|
||||
Math.sqrt(Math.sqrt((5 * 5) / 1) / 0.55)
|
||||
]);
|
||||
});
|
||||
test('chunk size larger than sample size creates single chunk', () => {
|
||||
const samples = [1, 1, 1];
|
||||
const chunkedLevels = computeChunkedRMS(samples, 7);
|
||||
// chunked to [1, 1, 1]
|
||||
// rms scaled with default unity of 0.55
|
||||
expect(chunkedLevels.length).toEqual(1);
|
||||
expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55)]);
|
||||
});
|
||||
test('chunk size as multiple is handled correctly', () => {
|
||||
const samples = [1, 1, 1, 1, 1, 1];
|
||||
const chunkedLevels = computeChunkedRMS(samples, 3);
|
||||
// chunked to [1, 1, 1], [1, 1, 1]
|
||||
// rms scaled with default unity of 0.55
|
||||
expect(chunkedLevels.length).toEqual(2);
|
||||
expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55), Math.sqrt(1 / 0.55)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downsampleIfNeeded', () => {
|
||||
const samples = {length: 1};
|
||||
const sampleRate = 44100;
|
||||
test('returns given data when no downsampling needed', async () => {
|
||||
samples.length = 1;
|
||||
const res = await downsampleIfNeeded({samples, sampleRate}, null);
|
||||
expect(res.samples).toEqual(samples);
|
||||
expect(res.sampleRate).toEqual(sampleRate);
|
||||
});
|
||||
test('downsamples to 22050 if that puts it under the limit', async () => {
|
||||
samples.length = 44100 * 3 * 60;
|
||||
const resampler = jest.fn(() => 'TEST');
|
||||
const res = await downsampleIfNeeded({samples, sampleRate}, resampler);
|
||||
expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 22050);
|
||||
expect(res).toEqual('TEST');
|
||||
});
|
||||
// TW: We allow resampling even if it would exceed the limit because our GUI handles this better.
|
||||
test.skip('fails if resampling would not put it under the limit', async () => {
|
||||
samples.length = 44100 * 4 * 60;
|
||||
try {
|
||||
await downsampleIfNeeded({samples, sampleRate}, null);
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Sound too large to save, refusing to edit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropEveryOtherSample', () => {
|
||||
const buffer = {
|
||||
samples: [1, 0, 2, 0, 3, 0],
|
||||
sampleRate: 2
|
||||
};
|
||||
test('result is half the length', () => {
|
||||
const {samples} = dropEveryOtherSample(buffer);
|
||||
expect(samples.length).toEqual(Math.floor(buffer.samples.length / 2));
|
||||
});
|
||||
test('result contains only even-index items', () => {
|
||||
const {samples} = dropEveryOtherSample(buffer);
|
||||
expect(samples).toEqual(new Float32Array([1, 2, 3]));
|
||||
});
|
||||
test('result sampleRate is given sampleRate / 2', () => {
|
||||
const {sampleRate} = dropEveryOtherSample(buffer);
|
||||
expect(sampleRate).toEqual(buffer.sampleRate / 2);
|
||||
});
|
||||
});
|
||||
401
scratch-gui/test/unit/util/cloud-manager-hoc.test.jsx
Normal file
401
scratch-gui/test/unit/util/cloud-manager-hoc.test.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import 'web-audio-test-api';
|
||||
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
|
||||
import VM from 'scratch-vm';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
import CloudProvider from '../../../src/lib/cloud-provider';
|
||||
const mockCloudProviderInstance = {
|
||||
connection: true,
|
||||
requestCloseConnection: jest.fn()
|
||||
};
|
||||
jest.mock('../../../src/lib/cloud-provider', () =>
|
||||
jest.fn().mockImplementation(() => mockCloudProviderInstance)
|
||||
);
|
||||
|
||||
import cloudManagerHOC from '../../../src/lib/cloud-manager-hoc.jsx';
|
||||
|
||||
describe.skip('CloudManagerHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
let vm;
|
||||
let stillLoadingStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {
|
||||
projectId: '1234',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
},
|
||||
mode: {
|
||||
hasEverEnteredEditor: false
|
||||
},
|
||||
tw: {}
|
||||
}
|
||||
});
|
||||
stillLoadingStore = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {
|
||||
projectId: '1234',
|
||||
loadingState: LoadingState.LOADING_WITH_ID
|
||||
},
|
||||
mode: {
|
||||
hasEverEnteredEditor: false
|
||||
}
|
||||
}
|
||||
});
|
||||
vm = new VM();
|
||||
vm.setCloudProvider = jest.fn();
|
||||
vm.runtime = {
|
||||
hasCloudData: jest.fn(() => true)
|
||||
};
|
||||
vm.extensionManager = {
|
||||
isExtensionLoaded: jest.fn(() => false)
|
||||
};
|
||||
CloudProvider.mockClear();
|
||||
mockCloudProviderInstance.requestCloseConnection.mockClear();
|
||||
});
|
||||
test('when it mounts, the cloud provider is set on the vm', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const onShowCloudInfo = jest.fn();
|
||||
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
onShowCloudInfo={onShowCloudInfo}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
|
||||
expect(CloudProvider).toHaveBeenCalledTimes(1);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
|
||||
expect(onShowCloudInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when cloudHost is missing, the cloud provider is not set on the vm', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
|
||||
});
|
||||
|
||||
test('when projectID is missing, the cloud provider is not set on the vm', () => {
|
||||
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when project is not showingWithId, the cloud provider is not set on the vm', () => {
|
||||
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when hasCloudPermission is false, the cloud provider is not set on the vm', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
cloudHost="nonEmpty"
|
||||
hasCloudPermission={false}
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when videoSensing extension is active, the cloud provider is not set on the vm', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
vm.extensionManager.isExtensionLoaded = jest.fn(extension => extension === 'videoSensing');
|
||||
|
||||
mount(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if the isShowingWithId prop becomes true, it sets the cloud provider on the vm', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const onShowCloudInfo = jest.fn();
|
||||
vm.runtime.hasCloudData = jest.fn(() => false);
|
||||
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
username="user"
|
||||
vm={vm}
|
||||
onShowCloudInfo={onShowCloudInfo}
|
||||
/>
|
||||
);
|
||||
expect(onShowCloudInfo).not.toHaveBeenCalled();
|
||||
|
||||
vm.runtime.hasCloudData = jest.fn(() => true);
|
||||
vm.emit('HAS_CLOUD_DATA_UPDATE', true);
|
||||
|
||||
mounted.setProps({
|
||||
isShowingWithId: true,
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
});
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
|
||||
expect(CloudProvider).toHaveBeenCalledTimes(1);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
|
||||
expect(onShowCloudInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('projectId change should not trigger cloudProvider connection unless isShowingWithId becomes true', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
projectId: 'a different id'
|
||||
});
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
mounted.setProps({
|
||||
isShowingWithId: true,
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
});
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
|
||||
expect(CloudProvider).toHaveBeenCalledTimes(1);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
|
||||
});
|
||||
|
||||
test('when it unmounts, the cloud provider is reset to null on the vm', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.unmount();
|
||||
|
||||
// vm.setCloudProvider is called twice,
|
||||
// once during mount and once during unmount
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('projectId changing should trigger cloudProvider disconnection', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.setProps({
|
||||
projectId: 'a different id'
|
||||
});
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
});
|
||||
|
||||
test('username changing should trigger cloudProvider disconnection', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.setProps({
|
||||
username: 'a different user'
|
||||
});
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(3); // tw: the test is wrong.
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
});
|
||||
|
||||
test('project without cloud data should not trigger cloud connection', () => {
|
||||
// Mock the vm runtime function so that has cloud data is not
|
||||
// initially true
|
||||
vm.runtime.hasCloudData = jest.fn(() => false);
|
||||
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('projectHasCloudData becoming true should trigger a cloud connection', () => {
|
||||
// Mock the vm runtime function so that has cloud data is not
|
||||
// initially true
|
||||
vm.runtime.hasCloudData = jest.fn(() => false);
|
||||
const onShowCloudInfo = jest.fn();
|
||||
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
onShowCloudInfo={onShowCloudInfo}
|
||||
/>
|
||||
);
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(0);
|
||||
expect(CloudProvider).not.toHaveBeenCalled();
|
||||
expect(onShowCloudInfo).not.toHaveBeenCalled();
|
||||
|
||||
// Mock VM hasCloudData becoming true and emitting an update
|
||||
vm.runtime.hasCloudData = jest.fn(() => true);
|
||||
vm.emit('HAS_CLOUD_DATA_UPDATE', true);
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(1);
|
||||
expect(CloudProvider).toHaveBeenCalledTimes(1);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(mockCloudProviderInstance);
|
||||
expect(onShowCloudInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('projectHasCloudDataUpdate becoming false should trigger cloudProvider disconnection', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
vm.runtime.hasCloudData = jest.fn(() => false);
|
||||
vm.emit('HAS_CLOUD_DATA_UPDATE', false);
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Editor Mode Connection/Disconnection Tests
|
||||
test('Entering editor mode and can\'t save project should disconnect cloud provider', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.setProps({
|
||||
canModifyCloudData: false
|
||||
});
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
211
scratch-gui/test/unit/util/cloud-provider.test.js
Normal file
211
scratch-gui/test/unit/util/cloud-provider.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import CloudProvider from '../../../src/lib/cloud-provider';
|
||||
|
||||
let websocketConstructorCount = 0;
|
||||
|
||||
// Stub the global websocket so we can call open/close/error/send on it
|
||||
global.WebSocket = function (url) {
|
||||
this._url = url;
|
||||
this._sentMessages = [];
|
||||
|
||||
// These are not real websocket methods, but used to trigger callbacks
|
||||
this._open = () => this.onopen();
|
||||
this._error = e => this.onerror(e);
|
||||
this._receive = msg => this.onmessage(msg);
|
||||
|
||||
// Stub the real websocket.send to store sent messages
|
||||
this.send = msg => this._sentMessages.push(msg);
|
||||
this.close = () => this.onclose();
|
||||
|
||||
websocketConstructorCount++;
|
||||
};
|
||||
global.WebSocket.CLOSING = 'CLOSING';
|
||||
global.WebSocket.CLOSED = 'CLOSED';
|
||||
|
||||
describe('CloudProvider', () => {
|
||||
let cloudProvider = null;
|
||||
let vmIOData = [];
|
||||
let timeout = 0;
|
||||
beforeEach(() => {
|
||||
vmIOData = [];
|
||||
cloudProvider = new CloudProvider();
|
||||
// Stub vm
|
||||
cloudProvider.vm = {
|
||||
postIOData: (_namespace, data) => {
|
||||
vmIOData.push(data);
|
||||
}
|
||||
};
|
||||
// Stub setTimeout so this can run instantly.
|
||||
cloudProvider.setTimeout = (fn, after) => {
|
||||
timeout = after;
|
||||
fn();
|
||||
};
|
||||
// Stub randomize to make it consistent for testing.
|
||||
cloudProvider.randomizeDuration = t => t;
|
||||
});
|
||||
|
||||
test('createVariable', () => {
|
||||
cloudProvider.createVariable('hello', 1);
|
||||
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
|
||||
expect(obj.method).toEqual('create');
|
||||
expect(obj.name).toEqual('hello');
|
||||
expect(obj.value).toEqual(1);
|
||||
});
|
||||
|
||||
test('updateVariable', () => {
|
||||
cloudProvider.updateVariable('hello', 1);
|
||||
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
|
||||
expect(obj.method).toEqual('set');
|
||||
expect(obj.name).toEqual('hello');
|
||||
expect(obj.value).toEqual(1);
|
||||
});
|
||||
|
||||
test('updateVariable with falsey value', () => {
|
||||
cloudProvider.updateVariable('hello', 0);
|
||||
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
|
||||
expect(obj.method).toEqual('set');
|
||||
expect(obj.name).toEqual('hello');
|
||||
expect(obj.value).toEqual(0);
|
||||
});
|
||||
|
||||
test('renameVariable', () => {
|
||||
cloudProvider.renameVariable('oldName', 'newName');
|
||||
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
|
||||
expect(obj.method).toEqual('rename');
|
||||
expect(obj.name).toEqual('oldName');
|
||||
expect(typeof obj.value).toEqual('undefined');
|
||||
expect(obj.new_name).toEqual('newName');
|
||||
});
|
||||
|
||||
test('deleteVariable', () => {
|
||||
cloudProvider.deleteVariable('hello');
|
||||
const obj = JSON.parse(cloudProvider.connection._sentMessages[0]);
|
||||
expect(obj.method).toEqual('delete');
|
||||
expect(obj.name).toEqual('hello');
|
||||
expect(typeof obj.value).toEqual('undefined');
|
||||
});
|
||||
|
||||
|
||||
test('onMessage set', () => {
|
||||
const msg = JSON.stringify({
|
||||
method: 'set',
|
||||
name: 'name',
|
||||
value: 'value'
|
||||
});
|
||||
cloudProvider.connection._receive({data: msg});
|
||||
expect(vmIOData[0].varUpdate.name).toEqual('name');
|
||||
expect(vmIOData[0].varUpdate.value).toEqual('value');
|
||||
});
|
||||
|
||||
test('onMessage with newline at the end', () => {
|
||||
const msg1 = JSON.stringify({
|
||||
method: 'set',
|
||||
name: 'name1',
|
||||
value: 'value'
|
||||
});
|
||||
cloudProvider.onMessage({data: `${msg1}\n`});
|
||||
expect(vmIOData[0].varUpdate.name).toEqual('name1');
|
||||
});
|
||||
|
||||
test('onMessage with multiple commands', () => {
|
||||
const msg1 = JSON.stringify({
|
||||
method: 'set',
|
||||
name: 'name1',
|
||||
value: 'value'
|
||||
});
|
||||
const msg2 = JSON.stringify({
|
||||
method: 'set',
|
||||
name: 'name2',
|
||||
value: 'value2'
|
||||
});
|
||||
cloudProvider.connection._receive({data: `${msg1}\n${msg2}`});
|
||||
expect(vmIOData[0].varUpdate.name).toEqual('name1');
|
||||
expect(vmIOData[1].varUpdate.name).toEqual('name2');
|
||||
});
|
||||
|
||||
test('connnection attempts set back to 1 when socket is opened', () => {
|
||||
cloudProvider.connectionAttempts = 100;
|
||||
cloudProvider.connection._open();
|
||||
expect(cloudProvider.connectionAttempts).toBe(1);
|
||||
});
|
||||
|
||||
test('disconnect waits for a period equal to 2^k-1 before trying again', () => {
|
||||
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
|
||||
// Constructor attempts to open connection, so attempts is initially 1
|
||||
expect(cloudProvider.connectionAttempts).toBe(1);
|
||||
|
||||
// Make sure a close without a previous OPEN still waits 1s before reconnecting
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(1 * 1000); // 2^1 - 1
|
||||
expect(websocketConstructorCount).toBe(2);
|
||||
expect(cloudProvider.connectionAttempts).toBe(2);
|
||||
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(3 * 1000); // 2^2 - 1
|
||||
expect(websocketConstructorCount).toBe(3);
|
||||
expect(cloudProvider.connectionAttempts).toBe(3);
|
||||
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(7 * 1000); // 2^3 - 1
|
||||
expect(websocketConstructorCount).toBe(4);
|
||||
expect(cloudProvider.connectionAttempts).toBe(4);
|
||||
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(15 * 1000); // 2^4 - 1
|
||||
expect(websocketConstructorCount).toBe(5);
|
||||
expect(cloudProvider.connectionAttempts).toBe(5);
|
||||
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(31 * 1000); // 2^5 - 1
|
||||
expect(websocketConstructorCount).toBe(6);
|
||||
expect(cloudProvider.connectionAttempts).toBe(6);
|
||||
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(31 * 1000); // maxed out at 2^5 - 1
|
||||
expect(websocketConstructorCount).toBe(7);
|
||||
expect(cloudProvider.connectionAttempts).toBe(7);
|
||||
});
|
||||
|
||||
test('close after connection is opened waits 1s before reconnecting', () => {
|
||||
// This test is basically to check that opening the connection does not impact
|
||||
// the time until reconnection for the first reconnect.
|
||||
// It is easy to introduce a bug that causes reconnection time to be different
|
||||
// based on whether an initial connection was made.
|
||||
websocketConstructorCount = 1;
|
||||
cloudProvider.connection._open();
|
||||
cloudProvider.connection.close();
|
||||
expect(timeout).toEqual(1 * 1000); // 2^1 - 1
|
||||
expect(websocketConstructorCount).toBe(2);
|
||||
expect(cloudProvider.connectionAttempts).toBe(2);
|
||||
});
|
||||
|
||||
test('exponentialTimeout caps connection attempt number', () => {
|
||||
cloudProvider.connectionAttempts = 1000;
|
||||
expect(cloudProvider.exponentialTimeout()).toEqual(31 * 1000);
|
||||
});
|
||||
|
||||
test('requestCloseConnection does not try to reconnect', () => {
|
||||
websocketConstructorCount = 1; // This is global, so set it back to 1 to start
|
||||
cloudProvider.requestCloseConnection();
|
||||
expect(websocketConstructorCount).toBe(1); // No reconnection attempts
|
||||
});
|
||||
|
||||
test('close with code 4002 triggers invalid username', () => {
|
||||
cloudProvider.onInvalidUsername = jest.fn();
|
||||
cloudProvider.onClose({code: 4002});
|
||||
expect(cloudProvider.onInvalidUsername).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('close with normal code does not trigger invalid username', () => {
|
||||
cloudProvider.username = 'aaa';
|
||||
cloudProvider.onInvalidUsername = jest.fn();
|
||||
cloudProvider.onClose({code: 1000});
|
||||
expect(cloudProvider.onInvalidUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('username anonymization', () => {
|
||||
const anonymized = new CloudProvider('', null, 'player1234', '');
|
||||
expect(anonymized.username).toBe('player');
|
||||
const verbatim = new CloudProvider('', null, 'abcdef', '');
|
||||
expect(verbatim.username).toBe('abcdef');
|
||||
});
|
||||
19
scratch-gui/test/unit/util/code-payload.test.js
Normal file
19
scratch-gui/test/unit/util/code-payload.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
jest.mock('../../../src/lib/backpack/block-to-image', () => () => Promise.resolve('block-image'));
|
||||
jest.mock('../../../src/lib/backpack/thumbnail', () => () => Promise.resolve('thumbnail'));
|
||||
|
||||
import codePayload from '../../../src/lib/backpack/code-payload';
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
describe('codePayload', () => {
|
||||
test('base64 encodes the blocks as json', () => {
|
||||
const blocks = '☁︎❤️🐻';
|
||||
const payload = codePayload({
|
||||
blockObjects: blocks
|
||||
});
|
||||
return payload.then(p => {
|
||||
expect(
|
||||
JSON.parse(Base64.decode(p.body))
|
||||
).toEqual(blocks);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
scratch-gui/test/unit/util/default-project.test.js
Normal file
21
scratch-gui/test/unit/util/default-project.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import defaultProjectGenerator from '../../../src/lib/default-project/index.js';
|
||||
|
||||
describe('defaultProject', () => {
|
||||
// This test ensures that the assets referenced in the default project JSON
|
||||
// do not get out of sync with the raw assets that are included alongside.
|
||||
// see https://github.com/LLK/scratch-gui/issues/4844
|
||||
test('assets referenced by the project are included', () => {
|
||||
const translatorFn = () => '';
|
||||
const defaultProject = defaultProjectGenerator(translatorFn);
|
||||
const includedAssetIds = defaultProject.map(obj => obj.id);
|
||||
const projectData = JSON.parse(defaultProject[0].data);
|
||||
projectData.targets.forEach(target => {
|
||||
target.costumes.forEach(costume => {
|
||||
expect(includedAssetIds.includes(costume.assetId)).toBe(true);
|
||||
});
|
||||
target.sounds.forEach(sound => {
|
||||
expect(includedAssetIds.includes(sound.assetId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
199
scratch-gui/test/unit/util/define-dynamic-block.test.js
Normal file
199
scratch-gui/test/unit/util/define-dynamic-block.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import defineDynamicBlock from '../../../src/lib/define-dynamic-block';
|
||||
|
||||
import BlockType from 'scratch-vm/src/extension-support/block-type';
|
||||
|
||||
const MockScratchBlocks = {
|
||||
OUTPUT_SHAPE_HEXAGONAL: 1,
|
||||
OUTPUT_SHAPE_ROUND: 2,
|
||||
OUTPUT_SHAPE_SQUARE: 3
|
||||
};
|
||||
|
||||
const categoryInfo = {
|
||||
name: 'test category',
|
||||
color1: '#111',
|
||||
color2: '#222',
|
||||
color3: '#333'
|
||||
};
|
||||
|
||||
const penIconURI = 'data:image/svg+xml;base64,fake_pen_icon_svg_base64_data';
|
||||
|
||||
const testBlockInfo = {
|
||||
commandWithIcon: {
|
||||
blockType: BlockType.COMMAND,
|
||||
blockIconURI: penIconURI,
|
||||
text: 'command with icon'
|
||||
},
|
||||
commandWithoutIcon: {
|
||||
blockType: BlockType.COMMAND,
|
||||
text: 'command without icon'
|
||||
},
|
||||
terminalCommand: {
|
||||
blockType: BlockType.COMMAND,
|
||||
isTerminal: true,
|
||||
text: 'terminal command'
|
||||
},
|
||||
reporter: {
|
||||
blockType: BlockType.REPORTER,
|
||||
text: 'reporter'
|
||||
},
|
||||
boolean: {
|
||||
blockType: BlockType.BOOLEAN,
|
||||
text: 'Boolean'
|
||||
},
|
||||
hat: {
|
||||
blockType: BlockType.HAT,
|
||||
text: 'hat'
|
||||
}
|
||||
};
|
||||
|
||||
// similar to goog.mixin from the Closure library
|
||||
const mixin = function (target, source) {
|
||||
for (const x in source) {
|
||||
target[x] = source[x];
|
||||
}
|
||||
};
|
||||
|
||||
class MockBlock {
|
||||
constructor (blockInfo, extendedOpcode) {
|
||||
// mimic Closure-style inheritance by mixing in `defineDynamicBlock` output as this instance's prototype
|
||||
// see also the `Blockly.Block` constructor
|
||||
const prototype = defineDynamicBlock(MockScratchBlocks, categoryInfo, blockInfo, extendedOpcode);
|
||||
mixin(this, prototype);
|
||||
this.init();
|
||||
|
||||
// bootstrap the mutation<->DOM cycle
|
||||
this.blockInfoText = JSON.stringify(blockInfo);
|
||||
const xmlElement = this.mutationToDom();
|
||||
|
||||
// parse blockInfo from XML to fill dynamic properties
|
||||
this.domToMutation(xmlElement);
|
||||
}
|
||||
|
||||
jsonInit (json) {
|
||||
this.result = Object.assign({}, json);
|
||||
}
|
||||
interpolate_ () {
|
||||
// TODO: add tests for this?
|
||||
}
|
||||
setCheckboxInFlyout (isEnabled) {
|
||||
this.result.checkboxInFlyout_ = isEnabled;
|
||||
}
|
||||
setOutput (isEnabled) {
|
||||
this.result.outputConnection = isEnabled; // Blockly calls `makeConnection_` here
|
||||
}
|
||||
setOutputShape (outputShape) {
|
||||
this.result.outputShape_ = outputShape;
|
||||
}
|
||||
setNextStatement (isEnabled) {
|
||||
this.result.nextConnection = isEnabled; // Blockly calls `makeConnection_` here
|
||||
}
|
||||
setPreviousStatement (isEnabled) {
|
||||
this.result.previousConnection = isEnabled; // Blockly calls `makeConnection_` here
|
||||
}
|
||||
}
|
||||
|
||||
describe('defineDynamicBlock', () => {
|
||||
test('is a function', () => {
|
||||
expect(typeof defineDynamicBlock).toBe('function');
|
||||
});
|
||||
test('can define a command block with an icon', () => {
|
||||
const extendedOpcode = 'test.commandWithIcon';
|
||||
const block = new MockBlock(testBlockInfo.commandWithIcon, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
extensions: ['scratch_extension'],
|
||||
inputsInline: true,
|
||||
nextConnection: true,
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
|
||||
previousConnection: true,
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
test('can define a command block without an icon', () => {
|
||||
const extendedOpcode = 'test.commandWithoutIcon';
|
||||
const block = new MockBlock(testBlockInfo.commandWithoutIcon, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
// extensions: undefined, // no icon means no extension
|
||||
inputsInline: true,
|
||||
nextConnection: true,
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
|
||||
previousConnection: true,
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
test('can define a terminal command', () => {
|
||||
const extendedOpcode = 'test.terminal';
|
||||
const block = new MockBlock(testBlockInfo.terminalCommand, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
// extensions: undefined, // no icon means no extension
|
||||
inputsInline: true,
|
||||
nextConnection: false, // terminal
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
|
||||
previousConnection: true,
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
test('can define a reporter', () => {
|
||||
const extendedOpcode = 'test.reporter';
|
||||
const block = new MockBlock(testBlockInfo.reporter, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
checkboxInFlyout_: true,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
// extensions: undefined, // no icon means no extension
|
||||
inputsInline: true,
|
||||
// nextConnection: undefined, // reporter
|
||||
outputConnection: true, // reporter
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_ROUND, // reporter
|
||||
// previousConnection: undefined, // reporter
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
test('can define a Boolean', () => {
|
||||
const extendedOpcode = 'test.boolean';
|
||||
const block = new MockBlock(testBlockInfo.boolean, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
// checkboxInFlyout_: undefined,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
// extensions: undefined, // no icon means no extension
|
||||
inputsInline: true,
|
||||
// nextConnection: undefined, // reporter
|
||||
outputConnection: true, // reporter
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_HEXAGONAL, // Boolean
|
||||
// previousConnection: undefined, // reporter
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
test('can define a hat', () => {
|
||||
const extendedOpcode = 'test.hat';
|
||||
const block = new MockBlock(testBlockInfo.hat, extendedOpcode);
|
||||
expect(block.result).toEqual({
|
||||
category: categoryInfo.name,
|
||||
colour: categoryInfo.color1,
|
||||
colourSecondary: categoryInfo.color2,
|
||||
colourTertiary: categoryInfo.color3,
|
||||
// extensions: undefined, // no icon means no extension
|
||||
inputsInline: true,
|
||||
nextConnection: true,
|
||||
outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
|
||||
// previousConnection: undefined, // hat
|
||||
type: extendedOpcode
|
||||
});
|
||||
});
|
||||
});
|
||||
86
scratch-gui/test/unit/util/detect-locale.test.js
Normal file
86
scratch-gui/test/unit/util/detect-locale.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import {detectLocale} from '../../../src/lib/detect-locale.js';
|
||||
|
||||
const supportedLocales = ['en', 'es', 'pt-br', 'de', 'it'];
|
||||
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?name=val', configurable: true}
|
||||
);
|
||||
Object.defineProperty(window.navigator,
|
||||
'language',
|
||||
{value: 'en-US', configurable: true}
|
||||
);
|
||||
|
||||
describe('detectLocale', () => {
|
||||
test('uses locale from the URL when present', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?locale=pt-br'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('pt-br');
|
||||
});
|
||||
|
||||
test('is case insensitive', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?locale=pt-BR'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('pt-br');
|
||||
});
|
||||
|
||||
test('also accepts lang from the URL when present', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?lang=it'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('it');
|
||||
});
|
||||
|
||||
test('ignores unsupported locales', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?lang=sv'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('en');
|
||||
});
|
||||
|
||||
test('ignores other parameters', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?enable=language'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('en');
|
||||
});
|
||||
|
||||
test('uses navigator language property for default if supported', () => {
|
||||
Object.defineProperty(window.navigator,
|
||||
'language',
|
||||
{value: 'pt-BR'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('pt-br');
|
||||
});
|
||||
|
||||
test('ignores navigator language property if unsupported', () => {
|
||||
Object.defineProperty(window.navigator,
|
||||
'language',
|
||||
{value: 'da'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('en');
|
||||
});
|
||||
|
||||
test('works with an empty locale', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?locale='}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('en');
|
||||
});
|
||||
|
||||
test('if multiple, uses the first locale', () => {
|
||||
Object.defineProperty(window.location,
|
||||
'search',
|
||||
{value: '?locale=de&locale=en'}
|
||||
);
|
||||
expect(detectLocale(supportedLocales)).toEqual('de');
|
||||
});
|
||||
});
|
||||
120
scratch-gui/test/unit/util/drag-recognizer.test.js
Normal file
120
scratch-gui/test/unit/util/drag-recognizer.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import DragRecognizer from '../../../src/lib/drag-recognizer';
|
||||
|
||||
describe('DragRecognizer', () => {
|
||||
let onDrag;
|
||||
let onDragEnd;
|
||||
let dragRecognizer;
|
||||
|
||||
beforeEach(() => {
|
||||
onDrag = jest.fn();
|
||||
onDragEnd = jest.fn();
|
||||
dragRecognizer = new DragRecognizer({onDrag, onDragEnd});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dragRecognizer.reset();
|
||||
});
|
||||
|
||||
test('start -> small drag', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 101, clientY: 101}));
|
||||
expect(onDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('start -> large vertical touch move -> scroll, not drag', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 106, clientY: 150}));
|
||||
expect(onDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('start -> large vertical mouse move -> mouse moves always drag)', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {clientX: 100, clientY: 150}));
|
||||
expect(onDrag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('start -> large horizontal touch move -> drag', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
expect(onDrag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('after starting a scroll, it cannot become a drag', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 110}));
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 100}));
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 110, clientY: 100}));
|
||||
expect(onDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('start -> end unbinds', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(1);
|
||||
window.dispatchEvent(new MouseEvent('touchend', {clientX: 150, clientY: 106}));
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
|
||||
});
|
||||
|
||||
test('start -> end calls dragEnd callback after resetting internal state', done => {
|
||||
onDragEnd = () => {
|
||||
expect(dragRecognizer.gestureInProgress()).toBe(false);
|
||||
done();
|
||||
};
|
||||
dragRecognizer = new DragRecognizer({onDrag, onDragEnd});
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
window.dispatchEvent(new MouseEvent('touchend', {clientX: 150, clientY: 106}));
|
||||
});
|
||||
|
||||
test('start -> reset unbinds', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(1);
|
||||
dragRecognizer.reset();
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
|
||||
});
|
||||
|
||||
test('scrolls do not call prevent default', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
const event = new MouseEvent('touchmove', {clientX: 100, clientY: 110});
|
||||
event.preventDefault = jest.fn();
|
||||
window.dispatchEvent(event);
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('confirmed drags have preventDefault called on them', () => {
|
||||
dragRecognizer.start({clientX: 100, clientY: 100});
|
||||
const event = new MouseEvent('touchmove', {clientX: 150, clientY: 106});
|
||||
event.preventDefault = jest.fn();
|
||||
window.dispatchEvent(event);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('multiple horizontal drag angles', () => {
|
||||
// +45 from horizontal => drag
|
||||
dragRecognizer.start({clientX: 0, clientY: 0});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: 10}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(1);
|
||||
dragRecognizer.reset();
|
||||
|
||||
// -45 from horizontal => drag
|
||||
dragRecognizer.start({clientX: 0, clientY: 0});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: -10}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(2);
|
||||
dragRecognizer.reset();
|
||||
|
||||
// +135 from horizontal => drag
|
||||
dragRecognizer.start({clientX: 0, clientY: 0});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: 10}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(3);
|
||||
dragRecognizer.reset();
|
||||
|
||||
// -135 from horizontal => drag
|
||||
dragRecognizer.start({clientX: 0, clientY: 0});
|
||||
window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: -10}));
|
||||
expect(onDrag).toHaveBeenCalledTimes(4);
|
||||
dragRecognizer.reset();
|
||||
});
|
||||
});
|
||||
74
scratch-gui/test/unit/util/drag-utils.test.js
Normal file
74
scratch-gui/test/unit/util/drag-utils.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import {indexForPositionOnList} from '../../../src/lib/drag-utils';
|
||||
|
||||
const box = (top, right, bottom, left) => ({top, right, bottom, left});
|
||||
|
||||
describe('indexForPositionOnList', () => {
|
||||
test('returns null when not given any boxes', () => {
|
||||
expect(indexForPositionOnList({x: 0, y: 0}, [])).toEqual(null);
|
||||
});
|
||||
|
||||
test('wrapped list with incomplete last row LTR', () => {
|
||||
const boxes = [
|
||||
box(0, 100, 100, 0), // index: 0
|
||||
box(0, 200, 100, 100), // index: 1
|
||||
box(0, 300, 100, 200), // index: 2
|
||||
box(100, 100, 200, 0), // index: 3 (second row)
|
||||
box(100, 200, 200, 100) // index: 4 (second row, left incomplete intentionally)
|
||||
];
|
||||
|
||||
// Inside the second box.
|
||||
expect(indexForPositionOnList({x: 150, y: 50}, boxes, false)).toEqual(1);
|
||||
|
||||
// On the border edge of the first and second box. Given to the first box.
|
||||
expect(indexForPositionOnList({x: 100, y: 50}, boxes, false)).toEqual(0);
|
||||
|
||||
// Off the top/left edge.
|
||||
expect(indexForPositionOnList({x: -100, y: -100}, boxes, false)).toEqual(0);
|
||||
|
||||
// Off the left edge, in the second row.
|
||||
expect(indexForPositionOnList({x: -100, y: 175}, boxes, false)).toEqual(3);
|
||||
|
||||
// Off the right edge, in the first row.
|
||||
expect(indexForPositionOnList({x: 400, y: 75}, boxes, false)).toEqual(2);
|
||||
|
||||
// Off the top edge, middle of second item.
|
||||
expect(indexForPositionOnList({x: 150, y: -75}, boxes, false)).toEqual(1);
|
||||
|
||||
// Within the right edge bounds, but on the second (incomplete) row.
|
||||
// This tests that wrapped lists with incomplete final rows work correctly.
|
||||
expect(indexForPositionOnList({x: 375, y: 175}, boxes, false)).toEqual(4);
|
||||
});
|
||||
|
||||
test('wrapped list with incomplete last row RTL', () => {
|
||||
const boxes = [
|
||||
box(0, 0, 100, -100), // index: 0
|
||||
box(0, -100, 100, -200), // index: 1
|
||||
box(0, -200, 100, -300), // index: 2
|
||||
box(100, 0, 200, -100), // index: 3 (second row)
|
||||
box(100, -100, 200, -200) // index: 4 (second row, left incomplete intentionally)
|
||||
];
|
||||
|
||||
// Inside the second box.
|
||||
expect(indexForPositionOnList({x: -150, y: 50}, boxes, true)).toEqual(1);
|
||||
|
||||
// On the border edge of the first and second box. Given to the first box.
|
||||
expect(indexForPositionOnList({x: -100, y: 50}, boxes, true)).toEqual(0);
|
||||
|
||||
// Off the top/right edge.
|
||||
expect(indexForPositionOnList({x: 100, y: -100}, boxes, true)).toEqual(0);
|
||||
|
||||
// Off the right edge, in the second row.
|
||||
expect(indexForPositionOnList({x: 100, y: 175}, boxes, true)).toEqual(3);
|
||||
|
||||
// Off the left edge, in the first row.
|
||||
expect(indexForPositionOnList({x: -400, y: 75}, boxes, true)).toEqual(2);
|
||||
|
||||
// Off the top edge, middle of second item.
|
||||
expect(indexForPositionOnList({x: -150, y: -75}, boxes, true)).toEqual(1);
|
||||
|
||||
// Within the left edge bounds, but on the second (incomplete) row.
|
||||
// This tests that wrapped lists with incomplete final rows work correctly.
|
||||
expect(indexForPositionOnList({x: -375, y: 175}, boxes, true)).toEqual(4);
|
||||
});
|
||||
|
||||
});
|
||||
11
scratch-gui/test/unit/util/get-costume-url.test.js
Normal file
11
scratch-gui/test/unit/util/get-costume-url.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import {HAS_FONT_REGEXP} from '../../../src/lib/get-costume-url';
|
||||
|
||||
describe('SVG Font Parsing', () => {
|
||||
test('Has font regexp works', () => {
|
||||
expect('font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
|
||||
expect('font-family="none" font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
|
||||
expect('font-family = "Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy();
|
||||
|
||||
expect('font-family="none"'.match(HAS_FONT_REGEXP)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
81
scratch-gui/test/unit/util/hash-project-loader-hoc.test.jsx
Normal file
81
scratch-gui/test/unit/util/hash-project-loader-hoc.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mount} from 'enzyme';
|
||||
|
||||
import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx';
|
||||
|
||||
jest.mock('react-ga');
|
||||
|
||||
describe('HashParserHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('when there is a hash, it passes the hash as projectId', () => {
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = HashParserHOC(Component);
|
||||
window.location.hash = '#1234567';
|
||||
const mockSetProjectIdFunc = jest.fn();
|
||||
mount(
|
||||
<WrappedComponent
|
||||
setProjectId={mockSetProjectIdFunc}
|
||||
store={store}
|
||||
/>
|
||||
);
|
||||
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('1234567');
|
||||
});
|
||||
|
||||
test('when there is no hash, it passes 0 as the projectId', () => {
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = HashParserHOC(Component);
|
||||
window.location.hash = '';
|
||||
const mockSetProjectIdFunc = jest.fn();
|
||||
mount(
|
||||
<WrappedComponent
|
||||
setProjectId={mockSetProjectIdFunc}
|
||||
store={store}
|
||||
/>
|
||||
);
|
||||
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('0');
|
||||
});
|
||||
|
||||
test('when the hash is not a number, it passes 0 as projectId', () => {
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = HashParserHOC(Component);
|
||||
window.location.hash = '#winning';
|
||||
const mockSetProjectIdFunc = jest.fn();
|
||||
mount(
|
||||
<WrappedComponent
|
||||
setProjectId={mockSetProjectIdFunc}
|
||||
store={store}
|
||||
/>
|
||||
);
|
||||
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('0');
|
||||
});
|
||||
|
||||
test('when hash change happens, the projectId state is changed', () => {
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = HashParserHOC(Component);
|
||||
window.location.hash = '';
|
||||
const mockSetProjectIdFunc = jest.fn();
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
setProjectId={mockSetProjectIdFunc}
|
||||
store={store}
|
||||
/>
|
||||
);
|
||||
window.location.hash = '#1234567';
|
||||
mounted
|
||||
.childAt(0)
|
||||
.instance()
|
||||
.handleHashChange();
|
||||
expect(mockSetProjectIdFunc.mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
15
scratch-gui/test/unit/util/opcode-labels.test.js
Normal file
15
scratch-gui/test/unit/util/opcode-labels.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import opcodeLabels from '../../../src/lib/opcode-labels';
|
||||
|
||||
describe('Opcode Labels', () => {
|
||||
test('day of week label', () => {
|
||||
const labelFun = opcodeLabels.getLabel('sensing_current').labelFn;
|
||||
expect(labelFun({CURRENTMENU: 'dayofweek'})).toBe('day of week');
|
||||
expect(labelFun({CURRENTMENU: 'DAYOFWEEK'})).toBe('day of week');
|
||||
});
|
||||
|
||||
test('unspecified opcodes default to extension category and opcode as label', () => {
|
||||
const labelInfo = opcodeLabels.getLabel('music_getTempo');
|
||||
expect(labelInfo.label).toBe('music_getTempo');
|
||||
expect(labelInfo.category).toBe('extension');
|
||||
});
|
||||
});
|
||||
68
scratch-gui/test/unit/util/project-fetcher-hoc.test.jsx
Normal file
68
scratch-gui/test/unit/util/project-fetcher-hoc.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
|
||||
|
||||
import ProjectFetcherHOC from '../../../src/lib/project-fetcher-hoc.jsx';
|
||||
import storage from '../../../src/lib/storage';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
|
||||
jest.mock('react-ga');
|
||||
|
||||
describe('ProjectFetcherHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {},
|
||||
vm: {
|
||||
clear: () => {},
|
||||
stop: () => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.skip('when there is an id, it tries to update the store with that id', () => {
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = ProjectFetcherHOC(Component);
|
||||
const mockSetProjectIdFunc = jest.fn();
|
||||
mountWithIntl(
|
||||
<WrappedComponent
|
||||
projectId="100"
|
||||
setProjectId={mockSetProjectIdFunc}
|
||||
store={store}
|
||||
/>
|
||||
);
|
||||
expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('100');
|
||||
});
|
||||
test.skip('when there is a reduxProjectId and isFetchingWithProjectId is true, it loads the project', () => {
|
||||
const mockedOnFetchedProject = jest.fn();
|
||||
const originalLoad = storage.load;
|
||||
storage.load = jest.fn((type, id) => Promise.resolve({data: id}));
|
||||
const Component = ({projectId}) => <div>{projectId}</div>;
|
||||
const WrappedComponent = ProjectFetcherHOC(Component);
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
onFetchedProjectData={mockedOnFetchedProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
reduxProjectId: '100',
|
||||
isFetchingWithId: true,
|
||||
loadingState: LoadingState.FETCHING_WITH_ID
|
||||
});
|
||||
expect(storage.load).toHaveBeenLastCalledWith(
|
||||
storage.AssetType.Project, '100', storage.DataFormat.JSON
|
||||
);
|
||||
storage.load = originalLoad;
|
||||
// nextTick needed since storage.load is async, and onFetchedProject is called in its then()
|
||||
process.nextTick(
|
||||
() => expect(mockedOnFetchedProject)
|
||||
.toHaveBeenLastCalledWith('100', LoadingState.FETCHING_WITH_ID)
|
||||
);
|
||||
});
|
||||
});
|
||||
491
scratch-gui/test/unit/util/project-saver-hoc.test.jsx
Normal file
491
scratch-gui/test/unit/util/project-saver-hoc.test.jsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import 'web-audio-test-api';
|
||||
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mount} from 'enzyme';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import projectSaverHOC from '../../../src/lib/project-saver-hoc.jsx';
|
||||
|
||||
describe('projectSaverHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectChanged: false,
|
||||
projectState: {},
|
||||
projectTitle: 'Scratch Project',
|
||||
timeout: {
|
||||
autoSaveTimeoutId: null
|
||||
}
|
||||
},
|
||||
locales: {
|
||||
locale: 'en'
|
||||
}
|
||||
});
|
||||
vm = new VM();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
test('if canSave becomes true when showing a project with an id, project will be saved', () => {
|
||||
const mockedUpdateProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isShowingWithId
|
||||
canSave={false}
|
||||
isCreatingNew={false}
|
||||
isShowingSaveable={false} // set explicitly because it relies on ownProps.canSave
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onAutoUpdateProject={mockedUpdateProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
canSave: true,
|
||||
isShowingSaveable: true
|
||||
});
|
||||
expect(mockedUpdateProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if canSave is already true and we show a project with an id, project will NOT be saved', () => {
|
||||
const mockedSaveProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.LOADING_VM_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onAutoUpdateProject={mockedSaveProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
canSave: true,
|
||||
isShowingWithId: true,
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
});
|
||||
expect(mockedSaveProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if canSave is false when showing a project without an id, project will NOT be created', () => {
|
||||
const mockedCreateProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isShowingWithoutId
|
||||
canSave={false}
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onCreateProject={mockedCreateProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isShowingWithoutId: true,
|
||||
loadingState: LoadingState.SHOWING_WITHOUT_ID
|
||||
});
|
||||
expect(mockedCreateProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if canCreateNew becomes true when showing a project without an id, project will be created', () => {
|
||||
const mockedCreateProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isShowingWithoutId
|
||||
canCreateNew={false}
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.SHOWING_WITHOUT_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onCreateProject={mockedCreateProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
canCreateNew: true
|
||||
});
|
||||
expect(mockedCreateProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if canCreateNew is true and we transition to showing new project, project will be created', () => {
|
||||
const mockedCreateProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canCreateNew
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onCreateProject={mockedCreateProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isShowingWithoutId: true,
|
||||
loadingState: LoadingState.SHOWING_WITHOUT_ID
|
||||
});
|
||||
expect(mockedCreateProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if we enter creating new state, vm project should be requested', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
// The first wrapper is redux's Connect HOC
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isCreatingCopy={false}
|
||||
isCreatingNew={false}
|
||||
isRemixing={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.LOADING_VM_NEW_DEFAULT}
|
||||
reduxProjectId={'100'}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isCreatingNew: true,
|
||||
loadingState: LoadingState.CREATING_NEW
|
||||
});
|
||||
expect(mockedStoreProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if we enter remixing state, vm project should be requested, and alert should show', () => {
|
||||
const mockedShowCreatingRemixAlert = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
// The first wrapper is redux's Connect HOC
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isCreatingCopy={false}
|
||||
isCreatingNew={false}
|
||||
isRemixing={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
reduxProjectId={'100'}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onShowCreatingRemixAlert={mockedShowCreatingRemixAlert}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isRemixing: true,
|
||||
loadingState: LoadingState.REMIXING
|
||||
});
|
||||
expect(mockedStoreProject).toHaveBeenCalled();
|
||||
expect(mockedShowCreatingRemixAlert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if we enter creating copy state, vm project should be requested, and alert should show', () => {
|
||||
const mockedShowCreatingCopyAlert = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
// The first wrapper is redux's Connect HOC
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isCreatingCopy={false}
|
||||
isCreatingNew={false}
|
||||
isRemixing={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
reduxProjectId={'100'}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onShowCreatingCopyAlert={mockedShowCreatingCopyAlert}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isCreatingCopy: true,
|
||||
loadingState: LoadingState.CREATING_COPY
|
||||
});
|
||||
expect(mockedStoreProject).toHaveBeenCalled();
|
||||
expect(mockedShowCreatingCopyAlert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if we enter updating/saving state, vm project should be requested', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
// The first wrapper is redux's Connect HOC
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.LOADING_VM_WITH_ID}
|
||||
reduxProjectId={'100'}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isUpdating: true,
|
||||
loadingState: LoadingState.MANUAL_UPDATING
|
||||
});
|
||||
expect(mockedStoreProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if we are already in updating/saving state, vm project ' +
|
||||
'should NOT requested, alert should NOT show', () => {
|
||||
const mockedShowCreatingAlert = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
// The first wrapper is redux's Connect HOC
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isUpdating
|
||||
isCreatingNew={false}
|
||||
isShowingWithId={false}
|
||||
isShowingWithoutId={false}
|
||||
loadingState={LoadingState.MANUAL_UPDATING}
|
||||
reduxProjectId={'100'}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onShowCreatingAlert={mockedShowCreatingAlert}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isUpdating: true,
|
||||
loadingState: LoadingState.AUTO_UPDATING,
|
||||
reduxProjectId: '99' // random change to force a re-render and componentDidUpdate
|
||||
});
|
||||
expect(mockedStoreProject).not.toHaveBeenCalled();
|
||||
expect(mockedShowCreatingAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if user saves, inline saving alert should show', () => {
|
||||
const mockedShowSavingAlert = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isShowingWithoutId
|
||||
canCreateNew={false}
|
||||
isCreatingNew={false}
|
||||
isManualUpdating={false}
|
||||
isShowingWithId={false}
|
||||
isUpdating={false}
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onShowSavingAlert={mockedShowSavingAlert}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isManualUpdating: true,
|
||||
isUpdating: true
|
||||
});
|
||||
expect(mockedShowSavingAlert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if project is changed, it should autosave after interval', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isShowingSaveable
|
||||
isShowingWithId
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onAutoUpdateProject={mockedAutoUpdate}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
projectChanged: true
|
||||
});
|
||||
// Fast-forward until all timers have been executed
|
||||
jest.runAllTimers();
|
||||
expect(mockedAutoUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if project is changed several times in a row, it should only autosave once', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isShowingSaveable
|
||||
isShowingWithId
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onAutoUpdateProject={mockedAutoUpdate}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
projectChanged: true,
|
||||
reduxProjectTitle: 'a'
|
||||
});
|
||||
mounted.setProps({
|
||||
projectChanged: true,
|
||||
reduxProjectTitle: 'b'
|
||||
});
|
||||
mounted.setProps({
|
||||
projectChanged: true,
|
||||
reduxProjectTitle: 'c'
|
||||
});
|
||||
// Fast-forward until all timers have been executed
|
||||
jest.runAllTimers();
|
||||
expect(mockedAutoUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('if project is not changed, it should not autosave after interval', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const mockedAutoUpdate = jest.fn(() => Promise.resolve());
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
isShowingSaveable
|
||||
isShowingWithId
|
||||
loadingState={LoadingState.SHOWING_WITH_ID}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onAutoUpdateProject={mockedAutoUpdate}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
projectChanged: false
|
||||
});
|
||||
// Fast-forward until all timers have been executed
|
||||
jest.runAllTimers();
|
||||
expect(mockedAutoUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when starting to remix, onRemixing should be called with param true', () => {
|
||||
const mockedOnRemixing = jest.fn();
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isRemixing={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onRemixing={mockedOnRemixing}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isRemixing: true
|
||||
});
|
||||
expect(mockedOnRemixing).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('when starting to remix, onRemixing should be called with param false', () => {
|
||||
const mockedOnRemixing = jest.fn();
|
||||
const mockedStoreProject = jest.fn(() => Promise.resolve());
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
WrappedComponent.WrappedComponent.prototype.storeProject = mockedStoreProject;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isRemixing
|
||||
store={store}
|
||||
vm={vm}
|
||||
onRemixing={mockedOnRemixing}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isRemixing: false
|
||||
});
|
||||
expect(mockedOnRemixing).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('uses onSetProjectThumbnailer on mount/unmount', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const setThumb = jest.fn();
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
onSetProjectThumbnailer={setThumb}
|
||||
/>
|
||||
);
|
||||
// Set project thumbnailer should be called on mount
|
||||
expect(setThumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
// And it should not pass that function on to wrapped element
|
||||
expect(mounted.find(Component).props().onSetProjectThumbnailer).toBeUndefined();
|
||||
|
||||
// Unmounting should call it again with null
|
||||
mounted.unmount();
|
||||
expect(setThumb).toHaveBeenCalledTimes(2);
|
||||
expect(setThumb.mock.calls[1][0]).toBe(null);
|
||||
});
|
||||
|
||||
test('uses onSetProjectSaver on mount/unmount', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
const setSaver = jest.fn();
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
onSetProjectSaver={setSaver}
|
||||
/>
|
||||
);
|
||||
// Set project saver should be called on mount
|
||||
expect(setSaver).toHaveBeenCalledTimes(1);
|
||||
|
||||
// And it should not pass that function on to wrapped element
|
||||
expect(mounted.find(Component).props().onSetProjectSaver).toBeUndefined();
|
||||
|
||||
// Unmounting should call it again with null
|
||||
mounted.unmount();
|
||||
expect(setSaver).toHaveBeenCalledTimes(2);
|
||||
expect(setSaver.mock.calls[1][0]).toBe(null);
|
||||
});
|
||||
});
|
||||
110
scratch-gui/test/unit/util/sb-file-uploader-hoc.test.jsx
Normal file
110
scratch-gui/test/unit/util/sb-file-uploader-hoc.test.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'web-audio-test-api';
|
||||
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mountWithIntl, shallowWithIntl} from '../../helpers/intl-helpers.jsx';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import SBFileUploaderHOC from '../../../src/lib/sb-file-uploader-hoc.jsx';
|
||||
|
||||
describe('SBFileUploaderHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
let vm;
|
||||
|
||||
// Wrap this in a function so it gets test specific states and can be reused.
|
||||
const getContainer = function () {
|
||||
const Component = () => <div />;
|
||||
return SBFileUploaderHOC(Component);
|
||||
};
|
||||
|
||||
const shallowMountWithContext = component => (
|
||||
shallowWithIntl(component, {context: {store}})
|
||||
);
|
||||
|
||||
const unwrappedInstance = () => {
|
||||
const WrappedComponent = getContainer();
|
||||
// default starting state: looking at a project you created, not logged in
|
||||
const wrapper = shallowMountWithContext(
|
||||
<WrappedComponent
|
||||
projectChanged
|
||||
canSave={false}
|
||||
cancelFileUpload={jest.fn()}
|
||||
closeFileMenu={jest.fn()}
|
||||
requestProjectUpload={jest.fn()}
|
||||
userOwnsProject={false}
|
||||
vm={vm}
|
||||
onLoadingFinished={jest.fn()}
|
||||
onLoadingStarted={jest.fn()}
|
||||
onUpdateProjectTitle={jest.fn()}
|
||||
/>
|
||||
);
|
||||
return wrapper
|
||||
.dive() // unwrap intl
|
||||
.dive() // unwrap redux Connect(SBFileUploaderComponent)
|
||||
.instance(); // SBFileUploaderComponent
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vm = new VM();
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {
|
||||
loadingState: LoadingState.SHOWING_WITHOUT_ID
|
||||
},
|
||||
vm: {}
|
||||
},
|
||||
locales: {
|
||||
locale: 'en'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('correctly sets title with .sb3 filename', () => {
|
||||
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb3');
|
||||
expect(projectName).toBe('my project is great');
|
||||
});
|
||||
|
||||
test('correctly sets title with .sb2 filename', () => {
|
||||
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb2');
|
||||
expect(projectName).toBe('my project is great');
|
||||
});
|
||||
|
||||
test('correctly sets title with .sb filename', () => {
|
||||
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb');
|
||||
expect(projectName).toBe('my project is great');
|
||||
});
|
||||
|
||||
test('sets blank title with filename with no extension', () => {
|
||||
const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great');
|
||||
expect(projectName).toBe('');
|
||||
});
|
||||
|
||||
/* tw: test is broken by flag required to fix issues with multiple instances
|
||||
test('if isLoadingUpload becomes true, without fileToUpload set, will call cancelFileUpload', () => {
|
||||
const mockedCancelFileUpload = jest.fn();
|
||||
const WrappedComponent = getContainer();
|
||||
const mounted = mountWithIntl(
|
||||
<WrappedComponent
|
||||
projectChanged
|
||||
canSave={false}
|
||||
cancelFileUpload={mockedCancelFileUpload}
|
||||
closeFileMenu={jest.fn()}
|
||||
isLoadingUpload={false}
|
||||
requestProjectUpload={jest.fn()}
|
||||
store={store}
|
||||
userOwnsProject={false}
|
||||
vm={vm}
|
||||
onLoadingFinished={jest.fn()}
|
||||
onLoadingStarted={jest.fn()}
|
||||
onUpdateProjectTitle={jest.fn()}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
isLoadingUpload: true
|
||||
});
|
||||
expect(mockedCancelFileUpload).toHaveBeenCalled();
|
||||
});
|
||||
*/
|
||||
});
|
||||
165
scratch-gui/test/unit/util/themes.test.js
Normal file
165
scratch-gui/test/unit/util/themes.test.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
DARK_THEME,
|
||||
defaultColors,
|
||||
DEFAULT_THEME,
|
||||
getColorsForTheme,
|
||||
HIGH_CONTRAST_THEME
|
||||
} from '../../../src/lib/themes';
|
||||
import {injectExtensionBlockTheme, injectExtensionCategoryTheme} from '../../../src/lib/themes/blockHelpers';
|
||||
import {detectTheme, persistTheme} from '../../../src/lib/themes/themePersistance';
|
||||
|
||||
jest.mock('../../../src/lib/themes/default');
|
||||
jest.mock('../../../src/lib/themes/dark');
|
||||
|
||||
describe('themes', () => {
|
||||
let serializeToString;
|
||||
|
||||
describe('core functionality', () => {
|
||||
test('provides the default theme colors', () => {
|
||||
expect(defaultColors.motion.primary).toEqual('#111111');
|
||||
});
|
||||
|
||||
test('returns the dark mode', () => {
|
||||
const colors = getColorsForTheme(DARK_THEME);
|
||||
|
||||
expect(colors.motion.primary).toEqual('#AAAAAA');
|
||||
});
|
||||
|
||||
test('uses default theme colors when not specified', () => {
|
||||
const colors = getColorsForTheme(DARK_THEME);
|
||||
|
||||
expect(colors.motion.secondary).toEqual('#222222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('block helpers', () => {
|
||||
beforeEach(() => {
|
||||
serializeToString = jest.fn(() => 'mocked xml');
|
||||
|
||||
global.XMLSerializer = () => ({
|
||||
serializeToString
|
||||
});
|
||||
});
|
||||
|
||||
test('updates extension block colors based on theme', () => {
|
||||
const blockInfoJson = {
|
||||
type: 'dummy_block',
|
||||
colour: '#0FBD8C',
|
||||
colourSecondary: '#0DA57A',
|
||||
colourTertiary: '#0B8E69'
|
||||
};
|
||||
|
||||
const updated = injectExtensionBlockTheme(blockInfoJson, DARK_THEME);
|
||||
|
||||
expect(updated).toEqual({
|
||||
type: 'dummy_block',
|
||||
colour: '#FFFFFF',
|
||||
colourSecondary: '#EEEEEE',
|
||||
colourTertiary: '#DDDDDD'
|
||||
});
|
||||
// The original value was not modified
|
||||
expect(blockInfoJson.colour).toBe('#0FBD8C');
|
||||
});
|
||||
|
||||
test('updates extension block icon based on theme', () => {
|
||||
const blockInfoJson = {
|
||||
type: 'pen_block',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_image',
|
||||
src: 'original'
|
||||
}
|
||||
],
|
||||
colour: '#0FBD8C',
|
||||
colourSecondary: '#0DA57A',
|
||||
colourTertiary: '#0B8E69'
|
||||
};
|
||||
|
||||
const updated = injectExtensionBlockTheme(blockInfoJson, DARK_THEME);
|
||||
|
||||
expect(updated).toEqual({
|
||||
type: 'pen_block',
|
||||
args0: [
|
||||
{
|
||||
type: 'field_image',
|
||||
src: 'darkPenIcon'
|
||||
}
|
||||
],
|
||||
colour: '#FFFFFF',
|
||||
colourSecondary: '#EEEEEE',
|
||||
colourTertiary: '#DDDDDD'
|
||||
});
|
||||
// The original value was not modified
|
||||
expect(blockInfoJson.args0[0].src).toBe('original');
|
||||
});
|
||||
|
||||
test('bypasses updates if using the default theme', () => {
|
||||
const blockInfoJson = {
|
||||
type: 'dummy_block',
|
||||
colour: '#0FBD8C',
|
||||
colourSecondary: '#0DA57A',
|
||||
colourTertiary: '#0B8E69'
|
||||
};
|
||||
|
||||
const updated = injectExtensionBlockTheme(blockInfoJson, DEFAULT_THEME);
|
||||
|
||||
expect(updated).toEqual({
|
||||
type: 'dummy_block',
|
||||
colour: '#0FBD8C',
|
||||
colourSecondary: '#0DA57A',
|
||||
colourTertiary: '#0B8E69'
|
||||
});
|
||||
});
|
||||
|
||||
test('updates extension category based on theme', () => {
|
||||
const dynamicBlockXML = [
|
||||
{
|
||||
id: 'pen',
|
||||
xml: '<category name="Pen" id="pen" colour="#0FBD8C" secondaryColour="#0DA57A"></category>'
|
||||
}
|
||||
];
|
||||
|
||||
injectExtensionCategoryTheme(dynamicBlockXML, DARK_THEME);
|
||||
|
||||
// XMLSerializer is not available outside the browser.
|
||||
// Verify the mocked XMLSerializer.serializeToString is called with updated colors.
|
||||
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('colour')).toBe('#FFFFFF');
|
||||
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('secondaryColour')).toBe('#DDDDDD');
|
||||
expect(serializeToString.mock.calls[0][0].documentElement.getAttribute('iconURI')).toBe('darkPenIcon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme persistance', () => {
|
||||
test('returns the theme stored in a cookie', () => {
|
||||
window.document.cookie = `scratchtheme=${HIGH_CONTRAST_THEME}`;
|
||||
|
||||
const theme = detectTheme();
|
||||
|
||||
expect(theme).toEqual(HIGH_CONTRAST_THEME);
|
||||
});
|
||||
|
||||
test('returns the system theme when no cookie', () => {
|
||||
window.document.cookie = 'scratchtheme=';
|
||||
|
||||
const theme = detectTheme();
|
||||
|
||||
expect(theme).toEqual(DEFAULT_THEME);
|
||||
});
|
||||
|
||||
test('persists theme to cookie', () => {
|
||||
window.document.cookie = 'scratchtheme=';
|
||||
|
||||
persistTheme(HIGH_CONTRAST_THEME);
|
||||
|
||||
expect(window.document.cookie).toEqual(`scratchtheme=${HIGH_CONTRAST_THEME}`);
|
||||
});
|
||||
|
||||
test('clears theme when matching system preferences', () => {
|
||||
window.document.cookie = `scratchtheme=${HIGH_CONTRAST_THEME}`;
|
||||
|
||||
persistTheme(DEFAULT_THEME);
|
||||
|
||||
expect(window.document.cookie).toEqual('scratchtheme=');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
scratch-gui/test/unit/util/throttled-property-hoc.test.jsx
Normal file
54
scratch-gui/test/unit/util/throttled-property-hoc.test.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import {mount} from 'enzyme';
|
||||
|
||||
import ThrottledPropertyHOC from '../../../src/lib/throttled-property-hoc.jsx';
|
||||
|
||||
describe('VMListenerHOC', () => {
|
||||
let mounted;
|
||||
const throttleTime = 500;
|
||||
beforeEach(() => {
|
||||
const Component = ({propToThrottle, doNotThrottle}) => (
|
||||
<input
|
||||
name={doNotThrottle}
|
||||
value={propToThrottle}
|
||||
/>
|
||||
);
|
||||
const WrappedComponent = ThrottledPropertyHOC('propToThrottle', throttleTime)(Component);
|
||||
|
||||
global.Date.now = () => 0;
|
||||
|
||||
mounted = mount(
|
||||
<WrappedComponent
|
||||
doNotThrottle="oldvalue"
|
||||
propToThrottle={0}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('it passes the props on initial render ', () => {
|
||||
expect(mounted.find('[value=0]').exists()).toEqual(true);
|
||||
expect(mounted.find('[name="oldvalue"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does not rerender if throttled prop is updated too soon', () => {
|
||||
global.Date.now = () => throttleTime / 2;
|
||||
mounted.setProps({propToThrottle: 1});
|
||||
mounted.update();
|
||||
expect(mounted.find('[value=0]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does rerender if throttled prop is updated after throttle timeout', () => {
|
||||
global.Date.now = () => throttleTime * 2;
|
||||
mounted.setProps({propToThrottle: 1});
|
||||
mounted.update();
|
||||
expect(mounted.find('[value=1]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does rerender if a non-throttled prop is changed', () => {
|
||||
global.Date.now = () => throttleTime / 2;
|
||||
mounted.setProps({doNotThrottle: 'newvalue', propToThrottle: 2});
|
||||
mounted.update();
|
||||
expect(mounted.find('[name="newvalue"]').exists()).toEqual(true);
|
||||
expect(mounted.find('[value=2]').exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
25
scratch-gui/test/unit/util/translate-video.test.js
Normal file
25
scratch-gui/test/unit/util/translate-video.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
describe('no-op', () => {
|
||||
test('no-op', () => {});
|
||||
});
|
||||
// tw: we intentionally break this test
|
||||
/*
|
||||
import {translateVideo} from '../../../src/lib/libraries/decks/translate-video.js';
|
||||
|
||||
describe('translateVideo', () => {
|
||||
test('returns the id if it is not found', () => {
|
||||
expect(translateVideo('not-a-key', 'en')).toEqual('not-a-key');
|
||||
});
|
||||
|
||||
test('returns the expected id for Japanese', () => {
|
||||
expect(translateVideo('intro-move-sayhello', 'ja')).toEqual('v2c2f3y2sc');
|
||||
});
|
||||
|
||||
test('returns the expected id for English', () => {
|
||||
expect(translateVideo('intro-move-sayhello', 'en')).toEqual('rpjvs3v9gj');
|
||||
});
|
||||
|
||||
test('returns the English id for non-existent locales', () => {
|
||||
expect(translateVideo('intro-move-sayhello', 'yum')).toEqual('rpjvs3v9gj');
|
||||
});
|
||||
});
|
||||
*/
|
||||
47
scratch-gui/test/unit/util/tutorial-from-url.test.js
Normal file
47
scratch-gui/test/unit/util/tutorial-from-url.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
jest.mock('../../../src/lib/analytics.js', () => ({
|
||||
event: () => {}
|
||||
}));
|
||||
|
||||
jest.mock('../../../src/lib/libraries/decks/index.jsx', () => ({
|
||||
noUrlId: {},
|
||||
foo: {urlId: 'one'},
|
||||
noUrlIdSandwich: {}
|
||||
}));
|
||||
|
||||
import queryString from 'query-string';
|
||||
import {detectTutorialId} from '../../../src/lib/tutorial-from-url.js';
|
||||
|
||||
test('returns the tutorial ID if the urlId matches', () => {
|
||||
const queryParams = queryString.parse('?tutorial=one');
|
||||
expect(detectTutorialId(queryParams)).toBe('foo');
|
||||
});
|
||||
|
||||
test('returns null if no matching urlId', () => {
|
||||
const queryParams = queryString.parse('?tutorial=10');
|
||||
expect(detectTutorialId(queryParams)).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null if empty template', () => {
|
||||
const queryParams = queryString.parse('?tutorial=');
|
||||
expect(detectTutorialId(queryParams)).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null if no query param', () => {
|
||||
const queryParams = queryString.parse('');
|
||||
expect(detectTutorialId(queryParams)).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null if unrecognized template', () => {
|
||||
const queryParams = queryString.parse('?tutorial=asdf');
|
||||
expect(detectTutorialId(queryParams)).toBe(null);
|
||||
});
|
||||
|
||||
test('takes the first of multiple', () => {
|
||||
const queryParams = queryString.parse('?tutorial=one&tutorial=two');
|
||||
expect(detectTutorialId(queryParams)).toBe('foo');
|
||||
});
|
||||
|
||||
test('returns all for the tutorial library shortcut', () => {
|
||||
const queryParams = queryString.parse('?tutorial=all');
|
||||
expect(detectTutorialId(queryParams)).toBe('all');
|
||||
});
|
||||
186
scratch-gui/test/unit/util/vm-listener-hoc.test.jsx
Normal file
186
scratch-gui/test/unit/util/vm-listener-hoc.test.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mount} from 'enzyme';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
import vmListenerHOC from '../../../src/lib/vm-listener-hoc.jsx';
|
||||
|
||||
describe('VMListenerHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = new VM();
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
mode: {},
|
||||
modals: {},
|
||||
vm: vm,
|
||||
tw: {hasCloudVariables: false}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('vm green flag event is bound to the passed in prop callback', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
const onGreenFlag = jest.fn();
|
||||
mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
onGreenFlag={onGreenFlag}
|
||||
/>
|
||||
);
|
||||
expect(onGreenFlag).not.toHaveBeenCalled();
|
||||
vm.emit('PROJECT_START');
|
||||
expect(onGreenFlag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onGreenFlag is not passed to the children', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
const wrapper = mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
onGreenFlag={jest.fn()}
|
||||
/>
|
||||
);
|
||||
const child = wrapper.find(Component);
|
||||
expect(child.props().onGreenFlag).toBeUndefined();
|
||||
});
|
||||
|
||||
test('targetsUpdate event from vm triggers targets update action', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
const targetList = [];
|
||||
const editingTarget = 'id';
|
||||
vm.emit('targetsUpdate', {targetList, editingTarget});
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual('scratch-gui/targets/UPDATE_TARGET_LIST');
|
||||
expect(actions[0].targets).toEqual(targetList);
|
||||
expect(actions[0].editingTarget).toEqual(editingTarget);
|
||||
});
|
||||
|
||||
test('targetsUpdate does not dispatch if the sound recorder is visible', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
mode: {},
|
||||
modals: {soundRecorder: true},
|
||||
vm: vm,
|
||||
tw: {hasCloudVariables: false}
|
||||
}
|
||||
});
|
||||
mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
const targetList = [];
|
||||
const editingTarget = 'id';
|
||||
vm.emit('targetsUpdate', {targetList, editingTarget});
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('PROJECT_CHANGED does dispatch if the sound recorder is visible', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
mode: {},
|
||||
modals: {soundRecorder: true},
|
||||
vm: vm,
|
||||
tw: {hasCloudVariables: false}
|
||||
}
|
||||
});
|
||||
mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
vm.emit('PROJECT_CHANGED');
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toEqual(1);
|
||||
});
|
||||
|
||||
test('PROJECT_CHANGED does not dispatch if in fullscreen mode', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
mode: {isFullScreen: true},
|
||||
modals: {soundRecorder: true},
|
||||
vm: vm,
|
||||
tw: {hasCloudVariables: false}
|
||||
}
|
||||
});
|
||||
mount(
|
||||
<WrappedComponent
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
vm.emit('PROJECT_CHANGED');
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('keypresses go to the vm', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmListenerHOC(Component);
|
||||
|
||||
// Mock document.addEventListener so we can trigger keypresses manually
|
||||
// Cannot use the enzyme simulate method because that only works on synthetic events
|
||||
const eventTriggers = {};
|
||||
document.addEventListener = jest.fn((event, cb) => {
|
||||
eventTriggers[event] = cb;
|
||||
});
|
||||
|
||||
vm.postIOData = jest.fn();
|
||||
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
mode: {isFullScreen: true},
|
||||
modals: {soundRecorder: true},
|
||||
vm: vm,
|
||||
tw: {hasCloudVariables: false}
|
||||
}
|
||||
});
|
||||
mount(
|
||||
<WrappedComponent
|
||||
attachKeyboardEvents
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
// keyboard events that do not target the document or body are ignored
|
||||
eventTriggers.keydown({key: 'A', target: null});
|
||||
expect(vm.postIOData).not.toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
|
||||
|
||||
// keydown/up with target as the document are sent to the vm via postIOData
|
||||
eventTriggers.keydown({key: 'A', target: document});
|
||||
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
|
||||
|
||||
eventTriggers.keyup({key: 'A', target: document});
|
||||
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: false});
|
||||
|
||||
// When key is 'Dead' e.g. bluetooth keyboards on iOS, it sends keyCode instead
|
||||
// because the VM can process both named keys or keyCodes as the `key` property
|
||||
eventTriggers.keyup({key: 'Dead', keyCode: 10, target: document});
|
||||
expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 10, isDown: false, keyCode: 10});
|
||||
});
|
||||
});
|
||||
199
scratch-gui/test/unit/util/vm-manager-hoc.test.jsx
Normal file
199
scratch-gui/test/unit/util/vm-manager-hoc.test.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* global WebAudioTestAPI */
|
||||
import 'web-audio-test-api';
|
||||
WebAudioTestAPI.setState({
|
||||
'AudioContext#resume': 'enabled'
|
||||
});
|
||||
|
||||
import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {mount} from 'enzyme';
|
||||
import VM from 'scratch-vm';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
|
||||
import vmManagerHOC from '../../../src/lib/vm-manager-hoc.jsx';
|
||||
|
||||
describe('VMManagerHOC', () => {
|
||||
const mockStore = configureStore();
|
||||
let store;
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
scratchGui: {
|
||||
projectState: {},
|
||||
mode: {},
|
||||
vmStatus: {}
|
||||
},
|
||||
locales: {
|
||||
locale: '',
|
||||
messages: {}
|
||||
}
|
||||
});
|
||||
vm = new VM();
|
||||
vm.attachAudioEngine = jest.fn();
|
||||
vm.setCompatibilityMode = jest.fn();
|
||||
vm.setLocale = jest.fn();
|
||||
vm.start = jest.fn();
|
||||
});
|
||||
test('when it mounts in player mode, the vm is initialized but not started', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
isPlayerOnly
|
||||
isStarted={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
|
||||
expect(vm.setLocale.mock.calls.length).toBe(1);
|
||||
expect(vm.initialized).toBe(true);
|
||||
|
||||
// But vm should not be started automatically
|
||||
expect(vm.start).not.toHaveBeenCalled();
|
||||
});
|
||||
test('when it mounts in editor mode, the vm is initialized and started', () => {
|
||||
const Component = () => (<div />);
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
isPlayerOnly={false}
|
||||
isStarted={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.attachAudioEngine.mock.calls.length).toBe(1);
|
||||
expect(vm.setLocale.mock.calls.length).toBe(1);
|
||||
expect(vm.initialized).toBe(true);
|
||||
|
||||
expect(vm.start).toHaveBeenCalled();
|
||||
});
|
||||
test('if it mounts with an initialized vm, it does not reinitialize the vm but will start it', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
vm.initialized = true;
|
||||
mount(
|
||||
<WrappedComponent
|
||||
isPlayerOnly={false}
|
||||
isStarted={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.attachAudioEngine.mock.calls.length).toBe(0);
|
||||
expect(vm.setLocale.mock.calls.length).toBe(0);
|
||||
expect(vm.initialized).toBe(true);
|
||||
|
||||
expect(vm.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if it mounts without starting the VM, it can be started by switching to editor mode', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
vm.initialized = true;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isPlayerOnly
|
||||
isStarted={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.start).not.toHaveBeenCalled();
|
||||
mounted.setProps({
|
||||
isPlayerOnly: false
|
||||
});
|
||||
expect(vm.start).toHaveBeenCalled();
|
||||
});
|
||||
test('if it mounts with an initialized and started VM, it does not start again', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
vm.initialized = true;
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isPlayerOnly
|
||||
isStarted
|
||||
store={store}
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
expect(vm.start).not.toHaveBeenCalled();
|
||||
mounted.setProps({
|
||||
isPlayerOnly: false
|
||||
});
|
||||
expect(vm.start).not.toHaveBeenCalled();
|
||||
});
|
||||
test('if the isLoadingWithId prop becomes true, it loads project data into the vm', () => {
|
||||
vm.loadProject = jest.fn(() => Promise.resolve());
|
||||
const mockedOnLoadedProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
fontsLoaded
|
||||
isLoadingWithId={false}
|
||||
store={store}
|
||||
vm={vm}
|
||||
onLoadedProject={mockedOnLoadedProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
canSave: true,
|
||||
isLoadingWithId: true,
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID,
|
||||
projectData: '100'
|
||||
});
|
||||
expect(vm.loadProject).toHaveBeenLastCalledWith('100');
|
||||
// nextTick needed since vm.loadProject is async, and we have to wait for it :/
|
||||
process.nextTick(() => (
|
||||
expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, true)
|
||||
));
|
||||
});
|
||||
test('if the fontsLoaded prop becomes true, it loads project data into the vm', () => {
|
||||
vm.loadProject = jest.fn(() => Promise.resolve());
|
||||
const mockedOnLoadedProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isLoadingWithId
|
||||
store={store}
|
||||
vm={vm}
|
||||
onLoadedProject={mockedOnLoadedProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
canSave: false,
|
||||
fontsLoaded: true,
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID,
|
||||
projectData: '100'
|
||||
});
|
||||
expect(vm.loadProject).toHaveBeenLastCalledWith('100');
|
||||
// nextTick needed since vm.loadProject is async, and we have to wait for it :/
|
||||
process.nextTick(() => (
|
||||
expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, false)
|
||||
));
|
||||
});
|
||||
test('if the fontsLoaded prop is false, project data is never loaded', () => {
|
||||
vm.loadProject = jest.fn(() => Promise.resolve());
|
||||
const mockedOnLoadedProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = vmManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
isLoadingWithId
|
||||
store={store}
|
||||
vm={vm}
|
||||
onLoadedProject={mockedOnLoadedProject}
|
||||
/>
|
||||
);
|
||||
mounted.setProps({
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID,
|
||||
projectData: '100'
|
||||
});
|
||||
expect(vm.loadProject).toHaveBeenCalledTimes(0);
|
||||
process.nextTick(() => expect(mockedOnLoadedProject).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user