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,705 @@
import SettingStore from '../../../src/addons/settings-store';
import upstreamMeta from '../../../src/addons/generated/upstream-meta.json';
class LocalStorageShim {
constructor () {
this.storage = Object.create(null);
}
getItem (key) {
return this.storage[key];
}
setItem (key, value) {
this.storage[key] = value.toString();
}
}
beforeEach(() => {
global.localStorage = new LocalStorageShim();
});
const lightTheme = {
isDark: () => false
};
const darkTheme = {
isDark: () => true
};
test('enabled, event', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(store.getAddonEnabled('editor-devtools')).toBe(true);
expect('enabled' in store.store['editor-devtools']).toBe(false);
store.setAddonEnabled('editor-devtools', false);
expect(store.getAddonEnabled('editor-devtools')).toBe(false);
expect('enabled' in store.store['editor-devtools']).toBe(true);
store.setAddonEnabled('editor-devtools', true);
store.setAddonEnabled('cat-blocks', true);
expect('enabled' in store.store['cat-blocks']).toBe(true);
store.setAddonEnabled('cat-blocks', null);
expect('enabled' in store.store['cat-blocks']).toBe(false);
expect(fn).toHaveBeenCalledTimes(4);
expect(fn.mock.calls[0][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[1][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[1][0].detail.value).toBe(true);
expect(fn.mock.calls[2][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[2][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[2][0].detail.value).toBe(true);
expect(fn.mock.calls[3][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[3][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[3][0].detail.value).toBe(false);
});
test('settings, event, default values', () => {
const store = new SettingStore();
const fn = jest.fn();
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect('default' in store.store['onion-skinning']).toBe(false);
store.addEventListener('setting-changed', fn);
store.setAddonSetting('onion-skinning', 'default', true);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(true);
expect('default' in store.store['onion-skinning']).toBe(true);
store.setAddonSetting('onion-skinning', 'default', null);
expect('default' in store.store['onion-skinning']).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('default');
expect(fn.mock.calls[0][0].detail.value).toBe(true);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('default');
expect(fn.mock.calls[1][0].detail.value).toBe(false);
});
test('no actual change emits no event', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
for (let i = 0; i < 5; i++) store.setAddonEnabled('cat-blocks', true);
expect(fn).toHaveBeenCalledTimes(1);
for (let i = 0; i < 5; i++) store.setAddonEnabled('cat-blocks', false);
expect(fn).toHaveBeenCalledTimes(2);
for (let i = 0; i < 5; i++) store.setAddonSetting('onion-skinning', 'default', true);
expect(fn).toHaveBeenCalledTimes(3);
for (let i = 0; i < 5; i++) store.setAddonSetting('onion-skinning', 'default', false);
expect(fn).toHaveBeenCalledTimes(4);
});
test('changing enabled throws on unknown addons', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonEnabled('egriohergoijergijregojiergdfoijre', true)).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing settings throws on unknown settings', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonSetting('onion-skinning', 'sdlkjfslkdjfljksd', true)).toThrow();
expect(() => store.setAddonSetting('ergfoijgi', 'sdflkjsfdlkj', true)).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing enabled throws on invalid values', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.setAddonEnabled('cat-blocks', 'sdfjlksdflk')).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', 0)).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', [])).toThrow();
expect(() => store.setAddonEnabled('cat-blocks', {})).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('changing settings checks value validity and throws', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
// boolean
expect(() => store.setAddonSetting('onion-skinning', 'default', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'default', 1)).toThrow();
// integer
expect(() => store.setAddonSetting('onion-skinning', 'next', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', '3')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'next', false)).toThrow();
// select
expect(() => store.setAddonSetting('onion-skinning', 'mode', '#abcdef')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', false)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 1)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 'tint')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'mode', 'merge')).not.toThrow();
// color
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '#abcdef')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '#abcDE1')).not.toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', [])).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', {})).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', '')).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', false)).toThrow();
expect(() => store.setAddonSetting('onion-skinning', 'beforeTint', 1)).toThrow();
expect(fn).toHaveBeenCalledTimes(4);
});
test('colors with alpha channel', () => {
const store = new SettingStore();
store.setAddonSetting('onion-skinning', 'beforeTint', '#123456');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#123456');
store.setAddonSetting('onion-skinning', 'beforeTint', '#234567ff');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#234567');
store.setAddonSetting('onion-skinning', 'beforeTint', '#abc67800');
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#abc678');
store.import({
addons: {
'onion-skinning': {
settings: {
beforeTint: '#56789aff'
}
}
}
});
expect(store.getAddonSetting('onion-skinning', 'beforeTint')).toBe('#56789a');
});
test('reset does not change enabled', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.resetAddon('cat-blocks');
expect(store.getAddonEnabled('cat-blocks')).toBe(true);
});
test('reset settings, event', () => {
const store = new SettingStore();
store.setAddonSetting('onion-skinning', 'default', true);
store.setAddonSetting('onion-skinning', 'next', 3);
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.resetAddon('onion-skinning');
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'next')).toBe(0);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('default');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('next');
expect(fn.mock.calls[1][0].detail.value).toBe(0);
});
test('reset all addons', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.setAddonSetting('onion-skinning', 'default', true);
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.resetAllAddons();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('cat-blocks');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('default');
expect(fn.mock.calls[1][0].detail.value).toBe(false);
});
test('apply preset', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
store.setAddonSetting('editor-theme3', 'motion-color', '#abcdef');
store.applyAddonPreset('editor-theme3', 'original');
expect(fn.mock.calls.length).toBeGreaterThan(5);
expect(store.getAddonSetting('editor-theme3', 'motion-color')).toBe('#4a6cd4');
// TODO: test that settings not specified in the preset don't change
});
test('unknown preset throws', () => {
const store = new SettingStore();
const fn = jest.fn();
store.addEventListener('setting-changed', fn);
expect(() => store.applyAddonPreset('alksdfjlksdf', 'jksdflkjsdf')).toThrow();
expect(() => store.applyAddonPreset('editor-theme3', 'jksdflkjsdf')).toThrow();
expect(fn).toHaveBeenCalledTimes(0);
});
test('export core', () => {
const store = new SettingStore();
const exported = store.export({theme: lightTheme});
expect(exported.core.version).toMatch(/tw/);
expect(exported.core.lightTheme).toBe(true);
const dark = store.export({theme: darkTheme});
expect(dark.core.lightTheme).toBe(false);
});
test('export settings', () => {
const store = new SettingStore();
let exported = store.export({theme: lightTheme});
expect(exported.addons['remove-sprite-confirm'].enabled).toBe(false);
expect(exported.addons['remove-sprite-confirm'].settings).toEqual({});
expect(exported.addons['onion-skinning'].enabled).toBe(true);
expect(exported.addons['onion-skinning'].settings.default).toEqual(false);
store.setAddonEnabled('remove-sprite-confirm', true);
store.setAddonSetting('onion-skinning', 'default', true);
exported = store.export({theme: lightTheme});
expect(exported.addons['remove-sprite-confirm'].enabled).toBe(true);
expect(exported.addons['remove-sprite-confirm'].settings).toEqual({});
expect(exported.addons['onion-skinning'].enabled).toBe(true);
expect(exported.addons['onion-skinning'].settings.default).toEqual(true);
});
test('export theme', () => {
const store = new SettingStore();
const exported = store.export({theme: lightTheme});
expect(exported.core.lightTheme).toBe(true);
const exported2 = store.export({theme: darkTheme});
expect(exported2.core.lightTheme).toBe(false);
});
test('import, event', () => {
const store = new SettingStore();
store.setAddonEnabled('onion-skinning', false);
store.setAddonSetting('onion-skinning', 'next', 5);
const newStore = new SettingStore();
newStore.setAddonSetting('onion-skinning', 'next', 10);
const fn = jest.fn();
newStore.addEventListener('setting-changed', fn);
newStore.import(store.export({theme: lightTheme}));
expect(newStore.getAddonEnabled('onion-skinning')).toBe(false);
expect(newStore.getAddonSetting('onion-skinning', 'next')).toBe(5);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[0][0].detail.settingId).toBe('enabled');
expect(fn.mock.calls[0][0].detail.value).toBe(false);
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
expect(fn.mock.calls[1][0].detail.settingId).toBe('next');
expect(fn.mock.calls[1][0].detail.value).toBe(5);
});
test('export is identical after import', () => {
const store = new SettingStore();
const fn = jest.fn();
const exported = store.export({theme: lightTheme});
store.import(exported);
expect(fn).toHaveBeenCalledTimes(0);
expect(store.export({theme: lightTheme})).toEqual(exported);
});
test('import format', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.import({
core: {
version: 'lksd',
lightTheme: false
},
addons: {
'onion-skinning': {
enabled: false,
settings: {
next: 7
}
}
}
});
expect(store.getAddonEnabled('onion-skinning')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'next')).toBe(7);
expect(store.getAddonEnabled('cat-blocks')).toBe(true);
});
test('invalid imports', () => {
const store = new SettingStore();
expect(() => store.import({
addons: {}
})).not.toThrow();
expect(() => store.import({
addons: {
'onion-skinning': {
enabled: false,
settings: {
dsjfokosdfj: 5
}
}
}
})).not.toThrow();
expect(() => store.import({
addons: {
grfdjiklk: {
enabled: true,
settings: {}
}
}
})).not.toThrow();
expect(() => store.import({
addons: {
'onion-skinning': {
enabled: '4',
settings: {
default: '3'
}
}
}
})).not.toThrow();
expect(store.getAddonEnabled('onion-skinning')).toBe(false);
expect(store.getAddonSetting('onion-skinning', 'default')).toBe(false);
});
test('local storage', () => {
const store = new SettingStore();
store.setAddonEnabled('cat-blocks', true);
store.setAddonSetting('onion-skinning', 'default', true);
const newStore = new SettingStore();
newStore.readLocalStorage();
expect(newStore.store).toEqual(store.store);
});
test('local storage is resistent to errors', () => {
global.localStorage = new LocalStorageShim();
const store = new SettingStore();
localStorage.getItem = () => {
throw new Error(':(');
};
store.readLocalStorage();
localStorage.getItem = () => 'eoiru4jtg)(R(';
store.readLocalStorage();
localStorage.setItem = () => {
throw new Error(':(');
};
store.setAddonEnabled('cat-blocks', true);
// eslint-disable-next-line no-undefined
global.localStorage = undefined;
store.readLocalStorage();
store.setAddonEnabled('cat-blocks', false);
});
test('setStore diffing', () => {
const settingsStore = new SettingStore();
const pageStore = new SettingStore();
settingsStore.setAddonEnabled('editor-devtools', false);
pageStore.setAddonEnabled('editor-devtools', false);
const fn = jest.fn();
pageStore.addEventListener('addon-changed', fn);
pageStore.setStore(settingsStore.store);
expect(fn).toHaveBeenCalledTimes(0);
settingsStore.setAddonEnabled('editor-devtools', true);
settingsStore.setAddonSetting('onion-skinning', 'next', 10);
pageStore.setStore(settingsStore.store);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn.mock.calls[0][0].detail.addonId).toBe('editor-devtools');
expect(fn.mock.calls[1][0].detail.addonId).toBe('onion-skinning');
});
test('setStore dynamic enable/disable', () => {
const settingsStore = new SettingStore();
const pageStore = new SettingStore();
settingsStore.setAddonEnabled('block-palette-icons', false);
pageStore.setStore(settingsStore.store);
const fn = jest.fn();
pageStore.addEventListener('addon-changed', fn);
settingsStore.setAddonEnabled('block-palette-icons', true);
pageStore.setStore(settingsStore.store);
expect(fn.mock.calls[0][0].detail.addonId).toBe('block-palette-icons');
expect(fn.mock.calls[0][0].detail.dynamicEnable).toBe(true);
expect(fn.mock.calls[0][0].detail.dynamicDisable).toBe(false);
settingsStore.setAddonEnabled('block-palette-icons', false);
pageStore.setStore(settingsStore.store);
expect(fn.mock.calls[1][0].detail.addonId).toBe('block-palette-icons');
expect(fn.mock.calls[1][0].detail.dynamicEnable).toBe(false);
expect(fn.mock.calls[1][0].detail.dynamicDisable).toBe(true);
});
test('setStore weird values', () => {
const settingsStore = new SettingStore();
expect(settingsStore.getAddonEnabled('pause')).toBe(true);
settingsStore.setAddonEnabled('pause', false);
settingsStore.setAddonEnabled('clones', true);
settingsStore.setStore({
invalid0: {},
invalid1: null,
pause: null
});
expect(settingsStore.getAddonEnabled('pause')).toBe(false);
});
test('resetting an addon through setStore', () => {
const store = new SettingStore();
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(100);
store.setAddonSetting('custom-block-shape', 'paddingSize', 50);
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(50);
const store2 = new SettingStore();
store.setStore(store2.store);
expect(store.getAddonSetting('custom-block-shape', 'paddingSize')).toBe(100);
});
test('setStoreWithVersionCheck', () => {
const store = new SettingStore();
store.setStore = jest.fn();
store.setStoreWithVersionCheck({
store: '1234',
version: upstreamMeta.commit
});
expect(store.setStore).toHaveBeenCalledTimes(1);
expect(store.setStore).toHaveBeenCalledWith('1234');
store.setStore = jest.fn();
store.setStoreWithVersionCheck({
store: '1234',
version: 'something invalid'
});
expect(store.setStore).toHaveBeenCalledTimes(0);
});
test('parseUrlParameter', () => {
const store = new SettingStore();
expect(store.getAddonEnabled('pause')).toBe(true);
expect(store.getAddonEnabled('mute-project')).toBe(true);
expect(store.getAddonEnabled('remove-curved-stage-border')).toBe(false);
expect(store.remote).toBe(false);
store.parseUrlParameter('pause,remove-curved-stage-border,,invalid addon??43t987(*&$');
expect(store.getAddonEnabled('pause')).toBe(true);
expect(store.getAddonEnabled('mute-project')).toBe(false);
expect(store.getAddonEnabled('remove-curved-stage-border')).toBe(true);
expect(store.remote).toBe(true);
});
test('Settings migration 1 -> 2', () => {
const store = new SettingStore();
// eslint-disable-next-line max-len
global.localStorage.getItem = () => `{"_":1,"tw-project-info":{"enabled":false},"tw-interface-customization":{"enabled":false,"removeFeedback":true,"removeBackpack":true}}`;
store.readLocalStorage();
expect(store.getAddonEnabled('block-count')).toBe(true);
expect(store.getAddonEnabled('tw-remove-backpack')).toBe(true);
expect(store.getAddonEnabled('tw-remove-feedback')).toBe(true);
// eslint-disable-next-line max-len
global.localStorage.getItem = () => `{"_":1,"tw-project-info":{"enabled":true},"tw-interface-customization":{"enabled":true,"removeFeedback":true,"removeBackpack":true}}`;
store.readLocalStorage();
expect(store.getAddonEnabled('block-count')).toBe(true);
expect(store.getAddonEnabled('tw-remove-backpack')).toBe(true);
expect(store.getAddonEnabled('tw-remove-feedback')).toBe(true);
});
test('Settings migration 2 -> 3', () => {
const store = new SettingStore();
global.localStorage.getItem = () => JSON.stringify({
'_': 2,
'hide-flyout': {
enabled: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('hide-flyout', 'toggle')).toBe('hover');
});
test('Settings migration 3 -> 4', () => {
const store = new SettingStore();
global.localStorage.getItem = () => JSON.stringify({
_: 3
});
store.readLocalStorage();
expect(store.getAddonEnabled('editor-devtools')).toBe(true);
expect(store.getAddonEnabled('find-bar')).toBe(true);
expect(store.getAddonEnabled('middle-click-popup')).toBe(true);
global.localStorage.getItem = () => JSON.stringify({
'_': 3,
'editor-devtools': {
enabled: false
}
});
store.readLocalStorage();
expect(store.getAddonEnabled('editor-devtools')).toBe(false);
expect(store.getAddonEnabled('find-bar')).toBe(false);
expect(store.getAddonEnabled('middle-click-popup')).toBe(false);
});
test('if', () => {
const store = new SettingStore();
store.setAddonEnabled('editor-devtools', true);
store.setAddonEnabled('onion-skinning', false);
store.setAddonSetting('editor-theme3', 'motion-color', '#000000');
store.setAddonSetting('editor-theme3', 'looks-color', '#FFFFFF');
// eslint-disable-next-line no-undefined
expect(store.evaluateCondition('editor-theme3', undefined)).toBe(true);
expect(store.evaluateCondition('editor-theme3', null)).toBe(true);
expect(store.evaluateCondition('editor-theme3', {})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning']
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: 'onion-skinning'
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools']
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: 'editor-devtools'
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools', 'onion-skinning']
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000000'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': '#FFFFFF'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': '#FFFFFE'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000000',
'looks-color': '#FFFFFF'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'motion-color': '#000001',
'looks-color': '#FFFFFF'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFF']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE', '#FFFFFF']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFF', '#FFFFFE']
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE']
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': ['#FFFFFE', '#FFFFFD']
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
settings: {
'looks-color': []
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools'],
settings: {
'motion-color': '#000000'
}
})).toBe(true);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning'],
settings: {
'motion-color': '#000000'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['editor-devtools'],
settings: {
'motion-color': '#000001'
}
})).toBe(false);
expect(store.evaluateCondition('editor-theme3', {
addonEnabled: ['onion-skinning'],
settings: {
'motion-color': '#000001'
}
})).toBe(false);
});
test('Settings migration 4 -> 5', () => {
const store = new SettingStore();
// implied default settings
global.localStorage.getItem = () => JSON.stringify({
_: 4
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// also implied default settings
global.localStorage.getItem = () => JSON.stringify({
_: 4,
fullscreen: {}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// explicit default settings
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: false
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('show');
// explicit hide, implied default hover setting
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hover');
// explicit hide and default hover
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true,
hoverToolbar: true
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hover');
// explicit hide, no hover
global.localStorage.getItem = () => JSON.stringify({
'_': 4,
'fullscreen': {
hideToolbar: true,
hoverToolbar: false
}
});
store.readLocalStorage();
expect(store.getAddonSetting('fullscreen', 'toolbar')).toBe('hide');
});

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ButtonComponent matches snapshot 1`] = `
<span
className=""
onClick={[Function]}
role="button"
>
<div
className={undefined}
/>
</span>
`;

View File

@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IconButtonComponent matches snapshot 1`] = `
<div
className="custom-class-name"
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="imgSrc"
/>
<div
className={undefined}
>
<div>
Text
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,565 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sound Editor Component matches snapshot 1`] = `
<div
className={undefined}
onMouseDown={undefined}
>
<div
className={undefined}
>
<div
className={undefined}
>
<label
className={undefined}
>
<span
className={undefined}
>
Sound
</span>
<input
className=""
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
onSubmit={[Function]}
tabIndex="1"
type="text"
value="sound name"
/>
</label>
<div
className={undefined}
>
<button
className={undefined}
disabled={false}
onClick={[Function]}
title="Undo"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
</button>
<button
className={undefined}
disabled={true}
onClick={[Function]}
title="Redo"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
</button>
</div>
</div>
<div
className={undefined}
>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Copy
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Paste
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Copy to New
</div>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
Delete
</div>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
<svg
className={undefined}
viewBox="0 0 600 160"
>
<g
transform="scale(1, -1) translate(0, -80)"
>
<path
className={undefined}
d="M0 0Q0 80 150 120 Q300 160 450 200 Q600 240 600 0 Q600 -240 450 -200 Q300 -160 150 -120 Q0 -80 0 0Z"
strokeLinejoin="round"
strokeWidth={1}
/>
</g>
</svg>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"left": "20%",
"width": "60.00000000000001%",
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
/>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
</div>
<div
className=""
onMouseDown={[Function]}
onTouchStart={[Function]}
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
<div
className=""
style={
Object {
"alignContent": undefined,
"alignItems": undefined,
"alignSelf": undefined,
"flexBasis": undefined,
"flexDirection": undefined,
"flexGrow": undefined,
"flexShrink": undefined,
"flexWrap": undefined,
"height": undefined,
"justifyContent": undefined,
"width": undefined,
}
}
>
<img
draggable={false}
src="test-file-stub"
/>
</div>
</div>
</div>
<div
className={undefined}
>
<div
className=""
style={
Object {
"transform": "translateX(50%)",
}
}
/>
</div>
</div>
</div>
</div>
<div
className=""
>
<div
className=""
>
<button
className=""
onClick={[Function]}
title="Stop"
>
<img
draggable={false}
src="test-file-stub"
/>
</button>
</div>
<div
className={undefined}
>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Faster
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Slower
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Louder
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Softer
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Mute
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Fade in
</span>
</div>
</div>
<div
className=""
onClick={undefined}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Fade out
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Reverse
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Robot
</span>
</div>
</div>
<div
className=""
onClick={[Function]}
role="button"
>
<img
className={undefined}
draggable={false}
src="test-file-stub"
/>
<div
className={undefined}
>
<span>
Echo
</span>
</div>
</div>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
00:00.30 / 00:00.60
</div>
<div
className={undefined}
>
Hz
<span>
Mono
</span>
(10.51KB)
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,179 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SpriteSelectorItemComponent matches snapshot when given a number and details to show 1`] = `
<div
className="react-contextmenu-wrapper ponies undefined"
onClick={[Function]}
onContextMenu={[Function]}
onMouseDown={[Function]}
onMouseEnter={undefined}
onMouseLeave={undefined}
onMouseOut={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className={undefined}
>
5
</div>
<div
className={undefined}
>
<div
className={undefined}
>
<img
className={undefined}
draggable={false}
src="https://scratch.mit.edu/foo/bar/pony"
/>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
Pony sprite
</div>
<div
className={undefined}
>
480 x 360
</div>
</div>
<div
aria-label="Delete"
className=""
onClick={[Function]}
role="button"
tabIndex={0}
>
<div
className={undefined}
>
<img
className={undefined}
src="test-file-stub"
/>
</div>
</div>
<nav
className="react-contextmenu"
onContextMenu={[Function]}
onMouseLeave={[Function]}
role="menu"
style={
Object {
"opacity": 0,
"pointerEvents": "none",
"position": "fixed",
}
}
tabIndex="-1"
>
<div
aria-disabled="false"
aria-orientation={null}
className="react-contextmenu-item"
onClick={[Function]}
onMouseLeave={[Function]}
onMouseMove={[Function]}
onTouchEnd={[Function]}
role="menuitem"
tabIndex="-1"
>
<span>
delete
</span>
</div>
</nav>
</div>
`;
exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = `
<div
className="react-contextmenu-wrapper ponies undefined"
onClick={[Function]}
onContextMenu={[Function]}
onMouseDown={[Function]}
onMouseEnter={undefined}
onMouseLeave={undefined}
onMouseOut={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className={undefined}
>
<div
className={undefined}
>
<img
className={undefined}
draggable={false}
src="https://scratch.mit.edu/foo/bar/pony"
/>
</div>
</div>
<div
className={undefined}
>
<div
className={undefined}
>
Pony sprite
</div>
</div>
<div
aria-label="Delete"
className=""
onClick={[Function]}
role="button"
tabIndex={0}
>
<div
className={undefined}
>
<img
className={undefined}
src="test-file-stub"
/>
</div>
</div>
<nav
className="react-contextmenu"
onContextMenu={[Function]}
onMouseLeave={[Function]}
role="menu"
style={
Object {
"opacity": 0,
"pointerEvents": "none",
"position": "fixed",
}
}
tabIndex="-1"
>
<div
aria-disabled="false"
aria-orientation={null}
className="react-contextmenu-item"
onClick={[Function]}
onMouseLeave={[Function]}
onMouseMove={[Function]}
onTouchEnd={[Function]}
role="menuitem"
tabIndex="-1"
>
<span>
delete
</span>
</div>
</nav>
</div>
`;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import {shallow} from 'enzyme';
import ButtonComponent from '../../../src/components/button/button';
import renderer from 'react-test-renderer';
describe('ButtonComponent', () => {
test('matches snapshot', () => {
const onClick = jest.fn();
const component = renderer.create(
<ButtonComponent onClick={onClick} />
);
expect(component.toJSON()).toMatchSnapshot();
});
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const componentShallowWrapper = shallow(
<ButtonComponent onClick={onClick} />
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,62 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
// Mock this utility because it uses dynamic imports that do not work with jest
jest.mock('../../../src/lib/libraries/decks/translate-image.js', () => {});
import Cards, {ImageStep, VideoStep} from '../../../src/components/cards/cards.jsx';
describe('Cards component', () => {
const defaultProps = () => ({
activeDeckId: 'id1',
content: {
id1: {
name: 'id1 - name',
img: 'id1 - img',
steps: [{video: 'videoUrl'}]
}
},
dragging: false,
expanded: true,
isRtl: false,
locale: 'en',
onActivateDeckFactory: jest.fn(),
onCloseCards: jest.fn(),
onDrag: jest.fn(),
onEndDrag: jest.fn(),
onNextStep: jest.fn(),
onPrevStep: jest.fn(),
onShowAll: jest.fn(),
onShrinkExpandCards: jest.fn(),
onStartDrag: jest.fn(),
showVideos: true,
step: 0,
x: 0,
y: 0
});
test('showVideos=true shows the video step', () => {
const component = mountWithIntl(
<Cards
{...defaultProps()}
showVideos
/>
);
expect(component.find(ImageStep).exists()).toEqual(false);
expect(component.find(VideoStep).exists()).toEqual(true);
});
test('showVideos=false shows the title image/name instead of video step', () => {
const component = mountWithIntl(
<Cards
{...defaultProps()}
showVideos={false}
/>
);
expect(component.find(VideoStep).exists()).toEqual(false);
const imageStep = component.find(ImageStep);
expect(imageStep.props().image).toEqual('id1 - img');
expect(imageStep.props().title).toEqual('id1 - name');
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import Controls from '../../../src/components/controls/controls';
import TurboMode from '../../../src/components/turbo-mode/turbo-mode';
import GreenFlag from '../../../src/components/green-flag/green-flag';
import StopAll from '../../../src/components/stop-all/stop-all';
describe('Controls component', () => {
const defaultProps = () => ({
active: false,
onGreenFlagClick: jest.fn(),
onStopAllClick: jest.fn(),
turbo: false
});
test('shows turbo mode when in turbo mode', () => {
const component = mountWithIntl(
<Controls
{...defaultProps()}
/>
);
expect(component.find(TurboMode).exists()).toEqual(false);
component.setProps({turbo: true});
expect(component.find(TurboMode).exists()).toEqual(true);
});
test('triggers the right callbacks when clicked', () => {
const props = defaultProps();
const component = mountWithIntl(
<Controls
{...props}
/>
);
component.find(GreenFlag).simulate('click');
expect(props.onGreenFlagClick).toHaveBeenCalled();
component.find(StopAll).simulate('click');
expect(props.onStopAllClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import {Provider} from 'react-redux';
const {mountWithIntl} = require('../../helpers/intl-helpers.jsx');
import configureStore from 'redux-mock-store';
import CrashMessageComponent from '../../../src/components/crash-message/crash-message.jsx';
import ErrorBoundary from '../../../src/containers/error-boundary.jsx';
const ChildComponent = () => <div>hello</div>;
describe('ErrorBoundary', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({
locales: {
isRtl: false,
locale: 'en-US'
}
});
});
test('ErrorBoundary shows children before error and CrashMessageComponent after', () => {
const child = <ChildComponent />;
const wrapper = mountWithIntl(
<Provider store={store}><ErrorBoundary action="test">{child}</ErrorBoundary></Provider>
);
const errorSite = wrapper.childAt(0).childAt(0);
// @ts-ignore: 'onReload' prop is absent because this component will only be used for pattern matching
const crashMessagePattern = <CrashMessageComponent />;
expect(wrapper.containsMatchingElement(child)).toBeTruthy();
expect(wrapper.containsMatchingElement(crashMessagePattern)).toBeFalsy();
errorSite.simulateError(new Error('fake error for testing purposes'));
expect(wrapper.containsMatchingElement(child)).toBeFalsy();
expect(wrapper.containsMatchingElement(crashMessagePattern)).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import {shallow} from 'enzyme';
import IconButton from '../../../src/components/icon-button/icon-button';
import renderer from 'react-test-renderer';
describe('IconButtonComponent', () => {
test('matches snapshot', () => {
const onClick = jest.fn();
const title = <div>Text</div>;
const imgSrc = 'imgSrc';
const className = 'custom-class-name';
const component = renderer.create(
<IconButton
className={className}
img={imgSrc}
title={title}
onClick={onClick}
/>
);
expect(component.toJSON()).toMatchSnapshot();
});
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const title = <div>Text</div>;
const imgSrc = 'imgSrc';
const componentShallowWrapper = shallow(
<IconButton
img={imgSrc}
title={title}
onClick={onClick}
/>
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
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 {mountWithIntl} from '../../helpers/intl-helpers';
import MenuBar from '../../../src/components/menu-bar/menu-bar';
import {menuInitialState} from '../../../src/reducers/menus';
import {LoadingState} from '../../../src/reducers/project-state';
import {DEFAULT_THEME} from '../../../src/lib/themes';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import VM from 'scratch-vm';
describe('MenuBar Component', () => {
const store = configureStore()({
locales: {
isRtl: false,
locale: 'en-US'
},
scratchGui: {
menus: menuInitialState,
projectState: {
loadingState: LoadingState.NOT_LOADED
},
theme: {
theme: DEFAULT_THEME
},
timeTravel: {
year: 'NOW'
},
vm: new VM()
}
});
const getComponent = function (props = {}) {
return <Provider store={store}><MenuBar {...props} /></Provider>;
};
test('menu bar with no About handler has no About button', () => {
const menuBar = mountWithIntl(getComponent());
const button = menuBar.find('AboutButton');
expect(button.exists()).toBe(false);
});
test('menu bar with an About handler has an About button', () => {
const onClickAbout = jest.fn();
const menuBar = mountWithIntl(getComponent({onClickAbout}));
const button = menuBar.find('AboutButton');
expect(button.exists()).toBe(true);
});
test('clicking on About button calls the handler', () => {
const onClickAbout = jest.fn();
const menuBar = mountWithIntl(getComponent({onClickAbout}));
const button = menuBar.find('AboutButton');
expect(onClickAbout).toHaveBeenCalledTimes(0);
button.simulate('click');
expect(onClickAbout).toHaveBeenCalledTimes(1);
});
});
*/

View File

@@ -0,0 +1,78 @@
import React from 'react';
import {OrderedMap} from 'immutable';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import MonitorList from '../../../src/components/monitor-list/monitor-list.jsx';
import {DEFAULT_THEME} from '../../../src/lib/themes';
describe('MonitorListComponent', () => {
const store = configureStore()({scratchGui: {
monitorLayout: {
monitors: {},
savedMonitorPositions: {}
},
theme: {
theme: DEFAULT_THEME
},
toolbox: {
toolboxXML: ''
},
vm: {
runtime: {
requestUpdateMonitor: () => {},
getLabelForOpcode: () => ''
}
}
}});
const draggable = false;
const onMonitorChange = jest.fn();
const stageSize = {
width: 100,
height: 100,
widthDefault: 100,
heightDefault: 100
};
let monitors = OrderedMap({});
// Wrap this in a function so it gets test specific states and can be reused.
const getComponent = function () {
return (
<Provider store={store}>
<MonitorList
draggable={draggable}
monitors={monitors}
stageSize={stageSize}
onMonitorChange={onMonitorChange}
/>
</Provider>
);
};
test('it renders the correct step size for discrete sliders', () => {
monitors = OrderedMap({
id1: {
visible: true,
mode: 'slider',
isDiscrete: true
}
});
const wrapper = mountWithIntl(getComponent());
const input = wrapper.find('input');
expect(input.props().step).toBe(1);
});
test('it renders the correct step size for non-discrete sliders', () => {
monitors = OrderedMap({
id1: {
visible: true,
mode: 'slider',
isDiscrete: false
}
});
const wrapper = mountWithIntl(getComponent());
const input = wrapper.find('input');
expect(input.props().step).toBe(0.01);
});
});

View File

@@ -0,0 +1,56 @@
import React from 'react';
import {shallow} from 'enzyme';
import DefaultMonitor from '../../../src/components/monitor/default-monitor';
import Monitor from '../../../src/components/monitor/monitor';
import {DARK_THEME, DEFAULT_THEME} from '../../../src/lib/themes';
jest.mock('../../../src/lib/themes/default');
jest.mock('../../../src/lib/themes/dark');
describe('Monitor Component', () => {
test('it selects the correct colors based on default theme', () => {
const noop = () => {};
const wrapper = shallow(<Monitor
category="motion"
// eslint-disable-next-line react/jsx-no-bind
componentRef={noop}
draggable={false}
label="My label"
mode="default"
// eslint-disable-next-line react/jsx-no-bind
onDragEnd={noop}
// eslint-disable-next-line react/jsx-no-bind
onNextMode={noop}
theme={DEFAULT_THEME}
/>);
const defaultMonitor = wrapper.find(DefaultMonitor);
// selects colors from mock value in src/lib/themes/__mocks__/default-colors.js
expect(defaultMonitor.props().categoryColor).toEqual({background: '#111111', text: '#444444'});
});
test('it selects the correct colors based on dark mode theme', () => {
const noop = () => {};
const wrapper = shallow(<Monitor
category="motion"
// eslint-disable-next-line react/jsx-no-bind
componentRef={noop}
draggable={false}
label="My label"
mode="default"
// eslint-disable-next-line react/jsx-no-bind
onDragEnd={noop}
// eslint-disable-next-line react/jsx-no-bind
onNextMode={noop}
theme={DARK_THEME}
/>);
const defaultMonitor = wrapper.find(DefaultMonitor);
// selects colors from mock value in src/lib/themes/__mocks__/dark-mode.js
expect(defaultMonitor.props().categoryColor).toEqual({background: '#AAAAAA', text: '#BBBBBB'});
});
});

View File

@@ -0,0 +1,146 @@
import React from 'react';
import {mountWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx';
import SoundEditor from '../../../src/components/sound-editor/sound-editor';
describe('Sound Editor Component', () => {
let props;
beforeEach(() => {
props = {
isStereo: false,
duration: 1,
size: 10507,
canUndo: true,
canRedo: false,
chunkLevels: [1, 2, 3],
name: 'sound name',
playhead: 0.5,
trimStart: 0.2,
trimEnd: 0.8,
onChangeName: jest.fn(),
onDelete: jest.fn(),
onPlay: jest.fn(),
onRedo: jest.fn(),
onReverse: jest.fn(),
onSofter: jest.fn(),
onLouder: jest.fn(),
onRobot: jest.fn(),
onEcho: jest.fn(),
onFaster: jest.fn(),
onSlower: jest.fn(),
onSetTrimEnd: jest.fn(),
onSetTrimStart: jest.fn(),
onStop: jest.fn(),
onUndo: jest.fn()
};
});
test('matches snapshot', () => {
const component = componentWithIntl(<SoundEditor {...props} />);
expect(component.toJSON()).toMatchSnapshot();
});
test('delete button appears when selection is not null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
trimEnd={0.75}
trimStart={0.25}
/>
);
wrapper.find('[children="Delete"]').simulate('click');
expect(props.onDelete).toHaveBeenCalled();
});
test('play button appears when playhead is null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
playhead={null}
/>
);
wrapper.find('button[title="Play"]').simulate('click');
expect(props.onPlay).toHaveBeenCalled();
});
test('stop button appears when playhead is not null', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
playhead={0.5}
/>
);
wrapper.find('button[title="Stop"]').simulate('click');
expect(props.onStop).toHaveBeenCalled();
});
test('submitting name calls the callback', () => {
const wrapper = mountWithIntl(
<SoundEditor {...props} />
);
wrapper.find('input')
.simulate('change', {target: {value: 'hello'}})
.simulate('blur');
expect(props.onChangeName).toHaveBeenCalled();
});
test('effect buttons call the correct callbacks', () => {
const wrapper = mountWithIntl(
<SoundEditor {...props} />
);
wrapper.find('[children="Reverse"]').simulate('click');
expect(props.onReverse).toHaveBeenCalled();
wrapper.find('[children="Robot"]').simulate('click');
expect(props.onRobot).toHaveBeenCalled();
wrapper.find('[children="Faster"]').simulate('click');
expect(props.onFaster).toHaveBeenCalled();
wrapper.find('[children="Slower"]').simulate('click');
expect(props.onSlower).toHaveBeenCalled();
wrapper.find('[children="Louder"]').simulate('click');
expect(props.onLouder).toHaveBeenCalled();
wrapper.find('[children="Softer"]').simulate('click');
expect(props.onSofter).toHaveBeenCalled();
});
test('undo and redo buttons can be disabled by canUndo/canRedo', () => {
let wrapper = mountWithIntl(
<SoundEditor
{...props}
canUndo
canRedo={false}
/>
);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true);
wrapper = mountWithIntl(
<SoundEditor
{...props}
canRedo
canUndo={false}
/>
);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false);
});
test.skip('undo/redo buttons call the correct callback', () => {
const wrapper = mountWithIntl(
<SoundEditor
{...props}
canRedo
canUndo
/>
);
wrapper.find('button[title="Undo"]').simulate('click');
expect(props.onUndo).toHaveBeenCalled();
wrapper.find('button[title="Redo"]').simulate('click');
expect(props.onRedo).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import React from 'react';
import {mountWithIntl, shallowWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx';
import SpriteSelectorItemComponent from '../../../src/components/sprite-selector-item/sprite-selector-item';
import DeleteButton from '../../../src/components/delete-button/delete-button';
describe('SpriteSelectorItemComponent', () => {
let className;
let costumeURL;
let name;
let onClick;
let onDeleteButtonClick;
let selected;
let number;
let details;
// Wrap this in a function so it gets test specific states and can be reused.
const getComponent = function () {
return (
<SpriteSelectorItemComponent
className={className}
costumeURL={costumeURL}
details={details}
name={name}
number={number}
selected={selected}
onClick={onClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
);
};
beforeEach(() => {
className = 'ponies';
costumeURL = 'https://scratch.mit.edu/foo/bar/pony';
name = 'Pony sprite';
onClick = jest.fn();
onDeleteButtonClick = jest.fn();
selected = true;
// Reset to undefined since they are optional props
number = undefined; // eslint-disable-line no-undefined
details = undefined; // eslint-disable-line no-undefined
});
test('matches snapshot when selected', () => {
const component = componentWithIntl(getComponent());
expect(component.toJSON()).toMatchSnapshot();
});
test('matches snapshot when given a number and details to show', () => {
number = 5;
details = '480 x 360';
const component = componentWithIntl(getComponent());
expect(component.toJSON()).toMatchSnapshot();
});
test('does not have a close box when not selected', () => {
selected = false;
const wrapper = shallowWithIntl(getComponent());
expect(wrapper.find(DeleteButton).exists()).toBe(false);
});
test('triggers callback when Box component is clicked', () => {
// Use `mount` here because of the way ContextMenuTrigger consumes onClick
const wrapper = mountWithIntl(getComponent());
wrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
test('triggers callback when CloseButton component is clicked', () => {
const wrapper = shallowWithIntl(getComponent());
wrapper.find(DeleteButton).simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalled();
});
test('it has a context menu with delete menu item and callback', () => {
const wrapper = mountWithIntl(getComponent());
const contextMenu = wrapper.find('ContextMenu');
expect(contextMenu.exists()).toBe(true);
contextMenu.find('[children="delete"]').simulate('click');
expect(onDeleteButtonClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {shallow} from 'enzyme';
import ToggleButtons from '../../../src/components/toggle-buttons/toggle-buttons';
describe('ToggleButtons', () => {
test('renders multiple buttons', () => {
const component = shallow(<ToggleButtons
buttons={[
{
title: 'Button 1',
handleClick: () => {},
icon: 'Button 1 icon'
},
{
title: 'Button 2',
handleClick: () => {},
icon: 'Button 2 icon'
}
]}
/>);
const buttons = component.find('button');
expect(buttons).toHaveLength(2);
expect(buttons.get(0).props.title).toBe('Button 1');
expect(buttons.get(1).props.title).toBe('Button 2');
});
test('calls correct click handler', () => {
const onClick1 = jest.fn();
const onClick2 = jest.fn();
const component = shallow(<ToggleButtons
buttons={[
{
title: 'Button 1',
handleClick: onClick1,
icon: 'Button 1 icon'
},
{
title: 'Button 2',
handleClick: onClick2,
icon: 'Button 2 icon'
}
]}
/>);
const button2 = component.find('button[title="Button 2"]');
button2.simulate('click');
expect(onClick2).toHaveBeenCalled();
expect(onClick1).not.toHaveBeenCalled();
});
});

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);
});
});

View File

@@ -0,0 +1,208 @@
// TODO: add tests of extension alerts
/* eslint-env jest */
import {AlertTypes, AlertLevels} from '../../../src/lib/alerts/index.jsx';
import alertsReducer, {
closeAlert,
closeAlertWithId,
filterInlineAlerts,
filterPopupAlerts,
showStandardAlert
} from '../../../src/reducers/alerts';
test('initialState', () => {
let defaultState;
/* alertsReducer(state, action) */
expect(alertsReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(alertsReducer(defaultState, {type: 'anything'}).visible).toBe(true);
expect(alertsReducer(defaultState, {type: 'anything'}).alertsList).toEqual([]);
});
test('create one standard alert', () => {
let defaultState;
const action = showStandardAlert('creating');
const resultState = alertsReducer(defaultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('creating');
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.STANDARD);
expect(resultState.alertsList[0].level).toBe(AlertLevels.SUCCESS);
});
test('add several standard alerts', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const action = showStandardAlert('creating');
let resultState = alertsReducer(initialState, action);
resultState = alertsReducer(resultState, action);
resultState = alertsReducer(resultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.STANDARD);
expect(resultState.alertsList[0].iconURL).not.toBe('/no_image_here.jpg');
expect(resultState.alertsList[0].alertId).toBe('creating');
});
test('create one inline alert message', () => {
let defaultState;
const action = showStandardAlert('saving');
const resultState = alertsReducer(defaultState, action);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('saving');
expect(resultState.alertsList[0].alertType).toBe(AlertTypes.INLINE);
expect(resultState.alertsList[0].level).toBe(AlertLevels.INFO);
});
test('can close alerts by index', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const closeAction = closeAlert(0);
let resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.length).toBe(0);
const createAction = showStandardAlert('creating');
resultState = alertsReducer(resultState, createAction);
expect(resultState.alertsList.length).toBe(1);
resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.length).toBe(0);
resultState = alertsReducer(resultState, createAction);
});
test('can close a single alert by id', () => {
const initialState = {
visible: true,
alertsList: [
{alertId: 'saving'},
{alertId: 'creating'},
{alertId: 'saving'},
{alertId: 'saving'}
]
};
const closeAction = closeAlertWithId('saving');
let resultState = alertsReducer(initialState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating', 'saving', 'saving'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating', 'saving'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating'
]);
resultState = alertsReducer(resultState, closeAction);
expect(resultState.alertsList.map(a => a.alertId)).toEqual([
'creating'
]);
});
test('related alerts can clear each other', () => {
const initialState = {
visible: true,
alertsList: [
{
alertId: 'saving',
alertType: AlertTypes.INLINE,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
},
{
alertId: 'creating',
alertType: AlertTypes.STANDARD,
level: AlertLevels.SUCCESS,
content: null,
iconURL: '/no_image_here.jpg'
}
]
};
const action = showStandardAlert('saveSuccess');
const resultState = alertsReducer(initialState, action);
expect(resultState.alertsList.length).toBe(2);
expect(resultState.alertsList[0].alertId).toBe('creating');
expect(resultState.alertsList[1].alertId).toBe('saveSuccess');
});
test('several related alerts can be cleared at once', () => {
const initialState = {
visible: true,
alertsList: []
};
const createAction = showStandardAlert('creating');
let resultState = alertsReducer(initialState, createAction);
resultState = alertsReducer(resultState, createAction);
resultState = alertsReducer(resultState, createAction);
const createSuccessAction = showStandardAlert('createSuccess');
resultState = alertsReducer(resultState, createSuccessAction);
expect(resultState.alertsList.length).toBe(1);
expect(resultState.alertsList[0].alertId).toBe('createSuccess');
});
test('filterInlineAlerts only returns inline type alerts', () => {
const alerts = [
{
alertId: 'extension',
alertType: AlertTypes.EXTENSION
},
{
alertId: 'inline',
alertType: AlertTypes.INLINE
},
{
alertId: 'standard',
alertType: AlertTypes.STANDARD
},
{
alertId: 'non-existent type',
alertType: 'wirly-burly'
}
];
const filtered = filterInlineAlerts(alerts);
expect(filtered.length).toEqual(1);
expect(filtered[0].alertId).toEqual('inline');
});
test('filterPopupAlerts returns standard and extension type alerts', () => {
const alerts = [
{
alertId: 'extension',
alertType: AlertTypes.EXTENSION
},
{
alertId: 'inline',
alertType: AlertTypes.INLINE
},
{
alertId: 'standard',
alertType: AlertTypes.STANDARD
},
{
alertId: 'non-existent type',
alertType: 'wirly-burly'
}
];
const filtered = filterPopupAlerts(alerts);
expect(filtered.length).toEqual(2);
expect(filtered[0].alertId).toEqual('extension');
expect(filtered[1].alertId).toEqual('standard');
});

View File

@@ -0,0 +1,53 @@
/* eslint-env jest */
import modeReducer from '../../../src/reducers/mode';
const SET_FULL_SCREEN = 'scratch-gui/mode/SET_FULL_SCREEN';
const SET_PLAYER = 'scratch-gui/mode/SET_PLAYER';
test('initialState', () => {
let defaultState;
/* modeReducer(state, action) */
expect(modeReducer(defaultState, {type: 'anything'})).toBeDefined();
});
test('set full screen mode', () => {
const previousState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
const action = {
type: SET_FULL_SCREEN,
isFullScreen: true
};
const newState = {
showBranding: false,
isFullScreen: true,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
/* modeReducer(state, action) */
expect(modeReducer(previousState, action)).toEqual(newState);
});
test('set player mode', () => {
const previousState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: false,
hasEverEnteredEditor: true
};
const action = {
type: SET_PLAYER,
isPlayerOnly: true
};
const newState = {
showBranding: false,
isFullScreen: false,
isPlayerOnly: true,
hasEverEnteredEditor: true
};
/* modeReducer(state, action) */
expect(modeReducer(previousState, action)).toEqual(newState);
});

View File

@@ -0,0 +1,302 @@
/* eslint-env jest */
import monitorLayoutReducer, {
addMonitorRect, moveMonitorRect,
resizeMonitorRect, removeMonitorRect,
getInitialPosition, PADDING, SCREEN_WIDTH, SCREEN_HEIGHT
} from '../../../src/reducers/monitor-layout';
test('initialState', () => {
let defaultState;
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined();
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).monitors).toBeDefined();
expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).savedMonitorPositions)
.toBeDefined();
});
test('addMonitorRect', () => {
let defaultState;
const monitorId = 1;
const monitorId2 = 2;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
expect(reduxState.monitors[monitorId]).toBeDefined();
expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd);
// Add monitor rect doesn't save position
expect(reduxState.savedMonitorPositions[monitorId]).toBeUndefined();
const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0));
// Add a second monitor rect
const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd));
expect(reduxState3.monitors[monitorId]).toBeDefined();
expect(reduxState3.monitors[monitorId2]).toBeDefined();
expect(reduxState3.monitors[monitorId2].upperStart).toEqual(upperStart);
expect(reduxState3.monitors[monitorId2].lowerEnd).toEqual(lowerEnd);
// Saved positions aren't changed by adding monitor
expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions);
});
test('addMonitorRectWithSavedPosition', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState,
addMonitorRect(monitorId, upperStart, lowerEnd, true /* savePosition */));
expect(reduxState.monitors[monitorId]).toBeDefined();
expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd);
// Save position
expect(reduxState.savedMonitorPositions[monitorId].x).toEqual(upperStart.x);
expect(reduxState.savedMonitorPositions[monitorId].y).toEqual(upperStart.y);
});
test('invalidRect', () => {
let defaultState;
const reduxState = monitorLayoutReducer(defaultState /* state */, {type: 'initialize'} /* action */);
// Problem: x end is before x start
expect(
monitorLayoutReducer(reduxState,
addMonitorRect(1, {x: 100, y: 100}, {x: 10, y: 200})))
.toEqual(reduxState);
// Problem: y end is before y start
expect(
monitorLayoutReducer(reduxState,
addMonitorRect(1, {x: 100, y: 100}, {x: 200, y: 10})))
.toEqual(reduxState);
});
test('invalidAddMonitorRect', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: 200, y: 200};
// Add a monitor rect
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
// Try to add the same one
expect(monitorLayoutReducer(reduxState, addMonitorRect(monitorId, upperStart, lowerEnd)))
.toEqual(reduxState);
});
test('moveMonitorRect', () => {
let defaultState;
const monitorId = 1;
const monitorId2 = 2;
const width = 102;
const height = 101;
const upperStart = {x: 100, y: 100};
const lowerEnd = {x: upperStart.x + width, y: upperStart.y + height};
const movedToPosition = {x: 0, y: 0};
const movedToPosition2 = {x: 543, y: 2};
// Add a monitor rect and move it. Expect it to be in monitors state and saved positions.
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd));
const reduxState2 = monitorLayoutReducer(reduxState,
moveMonitorRect(monitorId, movedToPosition.x, movedToPosition.y));
expect(reduxState2.monitors[monitorId]).toBeDefined();
expect(reduxState2.monitors[monitorId].upperStart).toEqual(movedToPosition);
expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(movedToPosition.x + width);
expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(movedToPosition.y + height);
expect(reduxState2.savedMonitorPositions[monitorId]).toBeDefined();
expect(reduxState2.savedMonitorPositions[monitorId].x).toEqual(movedToPosition.x);
expect(reduxState2.savedMonitorPositions[monitorId].y).toEqual(movedToPosition.y);
// Add a second monitor rect and move it. Expect there to now be 2 saved positions.
const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd));
const reduxState4 = monitorLayoutReducer(reduxState3,
moveMonitorRect(monitorId2, movedToPosition2.x, movedToPosition2.y));
expect(reduxState4.savedMonitorPositions[monitorId]).toEqual(reduxState2.savedMonitorPositions[monitorId]);
expect(reduxState4.savedMonitorPositions[monitorId2].x).toEqual(movedToPosition2.x);
expect(reduxState4.savedMonitorPositions[monitorId2].y).toEqual(movedToPosition2.y);
});
test('invalidMoveMonitorRect', () => {
let defaultState;
let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
const monitorId = 1;
// Try to move a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */, 1 /* newY */)))
.toEqual(reduxState);
// Add the monitor to move
reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200}));
// Invalid newX
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 'Oregon' /* newX */, 1 /* newY */)))
.toEqual(reduxState);
// Invalid newY
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */)))
.toEqual(reduxState);
});
test('resizeMonitorRect', () => {
let defaultState;
const monitorId = 1;
const upperStart = {x: 100, y: 100};
const newWidth = 10;
const newHeight = 20;
// Add a monitor rect and resize it
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, {x: 200, y: 200}));
const reduxState2 = monitorLayoutReducer(reduxState,
resizeMonitorRect(monitorId, newWidth, newHeight));
expect(reduxState2.monitors[monitorId]).toBeDefined();
expect(reduxState2.monitors[monitorId].upperStart).toEqual(upperStart);
expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(upperStart.x + newWidth);
expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(upperStart.y + newHeight);
// Saved positions aren't changed by resizing monitor
expect(reduxState2.savedMonitorPositions).toEqual(reduxState.savedMonitorPositions);
});
test('invalidResizeMonitorRect', () => {
let defaultState;
let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
const monitorId = 1;
// Try to resize a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// Add the monitor to resize
reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200}));
// Invalid newWidth
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 'Oregon' /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// Invalid newHeight
expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newWidth */)))
.toEqual(reduxState);
// newWidth < 0
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, -1 /* newWidth */, 1 /* newHeight */)))
.toEqual(reduxState);
// newHeight < 0
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, -1 /* newHeight */)))
.toEqual(reduxState);
});
test('removeMonitorRect', () => {
let defaultState;
const monitorId = 1;
// Add a monitor rect, move it, and remove it
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId,
{x: 100, y: 100},
{x: 200, y: 200}
));
const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0));
const reduxState3 = monitorLayoutReducer(reduxState2, removeMonitorRect(monitorId));
expect(reduxState3.monitors[monitorId]).toBeUndefined();
// Check that saved positions aren't changed by removing monitor
expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions);
});
test('invalidRemoveMonitorRect', () => {
let defaultState;
const reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */);
// Try to remove a monitor rect that doesn't exist
expect(monitorLayoutReducer(reduxState, resizeMonitorRect(1)))
.toEqual(reduxState);
});
test('getInitialPosition_lineUpTopLeft', () => {
let defaultState;
const width = 100;
const height = 200;
// Add monitors to right and bottom, but there is a space in the top left
let reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: width + PADDING, y: 0},
{x: 100, y: height}
));
reduxState = monitorLayoutReducer(defaultState, addMonitorRect(2,
{x: 0, y: height + PADDING},
{x: width, y: 100}
));
// Check that the added monitor appears in the space
const rect = getInitialPosition(reduxState, 3, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
expect(rect.upperStart.x).toEqual(PADDING);
expect(rect.upperStart.y).toEqual(PADDING);
});
test('getInitialPosition_savedPosition', () => {
const monitorId = 1;
const savedX = 100;
const savedY = 200;
const width = 7;
const height = 8;
const reduxState = {
monitors: {},
savedMonitorPositions: {[monitorId]: {x: savedX, y: savedY}}
};
// Check that initial position uses saved state
const rect = getInitialPosition(reduxState, monitorId, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
expect(rect.upperStart.x).toEqual(savedX);
expect(rect.upperStart.y).toEqual(savedY);
});
test('getInitialPosition_lineUpLeft', () => {
let defaultState;
const monitor1EndY = 60;
// Add a monitor that takes up the upper left corner
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, {x: 0, y: 0}, {x: 100, y: monitor1EndY}));
// Check that added monitor is under it and lines up left
const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */);
expect(rect.upperStart.y >= monitor1EndY + PADDING).toBeTruthy();
});
test('getInitialPosition_lineUpTop', () => {
let defaultState;
const monitor1EndX = 100;
// Add a monitor that takes up the whole left side
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: 0, y: 0},
{x: monitor1EndX, y: SCREEN_HEIGHT}
));
// Check that added monitor is to the right of it and lines up top
const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */);
expect(rect.upperStart.y).toEqual(PADDING);
expect(rect.upperStart.x >= monitor1EndX + PADDING).toBeTruthy();
});
test('getInitialPosition_noRoom', () => {
let defaultState;
const width = 7;
const height = 8;
// Add a monitor that takes up the whole screen
const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1,
{x: 0, y: 0},
{x: SCREEN_WIDTH, y: SCREEN_HEIGHT}
));
// Check that added monitor exists somewhere (we don't care where)
const rect = getInitialPosition(reduxState, 2, width, height);
expect(rect.upperStart).toBeDefined();
expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width);
expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height);
});

View File

@@ -0,0 +1,508 @@
/* eslint-env jest */
import projectStateReducer, {
LoadingState,
autoUpdateProject,
doneCreatingProject,
doneUpdatingProject,
manualUpdateProject,
onFetchedProjectData,
onLoadedProject,
projectError,
remixProject,
requestNewProject,
requestProjectUpload,
saveProjectAsCopy,
setProjectId
} from '../../../src/reducers/project-state';
test('initialState', () => {
let defaultState;
/* projectStateReducer(state, action) */
expect(projectStateReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(projectStateReducer(defaultState, {type: 'anything'}).error).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).projectData).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).projectId).toBe(null);
expect(projectStateReducer(defaultState, {type: 'anything'}).loadingState).toBe(LoadingState.NOT_LOADED);
});
test('doneCreatingProject for new project with projectId type string shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_NEW
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneCreatingProject for new project with projectId type number shows project with id of type number', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_NEW
};
const action = doneCreatingProject(100, initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe(100);
});
test('doneCreatingProject for remix shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.REMIXING
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneCreatingProject for save as copy shows project with that id', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.CREATING_COPY
};
const action = doneCreatingProject('100', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onFetchedProjectData with id loads project data into vm', () => {
const initialState = {
projectData: null,
loadingState: LoadingState.FETCHING_WITH_ID
};
const action = onFetchedProjectData('1010101', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_WITH_ID);
expect(resultState.projectData).toBe('1010101');
});
test('onFetchedProjectData new loads project data into vm', () => {
const initialState = {
projectData: null,
loadingState: LoadingState.FETCHING_NEW_DEFAULT
};
const action = onFetchedProjectData('1010101', initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_NEW_DEFAULT);
expect(resultState.projectData).toBe('1010101');
});
// onLoadedProject: LOADING_VM_WITH_ID
test('onLoadedProject(LOADING_VM_WITH_ID, true, true) results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_WITH_ID, false, true) results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
// case where we started out viewing a project with a projectId, then
// started to load another project; but loading fails. We go back to
// showing the original project.
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with project id, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
// case where we started out viewing a project with default projectId, then
// started to load one with an id, such as in standalone mode when user adds
// '#PROJECT_ID_NUMBER' to the URI; but loading fails. We go back to
// showing the original project.
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with no project id, ' +
'results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_WITH_ID
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
// onLoadedProject: LOADING_VM_FILE_UPLOAD
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, true, true) prepares to save', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we know project id, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we ' +
'don\'t know project id, results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
// onLoadedProject: LOADING_VM_NEW_DEFAULT
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, true, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, true, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, true) results in state SHOWING_WITHOUT_ID', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, false, true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, false) results in ERROR state', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
};
const action = onLoadedProject(initialState.loadingState, false, false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe('0');
});
// doneUpdatingProject
test('doneUpdatingProject with id results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.MANUAL_UPDATING
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('doneUpdatingProject with id, before copy occurs, results in state CREATING_COPY', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.UPDATING_BEFORE_COPY
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
expect(resultState.projectId).toBe('100');
});
test('doneUpdatingProject with id, before new, results in state FETCHING_NEW_DEFAULT', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.UPDATING_BEFORE_NEW
};
const action = doneUpdatingProject(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
expect(resultState.projectId).toBe('0'); // resets id
});
test('calling setProjectId, using with same id as already showing, ' +
'results in state SHOWING_WITH_ID', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId('100');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('calling setProjectId, using different id from project already showing, ' +
'results in state FETCHING_WITH_ID', () => {
const initialState = {
projectId: 99,
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId('100');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_WITH_ID);
expect(resultState.projectId).toBe('100');
});
test('setProjectId, with same id as before, but not same type, ' +
'results in FETCHING_WITH_ID because the two projectIds are not strictly equal', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = setProjectId(100);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_WITH_ID);
expect(resultState.projectId).toBe(100);
});
test('requestNewProject, when can\'t create/save, results in FETCHING_NEW_DEFAULT', () => {
const initialState = {
projectId: '0',
loadingState: LoadingState.SHOWING_WITHOUT_ID
};
const action = requestNewProject(false);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
expect(resultState.projectId).toBe('0');
});
test('requestNewProject, when can create/save, results in UPDATING_BEFORE_NEW ' +
'(in order to save before fetching default project)', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = requestNewProject(true);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_NEW);
expect(resultState.projectId).toBe('100');
});
test('requestProjectUpload when project not loaded results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.NOT_LOADED
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe(null);
});
test('requestProjectUpload when showing project with id results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe('100');
});
test('requestProjectUpload when showing project without id results in state LOADING_VM_FILE_UPLOAD', () => {
const initialState = {
projectId: null,
loadingState: LoadingState.SHOWING_WITHOUT_ID
};
const action = requestProjectUpload(initialState.loadingState);
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
expect(resultState.projectId).toBe(null);
});
test('manualUpdateProject should prepare to update', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = manualUpdateProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.MANUAL_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('autoUpdateProject should prepare to update', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = autoUpdateProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
expect(resultState.projectId).toBe('100');
});
test('saveProjectAsCopy should save, before preparing to save as a copy', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = saveProjectAsCopy();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY);
expect(resultState.projectId).toBe('100');
});
test('remixProject should prepare to remix', () => {
const initialState = {
projectId: '100',
loadingState: LoadingState.SHOWING_WITH_ID
};
const action = remixProject();
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.REMIXING);
expect(resultState.projectId).toBe('100');
});
test('projectError from various states should show error', () => {
const startStates = [
LoadingState.AUTO_UPDATING,
LoadingState.CREATING_NEW,
LoadingState.FETCHING_NEW_DEFAULT,
LoadingState.FETCHING_WITH_ID,
LoadingState.LOADING_VM_NEW_DEFAULT,
LoadingState.LOADING_VM_WITH_ID,
LoadingState.MANUAL_UPDATING,
LoadingState.REMIXING,
LoadingState.CREATING_COPY,
LoadingState.UPDATING_BEFORE_NEW
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.error).toEqual('Error string');
expect(resultState.projectId).toBe('100');
}
});
test('fatal projectError should show error state', () => {
const startStates = [
LoadingState.FETCHING_NEW_DEFAULT,
LoadingState.FETCHING_WITH_ID,
LoadingState.LOADING_VM_NEW_DEFAULT,
LoadingState.LOADING_VM_WITH_ID
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe('100');
}
});
test('non-fatal projectError should show normal state', () => {
const startStates = [
LoadingState.AUTO_UPDATING,
LoadingState.CREATING_COPY,
LoadingState.MANUAL_UPDATING,
LoadingState.REMIXING,
LoadingState.UPDATING_BEFORE_NEW
];
for (const startState of startStates) {
const initialState = {
error: null,
projectId: '100',
loadingState: startState
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('100');
}
});
test('projectError when creating new while viewing project with id should ' +
'go back to state SHOWING_WITH_ID', () => {
const initialState = {
error: null,
loadingState: LoadingState.CREATING_NEW,
projectId: '12345'
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
expect(resultState.projectId).toBe('12345');
});
test('projectError when creating new while logged out, looking at default project ' +
'should go back to state SHOWING_WITHOUT_ID', () => {
const initialState = {
error: null,
loadingState: LoadingState.CREATING_NEW,
projectId: '0'
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
expect(resultState.projectId).toBe('0');
});
test('projectError encountered while in state FETCHING_WITH_ID results in ' +
'ERROR state', () => {
const initialState = {
error: null,
projectId: null,
loadingState: LoadingState.FETCHING_WITH_ID
};
const action = projectError('Error string');
const resultState = projectStateReducer(initialState, action);
expect(resultState.loadingState).toBe(LoadingState.ERROR);
expect(resultState.projectId).toBe(null);
expect(resultState.error).toEqual('Error string');
});

View File

@@ -0,0 +1,25 @@
/* eslint-env jest */
import workspaceMetricsReducer, {updateMetrics} from '../../../src/reducers/workspace-metrics';
test('initialState', () => {
let defaultState;
/* workspaceMetricsReducer(state, action) */
expect(workspaceMetricsReducer(defaultState, {type: 'anything'})).toBeDefined();
expect(workspaceMetricsReducer(defaultState, {type: 'anything'})).toEqual({targets: {}});
});
test('updateMetrics action creator', () => {
let defaultState;
const action = updateMetrics({
targetID: 'abcde',
scrollX: 225,
scrollY: 315,
scale: 1.25
});
const resultState = workspaceMetricsReducer(defaultState, action);
expect(Object.keys(resultState.targets).length).toBe(1);
expect(resultState.targets.abcde).toBeDefined();
expect(resultState.targets.abcde.scrollX).toBe(225);
expect(resultState.targets.abcde.scrollY).toBe(315);
expect(resultState.targets.abcde.scale).toBe(1.25);
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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');
});

View 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);
});
});
});

View 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);
});
});
});
});

View 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
});
});
});

View 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');
});
});

View 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();
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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');
});
});

View 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)
);
});
});

View 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);
});
});

View 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();
});
*/
});

View 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=');
});
});
});

View 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);
});
});

View 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');
});
});
*/

View 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');
});

View 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});
});
});

View 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));
});
});