diff --git a/client/modules/IDE/components/Editor/stateUtils.test.js b/client/modules/IDE/components/Editor/stateUtils.test.js new file mode 100644 index 0000000000..c8dd38dfef --- /dev/null +++ b/client/modules/IDE/components/Editor/stateUtils.test.js @@ -0,0 +1,539 @@ +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { keymap } from '@codemirror/view'; +import * as tidier from './tidier'; +import { getFileMode, createNewFileState } from './stateUtils'; + +describe('getFileMode', () => { + it('Returns correct javascript file mode', () => { + const fileName = 'file1.js'; + const mode = getFileMode(fileName); + const expectedMode = 'javascript'; + + expect(mode).toBe(expectedMode); + }); + + it('Returns correct css file mode', () => { + const fileName = 'file1.css'; + const mode = getFileMode(fileName); + const expectedMode = 'css'; + + expect(mode).toBe(expectedMode); + }); + + it('Returns correct html file mode', () => { + const fileName = 'file1.html'; + const mode = getFileMode(fileName); + const expectedMode = 'html'; + + expect(mode).toBe(expectedMode); + }); + + it('Returns correct xml file mode', () => { + const fileName = 'file1.xml'; + const mode = getFileMode(fileName); + const expectedMode = 'xml'; + + expect(mode).toBe(expectedMode); + }); + + it('Returns correct json file mode', () => { + const fileName = 'file1.json'; + const mode = getFileMode(fileName); + const expectedMode = 'application/json'; + + expect(mode).toBe(expectedMode); + }); + + it('Returns correct frag|glsl file mode', () => { + const fileName = 'file1.frag'; + const fileName2 = 'file2.glsl'; + const mode = getFileMode(fileName); + const mode2 = getFileMode(fileName2); + const expectedMode = 'x-shader/x-fragment'; + + expect(mode).toBe(expectedMode); + expect(mode2).toBe(expectedMode); + }); + + it('Returns correct vert|stl|mtl file mode', () => { + const fileName = 'file1.vert'; + const fileName2 = 'file2.stl'; + const fileName3 = 'file3.mtl'; + const mode = getFileMode(fileName); + const mode2 = getFileMode(fileName2); + const mode3 = getFileMode(fileName3); + const expectedMode = 'x-shader/x-vertex'; + + expect(mode).toBe(expectedMode); + expect(mode2).toBe(expectedMode); + expect(mode3).toBe(expectedMode); + }); + + it('Returns plain text otherwise file mode', () => { + const fileName = 'file1.py'; + const mode = getFileMode(fileName); + const expectedMode = 'text/plain'; + expect(mode).toBe(expectedMode); + }); + + it('Empty fileName', () => { + const fileName = ''; + const mode = getFileMode(fileName); + const expectedMode = 'text/plain'; + expect(mode).toBe(expectedMode); + }); + + it('Unknown fileName', () => { + const fileName = 'file1.xyz'; + const mode = getFileMode(fileName); + const expectedMode = 'text/plain'; + expect(mode).toBe(expectedMode); + }); +}); + +describe('createNewFileState', () => { + function getDocText(cmView) { + return cmView.state.doc.toString(); + } + + it('Enables line wrap', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: true, + lineNumbers: true, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-lineWrapping'); + + expect(div).not.toBeNull(); + }); + + it('Enables line wrap', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: true, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-lineWrapping'); + + expect(div).toBeNull(); + }); + + it('Enables line numbers', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: true, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-lineNumbers'); + + expect(div).not.toBeNull(); + }); + + it('Disable line numbers', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-lineNumbers'); + + expect(div).toBeNull(); + }); + + it('Enable autocomplete', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: true, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-content'); + + expect(div).toHaveAttribute('aria-autocomplete', 'list'); + }); + + it('Disable autocomplete', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + const result = createNewFileState(fileName, content, settings); + + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + const div = parent.querySelector('.cm-content'); + + expect(div).not.toHaveAttribute('aria-autocomplete', 'list'); + }); + + it('Enables autoclose brackets and quotes', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: true, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + + const result = createNewFileState(fileName, content, settings); + + expect(result.closeBracketsCpt.get(result.cmState).length).toBeGreaterThan( + 0 + ); + }); + + it('Disable autoclose brackets and quotes', () => { + const fileName = 'file1.js'; + const content = ``; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + + const result = createNewFileState(fileName, content, settings); + + expect(result.closeBracketsCpt.get(result.cmState).length).toBe(0); + }); + + it('Tidy code keymap defined', () => { + const tidySpy = jest + .spyOn(tidier, 'tidyCodeWithPrettier') + .mockImplementation(() => {}); + + const fileName = 'file1.css'; + const content = `h1{color: blue;}`; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + + const result = createNewFileState(fileName, content, settings); + + const cmView = new EditorView({ state: result.cmState }); + + const km = result.cmState + .facet(keymap) + .flat() + .find((k) => k.key === 'Shift-Mod-F'); + + expect(km).toBeDefined(); + tidySpy.mockRestore(); + }); + + it('Tidy code keymap calls correct function', () => { + const tidySpy = jest + .spyOn(tidier, 'tidyCodeWithPrettier') + .mockImplementation(() => {}); + + const fileName = 'file1.css'; + const content = `h1{color: blue;}`; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + + const result = createNewFileState(fileName, content, settings); + + const cmView = new EditorView({ state: result.cmState }); + + const km = result.cmState + .facet(keymap) + .flat() + .find((k) => k.key === 'Shift-Mod-F'); + + km.run(cmView); + + expect(tidySpy).toHaveBeenCalledWith(cmView, expect.any(String)); + tidySpy.mockRestore(); + }); + + it('Tidy code keymap correctly formats code', () => { + const tidySpy = jest + .spyOn(tidier, 'tidyCodeWithPrettier') + .mockImplementation(() => {}); + + const fileName = 'file1.css'; + const content = `h1{color: blue;}`; + const formattedContent = `h1 {\n color: blue;\n}`; + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn() + }; + + const result = createNewFileState(fileName, content, settings); + + const cmView = new EditorView({ state: result.cmState }); + + const km = result.cmState + .facet(keymap) + .flat() + .find((k) => k.key === 'Shift-Mod-F'); + + km.run(cmView); + + expect(getDocText(cmView)).toBe(formattedContent); + tidySpy.mockRestore(); + }); +}); + +function makeView(fileName, content = '', extraSettings = {}) { + const settings = { + linewrap: false, + lineNumbers: false, + autocomplete: false, + autocloseBracketsQuotes: false, + onUpdateLinting: jest.fn(), + onViewUpdate: jest.fn(), + ...extraSettings + }; + const result = createNewFileState(fileName, content, settings); + const parent = document.createElement('div'); + const cmView = new EditorView({ state: result.cmState, parent }); + return { cmView, result, settings }; +} + +describe('getFileLanguage (indirect)', () => { + it('Loads a language extension for JS files without throwing', () => { + expect(() => makeView('sketch.js', 'var x = 1;')).not.toThrow(); + }); + + it('Loads a language extension for CSS files without throwing', () => { + expect(() => makeView('style.css', 'body { color: red; }')).not.toThrow(); + }); + + it('Loads a language extension for HTML files without throwing', () => { + expect(() => makeView('index.html', '
')).not.toThrow(); + }); + + it('Loads a language extension for XML files without throwing', () => { + expect(() => makeView('data.xml', '')).not.toThrow(); + }); + + it('Loads a language extension for JSON files without throwing', () => { + expect(() => makeView('data.json', '{}')).not.toThrow(); + }); + + it('Handles unsupported file types without throwing', () => { + expect(() => makeView('shader.frag', 'void main() {}')).not.toThrow(); + }); + + it('Handles plain text files without throwing', () => { + expect(() => makeView('readme.txt', 'hello world')).not.toThrow(); + }); +}); + +describe('getFileLinter (indirect)', () => { + it('Adds lint gutter for JS files', () => { + const { cmView } = makeView('sketch.js', 'var x = 1;'); + const lintGutter = cmView.dom.querySelector('.cm-gutter-lint'); + expect(lintGutter).not.toBeNull(); + }); + + it('Adds lint gutter for CSS files', () => { + const { cmView } = makeView('style.css', 'body { color: red; }'); + const lintGutter = cmView.dom.querySelector('.cm-gutter-lint'); + expect(lintGutter).not.toBeNull(); + }); + + it('Adds lint gutter for HTML files', () => { + const { cmView } = makeView('index.html', '
'); + const lintGutter = cmView.dom.querySelector('.cm-gutter-lint'); + expect(lintGutter).not.toBeNull(); + }); + + it('Does not add lint gutter for JSON files', () => { + const { cmView } = makeView('data.json', '{}'); + const lintGutter = cmView.dom.querySelector('.cm-gutter-lint'); + expect(lintGutter).toBeNull(); + }); + + it('Does not add lint gutter for plain text files', () => { + const { cmView } = makeView('readme.txt', 'hello'); + const lintGutter = cmView.dom.querySelector('.cm-gutter-lint'); + expect(lintGutter).toBeNull(); + }); +}); + +describe('makeJsLinter (indirect)', () => { + it('Calls onUpdateLinting for JS files', () => { + const onUpdateLinting = jest.fn(); + makeView('sketch.js', 'var x = 1;', { onUpdateLinting }); + expect(onUpdateLinting).toHaveBeenCalled(); + }); + + it('Reports diagnostics for invalid JS', () => { + const onUpdateLinting = jest.fn(); + makeView('sketch.js', 'var x = ;', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBeGreaterThan(0); + }); + + it('Reports no diagnostics for valid JS', () => { + const onUpdateLinting = jest.fn(); + makeView('sketch.js', 'var x = 1;', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBe(0); + }); + + it('Diagnostic has from, to, severity, and message fields', () => { + const onUpdateLinting = jest.fn(); + makeView('sketch.js', 'var x = ;', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics[0]).toHaveProperty('from'); + expect(diagnostics[0]).toHaveProperty('to'); + expect(diagnostics[0]).toHaveProperty('severity'); + expect(diagnostics[0]).toHaveProperty('message'); + }); +}); + +describe('makeCssLinter (indirect)', () => { + it('Calls onUpdateLinting for CSS files', () => { + const onUpdateLinting = jest.fn(); + makeView('style.css', 'body { color: red; }', { onUpdateLinting }); + expect(onUpdateLinting).toHaveBeenCalled(); + }); + + it('Reports diagnostics for invalid CSS', () => { + const onUpdateLinting = jest.fn(); + makeView('style.css', 'body { color: }', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBeGreaterThan(0); + }); + + it('Reports no diagnostics for valid CSS', () => { + const onUpdateLinting = jest.fn(); + makeView('style.css', 'body { color: red; }', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBe(0); + }); + + it('Diagnostic has from, to, severity, and message fields', () => { + const onUpdateLinting = jest.fn(); + makeView('style.css', 'body { color: }', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics[0]).toHaveProperty('from'); + expect(diagnostics[0]).toHaveProperty('to'); + expect(diagnostics[0]).toHaveProperty('severity'); + expect(diagnostics[0]).toHaveProperty('message'); + }); +}); + +describe('makeHtmlLinter (indirect)', () => { + it('Calls onUpdateLinting for HTML files', () => { + const onUpdateLinting = jest.fn(); + makeView('index.html', '
', { onUpdateLinting }); + expect(onUpdateLinting).toHaveBeenCalled(); + }); + + it('Reports diagnostics for invalid HTML', () => { + const onUpdateLinting = jest.fn(); + makeView('index.html', '
', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBeGreaterThan(0); + }); + + it('Reports no diagnostics for valid HTML', () => { + const onUpdateLinting = jest.fn(); + makeView('index.html', '

Hello

', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics.length).toBe(0); + }); + + it('Diagnostic has from, to, severity, and message fields', () => { + const onUpdateLinting = jest.fn(); + makeView('index.html', '
', { onUpdateLinting }); + const diagnostics = onUpdateLinting.mock.calls[0][0]; + expect(diagnostics[0]).toHaveProperty('from'); + expect(diagnostics[0]).toHaveProperty('to'); + expect(diagnostics[0]).toHaveProperty('severity'); + expect(diagnostics[0]).toHaveProperty('message'); + }); +}); + +describe('getFileEmmetConfig (indirect)', () => { + it('Creates state for HTML files with emmet config without throwing', () => { + expect(() => makeView('index.html', '
')).not.toThrow(); + }); + + it('Creates state for CSS files with emmet config without throwing', () => { + expect(() => makeView('style.css', 'body {}')).not.toThrow(); + }); + + it('Creates state for JS files without emmet config without throwing', () => { + expect(() => makeView('sketch.js', 'var x = 1;')).not.toThrow(); + }); + + it('Creates state for JSON files without emmet config without throwing', () => { + expect(() => makeView('data.json', '{}')).not.toThrow(); + }); +}); \ No newline at end of file