diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d7d0a17..6795ef2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Ability to write to files in `python` (#1146) - Support for the `outputPanels` attribute in the `PyodideRunner` (#1157) - Downloading project instructions (#1160) diff --git a/cypress/e2e/spec-wc-pyodide.cy.js b/cypress/e2e/spec-wc-pyodide.cy.js index 858c308a9..73bcbd314 100644 --- a/cypress/e2e/spec-wc-pyodide.cy.js +++ b/cypress/e2e/spec-wc-pyodide.cy.js @@ -91,6 +91,58 @@ describe("Running the code with pyodide", () => { .should("contain", "Hello Lois"); }); + it("runs a simple program to write to a file", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .contains(".files-list-item", "output.txt") + .click(); + cy.get("editor-wc") + .shadow() + .find(".cm-editor") + .should("contain", "Hello world"); + }); + + it("errors when trying to write to an existing file in 'x' mode", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .find(".files-list-item") + .should("contain", "output.txt"); + runCode('with open("output.txt", "x") as f:\n\tf.write("Something else")'); + cy.get("editor-wc") + .shadow() + .find(".error-message__content") + .should( + "contain", + "FileExistsError: File 'output.txt' already exists on line 1 of main.py", + ); + }); + + it("updates the file in the editor when the content is updated programatically", () => { + runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")'); + cy.get("editor-wc") + .shadow() + .find("div[class=cm-content]") + .invoke( + "text", + 'with open("output.txt", "a") as f:\n\tf.write("Hello again world")', + ); + cy.get("editor-wc") + .shadow() + .contains(".files-list-item", "output.txt") + .click(); + cy.get("editor-wc") + .shadow() + .find(".btn--run") + .should("not.be.disabled") + .click(); + cy.get("editor-wc") + .shadow() + .find(".cm-editor") + .should("contain", "Hello again world"); + }); + it("runs a simple program with a built-in python module", () => { runCode("from math import floor, pi\nprint(floor(pi))"); cy.get("editor-wc") diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index b50981742..200c742b0 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -60,12 +60,6 @@ const PyodideWorker = () => { const runPython = async (python) => { stopped = false; - await pyodide.loadPackage("pyodide_http"); - - await pyodide.runPythonAsync(` - import pyodide_http - pyodide_http.patch_all() - `); try { await withSupportForPackages(python, async () => { @@ -98,6 +92,52 @@ const PyodideWorker = () => { await pyodide.loadPackagesFromImports(python); checkIfStopped(); + await pyodide.runPythonAsync( + ` + import basthon + import builtins + import os + + MAX_FILES = 100 + MAX_FILE_SIZE = 8500000 + + def _custom_open(filename, mode="r", *args, **kwargs): + if "x" in mode and os.path.exists(filename): + raise FileExistsError(f"File '{filename}' already exists") + if ("w" in mode or "a" in mode or "x" in mode) and "b" not in mode: + if len(os.listdir()) > MAX_FILES and not os.path.exists(filename): + raise OSError(f"File system limit reached, no more than {MAX_FILES} files allowed") + class CustomFile: + def __init__(self, filename): + self.filename = filename + self.content = "" + + def write(self, content): + self.content += content + if len(self.content) > MAX_FILE_SIZE: + raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes") + with _original_open(self.filename, "w") as f: + f.write(self.content) + basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode }) + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + return CustomFile(filename) + else: + return _original_open(filename, mode, *args, **kwargs) + + # Override the built-in open function + builtins.open = _custom_open + `, + { filename: "__custom_open__.py" }, + ); await runPythonFn(); for (let name of imports) { @@ -337,6 +377,12 @@ const PyodideWorker = () => { postMessage({ method: "handleVisual", origin, content }); }, + write_file: (event) => { + const filename = event.toJs().get("filename"); + const content = event.toJs().get("content"); + const mode = event.toJs().get("mode"); + postMessage({ method: "handleFileWrite", filename, content, mode }); + }, locals: () => pyodide.runPython("globals()"), }, }; @@ -346,7 +392,7 @@ const PyodideWorker = () => { await pyodide.runPythonAsync(` # Clear all user-defined variables and modules for name in dir(): - if not name.startswith('_'): + if not name.startswith('_') and not name=='basthon': del globals()[name] `); postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer }); @@ -364,6 +410,8 @@ const PyodideWorker = () => { pyodide = await pyodidePromise; + pyodide.registerJsModule("basthon", fakeBasthonPackage); + await pyodide.runPythonAsync(` __old_input__ = input def __patched_input__(prompt=False): @@ -373,6 +421,18 @@ const PyodideWorker = () => { __builtins__.input = __patched_input__ `); + await pyodide.runPythonAsync(` + import builtins + # Save the original open function + _original_open = builtins.open + `); + + await pyodide.loadPackage("pyodide-http"); + await pyodide.runPythonAsync(` + import pyodide_http + pyodide_http.patch_all() + `); + if (supportsAllFeatures) { stdinBuffer = stdinBuffer || new Int32Array(new SharedArrayBuffer(1024 * 1024)); // 1 MiB @@ -416,6 +476,14 @@ const PyodideWorker = () => { const lines = trace.split("\n"); + // if the third from last line matches /File "__custom_open__\.py", line (\d+)/g then strip off the last three lines + if ( + lines.length > 3 && + /File "__custom_open__\.py", line (\d+)/g.test(lines[lines.length - 3]) + ) { + lines.splice(-3, 3); + } + const snippetLine = lines[lines.length - 2]; // print("hi")invalid const caretLine = lines[lines.length - 1]; // ^^^^^^^ @@ -424,7 +492,9 @@ const PyodideWorker = () => { ? [snippetLine.slice(4), caretLine.slice(4)].join("\n") : ""; - const matches = [...trace.matchAll(/File "(.*)", line (\d+)/g)]; + const matches = [ + ...trace.matchAll(/File "(?!__custom_open__\.py)(.*)", line (\d+)/g), + ]; const match = matches[matches.length - 1]; const path = match ? match[1] : ""; diff --git a/src/components/Editor/EditorPanel/EditorPanel.jsx b/src/components/Editor/EditorPanel/EditorPanel.jsx index 951cfb70b..21a930736 100644 --- a/src/components/Editor/EditorPanel/EditorPanel.jsx +++ b/src/components/Editor/EditorPanel/EditorPanel.jsx @@ -2,7 +2,10 @@ import "../../../assets/stylesheets/EditorPanel.scss"; import React, { useRef, useEffect, useContext, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; -import { updateProjectComponent } from "../../../redux/EditorSlice"; +import { + setCascadeUpdate, + updateProjectComponent, +} from "../../../redux/EditorSlice"; import { useCookies } from "react-cookie"; import { useTranslation } from "react-i18next"; import { basicSetup } from "codemirror"; @@ -27,8 +30,10 @@ const MAX_CHARACTERS = 8500000; const EditorPanel = ({ extension = "html", fileName = "index" }) => { const editor = useRef(); + const editorViewRef = useRef(); const project = useSelector((state) => state.editor.project); const readOnly = useSelector((state) => state.editor.readOnly); + const cascadeUpdate = useSelector((state) => state.editor.cascadeUpdate); const [cookies] = useCookies(["theme", "fontSize"]); const dispatch = useDispatch(); const { t } = useTranslation(); @@ -40,7 +45,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { updateProjectComponent({ extension: extension, name: fileName, - code: content, + content, + cascadeUpdate: false, }), ); }; @@ -74,11 +80,11 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { window.matchMedia("(prefers-color-scheme:dark)").matches); const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme; - useEffect(() => { - const file = project.components.find( - (item) => item.extension === extension && item.name === fileName, - ); + const file = project.components.find( + (item) => item.extension === extension && item.name === fileName, + ); + useEffect(() => { if (!file) { return; } @@ -123,6 +129,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { parent: editor.current, }); + editorViewRef.current = view; + // 'aria-hidden' to fix keyboard access accessibility error view.scrollDOM.setAttribute("aria-hidden", "true"); @@ -138,6 +146,23 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => { }; }, [cookies]); + useEffect(() => { + if ( + cascadeUpdate && + editorViewRef.current && + file.content !== editorViewRef.current.state.doc.toString() + ) { + editorViewRef.current.dispatch({ + changes: { + from: 0, + to: editorViewRef.current.state.doc.length, + insert: file.content, + }, + }); + dispatch(setCascadeUpdate(false)); + } + }, [file, cascadeUpdate, editorViewRef]); + return ( <>
diff --git a/src/components/Editor/Output/Output.test.js b/src/components/Editor/Output/Output.test.js index 0c23a4c37..2b736ac1f 100644 --- a/src/components/Editor/Output/Output.test.js +++ b/src/components/Editor/Output/Output.test.js @@ -26,6 +26,8 @@ describe("Output component", () => { project: { components: [], }, + focussedFileIndices: [0], + openFiles: [["main.py"]], }, auth: { user, diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index c2f3a9d86..50594e250 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -8,6 +8,8 @@ import { setError, codeRunHandled, setLoadedRunner, + updateProjectComponent, + addProjectComponent, } from "../../../../../redux/EditorSlice"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import { useMediaQuery } from "react-responsive"; @@ -51,6 +53,10 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { const projectImages = useSelector((s) => s.editor.project.image_list); const projectCode = useSelector((s) => s.editor.project.components); const projectIdentifier = useSelector((s) => s.editor.project.identifier); + const focussedFileIndex = useSelector( + (state) => state.editor.focussedFileIndices, + )[0]; + const openFiles = useSelector((state) => state.editor.openFiles)[0]; const user = useSelector((s) => s.auth.user); const userId = user?.profile?.user; const isSplitView = useSelector((s) => s.editor.isSplitView); @@ -97,6 +103,16 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { data.info, ); break; + case "handleFileWrite": + const cascadeUpdate = + openFiles[focussedFileIndex] === data.filename; + handleFileWrite( + data.filename, + data.content, + data.mode, + cascadeUpdate, + ); + break; case "handleVisual": handleVisual(data.origin, data.content); break; @@ -108,7 +124,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { } }; } - }, [pyodideWorker]); + }, [pyodideWorker, projectCode, openFiles, focussedFileIndex]); useEffect(() => { if (codeRunTriggered && active && output.current) { @@ -197,6 +213,35 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { disableInput(); }; + const handleFileWrite = (filename, content, mode, cascadeUpdate) => { + const [name, extension] = filename.split("."); + const componentToUpdate = projectCode.find( + (item) => item.extension === extension && item.name === name, + ); + let updatedContent; + if (mode === "w" || mode === "x") { + updatedContent = content; + } else if (mode === "a") { + updatedContent = + (componentToUpdate ? componentToUpdate.content + "\n" : "") + content; + } + + if (componentToUpdate) { + dispatch( + updateProjectComponent({ + extension, + name, + content: updatedContent, + cascadeUpdate, + }), + ); + } else { + dispatch( + addProjectComponent({ name, extension, content: updatedContent }), + ); + } + }; + const handleVisual = (origin, content) => { if (showVisualOutputPanel) { setHasVisual(true); diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index 25401d731..bea6c9264 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -18,6 +18,8 @@ import { setLoadedRunner, stopCodeRun, setSenseHatAlwaysEnabled, + openFile, + setFocussedFileIndex, } from "../../../../../redux/EditorSlice.js"; import store from "../../../../../app/store"; @@ -27,11 +29,8 @@ global.fetch = jest.fn(); const project = { components: [ { name: "a", extension: "py", content: "print('a')" }, - { - name: "main", - extension: "py", - content: "print('hello')", - }, + { name: "main", extension: "py", content: "print('hello')" }, + { name: "existing_file", extension: "txt", content: "hello" }, ], image_list: [ { filename: "image1.jpg", url: "http://example.com/image1.jpg" }, @@ -256,6 +255,130 @@ describe("When output is received", () => { }); }); +describe("When file write event is received", () => { + let worker; + beforeEach(() => { + render( + + , + , + ); + updateRunner({ project }); + worker = PyodideWorker.getLastInstance(); + }); + + test("it overwrites existing files in 'w' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "w", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "new content", + cascadeUpdate: false, + }, + }); + }); + + test("it creates new file if not already existing in 'w' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "w", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it appends to existing files in 'a' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "a", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "hello\nnew content", + cascadeUpdate: false, + }, + }); + }); + + test("it creates new file if not already existing in 'a' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "a", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it creates new file if not already existing in 'x' mode", () => { + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "new_file.txt", + content: "new content", + mode: "x", + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/addProjectComponent", + payload: { + name: "new_file", + extension: "txt", + content: "new content", + }, + }); + }); + + test("it cascades updates if the file is open and focused", () => { + store.dispatch(openFile({ name: "existing_file", extension: "txt" })); + store.dispatch(setFocussedFileIndex({ panelIndex: 0, fileIndex: 1 })); + + worker.postMessageFromWorker({ + method: "handleFileWrite", + filename: "existing_file.txt", + content: "new content", + mode: "a", + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: "editor/updateProjectComponent", + payload: { + name: "existing_file", + extension: "txt", + content: "hello\nnew content", + cascadeUpdate: false, + }, + }); + }); +}); + describe("When visual output is received", () => { beforeEach(() => { render( diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js index f54b07734..702cc4223 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideWorker.test.js @@ -89,29 +89,23 @@ describe("PyodideWorker", () => { }); test("it patches the input function", async () => { - await worker.onmessage({ - data: { - method: "runPython", - python: "print('hello')", - }, - }); expect(pyodide.runPythonAsync).toHaveBeenCalledWith( expect.stringMatching(/__builtins__.input = __patched_input__/), ); }); test("it patches urllib and requests modules", async () => { - await worker.onmessage({ - data: { - method: "runPython", - python: "print('hello')", - }, - }); expect(pyodide.runPythonAsync).toHaveBeenCalledWith( expect.stringMatching(/pyodide_http.patch_all()/), ); }); + test("it saves original open function", async () => { + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/_original_open = builtins.open/), + ); + }); + test("it tries to load package from file system", async () => { pyodide._api.pyodide_code.find_imports = () => new MockPythonArray("numpy"); await worker.onmessage({ @@ -178,6 +172,21 @@ describe("PyodideWorker", () => { }); }); + test("it patches the open function", async () => { + await worker.onmessage({ + data: { + method: "runPython", + python: "print('hello')", + }, + }); + await waitFor(() => + expect(pyodide.runPythonAsync).toHaveBeenCalledWith( + expect.stringMatching(/builtins.open = _custom_open/), + { filename: "__custom_open__.py" }, + ), + ); + }); + test("it runs the python code", async () => { await worker.onmessage({ data: { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx index 339da793c..dadb12c6f 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useSelector } from "react-redux"; import AstroPiModel from "../../../../AstroPiModel/AstroPiModel"; import Highcharts from "highcharts"; @@ -8,13 +8,68 @@ const VisualOutputPane = ({ visuals, setVisuals }) => { const senseHatAlways = useSelector((s) => s.editor.senseHatAlwaysEnabled); const output = useRef(); + const showVisual = useCallback((visual, output) => { + switch (visual.origin) { + case "sense_hat": + output.current.textContent = JSON.stringify(visual.content); + break; + case "pygal": + const chartContent = { + ...visual.content, + chart: { + ...visual.content.chart, + events: { + ...visual.content.chart.events, + load: function () { + this.renderTo.style.overflow = "visible"; + }, + }, + }, + tooltip: { + ...visual.content.tooltip, + formatter: + visual.content.chart.type === "pie" + ? function () { + return this.key + ": " + this.y; + } + : null, + }, + }; + Highcharts.chart(output.current, chartContent); + break; + case "turtle": + output.current.innerHTML = elementFromProps(visual.content).outerHTML; + break; + case "matplotlib": + // convert visual.content from Uint8Array to jpg + const img = document.createElement("img"); + img.style = "max-width: 100%; max-height: 100%;"; + img.src = `data:image/jpg;base64,${window.btoa( + String.fromCharCode(...new Uint8Array(visual.content)), + )}`; + output.current.innerHTML = img.outerHTML; + break; + default: + throw new Error(`Unsupported origin: ${visual.origin}`); + } + + visual.showing = true; + return visual; + }, []); + + const showVisuals = useCallback( + (visuals, output) => + visuals.map((v) => (v.showing ? v : showVisual(v, output))), + [showVisual], + ); + useEffect(() => { if (visuals.length === 0) { output.current.innerHTML = ""; } else if (visuals.some((v) => !v.showing)) { setVisuals((visuals) => showVisuals(visuals, output)); } - }, [visuals, setVisuals]); + }, [visuals, setVisuals, showVisuals]); return (
@@ -24,58 +79,6 @@ const VisualOutputPane = ({ visuals, setVisuals }) => { ); }; -const showVisuals = (visuals, output) => - visuals.map((v) => (v.showing ? v : showVisual(v, output))); - -const showVisual = (visual, output) => { - switch (visual.origin) { - case "sense_hat": - output.current.textContent = JSON.stringify(visual.content); - break; - case "pygal": - const chartContent = { - ...visual.content, - chart: { - ...visual.content.chart, - events: { - ...visual.content.chart.events, - load: function () { - this.renderTo.style.overflow = "visible"; - }, - }, - }, - tooltip: { - ...visual.content.tooltip, - formatter: - visual.content.chart.type === "pie" - ? function () { - return this.key + ": " + this.y; - } - : null, - }, - }; - Highcharts.chart(output.current, chartContent); - break; - case "turtle": - output.current.innerHTML = elementFromProps(visual.content).outerHTML; - break; - case "matplotlib": - // convert visual.content from Uint8Array to jpg - const img = document.createElement("img"); - img.style = "max-width: 100%; max-height: 100%;"; - img.src = `data:image/jpg;base64,${window.btoa( - String.fromCharCode(...new Uint8Array(visual.content)), - )}`; - output.current.innerHTML = img.outerHTML; - break; - default: - throw new Error(`Unsupported origin: ${visual.origin}`); - } - - visual.showing = true; - return visual; -}; - const elementFromProps = (map) => { const tag = map.get("tag"); if (!tag) { diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index ec5119c87..624ef3dd2 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -95,6 +95,7 @@ export const loadProjectList = createAsyncThunk( const initialState = { project: {}, + cascadeUpdate: false, readOnly: false, saveTriggered: false, saving: "idle", @@ -197,7 +198,7 @@ export const EditorSlice = createSlice({ state.project.components.push({ name: action.payload.name, extension: action.payload.extension, - content: "", + content: action.payload.content || "", }); state.saving = "idle"; }, @@ -262,18 +263,22 @@ export const EditorSlice = createSlice({ state.saveTriggered = true; }, updateProjectComponent: (state, action) => { - const extension = action.payload.extension; - const fileName = action.payload.name; - const code = action.payload.code; + const { + extension, + name: fileName, + content, + cascadeUpdate, + } = action.payload; const mapped = state.project.components.map((item) => { if (item.extension !== extension || item.name !== fileName) { return item; } - return { ...item, ...{ content: code } }; + return { ...item, ...{ content } }; }); state.project.components = mapped; + state.cascadeUpdate = cascadeUpdate; }, updateProjectName: (state, action) => { state.project.name = action.payload; @@ -295,6 +300,9 @@ export const EditorSlice = createSlice({ } state.saving = "idle"; }, + setCascadeUpdate: (state, action) => { + state.cascadeUpdate = action.payload; + }, setError: (state, action) => { state.error = action.payload; }, @@ -454,6 +462,7 @@ export const { setEmbedded, setIsOutputOnly, setBrowserPreview, + setCascadeUpdate, setError, setIsSplitView, setNameError, diff --git a/src/redux/EditorSlice.test.js b/src/redux/EditorSlice.test.js index 186dac7a8..7c7baf6e2 100644 --- a/src/redux/EditorSlice.test.js +++ b/src/redux/EditorSlice.test.js @@ -11,6 +11,9 @@ import reducer, { setIsOutputOnly, setErrorDetails, setReadOnly, + addProjectComponent, + updateProjectComponent, + setCascadeUpdate, } from "./EditorSlice"; const mockCreateRemix = jest.fn(); @@ -104,6 +107,82 @@ test("Action setReadOnly correctly sets readOnly", () => { expect(reducer(previousState, setReadOnly(true))).toEqual(expectedState); }); +test("Action addProjectComponent adds component to project with correct content", () => { + const previousState = { + project: { + components: [], + }, + }; + const expectedState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello world')", + }, + ], + }, + saving: "idle", + }; + expect( + reducer( + previousState, + addProjectComponent({ + name: "main", + extension: "py", + content: "print('hello world')", + }), + ), + ).toEqual(expectedState); +}); + +test("Action updateProjectComponent updates component in project with correct content", () => { + const previousState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello world')", + }, + ], + }, + cascadeUpdate: false, + }; + const expectedState = { + project: { + components: [ + { + name: "main", + extension: "py", + content: "print('hello there world!')", + }, + ], + }, + cascadeUpdate: true, + }; + expect( + reducer( + previousState, + updateProjectComponent({ + name: "main", + extension: "py", + content: "print('hello there world!')", + cascadeUpdate: true, + }), + ), + ).toEqual(expectedState); +}); + +test("Action setCascadeUpdate sets cascadeUpdate correctly", () => { + const previousState = { cascadeUpdate: true }; + const expectedState = { cascadeUpdate: false }; + expect(reducer(previousState, setCascadeUpdate(false))).toEqual( + expectedState, + ); +}); + test("Showing rename modal sets file state and showing status", () => { const previousState = { renameFileModalShowing: false,