diff --git a/.github/workflows/python-coverage.yml b/.github/workflows/python-coverage.yml index aadb7c0c..70dca56b 100644 --- a/.github/workflows/python-coverage.yml +++ b/.github/workflows/python-coverage.yml @@ -16,11 +16,18 @@ jobs: python-version: 3.9 - name: Install dependencies run: | + sudo apt-get update + sudo apt-get install python3-tk python3-dev + sudo apt-get install xvfb + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 + python -m pip install --upgrade pip pip install -r requirements.txt + pip install -r requirements-dev.txt + touch /home/runner/.Xauthority + - name: Build unit coverage using pytest-cov run: | - pip install -r requirements-dev.txt pytest --cov=opencodeblocks --cov-report=xml tests/unit score=$(python coverage_score.py --score) color=$(python coverage_score.py --color) @@ -40,14 +47,18 @@ jobs: style: plastic - name: Build integration coverage using pytest-cov run: | - pip install -r requirements-dev.txt - pytest --cov=opencodeblocks --cov-report=xml tests/integration + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1080x24 -ac +extension GLX + + pytest -s --cov=opencodeblocks --cov-report=xml tests/integration score=$(python coverage_score.py --score) color=$(python coverage_score.py --color) echo "COVERAGE_INTEGRATION_SCORE=$score" echo "COVERAGE_INTEGRATION_COLOR=$color" echo "COVERAGE_INTEGRATION_SCORE=$score" >> $GITHUB_ENV echo "COVERAGE_INTEGRATION_COLOR=$color" >> $GITHUB_ENV + env: + DISPLAY: :99 + # QT_DEBUG_PLUGINS: 1 # Uncomment to debug Qt library issues. - name: Create integration coverage badge uses: schneegans/dynamic-badges-action@v1.1.0 with: diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index aacf0e40..a4a7db6d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -8,7 +8,7 @@ on: ["push"] jobs: build: - runs-on: windows-latest + runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9] @@ -21,16 +21,18 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | + sudo apt-get update + sudo apt-get install python3-tk python3-dev + sudo apt-get install xvfb + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 + python -m pip install --upgrade pip pip install -r requirements.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + pip install -r requirements-dev.txt + touch /home/runner/.Xauthority - name: Test with pytest run: | - pip install -r requirements-dev.txt + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1080x24 -ac +extension GLX pytest tests + env: + DISPLAY: :99 diff --git a/pytest.ini b/pytest.ini index e3b129f9..cabfc9ec 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,4 @@ filterwarnings = ignore::DeprecationWarning addopts = --pspec +qt_api=pyqt5 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 764c45a9..cef44d30 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,5 +3,7 @@ pytest-cov pytest-mock pytest-check pytest-pspec +pytest-qt +pyautogui pylint pylint-pytest \ No newline at end of file diff --git a/tests/integration/test_blocks.py b/tests/integration/test_blocks.py new file mode 100644 index 00000000..3aa6e881 --- /dev/null +++ b/tests/integration/test_blocks.py @@ -0,0 +1,171 @@ +""" +Integration tests for OCB. + +We use xvfb to perform the tests without opening any windows. +We use pyautogui to move the mouse and interact with the application. + +To pass the tests on windows, you need to not move the mouse. +Use this if you need to understand why a test fails. + +To pass the tests on linux, you just need to install xvfb and it's dependencies. +On linux, no windows are opened to the user during the test. +To understand why a test fails, pass the flag "--no-xvfb" and use your own X server +to see the test running live. +""" + +# Imports needed for testing +import time, threading, queue, os, sys +import pytest +from pytest_mock import MockerFixture +import pytest_check as check +import pyautogui + +# Packages tested +from opencodeblocks.graphics.blocks.codeblock import OCBCodeBlock +from opencodeblocks.graphics.socket import OCBSocket +from opencodeblocks.graphics.window import OCBWindow +from opencodeblocks.graphics.widget import OCBWidget + +from qtpy.QtWidgets import QApplication +from PyQt5.QtWidgets import QWidget +from PyQt5.QtGui import QFocusEvent, QMouseEvent +from PyQt5.QtCore import QCoreApplication, QEvent, Qt, QPointF, QPoint +from PyQt5 import QtTest + +def test_window_opening(qtbot): + """ The OCBWindow should open and close correctly """ + wnd = OCBWindow() + wnd.close() + +""" +def test_running_python(qtbot): + # The blocks should run arbitrary python when unfocused + wnd = OCBWindow() + + EXPRESSION = "3 + 5 * 2" + SOURCE_TEST = \ + ''' + print(%s) + ''' % EXPRESSION + expected_result = str(eval(EXPRESSION)) + + # Let's add a block with the source to the window ! + ocb_widget = OCBWidget() + test_block = OCBCodeBlock(title="Testing block", source=SOURCE_TEST) + ocb_widget.scene.addItem(test_block) + wnd.mdiArea.addSubWindow(ocb_widget) + + # Let's run the block ! + pyeditor = test_block.source_editor.widget() + # pyeditor.setModified(True) + # test_block._source = "" + QApplication.processEvents() + QtTest.QTest.mouseClick(pyeditor,Qt.MouseButton.LeftButton) + QApplication.processEvents() + QtTest.QTest.keyPress(pyeditor," ") + QApplication.processEvents() + + # Click outside the block to lose focus of the previous block. + # This will need to be changed by the click to the run button. + QtTest.QTest.mouseClick(ocb_widget,Qt.MouseButton.LeftButton) + QApplication.processEvents() + + # When the execution becomes non-blocking for the UI, a refactor will be needed here. + result = test_block.stdout.strip() + + check.equal(expected_result,result) + wnd.close() +""" + +def test_move_blocks(qtbot): + """ + Newly created blocks are displayed in the center. + They can be dragged around with the mouse. + """ + wnd = OCBWindow() + + ocb_widget = OCBWidget() + subwnd = wnd.mdiArea.addSubWindow(ocb_widget) + + test_block1 = OCBCodeBlock(title="Testing block 1", source="print(1)") + ocb_widget.scene.addItem(test_block1) + + test_block2 = OCBCodeBlock(title="Testing block 2", source="print(2)") + ocb_widget.scene.addItem(test_block2) + + subwnd.show() + + QApplication.processEvents() + + expected_move_amount = [70,-30] + STOP_MSG = "stop" + CHECK_MSG = "check" + + msgQueue = queue.Queue() + + def testing_drag(msgQueue): + time.sleep(.4) # Wait for proper setup of app + + # test_block1 == (0,0) but it's not crucial for this test. + pos_block_1 = QPoint(int(test_block1.pos().x()),int(test_block1.pos().y())) + + pos_block_1.setX(pos_block_1.x() + test_block1.title_height//2) + pos_block_1.setY(pos_block_1.y() + test_block1.title_height//2) + + pos_block_1 = ocb_widget.view.mapFromScene(pos_block_1) + pos_block_1 = ocb_widget.view.mapToGlobal(pos_block_1) + + pyautogui.moveTo(pos_block_1.x(),pos_block_1.y()) + pyautogui.mouseDown(button="left") + + iterations = 5 + for i in range(iterations+1): + time.sleep(0.05) + pyautogui.moveTo( + pos_block_1.x() + expected_move_amount[0] * i // iterations, + pos_block_1.y() + expected_move_amount[1] * i // iterations + ) + + pyautogui.mouseUp(button="left") + time.sleep(.2) + + move_amount = [test_block2.pos().x(),test_block2.pos().y()] + # rectify because the scene can be zoomed : + move_amount[0] = int(move_amount[0] * ocb_widget.view.zoom) + move_amount[1] = int(move_amount[1] * ocb_widget.view.zoom) + + msgQueue.put([ + CHECK_MSG, + move_amount, + expected_move_amount, + "Block moved by the correct amound" + ]) + + msgQueue.put([STOP_MSG]) + + + t = threading.Thread(target=testing_drag, args=(msgQueue,)) + t.start() + + while True: + QApplication.processEvents() + time.sleep(0.02) + if not msgQueue.empty(): + msg = msgQueue.get() + if msg[0] == STOP_MSG: + break + elif msg[0] == CHECK_MSG: + check.equal(msg[1],msg[2],msg[3]) + t.join() + wnd.close() + +def test_open_file(): + """ + The application loads files properly. + """ + + wnd = OCBWindow() + file_example_path = "./tests/testing_assets/example_graph1.ipyg" + subwnd = wnd.createNewMdiChild(os.path.abspath(file_example_path)) + subwnd.show() + wnd.close() diff --git a/tests/testing_assets/example_graph1.ipyg b/tests/testing_assets/example_graph1.ipyg new file mode 100644 index 00000000..588e72bf --- /dev/null +++ b/tests/testing_assets/example_graph1.ipyg @@ -0,0 +1,401 @@ +{ + "id": 2205665405400, + "blocks": [ + { + "id": 2443477874008, + "title": "Model Train", + "block_type": "code", + "source": "print(\"training \")\r\nmodel.fit(x=x_train,y=y_train, epochs=10)\r\n\r\n", + "position": [ + 2202.0742187499986, + -346.82031249999983 + ], + "width": 1644.8125, + "height": 481.4375, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443477875016, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#e02c2c", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + }, + { + "id": 2443477875160, + "type": "output", + "position": [ + 1644.8125, + 42.0 + ], + "metadata": { + "color": "#35bc31", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2443477924600, + "title": "Keras Model Predict", + "block_type": "code", + "source": "prediction = model.predict(x_test[9].reshape(1, 28, 28, 1))", + "position": [ + 4207.046874999999, + -244.57812499999991 + ], + "width": 1239.6875, + "height": 305.9374999999999, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443477925608, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + }, + { + "id": 2443477925752, + "type": "output", + "position": [ + 1239.6875, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2443477997032, + "title": "Keras Model eval", + "block_type": "code", + "source": "model.evaluate(x_test, y_test)\r\n", + "position": [ + 4204.085937499997, + -707.0546874999997 + ], + "width": 1628.375, + "height": 209.875, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443477997896, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + }, + { + "id": 2443477998184, + "type": "output", + "position": [ + 1628.375, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2443478874872, + "title": "Load MNIST Dataset", + "block_type": "code", + "source": "print(\"Hello, world\")\r\nfrom tensorflow.keras.datasets import mnist\r\n(x_train, y_train), (x_test, y_test) = mnist.load_data()\r\n", + "position": [ + -535.75, + -687.0625 + ], + "width": 739.5, + "height": 343.5, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443478910728, + "type": "output", + "position": [ + 739.5, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2443478982728, + "title": "Normalize Image Dataset", + "block_type": "code", + "source": "x_train = x_train.astype('float32') / 255.0\r\nx_test = x_test.astype('float32') / 255.0\r\n\r\n\r\nx_train = x_train.reshape(x_train.shape[0], 28, 28, 1)\r\nx_test = x_test.reshape(x_test.shape[0], 28, 28, 1)\r\n\r\nprint('train:', x_train.shape, '|test:', x_test.shape)", + "position": [ + 281.2500000000002, + -149.74999999999977 + ], + "width": 705.7499999999998, + "height": 357.25, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443478983592, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + }, + { + "id": 2443478983880, + "type": "output", + "position": [ + 705.7499999999998, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2443479017656, + "title": "Build Keras CNN", + "block_type": "code", + "source": "import tensorflow as tf\r\nfrom tensorflow.keras.layers import Dense, Flatten, Conv2D, MaxPooling2D, Dropout\r\nfrom tensorflow.keras.models import Sequential\r\n\r\nmodel = Sequential()\r\nmodel.add(Conv2D(28, kernel_size=(3,3), input_shape=x_train.shape[1:]))\r\nmodel.add(MaxPooling2D(pool_size=(2, 2)))\r\nmodel.add(Flatten())\r\nmodel.add(Dense(128, activation=tf.nn.relu))\r\nmodel.add(Dropout(0.2))\r\nmodel.add(Dense(10,activation=tf.nn.softmax))\r\nprint(\"..\")\r\nmodel.compile(optimizer='adam', \r\n loss='sparse_categorical_crossentropy', \r\n metrics=['accuracy'])\r\n", + "position": [ + 1316.25, + -517.6249999999998 + ], + "width": 680.0, + "height": 468.75, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2443479018520, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + }, + { + "id": 2443479018808, + "type": "output", + "position": [ + 680.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + }, + { + "id": 2828158533848, + "title": "Plot Image Dataset Example", + "block_type": "code", + "source": "import matplotlib.pyplot as plt\r\nimport numpy as np\r\n\r\n# Display an example from the dataset\r\nrd_index = np.random.randint(len(x_train))\r\nplt.imshow(x_train[rd_index], cmap='gray')\r\nplt.title('Class '+ str(y_train[0]))\r\n", + "position": [ + 433.375, + -1221.75 + ], + "width": 778.9375, + "height": 763.25, + "metadata": { + "title_metadata": { + "color": "white", + "font": "Ubuntu", + "size": 10, + "padding": 4.0 + } + }, + "sockets": [ + { + "id": 2828158535432, + "type": "input", + "position": [ + 0.0, + 42.0 + ], + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0 + } + } + ] + } + ], + "edges": [ + { + "id": 1643571233840, + "path_type": "bezier", + "source": { + "block": 2443479017656, + "socket": 2443479018808 + }, + "destination": { + "block": 2443477874008, + "socket": 2443477875016 + } + }, + { + "id": 2006783605056, + "path_type": "bezier", + "source": { + "block": 2443478874872, + "socket": 2443478910728 + }, + "destination": { + "block": 2828158533848, + "socket": 2828158535432 + } + }, + { + "id": 2006783606064, + "path_type": "bezier", + "source": { + "block": 2443477874008, + "socket": 2443477875160 + }, + "destination": { + "block": 2443477924600, + "socket": 2443477925608 + } + }, + { + "id": 2111730223424, + "path_type": "bezier", + "source": { + "block": 2443478982728, + "socket": 2443478983880 + }, + "destination": { + "block": 2443479017656, + "socket": 2443479018520 + } + }, + { + "id": 2111730224144, + "path_type": "bezier", + "source": { + "block": 2443477874008, + "socket": 2443477875160 + }, + "destination": { + "block": 2443477997032, + "socket": 2443477997896 + } + }, + { + "id": 2111730844864, + "path_type": "bezier", + "source": { + "block": 2443478874872, + "socket": 2443478910728 + }, + "destination": { + "block": 2443478982728, + "socket": 2443478983592 + } + } + ] +} \ No newline at end of file