From ad6330e2ce685b828927cb4f97dc875325d843c6 Mon Sep 17 00:00:00 2001 From: timerke Date: Wed, 7 Feb 2024 13:43:05 +0300 Subject: [PATCH] Added tests --- .github/workflows/flake8.yaml | 19 -- .github/workflows/linter.yml | 27 +++ .github/workflows/tests.yml | 27 +++ PyQtExtendedScene/scene.py | 246 +++++++++++++++++--------- README.md | 53 +++--- example.py | 38 ++-- example.gif => images/example.gif | Bin workspace.png => images/workspace.png | Bin tests/__init__.py | 0 tests/data/background_1.png | Bin 0 -> 4933 bytes tests/data/background_2.png | Bin 0 -> 2768 bytes tests/test_scene.py | 107 +++++++++++ 12 files changed, 369 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/flake8.yaml create mode 100644 .github/workflows/linter.yml create mode 100644 .github/workflows/tests.yml rename example.gif => images/example.gif (100%) rename workspace.png => images/workspace.png (100%) create mode 100644 tests/__init__.py create mode 100644 tests/data/background_1.png create mode 100644 tests/data/background_2.png create mode 100644 tests/test_scene.py diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml deleted file mode 100644 index 310937a..0000000 --- a/.github/workflows/flake8.yaml +++ /dev/null @@ -1,19 +0,0 @@ -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up python 3.6 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Lint with flake8 - run: | - pip install flake8 - flake8 . --count --show-source --statistics diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..f70d22f --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,27 @@ +name: Linter + +on: + push: + branches: + - master + - 'dev-**' + pull_request: + branches: + - master + - 'dev-**' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 + - name: Check flake8 + run: python -m flake8 . --count --show-source --statistics --append-config .flake8 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bd0939c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: + - main + - 'dev-**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + - name: Run tests + run: | + export QT_QPA_PLATFORM=offscreen + python -m unittest discover tests + - name: Checking package installation + run: python setup.py install \ No newline at end of file diff --git a/PyQtExtendedScene/scene.py b/PyQtExtendedScene/scene.py index 0fb0692..4131411 100644 --- a/PyQtExtendedScene/scene.py +++ b/PyQtExtendedScene/scene.py @@ -2,14 +2,16 @@ from typing import List, Optional from PyQt5.QtCore import pyqtSignal, QPoint, QPointF, QRectF, Qt from PyQt5.QtGui import QBrush, QColor, QMouseEvent, QPainter, QPixmap, QResizeEvent, QWheelEvent -from PyQt5.QtWidgets import QFrame, QGraphicsItem, QGraphicsScene, QGraphicsView, QWidget +from PyQt5.QtWidgets import QFrame, QGraphicsItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, QWidget class AbstractComponent(QGraphicsItem): + """ + Abstract component for extended scene. + """ def __init__(self, draggable: bool = True, selectable: bool = True, unique_selection: bool = True) -> None: """ - Abstract component. :param draggable: True if component can be dragged; :param selectable: True if component can be selected; :param unique_selection: True if selecting this component should reset all others selections @@ -17,34 +19,64 @@ def __init__(self, draggable: bool = True, selectable: bool = True, unique_selec """ super().__init__() - self._draggable: bool = draggable self._selectable: bool = selectable self._unique_selection: bool = unique_selection @property def draggable(self) -> bool: + """ + :return: True if component can be dragged. + """ + return self._draggable @property def selectable(self) -> bool: + """ + :return: True if component can be selected. + """ + return self._selectable @property def unique_selection(self) -> bool: + """ + :return: True if selecting this component should reset all others selections. + """ + return self._unique_selection def boundingRect(self) -> QRectF: + """ + :return: the outer bounds of the component as a rectangle. + """ + # By default bounding rect of our object is a bounding rect of children items return self.childrenBoundingRect() def paint(self, painter: QPainter, option, widget: QWidget = None) -> None: + """ + :param painter: painter; + :param option: style options for the component, such as its state, exposed area and its level-of-detail hints; + :param widget: widget argument is optional. If provided, it points to the widget that is being painted on; + otherwise, it is None. + """ + pass - def select(self, selected: bool = True): + def select(self, selected: bool = True) -> None: + """ + :param selected: if True, then set the component as selected. + """ + pass - def update_scale(self, scale: float): + def update_scale(self, scale: float) -> None: + """ + :param scale: new scale factor for component. + """ + pass @@ -59,21 +91,29 @@ class ExtendedScene(QGraphicsView): minimum_scale = 0.1 class DragState(Enum): - no_drag = auto(), - drag = auto(), + no_drag = auto() + drag = auto() drag_component = auto() def __init__(self, background: Optional[QPixmap] = None, zoom_speed: float = 0.001, parent=None) -> None: + """ + :param background: pixmap background for scene; + :param zoom_speed: + :param parent: parent. + """ + super().__init__(parent) self._scale: float = 1.0 self._zoom_speed: float = zoom_speed - self._start_pos: Optional[QPointF] = None - self._drag_state: ExtendedScene.DragState = ExtendedScene.DragState.no_drag + self._components: List[AbstractComponent] = [] self._current_component: Optional[AbstractComponent] = None + self._drag_allowed: bool = True + self._drag_state: ExtendedScene.DragState = ExtendedScene.DragState.no_drag + self._start_pos: Optional[QPointF] = None self._scene: QGraphicsScene = QGraphicsScene() - self._background = self._scene.addPixmap(background) if background else None + self._background: Optional[QGraphicsPixmapItem] = self._scene.addPixmap(background) if background else None self.setScene(self._scene) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) @@ -82,33 +122,21 @@ def __init__(self, background: Optional[QPixmap] = None, zoom_speed: float = 0.0 self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setBackgroundBrush(QBrush(QColor(0, 0, 0))) self.setFrameShape(QFrame.NoFrame) - # Mouse self.setMouseTracking(True) - # For keyboard events self.setFocusPolicy(Qt.StrongFocus) - self._components: List[AbstractComponent] = [] - - self._drag_allowed = True - - def clear_scene(self) -> None: - self._scene.clear() - self._components = [] - self._background = None - self.resetTransform() - - def allow_drag(self, allow: bool = True) -> None: - self._drag_allowed = allow - - def is_drag_allowed(self) -> bool: - return self._drag_allowed + def _clicked_item(self, event: QMouseEvent) -> Optional[AbstractComponent]: + """ + :param event: mouse event. + :return: a component that is located at the point specified by the mouse. + """ - def set_background(self, background: QPixmap) -> None: - if self._background: - raise ValueError("Call 'clear_scene' first!") - self._background = self._scene.addPixmap(background) + for item in self.items(event.pos()): + if isinstance(item, AbstractComponent): + return item + return None def add_component(self, component: AbstractComponent) -> None: """ @@ -119,59 +147,50 @@ def add_component(self, component: AbstractComponent) -> None: self._scene.addItem(component) component.update_scale(self._scale) - def remove_component(self, component: AbstractComponent) -> None: + def all_components(self, class_filter: type = object) -> List[AbstractComponent]: """ - :param component: component to be removed from the scene. + :param class_filter: filter for components on scene. + :return: list of components that match a given filter. """ - self._components.remove(component) - self._scene.removeItem(component) - - def zoom(self, zoom_factor, pos) -> None: # pos in view coordinates - old_scene_pos = self.mapToScene(pos) + return list(filter(lambda x: isinstance(x, class_filter), self._components)) - # Note: Workaround! See: - # - https://bugreports.qt.io/browse/QTBUG-7328 - # - https://stackoverflow.com/questions/14610568/how-to-use-the-qgraphicsviews-translate-function - anchor = self.transformationAnchor() - self.setTransformationAnchor(QGraphicsView.NoAnchor) # Override transformation anchor - self.scale(zoom_factor, zoom_factor) - delta = self.mapToScene(pos) - old_scene_pos - self.translate(delta.x(), delta.y()) - self.setTransformationAnchor(anchor) # Restore old anchor + def allow_drag(self, allow: bool = True) -> None: + """ + :param allow: if True, then components are allowed to be moved around the scene. + """ - def move(self, delta): - # Note: Workaround! See: - # - https://bugreports.qt.io/browse/QTBUG-7328 - # - https://stackoverflow.com/questions/14610568/how-to-use-the-qgraphicsviews-translate-function - anchor = self.transformationAnchor() - self.setTransformationAnchor(QGraphicsView.NoAnchor) # Override transformation anchor - self.translate(delta.x(), delta.y()) - self.setTransformationAnchor(anchor) # Restore old anchor + self._drag_allowed = allow - def wheelEvent(self, event: QWheelEvent) -> None: - zoom_factor = 1.0 - zoom_factor += event.angleDelta().y() * self._zoom_speed - if self._scale * zoom_factor < self.minimum_scale and zoom_factor < 1.0: # minimum allowed zoom - return + def clear_scene(self) -> None: + self._scene.clear() + self._components = [] + self._background = None + self.resetTransform() - self.zoom(zoom_factor, event.pos()) - self._scale *= zoom_factor + def is_drag_allowed(self) -> bool: + """ + :return: if True, then components are allowed to be moved around the scene. + """ - for component in self._components: - component.update_scale(self._scale) + return self._drag_allowed - def _clicked_item(self, event: QMouseEvent) -> Optional[AbstractComponent]: - for item in self.items(event.pos()): - if isinstance(item, AbstractComponent): - return item - return None + def mouseMoveEvent(self, event: QMouseEvent) -> None: + """ + :param event: mouse event. + """ - def remove_all_selections(self) -> None: - for item in self._components: - item.select(False) + if self._drag_state == self.DragState.drag: + delta = self.mapToScene(event.pos()) - self._start_pos + self.move(delta) + elif self._drag_state == self.DragState.drag_component: + self._current_component.setPos(self.mapToScene(event.pos())) def mousePressEvent(self, event: QMouseEvent) -> None: + """ + :param event: mouse event. + """ + # Check for clicked pin item = self._clicked_item(event) @@ -205,14 +224,11 @@ def mousePressEvent(self, event: QMouseEvent) -> None: if event.button() & Qt.MiddleButton: self.on_middle_click.emit() - def mouseMoveEvent(self, event: QMouseEvent) -> None: - if self._drag_state == self.DragState.drag: - delta = self.mapToScene(event.pos()) - self._start_pos - self.move(delta) - elif self._drag_state == self.DragState.drag_component: - self._current_component.setPos(self.mapToScene(event.pos())) - def mouseReleaseEvent(self, event: QMouseEvent) -> None: + """ + :param event: mouse event. + """ + if event.button() & Qt.LeftButton: self.setDragMode(QGraphicsView.NoDrag) @@ -222,16 +238,33 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: self._drag_state = self.DragState.no_drag - def resizeEvent(self, event: QResizeEvent) -> None: - pass + def move(self, delta: QPoint) -> None: + """ + :param delta: offset to which the scene should be moved. + """ - def all_components(self, class_filter: type = object) -> List[AbstractComponent]: + # Note: Workaround! See: + # - https://bugreports.qt.io/browse/QTBUG-7328 + # - https://stackoverflow.com/questions/14610568/how-to-use-the-qgraphicsviews-translate-function + anchor = self.transformationAnchor() + self.setTransformationAnchor(QGraphicsView.NoAnchor) # Override transformation anchor + self.translate(delta.x(), delta.y()) + self.setTransformationAnchor(anchor) # Restore old anchor + + def remove_all_selections(self) -> None: + for item in self._components: + item.select(False) + + def remove_component(self, component: AbstractComponent) -> None: """ - :param class_filter: filter for components on scene. - :return: list of components that match a given filter. + :param component: component to be removed from the scene. """ - return list(filter(lambda x: isinstance(x, class_filter), self._components)) + self._components.remove(component) + self._scene.removeItem(component) + + def resizeEvent(self, event: QResizeEvent) -> None: + pass def scale_to_window_size(self, x: float, y: float) -> None: """ @@ -247,3 +280,46 @@ def scale_to_window_size(self, x: float, y: float) -> None: self.resetTransform() self._scale = factor self.zoom(factor, QPoint(0, 0)) + + def set_background(self, background: QPixmap) -> None: + """ + :param background: new pixmap background for scene. + """ + + if self._background: + raise ValueError("Call 'clear_scene' first!") + self._background = self._scene.addPixmap(background) + + def wheelEvent(self, event: QWheelEvent) -> None: + """ + :param event: wheel event. + """ + + zoom_factor = 1.0 + zoom_factor += event.angleDelta().y() * self._zoom_speed + if self._scale * zoom_factor < self.minimum_scale and zoom_factor < 1.0: # minimum allowed zoom + return + + self.zoom(zoom_factor, event.pos()) + self._scale *= zoom_factor + + for component in self._components: + component.update_scale(self._scale) + + def zoom(self, zoom_factor: float, pos: QPoint) -> None: # pos in view coordinates + """ + :param zoom_factor: scale factor; + :param pos: + """ + + old_scene_pos = self.mapToScene(pos) + + # Note: Workaround! See: + # - https://bugreports.qt.io/browse/QTBUG-7328 + # - https://stackoverflow.com/questions/14610568/how-to-use-the-qgraphicsviews-translate-function + anchor = self.transformationAnchor() + self.setTransformationAnchor(QGraphicsView.NoAnchor) # Override transformation anchor + self.scale(zoom_factor, zoom_factor) + delta = self.mapToScene(pos) - old_scene_pos + self.translate(delta.x(), delta.y()) + self.setTransformationAnchor(anchor) # Restore old anchor diff --git a/README.md b/README.md index f45362f..5ede142 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,53 @@ # PyQtExtendedScene PyQtExtendedScene is a little library for creating workspaces. -Having described the method of drawing your components, with this library you get (draggable) workspace (scene) where the -components you described can be selected, moved, deleted, etc. -The scene itself can be increased, reduced, the work area may be moved. -In short, with this library you can create minimalistic (very minimalistic) similarities of such programs as -AutoCAD, Labview, yEd, etc. +Having described the method of drawing your components, with this library you get (draggable) workspace (scene) where the components you described can be selected, moved, deleted, etc. The scene itself can be increased, reduced, the work area may be moved. + +In short, with this library you can create minimalistic (very minimalistic) similarities of such programs as AutoCAD, Labview, yEd, etc. Repository: https://github.com/EPC-MSU/PyQtExtendedScene -## Installation: +## Installation Installation is very simple: ```bash pip install PyQtExtendedScene ``` -## Working example: +## Working example ```Python -from PyQt5.QtWidgets import QGraphicsEllipseItem -from PyQt5.QtCore import QRectF, QPointF -from PyQt5.QtGui import QBrush, QColor -from PyQtExtendedScene import ExtendedScene, AbstractComponent +import os +import sys +from PyQt5.QtCore import QPointF, QRectF +from PyQt5.QtGui import QBrush, QColor, QPixmap +from PyQt5.QtWidgets import QApplication, QFileDialog, QGraphicsEllipseItem +from PyQtExtendedScene import AbstractComponent, ExtendedScene # Let's describe our own component class MyComponent(AbstractComponent): + selected_size = 20 normal_size = 10 - def __init__(self, x: float, y: float, descr: str = ""): + def __init__(self, x: float, y: float, descr: str = "") -> None: super().__init__(draggable=True, selectable=True, unique_selection=True) self._r = self.normal_size self.setPos(QPointF(x, y)) - # We must describe how to draw our own component - # Our own component will be just a circle + # We must describe how to draw our own component. Our own component will be just a circle self._item = QGraphicsEllipseItem(-self._r, -self._r, self._r * 2, self._r * 2, self) - # .. yellow circle + # ... yellow circle self._item.setBrush(QBrush(QColor(0xFFFF00))) # Add description to our object - it will be used in "click" callback function self._descr = descr # We must override parent method "select" because our component changes shape when selected - def select(self, selected: bool = True): + def select(self, selected: bool = True) -> None: # Radius of our circle changes when selected self._r = self.selected_size if selected else self.normal_size # redraw our object with new radius @@ -55,21 +55,21 @@ class MyComponent(AbstractComponent): @property # That is our own property - def description(self): + def description(self) -> str: return self._descr + +def left_click(component) -> None: + if isinstance(component, MyComponent): + print(f"Left click on '{component.description}'") -if __name__ == '__main__': - import sys - from os.path import isfile - from PyQt5.QtWidgets import QFileDialog, QApplication - from PyQt5.QtGui import QPixmap +if __name__ == '__main__': app = QApplication(sys.argv) # Open workspace background image path_to_image = "workspace.png" - if not isfile(path_to_image): + if not os.path.isfile(path_to_image): path_to_image = QFileDialog().getOpenFileName(caption="Open workspace image", filter="Image Files (*.png *.jpg *.bmp *.tiff)")[0] @@ -82,15 +82,8 @@ if __name__ == '__main__': widget.add_component(MyComponent(10, 10, "My component 1")) widget.add_component(MyComponent(100, 200, "My component 2")) - - def left_click(component): - if isinstance(component, MyComponent): - print(f"Left click on '{component.description}'") - - # Handle left click widget.on_component_left_click.connect(left_click) - widget.show() sys.exit(app.exec_()) diff --git a/example.py b/example.py index d0c3bbb..0b577f7 100644 --- a/example.py +++ b/example.py @@ -1,6 +1,6 @@ +import os import sys -from os.path import isfile -from PyQt5.QtCore import QRectF, QPointF +from PyQt5.QtCore import QPointF, QRectF from PyQt5.QtGui import QBrush, QColor, QPixmap from PyQt5.QtWidgets import QApplication, QFileDialog, QGraphicsEllipseItem from PyQtExtendedScene import AbstractComponent, ExtendedScene @@ -9,12 +9,20 @@ # Let's describe our own component class MyComponent(AbstractComponent): - selected_size = 20 normal_size = 10 + selected_size = 20 + + def __init__(self, x: float, y: float, description: str = "") -> None: + """ + :param x: horizontal coordinate for the component; + :param y: vertical coordinate for the component; + :param description: some description for the component. + """ - def __init__(self, x: float, y: float, descr: str = "") -> None: super().__init__(draggable=True, selectable=True, unique_selection=True) - self._r = self.normal_size + # Add description to our object - it will be used in "click" callback function + self._descr: str = description + self._r: float = self.normal_size self.setPos(QPointF(x, y)) @@ -23,12 +31,13 @@ def __init__(self, x: float, y: float, descr: str = "") -> None: # ... yellow circle self._item.setBrush(QBrush(QColor(0xFFFF00))) - # Add description to our object - it will be used in "click" callback function - self._descr = descr - @property # That is our own property def description(self) -> str: + """ + :return: description for the component. + """ + return self._descr # We must override parent method "select" because our component changes shape when selected @@ -39,12 +48,17 @@ def select(self, selected: bool = True) -> None: self._item.setRect(QRectF(-self._r, -self._r, self._r * 2, self._r * 2)) +def left_click(component: MyComponent) -> None: + if isinstance(component, MyComponent): + print(f"Left click on '{component.description}'") + + if __name__ == "__main__": app = QApplication(sys.argv) # Open workspace background image - path_to_image = "workspace.png" - if not isfile(path_to_image): + path_to_image = os.path.join("images", "workspace.png") + if not os.path.isfile(path_to_image): path_to_image = QFileDialog().getOpenFileName(caption="Open workspace image", filter="Image Files (*.png *.jpg *.bmp *.tiff)")[0] @@ -57,10 +71,6 @@ def select(self, selected: bool = True) -> None: widget.add_component(MyComponent(10, 10, "My component 1")) widget.add_component(MyComponent(100, 200, "My component 2")) - def left_click(component): - if isinstance(component, MyComponent): - print(f"Left click on '{component.description}'") - # Handle left click widget.on_component_left_click.connect(left_click) widget.show() diff --git a/example.gif b/images/example.gif similarity index 100% rename from example.gif rename to images/example.gif diff --git a/workspace.png b/images/workspace.png similarity index 100% rename from workspace.png rename to images/workspace.png diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/background_1.png b/tests/data/background_1.png new file mode 100644 index 0000000000000000000000000000000000000000..fd909960a6f54b97db29bcf7d7a1e0823b016558 GIT binary patch literal 4933 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 literal 0 HcmV?d00001 diff --git a/tests/test_scene.py b/tests/test_scene.py new file mode 100644 index 0000000..17c5ec8 --- /dev/null +++ b/tests/test_scene.py @@ -0,0 +1,107 @@ +import os +import sys +import unittest +from typing import List +from PyQt5.QtCore import QPointF, QRectF +from PyQt5.QtGui import QBrush, QColor, QPixmap +from PyQt5.QtWidgets import QApplication, QGraphicsEllipseItem +from PyQtExtendedScene import AbstractComponent, ExtendedScene + + +class SimpleComponent(AbstractComponent): + + NORMAL_SIZE: float = 10 + SELECTED_SIZE: float = 20 + + def __init__(self, x: float, y: float, description: str = "") -> None: + """ + :param x: horizontal coordinate for the component; + :param y: vertical coordinate for the component; + :param description: some description for the component. + """ + + super().__init__(draggable=True, selectable=True, unique_selection=True) + self._description: str = description + self._r: float = SimpleComponent.NORMAL_SIZE + self.setPos(QPointF(x, y)) + self._item = QGraphicsEllipseItem(-self._r, -self._r, self._r * 2, self._r * 2, self) + self._item.setBrush(QBrush(QColor(0xFFFF00))) + + @property + def description(self) -> str: + """ + :return: description for the component. + """ + + return self._description + + def select(self, selected: bool = True) -> None: + self._r = SimpleComponent.SELECTED_SIZE if selected else SimpleComponent.NORMAL_SIZE + self._item.setRect(QRectF(-self._r, -self._r, self._r * 2, self._r * 2)) + + +class OtherComponent(AbstractComponent): + pass + + +class TestExtendedScene(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls._app: QApplication = QApplication(sys.argv) + + def setUp(self) -> None: + path = os.path.join(os.path.dirname(__file__), "data", "background_1.png") + background = QPixmap(path) + self.scene: ExtendedScene = ExtendedScene(background) + + self.simple_components: List[SimpleComponent] = [] + for i in range(5): + component = SimpleComponent(i, i, f"simple component {i}") + self.scene.add_component(component) + self.simple_components.append(component) + + self.other_components: List[OtherComponent] = [] + for i in range(7): + component = OtherComponent() + self.scene.add_component(component) + self.other_components.append(component) + + def test_add_component_and_all_components(self) -> None: + self.assertEqual(len(self.scene.all_components()), 12) + + simple_components_from_scene = self.scene.all_components(SimpleComponent) + self.assertEqual(len(simple_components_from_scene), 5) + for i, component in enumerate(simple_components_from_scene): + self.assertEqual(component, self.simple_components[i]) + + other_components_from_scene = self.scene.all_components(OtherComponent) + self.assertEqual(len(other_components_from_scene), 7) + for i, component in enumerate(other_components_from_scene): + self.assertEqual(component, self.other_components[i]) + + def test_allow_drag_and_is_drag_allowed(self) -> None: + scene = ExtendedScene() + self.assertTrue(scene.is_drag_allowed()) + + scene.allow_drag(False) + self.assertFalse(scene.is_drag_allowed()) + + def test_clear_scene(self) -> None: + self.assertEqual(len(self.scene.all_components()), 12) + self.assertIsNotNone(self.scene._background) + + self.scene.clear_scene() + self.assertEqual(len(self.scene.all_components()), 0) + self.assertIsNone(self.scene._background) + + def test_set_background(self) -> None: + path = os.path.join(os.path.dirname(__file__), "data", "background_2.png") + background = QPixmap(path) + + with self.assertRaises(ValueError): + self.scene.set_background(background) + + self.scene.clear_scene() + self.scene.set_background(background) + self.assertIsNotNone(self.scene._background)