From 165283d56c2c8f4299c5c956d7a612f9725e048f Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Sat, 3 Apr 2021 16:53:19 +0200 Subject: [PATCH] Unittests for multi selections --- beeref/items.py | 312 +---------------------- beeref/scene.py | 26 +- beeref/selection.py | 332 ++++++++++++++++++++++++ tests/test_items.py | 474 ++++------------------------------ tests/test_scene.py | 244 +++++++++++++++++- tests/test_selection.py | 553 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1192 insertions(+), 749 deletions(-) create mode 100644 beeref/selection.py create mode 100644 tests/test_selection.py diff --git a/beeref/items.py b/beeref/items.py index e1f5a93..2489874 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -20,247 +20,13 @@ text). import logging from PyQt6 import QtCore, QtGui, QtWidgets -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGraphicsItem -from beeref import commands -from beeref.config import CommandlineArgs +from beeref.selection import SelectableMixin -commandline_args = CommandlineArgs() logger = logging.getLogger('BeeRef') -class SelectableMixin: - """Common code for selectable items: Selection outline, handles etc.""" - - select_color = QtGui.QColor(116, 234, 231, 255) - SELECT_LINE_WIDTH = 4 # line width for the selection box - SELECT_HANDLE_SIZE = 15 # size of selection handles for scaling - SELECT_RESIZE_SIZE = 20 # size of hover area for scaling - SELECT_ROTATE_SIZE = 20 # size of hover area for rotating - - def init_selectable(self): - self.setAcceptHoverEvents(True) - self.setFlags( - QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable - | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) - - self.scale_active = False - self.viewport_scale = 1 - self.conf_debug_shapes = commandline_args.draw_debug_shapes - - def bring_to_front(self): - self.setZValue(self.scene().max_z + 0.001) - - def fixed_length_for_viewport(self, value): - """The interactable areas need to stay the same size on the - screen so we need to adjust the values according to the scale - factor sof the view and the item.""" - - if self.scene(): - scale = self.scene().views()[0].get_scale() - self._view_scale = scale - - # It can happen that the item is already removed from - # the scene but its boundingRect is still needed. Keep the - # last known scaling factor for that case - return value / getattr(self, '_view_scale', 1) / self.scale() - - @property - def select_resize_size(self): - return self.fixed_length_for_viewport(self.SELECT_RESIZE_SIZE) - - @property - def select_rotate_size(self): - return self.fixed_length_for_viewport(self.SELECT_ROTATE_SIZE) - - def draw_debug_shape(self, painter, shape, r, g, b): - color = QtGui.QColor(r, g, b, 50) - if isinstance(shape, QtCore.QRectF): - painter.fillRect(shape, color) - else: - painter.fillPath(shape, color) - - def paint_selectable(self, painter, option, widget): - if self.conf_debug_shapes: - self.draw_debug_shape(painter, self.boundingRect(), 0, 255, 0) - self.draw_debug_shape(painter, self.shape(), 255, 0, 0) - - if not self.has_selection_outline(): - return - - pen = QtGui.QPen(self.select_color) - pen.setWidth(self.SELECT_LINE_WIDTH) - pen.setCosmetic(True) - painter.setPen(pen) - - # Draw the main selection rectangle - painter.drawRect(0, 0, self.width, self.height) - - # If it's a single selection, draw the handles: - if self.has_selection_handles(): - pen.setWidth(self.SELECT_HANDLE_SIZE) - painter.setPen(pen) - for corner in self.corners: - painter.drawPoint(*corner) - - @property - def corners(self): - """The corners of the items. Used for scale and rotate handles.""" - return ((0, 0), - (0, self.height), - (self.width, 0), - (self.width, self.height)) - - def get_scale_bounds(self, center): - """The interactable shape of the scale handles.""" - return QtCore.QRectF( - center[0] - self.select_resize_size/2, - center[1] - self.select_resize_size/2, - self.select_resize_size, - self.select_resize_size) - - @property - def bottom_right_rotate_bounds(self): - """The interactable shape of the bottom right rotate handle""" - return QtCore.QRectF( - self.width + self.select_resize_size / 2, - self.height + self.select_resize_size / 2, - self.select_rotate_size, self.select_rotate_size) - - def boundingRect(self): - bounds = super().boundingRect() - if not self.has_selection_outline(): - return bounds - - # Add extra space for scale and rotate interactive areas - margin = self.select_resize_size / 2 + self.select_rotate_size - return QtCore.QRectF( - bounds.topLeft().x() - margin, - bounds.topLeft().y() - margin, - bounds.bottomRight().x() + 2 * margin, - bounds.bottomRight().y() + 2 * margin) - - def shape(self): - shape_ = super().shape() - if self.has_selection_outline(): - # Add extra space for scale and rotate interactive areas - path = QtGui.QPainterPath() - for corner in self.corners: - path.addRect(self.get_scale_bounds(corner)) - path.addRect(self.bottom_right_rotate_bounds) - shape_ = shape_ + path - return shape_ - - def hoverMoveEvent(self, event): - if not self.has_selection_handles(): - return - - for corner in self.corners: - # See if we need to change the cursor for scale areas - if self.get_scale_bounds(corner).contains(event.pos()): - direction = self.get_scale_direction(corner) - if direction[0] == direction[1]: - self.setCursor(Qt.CursorShape.SizeFDiagCursor) - else: - self.setCursor(Qt.CursorShape.SizeBDiagCursor) - return - - if self.bottom_right_rotate_bounds.contains(event.pos()): - self.setCursor(Qt.CursorShape.ForbiddenCursor) - return - - self.setCursor(Qt.CursorShape.ArrowCursor) - - def hoverEnterEvent(self, event): - # Always return regular cursor when there aren't any selection handles - if not self.has_selection_handles(): - self.setCursor(Qt.CursorShape.ArrowCursor) - - def mousePressEvent(self, event): - if (event.button() == Qt.MouseButtons.LeftButton - and self.has_selection_handles()): - for corner in self.corners: - # Check if we are in one of the corner's scale areas - if self.get_scale_bounds(corner).contains(event.pos()): - # Start scale action for this corner - self.scale_active = True - self.scale_start = event.scenePos() - self.scale_direction = self.get_scale_direction(corner) - for item in self.selection_action_items(): - item.scale_anchor = self.get_scale_anchor(item, corner) - item.scale_orig_factor = item.scale() - item.scale_orig_pos = item.pos() - event.accept() - return - - super().mousePressEvent(event) - - def get_scale_factor(self, event): - imgsize = self.width + self.height - p = event.scenePos() - self.scale_start - direction = self.scale_direction - delta = (direction[0] * p.x() + direction[1] * p.y()) / imgsize - return (self.scale_orig_factor + delta) / self.scale_orig_factor - - def get_scale_anchor(self, item, corner): - """Get the anchor around which the scale for this corner operates.""" - return item.mapFromScene( - self.mapToScene(self.width - corner[0], self.height - corner[1])) - - def get_scale_direction(self, corner): - """Get the direction in which the scale for this corner increases""" - x = 1 if corner[0] > 0 else -1 - y = 1 if corner[1] > 0 else -1 - return (x, y) - - def translate_for_scale_anchor(self, scale_factor): - """Adjust the item's position so that a scale with the given scale - factor appears to operate around the scale anchor. ``setScale`` - needs to be called separately with ``scale_factor`` multiplied by - the item's current scale factor. - """ - - factor = self.scale_orig_factor * (scale_factor - 1) - self.setPos( - self.scale_orig_pos.x() - self.scale_anchor.x() * factor, - self.scale_orig_pos.y() - self.scale_anchor.y() * factor, - ) - - def mouseMoveEvent(self, event): - if self.scale_active: - factor = self.get_scale_factor(event) - for item in self.selection_action_items(): - item.setScale(item.scale_orig_factor * factor) - item.translate_for_scale_anchor(factor) - event.accept() - else: - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event): - if self.scale_active: - self.scene().undo_stack.push( - commands.ScaleItemsBy( - self.selection_action_items(), - self.get_scale_factor(event), - ignore_first_redo=True)) - self.scale_active = False - event.accept() - else: - super().mouseReleaseEvent(event) - - def on_view_scale_change(self): - self.prepareGeometryChange() - - def itemChange(self, change, value): - if change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: - self.prepareGeometryChange() - if hasattr(self, 'on_selected_change'): - self.on_selected_change(value) - return super().itemChange(change, value) - - class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem): """Class for images added by the user.""" @@ -272,21 +38,7 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem): self.init_selectable() def __str__(self): - return (f'Image "{self.filename}" ' - f'with dimensions {self.width} x {self.height}') - - def setScale(self, factor): - if factor <= 0: - return - - logger.debug(f'Setting scale for image "{self.filename}" to {factor}') - self.prepareGeometryChange() - super().setScale(factor) - - def setZValue(self, value): - logger.debug(f'Setting z-value for image "{self.filename}" to {value}') - super().setZValue(value) - self.scene().max_z = max(self.scene().max_z, value) + return (f'Image "{self.filename}" {self.width} x {self.height}') def set_pos_center(self, x, y): """Sets the position using the item's center as the origin point.""" @@ -302,11 +54,6 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem): def height(self): return self.pixmap().size().height() - @property - def scene_bottomright(self): - """The bottom right corner in scene coordinates.""" - return self.mapToScene(QtCore.QPointF(self.width, self.height)) - def pixmap_to_bytes(self): """Convert the pixmap data to PNG bytestring.""" barray = QtCore.QByteArray() @@ -340,58 +87,3 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem): def on_selected_change(self, value): if(value and self.scene() and not self.scene().has_selection()): self.bring_to_front() - - -class MultiSelectItem(SelectableMixin, QtWidgets.QGraphicsRectItem): - """Class for images added by the user.""" - - def __init__(self): - super().__init__() - self.init_selectable() - - def __str__(self): - return (f'MultiSelectItem ' - f'with dimensions {self.width} x {self.height}') - - @property - def width(self): - return self.rect().width() - - @property - def height(self): - return self.rect().height() - - def paint(self, painter, option, widget): - self.paint_selectable(painter, option, widget) - - def has_selection_outline(self): - return True - - def has_selection_handles(self): - return True - - def selection_action_items(self): - """The items affected by selection actions like scaling and rotating. - """ - return list(self.scene().selectedItems()) - - def fit_selection_area(self, rect): - """Updates itself to fit the given selection area.""" - - logging.debug(f'Fit selection area to {rect}') - self.setRect(0, 0, rect.width(), rect.height()) - self.setPos(rect.topLeft()) - self.setScale(1) - self.setRotation(0) - self.setSelected(True) - - def mousePressEvent(self, event): - if (event.button() == Qt.MouseButtons.LeftButton - and event.modifiers() == Qt.KeyboardModifiers.ControlModifier): - # We still need to be able to select additional images - # within/"under" the multi select rectangle, so let ctrl+click - # events pass through - event.ignore() - return - - super().mousePressEvent(event) diff --git a/beeref/scene.py b/beeref/scene.py index 3f87817..3147c45 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -20,7 +20,7 @@ from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import Qt from beeref import commands -from beeref.items import MultiSelectItem +from beeref.selection import MultiSelectItem logger = logging.getLogger('BeeRef') @@ -119,13 +119,13 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): commands.MoveItemsBy(self.selectedItems(), delta.x(), delta.y(), ignore_first_redo=True)) - self.move_active = False + self.move_active = False super().mouseReleaseEvent(event) def items_for_save(self): """Returns the items that are to be saved. - Items to be saved are items that have an save_id attribute. + Items to be saved are items that have a save_id attribute. """ return filter(lambda i: hasattr(i, 'save_id'), @@ -144,19 +144,17 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): items = list(filter(lambda i: hasattr(i, 'save_id'), self.selectedItems())) + x = [] + y = [] - topleft = items[0].pos() - bottomright = items[0].scene_bottomright + for item in items: + for corner in item.corners_scene_coords: + x.append(corner.x()) + y.append(corner.y()) - for item in items[1:]: - tl = item.pos() - br = item.scene_bottomright - topleft.setX(min(topleft.x(), tl.x(), br.x())) - topleft.setY(min(topleft.y(), tl.y(), br.y())) - bottomright.setX(max(bottomright.x(), tl.x(), br.x())) - bottomright.setY(max(bottomright.y(), tl.y(), br.y())) - - return QtCore.QRectF(topleft, bottomright) + return QtCore.QRectF( + QtCore.QPointF(min(x), min(y)), + QtCore.QPointF(max(x), max(y))) def on_selection_change(self): if self.has_multi_selection(): diff --git a/beeref/selection.py b/beeref/selection.py new file mode 100644 index 0000000..c02bd60 --- /dev/null +++ b/beeref/selection.py @@ -0,0 +1,332 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +"""Classes for items that draw and handle selection stuff.""" + +import logging + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QGraphicsItem + +from beeref import commands +from beeref.config import CommandlineArgs + + +commandline_args = CommandlineArgs() +logger = logging.getLogger('BeeRef') + + +class SelectableMixin: + """Common code for selectable items: Selection outline, handles etc.""" + + select_color = QtGui.QColor(116, 234, 231, 255) + SELECT_LINE_WIDTH = 4 # line width for the selection box + SELECT_HANDLE_SIZE = 15 # size of selection handles for scaling + SELECT_RESIZE_SIZE = 20 # size of hover area for scaling + SELECT_ROTATE_SIZE = 20 # size of hover area for rotating + + def init_selectable(self): + self.setAcceptHoverEvents(True) + self.setFlags( + QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable + | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) + + self.scale_active = False + self.viewport_scale = 1 + self.conf_debug_shapes = commandline_args.draw_debug_shapes + + def setScale(self, factor): + if factor <= 0: + return + + logger.debug(f'Setting scale for {self} to {factor}') + self.prepareGeometryChange() + super().setScale(factor) + + def setZValue(self, value): + logger.debug(f'Setting z-value for image {self} to {value}') + super().setZValue(value) + self.scene().max_z = max(self.scene().max_z, value) + + def bring_to_front(self): + self.setZValue(self.scene().max_z + 0.001) + + def fixed_length_for_viewport(self, value): + """The interactable areas need to stay the same size on the + screen so we need to adjust the values according to the scale + factor sof the view and the item.""" + + if self.scene(): + scale = self.scene().views()[0].get_scale() + self._view_scale = scale + + # It can happen that the item is already removed from + # the scene but its boundingRect is still needed. Keep the + # last known scaling factor for that case + return value / getattr(self, '_view_scale', 1) / self.scale() + + @property + def select_resize_size(self): + return self.fixed_length_for_viewport(self.SELECT_RESIZE_SIZE) + + @property + def select_rotate_size(self): + return self.fixed_length_for_viewport(self.SELECT_ROTATE_SIZE) + + def draw_debug_shape(self, painter, shape, r, g, b): + color = QtGui.QColor(r, g, b, 50) + if isinstance(shape, QtCore.QRectF): + painter.fillRect(shape, color) + else: + painter.fillPath(shape, color) + + def paint_selectable(self, painter, option, widget): + if self.conf_debug_shapes: + self.draw_debug_shape(painter, self.boundingRect(), 0, 255, 0) + self.draw_debug_shape(painter, self.shape(), 255, 0, 0) + + if not self.has_selection_outline(): + return + + pen = QtGui.QPen(self.select_color) + pen.setWidth(self.SELECT_LINE_WIDTH) + pen.setCosmetic(True) + painter.setPen(pen) + + # Draw the main selection rectangle + painter.drawRect(0, 0, self.width, self.height) + + # If it's a single selection, draw the handles: + if self.has_selection_handles(): + pen.setWidth(self.SELECT_HANDLE_SIZE) + painter.setPen(pen) + for corner in self.corners: + painter.drawPoint(corner) + + @property + def corners(self): + """The corners of the item. Used for scale and rotate handles.""" + return (QtCore.QPointF(0, 0), + QtCore.QPointF(0, self.height), + QtCore.QPointF(self.width, 0), + QtCore.QPointF(self.width, self.height)) + + @property + def corners_scene_coords(self): + """The corners of the item mapped to scene coordinates.""" + + return [self.mapToScene(corner) for corner in self.corners] + + def get_scale_bounds(self, center): + """The interactable shape of the scale handles.""" + return QtCore.QRectF( + center.x() - self.select_resize_size/2, + center.y() - self.select_resize_size/2, + self.select_resize_size, + self.select_resize_size) + + @property + def bottom_right_rotate_bounds(self): + """The interactable shape of the bottom right rotate handle""" + return QtCore.QRectF( + self.width + self.select_resize_size / 2, + self.height + self.select_resize_size / 2, + self.select_rotate_size, self.select_rotate_size) + + def boundingRect(self): + bounds = super().boundingRect() + if not self.has_selection_outline(): + return bounds + + # Add extra space for scale and rotate interactive areas + margin = self.select_resize_size / 2 + self.select_rotate_size + return QtCore.QRectF( + bounds.topLeft().x() - margin, + bounds.topLeft().y() - margin, + bounds.bottomRight().x() + 2 * margin, + bounds.bottomRight().y() + 2 * margin) + + def shape(self): + shape_ = super().shape() + if self.has_selection_outline(): + # Add extra space for scale and rotate interactive areas + path = QtGui.QPainterPath() + for corner in self.corners: + path.addRect(self.get_scale_bounds(corner)) + path.addRect(self.bottom_right_rotate_bounds) + shape_ = shape_ + path + return shape_ + + def hoverMoveEvent(self, event): + if not self.has_selection_handles(): + return + + for corner in self.corners: + # See if we need to change the cursor for scale areas + if self.get_scale_bounds(corner).contains(event.pos()): + direction = self.get_scale_direction(corner) + if direction[0] == direction[1]: + self.setCursor(Qt.CursorShape.SizeFDiagCursor) + else: + self.setCursor(Qt.CursorShape.SizeBDiagCursor) + return + + if self.bottom_right_rotate_bounds.contains(event.pos()): + self.setCursor(Qt.CursorShape.ForbiddenCursor) + return + + self.setCursor(Qt.CursorShape.ArrowCursor) + + def hoverEnterEvent(self, event): + # Always return regular cursor when there aren't any selection handles + if not self.has_selection_handles(): + self.setCursor(Qt.CursorShape.ArrowCursor) + + def mousePressEvent(self, event): + if (event.button() == Qt.MouseButtons.LeftButton + and self.has_selection_handles()): + for corner in self.corners: + # Check if we are in one of the corner's scale areas + if self.get_scale_bounds(corner).contains(event.pos()): + # Start scale action for this corner + self.scale_active = True + self.scale_start = event.scenePos() + self.scale_direction = self.get_scale_direction(corner) + for item in self.selection_action_items(): + item.scale_anchor = self.get_scale_anchor(item, corner) + item.scale_orig_factor = item.scale() + item.scale_orig_pos = item.pos() + event.accept() + return + + super().mousePressEvent(event) + + def get_scale_factor(self, event): + imgsize = self.width + self.height + p = event.scenePos() - self.scale_start + direction = self.scale_direction + delta = (direction[0] * p.x() + direction[1] * p.y()) / imgsize + return (self.scale_orig_factor + delta) / self.scale_orig_factor + + def get_scale_anchor(self, item, corner): + """Get the anchor around which the scale for this corner operates.""" + return item.mapFromScene( + self.mapToScene(self.width - corner.x(), self.height - corner.y())) + + def get_scale_direction(self, corner): + """Get the direction in which the scale for this corner increases""" + x = 1 if corner.x() > 0 else -1 + y = 1 if corner.y() > 0 else -1 + return (x, y) + + def translate_for_scale_anchor(self, scale_factor): + """Adjust the item's position so that a scale with the given scale + factor appears to operate around the scale anchor. ``setScale`` + needs to be called separately with ``scale_factor`` multiplied by + the item's current scale factor. + """ + + factor = self.scale_orig_factor * (scale_factor - 1) + self.setPos( + self.scale_orig_pos.x() - self.scale_anchor.x() * factor, + self.scale_orig_pos.y() - self.scale_anchor.y() * factor, + ) + + def mouseMoveEvent(self, event): + if self.scale_active: + factor = self.get_scale_factor(event) + for item in self.selection_action_items(): + item.setScale(item.scale_orig_factor * factor) + item.translate_for_scale_anchor(factor) + event.accept() + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self.scale_active: + self.scene().undo_stack.push( + commands.ScaleItemsBy( + self.selection_action_items(), + self.get_scale_factor(event), + ignore_first_redo=True)) + self.scale_active = False + event.accept() + else: + super().mouseReleaseEvent(event) + + def on_view_scale_change(self): + self.prepareGeometryChange() + + def itemChange(self, change, value): + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: + self.prepareGeometryChange() + if hasattr(self, 'on_selected_change'): + self.on_selected_change(value) + return super().itemChange(change, value) + + +class MultiSelectItem(SelectableMixin, QtWidgets.QGraphicsRectItem): + """Class for images added by the user.""" + + def __init__(self): + super().__init__() + self.init_selectable() + + def __str__(self): + return (f'MultiSelectItem {self.width} x {self.height}') + + @property + def width(self): + return self.rect().width() + + @property + def height(self): + return self.rect().height() + + def paint(self, painter, option, widget): + self.paint_selectable(painter, option, widget) + + def has_selection_outline(self): + return True + + def has_selection_handles(self): + return True + + def selection_action_items(self): + """The items affected by selection actions like scaling and rotating. + """ + return list(self.scene().selectedItems()) + + def fit_selection_area(self, rect): + """Updates itself to fit the given selection area.""" + + logging.debug(f'Fit selection area to {rect}') + self.setRect(0, 0, rect.width(), rect.height()) + self.setPos(rect.topLeft()) + self.setScale(1) + self.setRotation(0) + self.setSelected(True) + + def mousePressEvent(self, event): + if (event.button() == Qt.MouseButtons.LeftButton + and event.modifiers() == Qt.KeyboardModifiers.ControlModifier): + # We still need to be able to select additional images + # within/"under" the multi select rectangle, so let ctrl+click + # events pass through + event.ignore() + return + + super().mousePressEvent(event) diff --git a/tests/test_items.py b/tests/test_items.py index c0c97fd..00b91c2 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -1,7 +1,6 @@ from unittest.mock import patch, MagicMock, PropertyMock -from PyQt6 import QtCore, QtGui, QtWidgets -from PyQt6.QtCore import Qt +from PyQt6 import QtGui from beeref.items import BeePixmapItem from beeref.scene import BeeGraphicsScene @@ -13,37 +12,16 @@ class BeePixmapItemTestCase(BeeTestCase): def setUp(self): self.scene = BeeGraphicsScene(None) - def test_init(self): + @patch('beeref.selection.SelectableMixin.init_selectable') + def test_init(self, selectable_mock): item = BeePixmapItem( QtGui.QImage(self.imgfilename3x3), self.imgfilename3x3) assert item.save_id is None assert item.width == 3 assert item.height == 3 assert item.scale() == 1 - assert item.flags() == ( - QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable - | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) assert item.filename == self.imgfilename3x3 - - def test_set_scale(self): - item = BeePixmapItem( - QtGui.QImage(self.imgfilename3x3), self.imgfilename3x3) - item.prepareGeometryChange = MagicMock() - item.setScale(3) - assert item.scale() == 3 - assert item.pos().x() == 0 - assert item.pos().y() == 0 - item.prepareGeometryChange.assert_called_once() - - def test_set_scale_ignores_zero(self): - item = BeePixmapItem(QtGui.QImage()) - item.setScale(0) - assert item.scale() == 1 - - def test_set_scale_ignores_negative(self): - item = BeePixmapItem(QtGui.QImage()) - item.setScale(-0.1) - assert item.scale() == 1 + selectable_mock.assert_called_once() def test_set_pos_center(self): item = BeePixmapItem(QtGui.QImage()) @@ -55,411 +33,69 @@ class BeePixmapItemTestCase(BeeTestCase): assert item.pos().x() == -100 assert item.pos().y() == -50 - def test_set_zvalue_sets_new_max(self): + def test_pixmap_to_bytes(self): + item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3)) + assert item.pixmap_to_bytes().startswith(b'\x89PNG') + + def test_pixmap_from_bytes(self): + item = BeePixmapItem(QtGui.QImage()) + with open(self.imgfilename3x3, 'rb') as f: + imgdata = f.read() + item.pixmap_from_bytes(imgdata) + assert item.width == 3 + assert item.height == 3 + + def test_paint(self): + item = BeePixmapItem(QtGui.QImage()) + item.pixmap = MagicMock(return_value='bee') + item.paint_selectable = MagicMock() + painter = MagicMock() + item.paint(painter, None, None) + item.paint_selectable.assert_called_once() + painter.drawPixmap.assert_called_with(0, 0, 'bee') + + def test_has_selection_outline_when_not_selected(self): item = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item) - item.setZValue(1.1) - assert item.zValue() == 1.1 - assert self.scene.max_z == 1.1 + item.setSelected(False) + item.has_selection_outline() is False - def test_set_zvalue_keeps_old_max(self): + def test_has_selection_outline_when_selected(self): item = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item) - self.scene.max_z = 3.3 - item.setZValue(1.1) - assert item.zValue() == 1.1 - assert self.scene.max_z == 3.3 + item.setSelected(True) + item.has_selection_outline() is True - def test_bring_to_front(self): + def test_has_selection_handles_when_not_selected(self): item1 = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item1) - item1.setZValue(3.3) + item1.setSelected(False) item2 = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item2) - item2.bring_to_front() - assert item2.zValue() > item1.zValue() - assert item2.zValue() == self.scene.max_z + item2.setSelected(False) + item1.has_selection_handles() is False - def test_on_view_scale_change(self): - item = BeePixmapItem(QtGui.QImage()) - with patch('beeref.items.BeePixmapItem.prepareGeometryChange') as m: - item.on_view_scale_change() - m.assert_called_once() - - -class BeePixmapItemWithViewBaseTestCase(BeeTestCase): - - def setUp(self): - self.scene = BeeGraphicsScene(None) - self.item = BeePixmapItem(QtGui.QImage()) - self.scene.addItem(self.item) - self.view = MagicMock(get_scale=MagicMock(return_value=1)) - views_patcher = patch('beeref.scene.BeeGraphicsScene.views', - return_value=[self.view]) - views_patcher.start() - self.addCleanup(views_patcher.stop) - width_patcher = patch('beeref.items.BeePixmapItem.width', - new_callable=PropertyMock, - return_value=100) - width_patcher.start() - self.addCleanup(width_patcher.stop) - height_patcher = patch('beeref.items.BeePixmapItem.height', - new_callable=PropertyMock, - return_value=80) - height_patcher.start() - self.addCleanup(height_patcher.stop) - - -class BeePixmapItemPaintstuffTestCase(BeePixmapItemWithViewBaseTestCase): - - def test_fixed_length_for_viewport_when_default_scales(self): - self.view.get_scale = MagicMock(return_value=1) - assert self.item.fixed_length_for_viewport(100) == 100 - assert self.item._view_scale == 1 - - def test_fixed_length_for_viewport_when_viewport_scaled(self): - self.view.get_scale = MagicMock(return_value=2) - assert self.item.fixed_length_for_viewport(100) == 50 - assert self.item._view_scale == 2 - - def test_fixed_length_for_viewport_when_item_scaled(self): - self.view.get_scale = MagicMock(return_value=1) - self.item.setScale(5) - assert self.item.fixed_length_for_viewport(100) == 20 - assert self.item._view_scale == 1 - - def test_fixed_length_for_viewport_when_no_scene(self): - item = BeePixmapItem(QtGui.QImage()) - item._view_scale = 0.5 - assert item.fixed_length_for_viewport(100) == 200 - - def test_resize_size_when_scaled(self): - self.view.get_scale = MagicMock(return_value=2) - self.item.setScale(2) - self.item.SELECT_RESIZE_SIZE = 100 - assert self.item.select_resize_size == 25 - - def test_rotate_size_when_scaled(self): - self.view.get_scale = MagicMock(return_value=2) - self.item.setScale(2) - self.item.SELECT_ROTATE_SIZE = 100 - assert self.item.select_rotate_size == 25 - - @patch('beeref.items.BeePixmapItem.draw_debug_shape') - def test_paint_when_not_selected(self, debug_mock): - painter = MagicMock() - self.item.setSelected(False) - self.item.pixmap = MagicMock(return_value='pixmap') - self.item.paint(painter, None, None) - painter.drawPixmap.assert_called_once_with(0, 0, 'pixmap') - painter.drawRect.assert_not_called() - painter.drawPoint.assert_not_called() - debug_mock.assert_not_called() - - def test_paint_when_selected_single_selection(self): - painter = MagicMock() - self.item.setSelected(True) - self.item.paint(painter, None, None) - painter.drawPixmap.assert_called_once() - painter.drawRect.assert_called_once() - assert painter.drawPoint.call_count == 4 - - def test_paint_when_selected_multi_selection(self): + def test_has_selection_handles_when_selected_single(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) item2 = BeePixmapItem(QtGui.QImage()) - item2.setSelected(True) self.scene.addItem(item2) - painter = MagicMock() - self.item.setSelected(True) - self.item.paint(painter, None, None) - painter.drawPixmap.assert_called_once() - painter.drawRect.assert_called_once() - painter.drawPoint.assert_not_called() + item2.setSelected(False) + item1.has_selection_handles() is True - def test_paint_when_draw_debug_shapes(self): - with patch('beeref.items.commandline_args') as args_mock: - with patch('beeref.items.BeePixmapItem.draw_debug_shape') as m: - args_mock.draw_debug_shapes = True - item = BeePixmapItem(QtGui.QImage()) - item.paint(MagicMock(), None, None) - m.assert_called() + def test_has_selection_handles_when_selected_multi(self): + view = MagicMock(get_scale=MagicMock(return_value=1)) + with patch('beeref.scene.BeeGraphicsScene.views', + return_value=[view]): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + item2.setSelected(True) + item1.has_selection_handles() is False - def test_corners(self): - assert set(self.item.corners) == set(( - (0, 0), - (100, 0), - (0, 80), - (100, 80))) - - def test_get_scale_bounds(self): - self.view.get_scale = MagicMock(return_value=1) - self.item.SELECT_RESIZE_SIZE = 10 - rect = self.item.get_scale_bounds((100, 100)) - assert rect.topLeft().x() == 95 - assert rect.topLeft().y() == 95 - assert rect.bottomRight().x() == 105 - assert rect.bottomRight().y() == 105 - - def test_bottom_right_rotate_bounds(self): - self.view.get_scale = MagicMock(return_value=1) - self.item.SELECT_RESIZE_SIZE = 10 - self.item.SELECT_ROTATE_SIZE = 10 - rect = self.item.bottom_right_rotate_bounds - assert rect.topLeft().x() == 105 - assert rect.topLeft().y() == 85 - assert rect.bottomRight().x() == 115 - assert rect.bottomRight().y() == 95 - - def test_bounding_rect_when_not_selected(self): - self.view.get_scale = MagicMock(return_value=1) - self.item.setSelected(False) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.boundingRect', - return_value=QtCore.QRectF(0, 0, 100, 80)): - rect = self.item.boundingRect() - assert rect.topLeft().x() == 0 - assert rect.topLeft().y() == 0 - assert rect.bottomRight().x() == 100 - assert rect.bottomRight().y() == 80 - - def test_bounding_rect_when_selected(self): - self.item.SELECT_RESIZE_SIZE = 10 - self.item.SELECT_ROTATE_SIZE = 10 - self.view.get_scale = MagicMock(return_value=1) - self.item.setSelected(True) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.boundingRect', - return_value=QtCore.QRectF(0, 0, 100, 80)): - rect = self.item.boundingRect() - assert rect.topLeft().x() == -15 - assert rect.topLeft().y() == -15 - assert rect.bottomRight().x() == 115 - assert rect.bottomRight().y() == 95 - - def test_shape_when_not_selected(self): - self.view.get_scale = MagicMock(return_value=1) - self.item.setSelected(False) - path = QtGui.QPainterPath() - path.addRect(QtCore.QRectF(0, 0, 100, 80)) - - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape', - return_value=path): - shape = self.item.shape().boundingRect() - assert shape.topLeft().x() == 0 - assert shape.topLeft().y() == 0 - assert shape.bottomRight().x() == 100 - assert shape.bottomRight().y() == 80 - - def test_shape_when_selected(self): - self.item.SELECT_RESIZE_SIZE = 10 - self.item.SELECT_ROTATE_SIZE = 10 - self.view.get_scale = MagicMock(return_value=1) - self.item.setSelected(True) - path = QtGui.QPainterPath() - path.addRect(QtCore.QRectF(0, 0, 100, 80)) - - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape', - return_value=path): - shape = self.item.shape().boundingRect() - assert shape.topLeft().x() == -5 - assert shape.topLeft().y() == -5 - assert shape.bottomRight().x() == 115 - assert shape.bottomRight().y() == 95 - - -class BeePixmapItemScalingTestCase(BeePixmapItemWithViewBaseTestCase): - - def test_get_scale_factor_bottomright(self): - self.item.scale_start = QtCore.QPoint(10, 10) - self.item.scale_direction = (1, 1) - self.item.scale_orig_factor = 1 - event = MagicMock() - event.scenePos = MagicMock(return_value=QtCore.QPoint(20, 90)) - assert self.item.get_scale_factor(event) == 1.5 - - def test_get_scale_delta_topleft(self): - self.item.scale_start = QtCore.QPoint(10, 10) - self.item.scale_direction = (-1, -1) - self.item.scale_orig_factor = 0.5 - event = MagicMock() - event.scenePos = MagicMock(return_value=QtCore.QPoint(-10, -60)) - assert self.item.get_scale_factor(event) == 2 - - def test_get_scale_anchor_topleft(self): - anchor = self.item.get_scale_anchor(self.item, (0, 0)) - assert anchor.x() == 100 - assert anchor.y() == 80 - - def test_get_scale_anchor_bottomright(self): - anchor = self.item.get_scale_anchor(self.item, (100, 80)) - assert anchor.x() == 0 - assert anchor.y() == 0 - - def test_get_scale_anchor_topright(self): - anchor = self.item.get_scale_anchor(self.item, (100, 0)) - assert anchor.x() == 0 - assert anchor.y() == 80 - - def test_get_scale_anchor_bottomleft(self): - anchor = self.item.get_scale_anchor(self.item, (0, 80)) - assert anchor.x() == 100 - assert anchor.y() == 0 - - def test_get_scale_direction_topleft(self): - assert self.item.get_scale_direction((0, 0)) == (-1, -1) - - def test_get_scale_direction_bottomright(self): - assert self.item.get_scale_direction((100, 80)) == (1, 1) - - def test_get_scale_direction_topright(self): - assert self.item.get_scale_direction((100, 0)) == (1, -1) - - def test_get_scale_direction_bottomleft(self): - assert self.item.get_scale_direction((0, 80)) == (-1, 1) - - def test_translate_for_scale_anchor(self): - self.item.scale_orig_pos = QtCore.QPoint(50, 70) - self.item.scale_anchor = QtCore.QPoint(100, 80) - self.item.scale_orig_factor = 1 - self.item.translate_for_scale_anchor(3) - assert self.item.pos().x() == -150 - assert self.item.pos().y() == -90 - - -class BeePixmapItemEventsstuffTestCase(BeePixmapItemWithViewBaseTestCase): - - def setUp(self): - super().setUp() - self.event = MagicMock() - self.item.setCursor = MagicMock() - - def test_hover_move_event_no_selection(self): - self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0)) - self.item.hoverMoveEvent(self.event) - self.item.setCursor.assert_not_called() - - def test_hover_move_event_topleft_scale(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0)) - self.item.hoverMoveEvent(self.event) - self.item.setCursor.assert_called_once_with( - Qt.CursorShape.SizeFDiagCursor) - - def test_hover_move_event_bottomright_scale(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 80)) - self.item.hoverMoveEvent(self.event) - self.item.setCursor.assert_called_once_with( - Qt.CursorShape.SizeFDiagCursor) - - def test_hover_move_event_topright_scale(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 0)) - self.item.hoverMoveEvent(self.event) - self.item.setCursor.assert_called_once_with( - Qt.CursorShape.SizeBDiagCursor) - - def test_hover_move_event_not_in_handles(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 50)) - self.item.hoverMoveEvent(self.event) - self.item.setCursor.assert_called_once_with( - Qt.CursorShape.ArrowCursor) - - def test_hover_enter_event_when_selected(self): - self.item.setSelected(True) - self.item.hoverEnterEvent(self.event) - self.item.setCursor.assert_not_called() - - def test_hover_enter_event_when_not_selected(self): - self.item.setSelected(False) - self.item.hoverEnterEvent(self.event) - self.item.setCursor.assert_called_once_with( - Qt.CursorShape.ArrowCursor) - - def test_mouse_press_event_topleft_scale(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(2, 2)) - self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99)) - self.event.button = MagicMock( - return_value=Qt.MouseButtons.LeftButton) - self.item.mousePressEvent(self.event) - assert self.item.scale_active is True - assert self.item.scale_start == QtCore.QPointF(66, 99) - assert self.item.scale_direction == (-1, -1) - assert self.item.scale_orig_factor == 1 - assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) - - def test_mouse_press_event_bottomright_scale(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(99, 79)) - self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99)) - self.event.button = MagicMock( - return_value=Qt.MouseButtons.LeftButton) - self.item.mousePressEvent(self.event) - assert self.item.scale_active is True - assert self.item.scale_direction == (1, 1) - assert self.item.scale_start == QtCore.QPointF(66, 99) - assert self.item.scale_orig_factor == 1 - assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) - - def test_mouse_press_event_not_selected(self): - self.item.setSelected(False) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: - self.item.mousePressEvent(self.event) - m.assert_called_once_with(self.event) - assert self.item.scale_active is False - - def test_mouse_press_not_in_handles(self): - self.item.setSelected(True) - self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 40)) - self.event.button = MagicMock( - return_value=Qt.MouseButtons.LeftButton) - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: - self.item.mousePressEvent(self.event) - m.assert_called_once_with(self.event) - assert self.item.scale_active is False - - def test_mouse_move_event_when_no_action(self): - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mouseMoveEvent') as m: - self.item.mouseMoveEvent(self.event) - m.assert_called_once_with(self.event) - - def test_move_event_when_scale_action(self): - self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) - self.item.scale_active = True - self.item.scale_direction = (1, 1) - self.item.scale_anchor = QtCore.QPointF(100, 80) - self.item.scale_start = QtCore.QPointF(10, 10) - self.item.scale_orig_factor = 1 - self.item.scale_orig_pos = QtCore.QPointF(0, 0) - - self.item.mouseMoveEvent(self.event) - assert self.item.scale() == 1.5 - - def test_mouse_release_event_when_no_action(self): - with patch('PyQt6.QtWidgets.QGraphicsPixmapItem' - '.mouseReleaseEvent') as m: - self.item.mouseReleaseEvent(self.event) - m.assert_called_once_with(self.event) - - def test_mouse_release_event_when_scale_action(self): - self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) - self.item.scale_active = True - self.item.scale_direction = (1, 1) - self.item.scale_anchor = QtCore.QPointF(100, 80) - self.item.scale_start = QtCore.QPointF(10, 10) - self.item.scale_orig_factor = 1 - self.item.scale_orig_pos = QtCore.QPointF(0, 0) - self.scene.undo_stack = MagicMock() - self.scene.undo_stack.push = MagicMock() - - self.item.mouseReleaseEvent(self.event) - self.scene.undo_stack.push.assert_called_once() - args = self.scene.undo_stack.push.call_args_list[0][0] - cmd = args[0] - assert cmd.items == [self.item] - assert cmd.factor == 1.5 - assert cmd.item_data == [{ - 'anchor': QtCore.QPointF(100, 80), - 'orig_factor': 1, - 'orig_pos': QtCore.QPointF(0, 0)}] - assert cmd.ignore_first_redo is True - assert self.item.scale_active is False + def test_selection_action_items(self): + item = BeePixmapItem(QtGui.QImage()) + assert item.selection_action_items() == [item] diff --git a/tests/test_scene.py b/tests/test_scene.py index 30b753e..ad77211 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -1,7 +1,10 @@ import math -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, PropertyMock -from PyQt6 import QtGui, QtWidgets +from pytest import approx + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt from beeref.items import BeePixmapItem from beeref.scene import BeeGraphicsScene @@ -13,6 +16,10 @@ class BeeGraphicsSceneNormalizeTestCase(BeeTestCase): def setUp(self): self.undo_stack = QtGui.QUndoStack() self.scene = BeeGraphicsScene(self.undo_stack) + self.view = MagicMock(get_scale=MagicMock(return_value=1)) + views_patcher = patch('beeref.scene.BeeGraphicsScene.views', + return_value=[self.view]) + views_patcher.start() def test_normalize_height(self): item1 = MagicMock(height=100, scale_factor=1) @@ -56,6 +63,144 @@ class BeeGraphicsSceneNormalizeTestCase(BeeTestCase): def test_normalize_size_when_no_items(self): self.scene.normalize_size() + def test_has_selection_when_no_selection(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + assert self.scene.has_selection() is False + + def test_has_selection_when_selection(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + assert self.scene.has_selection() is True + + def test_has_single_selection_when_no_selection(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + assert self.scene.has_single_selection() is False + + def test_has_single_selection_when_single_selection(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + assert self.scene.has_single_selection() is True + + def test_has_single_selection_when_multi_selection(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + item2.setSelected(True) + assert self.scene.has_single_selection() is False + + def test_has_multi_selection_when_no_selection(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + assert self.scene.has_multi_selection() is False + + def test_has_multi_selection_when_single_selection(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + assert self.scene.has_multi_selection() is False + + def test_has_multi_selection_when_multi_selection(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setSelected(True) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + item2.setSelected(True) + assert self.scene.has_multi_selection() is True + + @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') + def test_mouse_press_event_when_right_click(self, mouse_mock): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButtons.RightButton)) + self.scene.mousePressEvent(event) + event.accept.assert_not_called() + mouse_mock.assert_not_called() + + @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') + def test_mouse_press_event_when_left_click(self, mouse_mock): + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButtons.LeftButton), + scenePos=MagicMock(return_value=QtCore.QPoint(10, 20)), + ) + self.scene.mousePressEvent(event) + event.accept.assert_not_called() + mouse_mock.assert_called_once_with(event) + assert self.scene.move_active is True + assert self.scene.move_start == QtCore.QPoint(10, 20) + + @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') + def test_mouse_release_event_when_move_active(self, mouse_mock): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + event = MagicMock( + scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) + self.scene.move_active = True + self.scene.move_start = QtCore.QPoint(0, 0) + self.scene.undo_stack = MagicMock(push=MagicMock()) + + self.scene.mouseReleaseEvent(event) + self.scene.undo_stack.push.assert_called_once() + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert cmd.items == [item] + assert cmd.ignore_first_redo is True + assert cmd.delta_x == 10 + assert cmd.delta_y == 20 + mouse_mock.assert_called_once_with(event) + assert self.scene.move_active is False + + @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') + def test_mouse_release_event_when_move_not_active(self, mouse_mock): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + event = MagicMock( + scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) + self.scene.move_active = False + self.scene.undo_stack = MagicMock(push=MagicMock()) + + self.scene.mouseReleaseEvent(event) + self.scene.undo_stack.push.assert_not_called() + mouse_mock.assert_called_once_with(event) + assert self.scene.move_active is False + + @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') + def test_mouse_release_event_when_no_selection(self, mouse_mock): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(False) + event = MagicMock( + scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) + self.scene.move_active = True + self.scene.undo_stack = MagicMock(push=MagicMock()) + + self.scene.mouseReleaseEvent(event) + self.scene.undo_stack.push.assert_not_called() + mouse_mock.assert_called_once_with(event) + assert self.scene.move_active is False + + def test_items_for_save(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + item3 = QtWidgets.QGraphicsRectItem() + self.scene.addItem(item3) + + items = list(self.scene.items_for_save()) + assert items == [item1, item2] + def test_clear_save_ids(self): item1 = BeePixmapItem(QtGui.QImage()) item1.save_id = 5 @@ -70,13 +215,100 @@ class BeeGraphicsSceneNormalizeTestCase(BeeTestCase): assert item2.save_id is None assert hasattr(item3, 'save_id') is False - def test_items_for_save(self): + def test_on_view_scale_change(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + item.on_view_scale_change = MagicMock() + self.scene.on_view_scale_change() + item.on_view_scale_change.assert_called_once() + + def test_get_selection_rect_two_items(self): item1 = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item1) + item1.setSelected(True) + item1.setPos(4, -6) item2 = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item2) - item3 = QtWidgets.QGraphicsRectItem() + item2.setSelected(True) + item2.setPos(-33, 22) + item3 = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item3) + item3.setSelected(False) + item3.setPos(1000, 1000) - items = list(self.scene.items_for_save()) - assert items == [item1, item2] + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=100): + rect = self.scene.get_selection_rect() + + assert rect.topLeft().x() == -33 + assert rect.topLeft().y() == -6 + assert rect.bottomRight().x() == 104 + assert rect.bottomRight().y() == 122 + + def test_get_selection_rect_rotated_item(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setSelected(True) + item.setRotation(-45) + + with patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, return_value=100): + with patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, return_value=100): + rect = self.scene.get_selection_rect() + + assert rect.topLeft().x() == 0 + assert rect.topLeft().y() == approx(-math.sqrt(2) * 50) + assert rect.bottomRight().x() == approx(math.sqrt(2) * 100) + assert rect.bottomRight().y() == approx(math.sqrt(2) * 50) + + def test_on_selection_change_when_multi_selection_new(self): + self.scene.has_multi_selection = MagicMock(return_value=True) + self.scene.multi_select_item.fit_selection_area = MagicMock() + self.scene.multi_select_item.bring_to_front = MagicMock() + self.scene.get_selection_rect = MagicMock( + return_value=QtCore.QRectF(0, 0, 100, 80)) + self.scene.addItem = MagicMock() + + self.scene.on_selection_change() + + m_item = self.scene.multi_select_item + m_item.fit_selection_area.assert_called_once_with( + QtCore.QRectF(0, 0, 100, 80)) + m_item.bring_to_front.assert_called_once() + self.scene.addItem.assert_called_once_with(m_item) + + def test_on_selection_change_when_multi_selection_existing(self): + self.scene.addItem(self.scene.multi_select_item) + self.scene.has_multi_selection = MagicMock(return_value=True) + self.scene.multi_select_item.fit_selection_area = MagicMock() + self.scene.multi_select_item.bring_to_front = MagicMock() + self.scene.get_selection_rect = MagicMock( + return_value=QtCore.QRectF(0, 0, 100, 80)) + self.scene.addItem = MagicMock() + + self.scene.on_selection_change() + + m_item = self.scene.multi_select_item + m_item.fit_selection_area.assert_called_once_with( + QtCore.QRectF(0, 0, 100, 80)) + m_item.bring_to_front.assert_not_called() + self.scene.addItem.assert_not_called() + + def test_on_selection_change_when_multi_selection_ended(self): + self.scene.addItem(self.scene.multi_select_item) + self.scene.has_multi_selection = MagicMock(return_value=False) + self.scene.multi_select_item.fit_selection_area = MagicMock() + self.scene.multi_select_item.bring_to_front = MagicMock() + self.scene.get_selection_rect = MagicMock( + return_value=QtCore.QRectF(0, 0, 100, 80)) + self.scene.removeItem = MagicMock() + + self.scene.on_selection_change() + + m_item = self.scene.multi_select_item + m_item.fit_selection_area.assert_not_called() + self.scene.removeItem.assert_called_once_with(m_item) diff --git a/tests/test_selection.py b/tests/test_selection.py new file mode 100644 index 0000000..3987c4f --- /dev/null +++ b/tests/test_selection.py @@ -0,0 +1,553 @@ +from unittest.mock import patch, MagicMock, PropertyMock + +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import Qt + +from beeref.items import BeePixmapItem +from beeref.scene import BeeGraphicsScene +from beeref.selection import MultiSelectItem +from .base import BeeTestCase + + +class SelectableMixinBaseTestCase(BeeTestCase): + + def setUp(self): + self.scene = BeeGraphicsScene(None) + self.item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(self.item) + self.view = MagicMock(get_scale=MagicMock(return_value=1)) + views_patcher = patch('beeref.scene.BeeGraphicsScene.views', + return_value=[self.view]) + views_patcher.start() + self.addCleanup(views_patcher.stop) + width_patcher = patch('beeref.items.BeePixmapItem.width', + new_callable=PropertyMock, + return_value=100) + width_patcher.start() + self.addCleanup(width_patcher.stop) + height_patcher = patch('beeref.items.BeePixmapItem.height', + new_callable=PropertyMock, + return_value=80) + height_patcher.start() + self.addCleanup(height_patcher.stop) + + +class SelectableMixinTestCase(SelectableMixinBaseTestCase): + + def test_set_scale(self): + item = BeePixmapItem( + QtGui.QImage(self.imgfilename3x3), self.imgfilename3x3) + item.prepareGeometryChange = MagicMock() + item.setScale(3) + assert item.scale() == 3 + assert item.pos().x() == 0 + assert item.pos().y() == 0 + item.prepareGeometryChange.assert_called_once() + + def test_set_scale_ignores_zero(self): + item = BeePixmapItem(QtGui.QImage()) + item.setScale(0) + assert item.scale() == 1 + + def test_set_scale_ignores_negative(self): + item = BeePixmapItem(QtGui.QImage()) + item.setScale(-0.1) + assert item.scale() == 1 + + def test_set_zvalue_sets_new_max(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item.setZValue(1.1) + assert item.zValue() == 1.1 + assert self.scene.max_z == 1.1 + + def test_set_zvalue_keeps_old_max(self): + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + self.scene.max_z = 3.3 + item.setZValue(1.1) + assert item.zValue() == 1.1 + assert self.scene.max_z == 3.3 + + def test_bring_to_front(self): + item1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item1) + item1.setZValue(3.3) + item2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item2) + item2.bring_to_front() + assert item2.zValue() > item1.zValue() + assert item2.zValue() == self.scene.max_z + + def test_on_view_scale_change(self): + item = BeePixmapItem(QtGui.QImage()) + with patch('beeref.items.BeePixmapItem.prepareGeometryChange') as m: + item.on_view_scale_change() + m.assert_called_once() + + def test_fixed_length_for_viewport_when_default_scales(self): + self.view.get_scale = MagicMock(return_value=1) + assert self.item.fixed_length_for_viewport(100) == 100 + assert self.item._view_scale == 1 + + def test_fixed_length_for_viewport_when_viewport_scaled(self): + self.view.get_scale = MagicMock(return_value=2) + assert self.item.fixed_length_for_viewport(100) == 50 + assert self.item._view_scale == 2 + + def test_fixed_length_for_viewport_when_item_scaled(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.setScale(5) + assert self.item.fixed_length_for_viewport(100) == 20 + assert self.item._view_scale == 1 + + def test_fixed_length_for_viewport_when_no_scene(self): + item = BeePixmapItem(QtGui.QImage()) + item._view_scale = 0.5 + assert item.fixed_length_for_viewport(100) == 200 + + def test_resize_size_when_scaled(self): + self.view.get_scale = MagicMock(return_value=2) + self.item.setScale(2) + self.item.SELECT_RESIZE_SIZE = 100 + assert self.item.select_resize_size == 25 + + def test_rotate_size_when_scaled(self): + self.view.get_scale = MagicMock(return_value=2) + self.item.setScale(2) + self.item.SELECT_ROTATE_SIZE = 100 + assert self.item.select_rotate_size == 25 + + @patch('beeref.items.BeePixmapItem.draw_debug_shape') + def test_paint_when_not_selected(self, debug_mock): + painter = MagicMock() + self.item.setSelected(False) + self.item.pixmap = MagicMock(return_value='pixmap') + self.item.paint(painter, None, None) + painter.drawPixmap.assert_called_once_with(0, 0, 'pixmap') + painter.drawRect.assert_not_called() + painter.drawPoint.assert_not_called() + debug_mock.assert_not_called() + + def test_paint_when_selected_single_selection(self): + painter = MagicMock() + self.item.setSelected(True) + self.item.paint(painter, None, None) + painter.drawPixmap.assert_called_once() + painter.drawRect.assert_called_once() + assert painter.drawPoint.call_count == 4 + + def test_paint_when_selected_multi_selection(self): + item2 = BeePixmapItem(QtGui.QImage()) + item2.setSelected(True) + self.scene.addItem(item2) + painter = MagicMock() + self.item.setSelected(True) + self.item.paint(painter, None, None) + painter.drawPixmap.assert_called_once() + painter.drawRect.assert_called_once() + painter.drawPoint.assert_not_called() + + def test_paint_when_draw_debug_shapes(self): + with patch('beeref.selection.commandline_args') as args_mock: + with patch('beeref.items.BeePixmapItem.draw_debug_shape') as m: + args_mock.draw_debug_shapes = True + item = BeePixmapItem(QtGui.QImage()) + item.paint(MagicMock(), None, None) + m.assert_called() + + def test_corners(self): + assert len(self.item.corners) == 4 + assert QtCore.QPointF(0, 0) in self.item.corners + assert QtCore.QPointF(100, 0) in self.item.corners + assert QtCore.QPointF(0, 80) in self.item.corners + assert QtCore.QPointF(100, 80) in self.item.corners + + def test_corners_scene_coords_translated(self): + self.item.setPos(5, 5) + corners = self.item.corners_scene_coords + assert len(self.item.corners) == 4 + assert QtCore.QPointF(5, 5) in corners + assert QtCore.QPointF(105, 5) in corners + assert QtCore.QPointF(5, 85) in corners + assert QtCore.QPointF(105, 85) in corners + + def test_corners_scene_coords_scaled(self): + self.item.setScale(2) + corners = self.item.corners_scene_coords + assert len(self.item.corners) == 4 + assert QtCore.QPointF(0, 0) in corners + assert QtCore.QPointF(200, 0) in corners + assert QtCore.QPointF(0, 160) in corners + assert QtCore.QPointF(200, 160) in corners + + def test_get_scale_bounds(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.SELECT_RESIZE_SIZE = 10 + rect = self.item.get_scale_bounds(QtCore.QPointF(100, 100)) + assert rect.topLeft().x() == 95 + assert rect.topLeft().y() == 95 + assert rect.bottomRight().x() == 105 + assert rect.bottomRight().y() == 105 + + def test_bottom_right_rotate_bounds(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.SELECT_RESIZE_SIZE = 10 + self.item.SELECT_ROTATE_SIZE = 10 + rect = self.item.bottom_right_rotate_bounds + assert rect.topLeft().x() == 105 + assert rect.topLeft().y() == 85 + assert rect.bottomRight().x() == 115 + assert rect.bottomRight().y() == 95 + + def test_bounding_rect_when_not_selected(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.setSelected(False) + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.boundingRect', + return_value=QtCore.QRectF(0, 0, 100, 80)): + rect = self.item.boundingRect() + assert rect.topLeft().x() == 0 + assert rect.topLeft().y() == 0 + assert rect.bottomRight().x() == 100 + assert rect.bottomRight().y() == 80 + + def test_bounding_rect_when_selected(self): + self.item.SELECT_RESIZE_SIZE = 10 + self.item.SELECT_ROTATE_SIZE = 10 + self.view.get_scale = MagicMock(return_value=1) + self.item.setSelected(True) + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.boundingRect', + return_value=QtCore.QRectF(0, 0, 100, 80)): + rect = self.item.boundingRect() + assert rect.topLeft().x() == -15 + assert rect.topLeft().y() == -15 + assert rect.bottomRight().x() == 115 + assert rect.bottomRight().y() == 95 + + def test_shape_when_not_selected(self): + self.view.get_scale = MagicMock(return_value=1) + self.item.setSelected(False) + path = QtGui.QPainterPath() + path.addRect(QtCore.QRectF(0, 0, 100, 80)) + + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape', + return_value=path): + shape = self.item.shape().boundingRect() + assert shape.topLeft().x() == 0 + assert shape.topLeft().y() == 0 + assert shape.bottomRight().x() == 100 + assert shape.bottomRight().y() == 80 + + def test_shape_when_selected(self): + self.item.SELECT_RESIZE_SIZE = 10 + self.item.SELECT_ROTATE_SIZE = 10 + self.view.get_scale = MagicMock(return_value=1) + self.item.setSelected(True) + path = QtGui.QPainterPath() + path.addRect(QtCore.QRectF(0, 0, 100, 80)) + + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.shape', + return_value=path): + shape = self.item.shape().boundingRect() + assert shape.topLeft().x() == -5 + assert shape.topLeft().y() == -5 + assert shape.bottomRight().x() == 115 + assert shape.bottomRight().y() == 95 + + +class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): + + def test_get_scale_factor_bottomright(self): + self.item.scale_start = QtCore.QPoint(10, 10) + self.item.scale_direction = (1, 1) + self.item.scale_orig_factor = 1 + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPoint(20, 90)) + assert self.item.get_scale_factor(event) == 1.5 + + def test_get_scale_factor_topleft(self): + self.item.scale_start = QtCore.QPoint(10, 10) + self.item.scale_direction = (-1, -1) + self.item.scale_orig_factor = 0.5 + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPoint(-10, -60)) + assert self.item.get_scale_factor(event) == 2 + + def test_get_scale_anchor_topleft(self): + anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 0)) + assert anchor.x() == 100 + assert anchor.y() == 80 + + def test_get_scale_anchor_bottomright(self): + anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 80)) + assert anchor.x() == 0 + assert anchor.y() == 0 + + def test_get_scale_anchor_topright(self): + anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 0)) + assert anchor.x() == 0 + assert anchor.y() == 80 + + def test_get_scale_anchor_bottomleft(self): + anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 80)) + assert anchor.x() == 100 + assert anchor.y() == 0 + + def test_get_scale_anchor_topleft_when_anchor_other_item(self): + item2 = BeePixmapItem(QtGui.QImage()) + item2.setPos(22, 33) + anchor = self.item.get_scale_anchor(item2, QtCore.QPointF(0, 0)) + assert anchor.x() == 78 + assert anchor.y() == 47 + + def test_get_scale_direction_topleft(self): + assert self.item.get_scale_direction(QtCore.QPointF(0, 0)) == (-1, -1) + + def test_get_scale_direction_bottomright(self): + assert self.item.get_scale_direction(QtCore.QPointF(100, 80)) == (1, 1) + + def test_get_scale_direction_topright(self): + assert self.item.get_scale_direction(QtCore.QPointF(100, 0)) == (1, -1) + + def test_get_scale_direction_bottomleft(self): + assert self.item.get_scale_direction(QtCore.QPointF(0, 80)) == (-1, 1) + + def test_translate_for_scale_anchor(self): + self.item.scale_orig_pos = QtCore.QPoint(50, 70) + self.item.scale_anchor = QtCore.QPoint(100, 80) + self.item.scale_orig_factor = 1 + self.item.translate_for_scale_anchor(3) + assert self.item.pos().x() == -150 + assert self.item.pos().y() == -90 + + +class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): + + def setUp(self): + super().setUp() + self.event = MagicMock() + self.item.setCursor = MagicMock() + + def test_hover_move_event_no_selection(self): + self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0)) + self.item.hoverMoveEvent(self.event) + self.item.setCursor.assert_not_called() + + def test_hover_move_event_topleft_scale(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0)) + self.item.hoverMoveEvent(self.event) + self.item.setCursor.assert_called_once_with( + Qt.CursorShape.SizeFDiagCursor) + + def test_hover_move_event_bottomright_scale(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 80)) + self.item.hoverMoveEvent(self.event) + self.item.setCursor.assert_called_once_with( + Qt.CursorShape.SizeFDiagCursor) + + def test_hover_move_event_topright_scale(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(100, 0)) + self.item.hoverMoveEvent(self.event) + self.item.setCursor.assert_called_once_with( + Qt.CursorShape.SizeBDiagCursor) + + def test_hover_move_event_not_in_handles(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 50)) + self.item.hoverMoveEvent(self.event) + self.item.setCursor.assert_called_once_with( + Qt.CursorShape.ArrowCursor) + + def test_hover_enter_event_when_selected(self): + self.item.setSelected(True) + self.item.hoverEnterEvent(self.event) + self.item.setCursor.assert_not_called() + + def test_hover_enter_event_when_not_selected(self): + self.item.setSelected(False) + self.item.hoverEnterEvent(self.event) + self.item.setCursor.assert_called_once_with( + Qt.CursorShape.ArrowCursor) + + def test_mouse_press_event_topleft_scale(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(2, 2)) + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99)) + self.event.button = MagicMock( + return_value=Qt.MouseButtons.LeftButton) + self.item.mousePressEvent(self.event) + assert self.item.scale_active is True + assert self.item.scale_start == QtCore.QPointF(66, 99) + assert self.item.scale_direction == (-1, -1) + assert self.item.scale_orig_factor == 1 + assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) + + def test_mouse_press_event_bottomright_scale(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(99, 79)) + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99)) + self.event.button = MagicMock( + return_value=Qt.MouseButtons.LeftButton) + self.item.mousePressEvent(self.event) + assert self.item.scale_active is True + assert self.item.scale_direction == (1, 1) + assert self.item.scale_start == QtCore.QPointF(66, 99) + assert self.item.scale_orig_factor == 1 + assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) + + def test_mouse_press_event_not_selected(self): + self.item.setSelected(False) + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: + self.item.mousePressEvent(self.event) + m.assert_called_once_with(self.event) + assert self.item.scale_active is False + + def test_mouse_press_not_in_handles(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 40)) + self.event.button = MagicMock( + return_value=Qt.MouseButtons.LeftButton) + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: + self.item.mousePressEvent(self.event) + m.assert_called_once_with(self.event) + assert self.item.scale_active is False + + def test_mouse_move_event_when_no_action(self): + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mouseMoveEvent') as m: + self.item.mouseMoveEvent(self.event) + m.assert_called_once_with(self.event) + + def test_move_event_when_scale_action(self): + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) + self.item.scale_active = True + self.item.scale_direction = (1, 1) + self.item.scale_anchor = QtCore.QPointF(100, 80) + self.item.scale_start = QtCore.QPointF(10, 10) + self.item.scale_orig_factor = 1 + self.item.scale_orig_pos = QtCore.QPointF(0, 0) + + self.item.mouseMoveEvent(self.event) + assert self.item.scale() == 1.5 + + def test_mouse_release_event_when_no_action(self): + with patch('PyQt6.QtWidgets.QGraphicsPixmapItem' + '.mouseReleaseEvent') as m: + self.item.mouseReleaseEvent(self.event) + m.assert_called_once_with(self.event) + + def test_mouse_release_event_when_scale_action(self): + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) + self.item.scale_active = True + self.item.scale_direction = (1, 1) + self.item.scale_anchor = QtCore.QPointF(100, 80) + self.item.scale_start = QtCore.QPointF(10, 10) + self.item.scale_orig_factor = 1 + self.item.scale_orig_pos = QtCore.QPointF(0, 0) + self.scene.undo_stack = MagicMock(push=MagicMock()) + + self.item.mouseReleaseEvent(self.event) + self.scene.undo_stack.push.assert_called_once() + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert cmd.items == [self.item] + assert cmd.factor == 1.5 + assert cmd.item_data == [{ + 'anchor': QtCore.QPointF(100, 80), + 'orig_factor': 1, + 'orig_pos': QtCore.QPointF(0, 0)}] + assert cmd.ignore_first_redo is True + assert self.item.scale_active is False + + +class MultiSelectItemItemTestCase(BeeTestCase): + + def setUp(self): + self.scene = BeeGraphicsScene(None) + + @patch('beeref.selection.SelectableMixin.init_selectable') + def test_init(self, selectable_mock): + item = MultiSelectItem() + assert hasattr(item, 'save_id') is False + selectable_mock.assert_called_once() + + def test_width(self): + item = MultiSelectItem() + item.setRect(0, 0, 50, 100) + assert item.width == 50 + + def test_height(self): + item = MultiSelectItem() + item.setRect(0, 0, 50, 100) + assert item.height == 100 + + def test_paint(self): + item = MultiSelectItem() + item.paint_selectable = MagicMock() + painter = MagicMock() + item.paint(painter, None, None) + item.paint_selectable.assert_called_once() + painter.drawRect.assert_not_called() + + def test_has_selection_outline(self): + item = MultiSelectItem() + item.has_selection_outline() is True + + def test_has_selection_handles(self): + item = MultiSelectItem() + item.has_selection_handles() is True + + def test_selection_action_items(self): + view = MagicMock(get_scale=MagicMock(return_value=1)) + with patch('beeref.scene.BeeGraphicsScene.views', + return_value=[view]): + img1 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(img1) + img1.setSelected(True) + img2 = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(img2) + img2.setSelected(False) + item = MultiSelectItem() + self.scene.addItem(item) + item.setSelected(True) + action_items = item.selection_action_items() + assert img1 in action_items + assert item in action_items + assert img2 not in action_items + + def test_fit_selection_area(self): + item = MultiSelectItem() + item.setScale(5) + item.setRotation(-20) + item.fit_selection_area(QtCore.QRectF(-10, -20, 100, 80)) + assert item.pos().x() == -10 + assert item.pos().y() == -20 + assert item.width == 100 + assert item.height == 80 + assert item.scale() == 1 + assert item.rotation() == 0 + assert item.isSelected() is True + + @patch('PyQt6.QtWidgets.QGraphicsRectItem.mousePressEvent') + def test_mouse_press_event_when_ctrl_leftclick(self, mouse_mock): + item = MultiSelectItem() + item.fit_selection_area(QtCore.QRectF(0, 0, 100, 80)) + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButtons.LeftButton), + modifiers=MagicMock( + return_value=Qt.KeyboardModifiers.ControlModifier)) + item.mousePressEvent(event) + event.ignore.assert_called_once() + mouse_mock.assert_not_called() + + @patch('beeref.selection.SelectableMixin.mousePressEvent') + def test_mouse_press_event_when_leftclick(self, mouse_mock): + item = MultiSelectItem() + item.fit_selection_area(QtCore.QRectF(0, 0, 100, 80)) + event = MagicMock( + button=MagicMock(return_value=Qt.MouseButtons.LeftButton)) + item.mousePressEvent(event) + event.ignore.assert_not_called() + mouse_mock.assert_called_once_with(event)