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:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
describe('no-op', () => {
test('no-op', () => {});
});
// tw: these seem to be hopelessly broken to the increasing scope of changes we make to the menu bar, disable for now...
/*
import React from 'react';
import {mount} from 'enzyme';
import configureStore from 'redux-mock-store';
import MenuBarHOC from '../../../src/containers/menu-bar-hoc.jsx';
describe('Menu Bar HOC', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
scratchGui: {
projectChanged: true
}
});
});
test('Logged in user who IS owner and HAS changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
canSave
projectChanged
// assume the user will click "cancel" on the confirm dialog
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who IS owner and has NOT changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
canSave
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who is NOT owner and HAS changed project will NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew
projectChanged
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged OUT user who HAS changed project WILL be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
projectChanged
canCreateNew={false}
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(false);
});
test('Logged OUT user who has NOT changed project WILL NOT be prompted to save', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canCreateNew={false}
canSave={false}
confirmWithMessage={() => (false)} // eslint-disable-line react/jsx-no-bind
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().confirmReadyToReplaceProject('message')).toBe(true);
});
test('Logged in user who IS owner and HAS changed project SHOULD save before transition to project page', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave
projectChanged
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(true);
});
test('Logged in user who IS owner and has NOT changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
test('Logged in user who is NOT owner and HAS changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
projectChanged
canSave={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
test('Logged in user who is NOT owner and has NOT changed project should NOT save before transition', () => {
const Component = () => (<div />);
const WrappedComponent = MenuBarHOC(Component);
const wrapper = mount(
<WrappedComponent
canSave={false}
projectChanged={false}
store={store}
/>
);
const child = wrapper.find(Component);
expect(child.props().projectChanged).toBeUndefined();
expect(child.props().shouldSaveBeforeTransition()).toBe(false);
});
});
*/

View File

@@ -0,0 +1,76 @@
import React from 'react';
import {Provider} from 'react-redux';
import configureStore from 'redux-mock-store';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import SaveStatus from '../../../src/components/menu-bar/save-status.jsx';
import InlineMessages from '../../../src/containers/inline-messages.jsx';
import {AlertTypes} from '../../../src/lib/alerts/index.jsx';
// Stub the manualUpdateProject action creator for later testing
jest.mock('../../../src/reducers/project-state', () => ({
manualUpdateProject: jest.fn(() => ({type: 'stubbed'}))
}));
describe('SaveStatus container', () => {
const mockStore = configureStore();
test('if there are inline messages, they are shown instead of save now', () => {
const store = mockStore({
scratchGui: {
projectChanged: true,
alerts: {
alertsList: [
{alertId: 'saveSuccess', alertType: AlertTypes.INLINE}
]
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).toBe(true);
expect(wrapper.contains('Save Now')).not.toBe(true);
});
test('save now is shown if there are project changes and no inline messages', () => {
const store = mockStore({
scratchGui: {
projectChanged: true,
alerts: {
alertsList: []
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).not.toBe(true);
expect(wrapper.contains('Save Now')).toBe(true);
// Clicking save now should dispatch the manualUpdateProject action (stubbed above)
wrapper.find('[children="Save Now"]').simulate('click');
expect(store.getActions()[0].type).toEqual('stubbed');
});
test('neither is shown if there are no project changes or inline messages', () => {
const store = mockStore({
scratchGui: {
projectChanged: false,
alerts: {
alertsList: []
}
}
});
const wrapper = mountWithIntl(
<Provider store={store}>
<SaveStatus />
</Provider>
);
expect(wrapper.find(InlineMessages).exists()).not.toBe(true);
expect(wrapper.contains('Save Now')).not.toBe(true);
});
});

View File

@@ -0,0 +1,111 @@
import React from 'react';
import {shallow} from 'enzyme';
import SliderPrompt from '../../../src/containers/slider-prompt.jsx';
import SliderPromptComponent from '../../../src/components/slider-prompt/slider-prompt.jsx';
describe('Slider Prompt Container', () => {
let onCancel;
let onOk;
beforeEach(() => {
onCancel = jest.fn();
onOk = jest.fn();
});
test('Min/max are shown with decimal when isDiscrete is false', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
expect(componentProps.minValue).toBe('0.00');
expect(componentProps.maxValue).toBe('100.00');
});
test('Min/max are NOT shown with decimal when isDiscrete is true', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
expect(componentProps.minValue).toBe('0');
expect(componentProps.maxValue).toBe('100');
});
test('Entering a number with a decimal submits with isDiscrete=false', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete
maxValue={100}
minValue={0}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1.0'}});
componentProps.onOk();
expect(onOk).toHaveBeenCalledWith(1, 100, false);
});
test('Entering integers submits with isDiscrete=true', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1'}});
componentProps.onChangeMax({target: {value: '2'}});
componentProps.onOk();
expect(onOk).toHaveBeenCalledWith(1, 2, true);
});
test('Enter button submits the form', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: '1'}});
componentProps.onChangeMax({target: {value: '2'}});
componentProps.onKeyPress({key: 'Enter'});
expect(onOk).toHaveBeenCalledWith(1, 2, true);
});
test('Validates number-ness before submitting', () => {
const wrapper = shallow(
<SliderPrompt
isDiscrete={false}
maxValue={100.1}
minValue={12.32}
onCancel={onCancel}
onOk={onOk}
/>
);
const componentProps = wrapper.find(SliderPromptComponent).props();
componentProps.onChangeMin({target: {value: 'hello'}});
componentProps.onOk();
expect(onOk).not.toHaveBeenCalled();
expect(onCancel).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,306 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import configureStore from 'redux-mock-store';
import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js';
import mockAudioEffects from '../../__mocks__/audio-effects.js';
import SoundEditor from '../../../src/containers/sound-editor';
import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor';
jest.mock('react-ga');
jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer);
jest.mock('../../../src/lib/audio/audio-effects', () => mockAudioEffects);
describe('Sound Editor Container', () => {
const mockStore = configureStore();
let store;
let soundIndex;
let soundBuffer;
const samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef
let vm;
beforeEach(() => {
soundIndex = 0;
soundBuffer = {
numberOfChannels: 1,
sampleRate: 0,
getChannelData: jest.fn(() => samples)
};
vm = {
getSoundBuffer: jest.fn(() => soundBuffer),
renameSound: jest.fn(),
updateSoundBuffer: jest.fn(),
editingTarget: {
sprite: {
sounds: [{name: 'first name', id: 'first id'}]
}
}
};
store = mockStore({scratchGui: {vm: vm, mode: {isFullScreen: false}}});
});
test('should pass the correct data to the component from the store', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const componentProps = wrapper.find(SoundEditorComponent).props();
// Data retreived and processed by the `connect` with the store
expect(componentProps.name).toEqual('first name');
expect(componentProps.chunkLevels).toEqual([0]);
expect(mockAudioBufferPlayer.instance.samples).toEqual(samples);
// Initial data
expect(componentProps.playhead).toEqual(null);
expect(componentProps.trimStart).toEqual(null);
expect(componentProps.trimEnd).toEqual(null);
});
test('it plays when clicked and stops when clicked again', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Ensure rendering doesn't start playing any sounds
expect(mockAudioBufferPlayer.instance.play.mock.calls).toEqual([]);
expect(mockAudioBufferPlayer.instance.stop.mock.calls).toEqual([]);
component.props().onPlay();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
// Mock the audio buffer player calling onUpdate
mockAudioBufferPlayer.instance.onUpdate(0.5);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.props().playhead).toEqual(0.5);
component.props().onStop();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(mockAudioBufferPlayer.instance.stop).toHaveBeenCalled();
expect(component.props().playhead).toEqual(null);
});
test('it submits name changes to the vm', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onChangeName('hello');
expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
});
test('it handles an effect by submitting the result and playing', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onReverse(); // Could be any of the effects, just testing the end result
await mockAudioEffects.instance._finishProcessing(soundBuffer);
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
});
test('it handles reverse effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onReverse();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.REVERSE);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles louder effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onLouder();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.LOUDER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles softer effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onSofter();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SOFTER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles faster effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onFaster();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.FASTER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles slower effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onSlower();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SLOWER);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles echo effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onEcho();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ECHO);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('it handles robot effect correctly', () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
component.props().onRobot();
expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT);
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
});
test('undo/redo stack state', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Undo and redo should be disabled initially
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(false);
// Submitting new samples should make it possible to undo
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// Undoing should make it possible to redo and not possible to undo again
await component.props().onUndo();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(true);
// Redoing should make it possible to undo and not possible to redo again
await component.props().onRedo();
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// New submission should clear the redo stack
await component.props().onUndo(); // Undo to go back to a state where redo is enabled
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canRedo')).toEqual(true);
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
expect(component.prop('canRedo')).toEqual(false);
});
test('undo and redo submit new samples and play the sound', async () => {
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
let component = wrapper.find(SoundEditorComponent);
// Set up an undoable state
component.props().onFaster();
await mockAudioEffects.instance._finishProcessing(soundBuffer);
wrapper.update();
component = wrapper.find(SoundEditorComponent);
// Undo should update the sound buffer and play the new samples
await component.props().onUndo();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
// Clear the mocks call history to assert again for redo.
vm.updateSoundBuffer.mockClear();
mockAudioBufferPlayer.instance.play.mockClear();
// Undo should update the sound buffer and play the new samples
await component.props().onRedo();
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
expect(vm.updateSoundBuffer).toHaveBeenCalled();
});
test('isStereo numberOfChannels=1', () => {
soundBuffer.numberOfChannels = 1;
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
expect(component.props().isStereo).toEqual(false);
});
test('isStereo numberOfChannels=2', () => {
soundBuffer.numberOfChannels = 2;
const wrapper = mountWithIntl(
<SoundEditor
soundIndex={soundIndex}
store={store}
/>
);
const component = wrapper.find(SoundEditorComponent);
expect(component.props().isStereo).toEqual(true);
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import SpriteSelectorItem from '../../../src/containers/sprite-selector-item';
import DeleteButton from '../../../src/components/delete-button/delete-button';
describe('SpriteSelectorItem Container', () => {
const mockStore = configureStore();
let className;
let costumeURL;
let name;
let onClick;
let dispatchSetHoveredSprite;
let onDeleteButtonClick;
let selected;
let id;
let store;
// Wrap this in a function so it gets test specific states and can be reused.
const getContainer = function () {
return (
<Provider store={store}>
<SpriteSelectorItem
className={className}
costumeURL={costumeURL}
dispatchSetHoveredSprite={dispatchSetHoveredSprite}
id={id}
name={name}
selected={selected}
onClick={onClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
</Provider>
);
};
beforeEach(() => {
store = mockStore({scratchGui: {
hoveredTarget: {receivedBlocks: false, sprite: null},
assetDrag: {dragging: false}
}});
className = 'ponies';
costumeURL = 'https://scratch.mit.edu/foo/bar/pony';
id = 1337;
name = 'Pony sprite';
onClick = jest.fn();
onDeleteButtonClick = jest.fn();
dispatchSetHoveredSprite = jest.fn();
selected = true;
});
test('should delete the sprite', () => {
const wrapper = mountWithIntl(getContainer());
wrapper.find(DeleteButton).simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalledWith(1337);
});
});