Add text item

This commit is contained in:
Rebecca Breu 2021-07-14 21:24:22 +02:00
parent 849de6197a
commit 5d115b9391
23 changed files with 1022 additions and 137 deletions

View file

@ -1,6 +1,11 @@
0.2.0 - unreleased
==================
Added
-----
* You can now add plain text notes
Changed
-------

View file

@ -34,7 +34,7 @@ actions = [
},
{
'id': 'quit',
'text': '&Quit...',
'text': '&Quit',
'shortcuts': ['Ctrl+Q'],
'callback': 'on_action_quit',
},
@ -44,6 +44,12 @@ actions = [
'shortcuts': ['Ctrl+I'],
'callback': 'on_action_insert_images',
},
{
'id': 'insert_text',
'text': '&Text',
'shortcuts': ['Ctrl+T'],
'callback': 'on_action_insert_text',
},
{
'id': 'undo',
'text': '&Undo',

View file

@ -67,6 +67,7 @@ menu_structure = [
'menu': '&Insert',
'items': [
'insert_images',
'insert_text',
],
},
{

View file

@ -36,4 +36,5 @@ COLORS = {
# BeeRef specific:
'Scene:Selection': (116, 234, 231),
'Scene:Canvas': (60, 60, 60),
'Scene:Text': (200, 200, 200)
}

View file

@ -42,5 +42,14 @@
<h4>Move Window</h4>
<p>
Ctrl + Alt + Left Cick + Drag
Ctrl + Alt + Left Click + Drag
</p>
<h4>Edit Text</h4>
<p>
Double Click on Text<br/>
Press Enter or click elsewhere to stop editing<br/>
Shift + Enter for newline
</p>

View file

@ -65,12 +65,15 @@ def load_images(filenames, pos, scene, worker):
logger.info(f'Could not load file {filename}')
errors.append(filename)
continue
item = BeePixmapItem(img, filename)
item.set_pos_center(pos)
scene.add_item_later(item, selected=True)
scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True)
items.append(item)
if worker.canceled:
break
# Give main thread time to process items:
worker.msleep(10)
scene.undo_stack.push(
commands.InsertItems(scene, items, ignore_first_redo=True))

View file

@ -40,8 +40,8 @@ def exif_rotated_image(path=None):
with open(path, 'rb') as f:
try:
exifimg = exif.Image(f)
except plum.exceptions.UnpackError:
logger.info(f'Exif parser failed on image: {path}')
except (plum.exceptions.UnpackError, NotImplementedError):
logger.exception(f'Exif parser failed on image: {path}')
return img
if 'orientation' in exifimg.list_all():

View file

@ -1,3 +1,7 @@
USER_VERSION = 2
APPLICATION_ID = 2060242126
SCHEMA = [
"""
CREATE TABLE items (
@ -9,7 +13,7 @@ SCHEMA = [
scale REAL DEFAULT 1,
rotation REAL DEFAULT 0,
flip INTEGER DEFAULT 1,
filename TEXT
data JSON
)
""",
"""
@ -27,3 +31,11 @@ SCHEMA = [
)
""",
]
MIGRATIONS = {
2: [
"ALTER TABLE items ADD COLUMN data JSON",
"UPDATE items SET data = json_object('filename', filename)",
],
}

View file

@ -23,16 +23,20 @@ https://www.sqlite.org/appfileformat.html
https://www.sqlite.org/sqlar.html
"""
import json
import logging
import os
import pathlib
import shutil
import sqlite3
import tempfile
from PyQt6 import QtGui
from beeref import constants
from beeref.items import BeePixmapItem
from .errors import BeeFileIOError
from .schema import SCHEMA
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
logger = logging.getLogger(__name__)
@ -54,8 +58,6 @@ def handle_sqlite_errors(func):
class SQLiteIO:
USER_VERSION = 1
APPLICATION_ID = 2060242126
def __init__(self, filename, scene, create_new=False, readonly=False,
worker=None):
@ -74,6 +76,9 @@ class SQLiteIO:
delattr(self, '_connection')
if hasattr(self, '_cursor'):
delattr(self, '_cursor')
if hasattr(self, '_tmpdir'):
self._tmpdir.cleanup()
delattr(self, '_tmpdir')
def _establish_connection(self):
if (self.create_new
@ -86,9 +91,42 @@ class SQLiteIO:
uri = pathlib.Path(self.filename).resolve().as_uri()
if self.readonly:
uri = f'{uri}?mode=ro'
uri = f'{uri}?mode=rw'
self._connection = sqlite3.connect(uri, uri=True)
self._cursor = self.connection.cursor()
if not self.create_new:
self._migrate()
def _migrate(self):
"""Migrate database if necessary."""
version = self.fetchone('PRAGMA user_version')[0]
logger.debug(f'Found bee file version: {version}')
if version == USER_VERSION:
logger.debug('Version ok; no migrations necessary')
return
if self.readonly:
try:
# See whether file is writable so we can migrate it directly
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
except sqlite3.Error:
logger.debug('File not writable; use temporary copy instead')
self._connection.close()
self._tmpdir = tempfile.TemporaryDirectory(
prefix=constants.APPNAME)
tmpname = os.path.join(self._tmpdir.name, 'mig.bee')
shutil.copyfile(self.filename, tmpname)
self._connection = sqlite3.connect(tmpname)
self._cursor = self.connection.cursor()
for i in range(version, USER_VERSION):
logger.debug(f'Migrating from version {i} to {i + 1}...')
for migration in MIGRATIONS[i + 1]:
self.ex(migration)
self.write_meta()
self.connection.commit()
logger.debug('Migration finished')
@property
def connection(self):
@ -117,47 +155,61 @@ class SQLiteIO:
return self.cursor.fetchall()
def write_meta(self):
self.ex('PRAGMA application_id=%s' % self.APPLICATION_ID)
self.ex('PRAGMA user_version=%s' % self.USER_VERSION)
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
self.ex('PRAGMA user_version=%s' % USER_VERSION)
self.ex('PRAGMA foreign_keys=1')
def create_schema_on_new(self):
if self.create_new:
self.write_meta()
for schema in SCHEMA:
self.ex(schema)
@handle_sqlite_errors
def read(self):
rows = self.fetchall(
'SELECT items.id, x, y, z, scale, rotation, flip, filename, '
'sqlar.data '
'FROM items INNER JOIN sqlar on sqlar.item_id = items.id')
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
'items.data, sqlar.data '
'FROM items LEFT OUTER JOIN sqlar on sqlar.item_id = items.id')
if self.worker:
self.worker.begin_processing.emit(len(rows))
for i, row in enumerate(rows):
item = BeePixmapItem(QtGui.QImage(), filename=row[7])
item.save_id = row[0]
item.pixmap_from_bytes(row[8])
item.setPos(row[1], row[2])
item.setZValue(row[3])
item.setScale(row[4])
item.setRotation(row[5])
if row[6] == -1:
item.do_flip()
self.scene.add_item_later(item)
data = {
'save_id': row[0],
'type': row[1],
'x': row[2],
'y': row[3],
'z': row[4],
'scale': row[5],
'rotation': row[6],
'flip': row[7],
'data': json.loads(row[8]),
}
if data['type'] == 'pixmap':
data['item'] = BeePixmapItem(QtGui.QImage(), **data['data'])
data['item'].pixmap_from_bytes(row[9])
self.scene.add_item_later(data)
if self.worker:
logger.trace(f'Emit progress: {i}')
self.worker.progress.emit(i)
if self.worker.canceled:
self.worker.finished.emit('', [])
return
# Give main thread time to process items:
self.worker.msleep(10)
if self.worker:
self.worker.finished.emit(self.filename, [])
@handle_sqlite_errors
def write(self):
if self.readonly:
raise sqlite3.OperationalError(
'attempt to write a readonly database')
try:
self.write_meta()
self.create_schema_on_new()
self.write_data()
except sqlite3.Error:
@ -198,23 +250,26 @@ class SQLiteIO:
def insert_item(self, item):
self.ex(
'INSERT INTO items (type, x, y, z, scale, rotation, flip, '
'filename) '
'data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
('pixmap', item.pos().x(), item.pos().y(), item.zValue(),
item.scale(), item.rotation(), item.flip(), item.filename))
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(),
item.scale(), item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data())))
item.save_id = self.cursor.lastrowid
pixmap = item.pixmap_to_bytes()
if item.filename:
basename = os.path.splitext(os.path.basename(item.filename))[0]
name = '%04d-%s.png' % (item.save_id, basename)
else:
name = '%04d.png' % item.save_id
if hasattr(item, 'pixmap_to_bytes'):
pixmap = item.pixmap_to_bytes()
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',
(item.save_id, name, 0o644, len(pixmap), pixmap))
if item.filename:
basename = os.path.splitext(os.path.basename(item.filename))[0]
name = '%04d-%s.png' % (item.save_id, basename)
else:
name = '%04d.png' % item.save_id
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',
(item.save_id, name, 0o644, len(pixmap), pixmap))
self.connection.commit()
def update_item(self, item):
@ -225,8 +280,10 @@ class SQLiteIO:
"""
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, '
'filename=? '
'data=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(),
item.rotation(), item.flip(), item.filename, item.save_id))
item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data()),
item.save_id))
self.connection.commit()

View file

@ -20,16 +20,64 @@ text).
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref.constants import COLORS
from beeref.selection import SelectableMixin
logger = logging.getLogger(__name__)
item_registry = {}
class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
def register_item(cls):
item_registry[cls.TYPE] = cls
return cls
class BeeItemMixin(SelectableMixin):
"""Base for all items added by the user."""
def set_pos_center(self, pos):
"""Sets the position using the item's center as the origin point."""
self.setPos(pos - self.center_scene_coords)
def has_selection_outline(self):
return self.isSelected()
def has_selection_handles(self):
return self.isSelected() and self.scene().has_single_selection()
def selection_action_items(self):
"""The items affected by selection actions like scaling and rotating.
"""
return [self]
def on_selected_change(self, value):
if (value and self.scene()
and not self.scene().has_selection()
and not self.scene().rubberband_active):
self.bring_to_front()
def update_from_data(self, **kwargs):
self.save_id = kwargs.get('save_id', self.save_id)
self.setPos(kwargs.get('x', self.pos().x()),
kwargs.get('y', self.pos().y()))
self.setZValue(kwargs.get('z', self.zValue()))
self.setScale(kwargs.get('scale', self.scale()))
self.setRotation(kwargs.get('rotation', self.rotation()))
if kwargs.get('flip') != self.flip():
self.do_flip()
@register_item
class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
"""Class for images added by the user."""
TYPE = 'pixmap'
def __init__(self, image, filename=None):
super().__init__(QtGui.QPixmap.fromImage(image))
self.save_id = None
@ -37,14 +85,16 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
logger.debug(f'Initialized {self}')
self.init_selectable()
@classmethod
def create_from_data(self, **kwargs):
item = kwargs.pop('item')
data = kwargs.pop('data', {})
item.filename = item.filename or data.get('filename')
return item
def __str__(self):
return (f'Image "{self.filename}" {self.width} x {self.height}')
def set_pos_center(self, pos):
"""Sets the position using the item's center as the origin point."""
self.setPos(pos - self.center_scene_coords)
@property
def width(self):
return self.pixmap().size().width()
@ -53,6 +103,9 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
def height(self):
return self.pixmap().size().height()
def get_extra_save_data(self):
return {'filename': self.filename}
def pixmap_to_bytes(self):
"""Convert the pixmap data to PNG bytestring."""
barray = QtCore.QByteArray()
@ -72,23 +125,6 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
painter.drawPixmap(0, 0, self.pixmap())
self.paint_selectable(painter, option, widget)
def has_selection_outline(self):
return self.isSelected()
def has_selection_handles(self):
return self.isSelected() and self.scene().has_single_selection()
def selection_action_items(self):
"""The items affected by selection actions like scaling and rotating.
"""
return [self]
def on_selected_change(self, value):
if (value and self.scene()
and not self.scene().has_selection()
and not self.scene().rubberband_active):
self.bring_to_front()
def create_copy(self):
item = BeePixmapItem(QtGui.QImage(), self.filename)
item.setPixmap(self.pixmap())
@ -99,3 +135,87 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
if self.flip() == -1:
item.do_flip()
return item
@register_item
class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
"""Class for text added by the user."""
TYPE = 'text'
def __init__(self, text=None):
super().__init__(text or "Text")
self.save_id = None
logger.debug(f'Initialized {self}')
self.init_selectable()
self.is_editable = True
self.edit_mode = False
self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text']))
@classmethod
def create_from_data(cls, **kwargs):
data = kwargs.get('data', {})
item = cls(**data)
return item
def __str__(self):
txt = self.toPlainText()[:40]
return (f'Text "{txt}"')
@property
def width(self):
return QtWidgets.QGraphicsTextItem.boundingRect(self).width()
@property
def height(self):
return QtWidgets.QGraphicsTextItem.boundingRect(self).height()
def get_extra_save_data(self):
return {'text': self.toPlainText()}
def contains(self, point):
return self.boundingRect().contains(point)
def paint(self, painter, option, widget):
painter.setPen(Qt.PenStyle.NoPen)
color = QtGui.QColor(0, 0, 0)
color.setAlpha(40)
brush = QtGui.QBrush(color)
painter.setBrush(brush)
painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self))
option.state = QtWidgets.QStyle.StateFlag.State_Enabled
super().paint(painter, option, widget)
self.paint_selectable(painter, option, widget)
def create_copy(self):
item = BeeTextItem(self.toPlainText())
item.setPos(self.pos())
item.setZValue(self.zValue())
item.setScale(self.scale())
item.setRotation(self.rotation())
if self.flip() == -1:
item.do_flip()
return item
def enter_edit_mode(self):
self.edit_mode = True
self.setTextInteractionFlags(
Qt.TextInteractionFlag.TextEditorInteraction)
def exit_edit_mode(self):
self.edit_mode = False
# reset selection:
self.setTextCursor(QtGui.QTextCursor(self.document()))
self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
def has_selection_handles(self):
return super().has_selection_handles() and not self.edit_mode
def keyPressEvent(self, event):
if (event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return)
and event.modifiers() == Qt.KeyboardModifier.NoModifier):
self.exit_edit_mode()
self.scene().edit_item = None
event.accept()
return
super().keyPressEvent(event)

View file

@ -23,6 +23,7 @@ from PyQt6.QtCore import Qt
import rpack
from beeref import commands
from beeref.items import item_registry
from beeref.selection import MultiSelectItem, RubberbandItem
@ -45,6 +46,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.changed.connect(self.on_change)
self.items_to_add = Queue()
self.internal_clipboard = []
self.edit_item = None
def addItem(self, item):
logger.debug(f'Adding item {item}')
@ -249,7 +251,17 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if event.button() == Qt.MouseButton.LeftButton:
self.event_start = event.scenePos()
if self.itemAt(event.scenePos(), self.views()[0].transform()):
item_at_pos = self.itemAt(
event.scenePos(), self.views()[0].transform())
if self.edit_item:
if item_at_pos != self.edit_item:
self.edit_item.exit_edit_mode()
self.edit_item = None
else:
super().mousePressEvent(event)
return
if item_at_pos:
self.move_active = True
elif self.items():
self.rubberband_active = True
@ -262,9 +274,14 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.move_active = False
if not item.isSelected():
item.setSelected(True)
self.views()[0].fit_rect(
self.itemsBoundingRect(items=[item]),
toggle_item=item)
if item.is_editable:
self.edit_item = item
item.enter_edit_mode()
self.mousePressEvent(event)
else:
self.views()[0].fit_rect(
self.itemsBoundingRect(items=[item]),
toggle_item=item)
return
super().mouseDoubleClickEvent(event)
@ -381,19 +398,26 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
def add_item_later(self, item, selected=False):
"""Keep an item for adding later via ``add_queued_items``"""
def add_item_later(self, itemdata, selected=False):
"""Keep an item for adding later via ``add_queued_items``
self.items_to_add.put((item, selected))
:param dict itemdata: Defines the item's data
:param bool selected: Whether the item is initialised as selected
"""
self.items_to_add.put((itemdata, selected))
def add_queued_items(self):
"""Adds items added via ``add_items_later``"""
while not self.items_to_add.empty():
item, selected = self.items_to_add.get()
data, selected = self.items_to_add.get()
cls = item_registry[data.pop('type')]
item = cls.create_from_data(**data)
item.update_from_data(**data)
self.addItem(item)
# Force recalculation of min/max z values:
item.setZValue(item.zValue())
if selected:
item.setSelected(True)
item.bring_to_front()
self.max_z = max(self.max_z, item.zValue())
self.min_z = min(self.min_z, item.zValue())

View file

@ -124,6 +124,7 @@ class SelectableMixin(BaseItemMixin):
self.viewport_scale = 1
self.reset_actions()
self.is_editable = False
def reset_actions(self):
self.scale_active = False

View file

@ -13,10 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging.handlers
import os
import os.path
from PyQt6 import QtCore, QtGui

View file

@ -26,7 +26,7 @@ from beeref.config import CommandlineArgs, BeeSettings
from beeref import constants
from beeref import fileio
from beeref import gui
from beeref.items import BeePixmapItem
from beeref.items import BeePixmapItem, BeeTextItem
from beeref.scene import BeeGraphicsScene
@ -120,8 +120,12 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.context_menu.exec(self.mapToGlobal(point))
def get_supported_image_formats(self, cls):
formats = map(lambda f: f'*.{f.data().decode()}',
cls.supportedImageFormats())
formats = []
for f in cls.supportedImageFormats():
string = f'*.{f.data().decode()}'
formats.extend((string, string.upper()))
print(formats)
return ' '.join(formats)
def get_view_center(self):
@ -162,6 +166,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
# It seems to be more reliable when we fit a second time
# Sometimes a changing scene rect can mess up the fitting
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
logger.trace('Fit view done')
def on_action_fit_scene(self):
self.fit_rect(self.scene.itemsBoundingRect())
@ -281,7 +286,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.scene.selectedItems(user_only=True)))
def on_items_loaded(self, value):
logger.debug('On items loded: add queued images')
logger.debug('On items loaded: add queued items')
self.scene.add_queued_items()
def on_loading_finished(self, filename, errors):
@ -413,12 +418,19 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def on_action_insert_images(self):
formats = self.get_supported_image_formats(QtGui.QImageReader)
logger.debug(f'Supported image types for reading: {formats}')
filenames, f = QtWidgets.QFileDialog.getOpenFileNames(
parent=self,
caption='Select one ore more images to open',
filter=f'Images ({formats})')
self.do_insert_images(filenames)
def on_action_insert_text(self):
item = BeeTextItem()
pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
item.setScale(1 / self.get_scale())
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
def on_action_copy(self):
logger.debug('Copying to clipboard...')
clipboard = QtWidgets.QApplication.clipboard()
@ -454,7 +466,13 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
item = BeePixmapItem(img)
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
logger.info('No image data in clipboard')
text = clipboard.text()
if text:
item = BeeTextItem(text)
item.setScale(1 / self.get_scale())
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
logger.info('No image data or text in clipboard')
def on_selection_changed(self):
logger.debug('Currently selected items: %s',
@ -497,7 +515,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
much (causing overflow errors).
:param func: Function which takes the width and height as
arguments and turns it into a number, for ex. ``min`` or ``max``.
arguments and turns it into a number, for ex. ``min`` or ``max``.
"""
topleft = self.mapFromScene(

Binary file not shown.

View file

@ -1,5 +1,7 @@
import os.path
import pytest
import uuid
from unittest.mock import MagicMock, patch
from PyQt6 import QtGui, QtWidgets
@ -66,7 +68,7 @@ def imgdata3x3(imgfilename3x3):
@pytest.fixture
def tmpfile(tmpdir):
yield os.path.join(tmpdir, 'test')
yield os.path.join(tmpdir, str(uuid.uuid4()))
@pytest.fixture

View file

@ -23,8 +23,8 @@ def test_exif_rotated_image_not_a_file(qapp):
def test_exif_rotated_image_exif_unpack_error(qapp, imgfilename3x3):
with patch('beeref.fileio.image.exif.Image') as exif_mock:
exif_mock.raise_error = plum.exceptions.UnpackError()
with patch('beeref.fileio.image.exif.Image',
side_effect=plum.exceptions.UnpackError()):
img = exif_rotated_image(imgfilename3x3)
assert img.isNull() is False

View file

@ -33,9 +33,9 @@ def test_load_images_loads(view, imgfilename3x3):
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with('', [])
items = queue2list(view.scene.items_to_add)
assert len(items) == 1
item = items[0][0]
itemdata = queue2list(view.scene.items_to_add)
assert len(itemdata) == 1
item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)
@ -53,9 +53,9 @@ def test_load_images_canceled(view, imgfilename3x3):
worker.begin_processing.emit.assert_called_once_with(2)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with('', [])
items = queue2list(view.scene.items_to_add)
assert len(items) == 1
item = items[0][0]
itemdata = queue2list(view.scene.items_to_add)
assert len(itemdata) == 1
item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)
@ -74,9 +74,9 @@ def test_load_images_error(view, imgfilename3x3):
worker.progress.emit.assert_any_call(0)
worker.progress.emit.assert_any_call(1)
worker.finished.emit.assert_called_once_with('', ['foo.jpg'])
items = queue2list(view.scene.items_to_add)
assert len(items) == 1
item = items[0][0]
itemdata = queue2list(view.scene.items_to_add)
assert len(itemdata) == 1
item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)

View file

@ -1,26 +1,80 @@
import json
import os
import os.path
import stat
from unittest.mock import MagicMock, patch
from PyQt6 import QtGui
import pytest
from beeref.fileio import schema
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.sql import SQLiteIO
from beeref.items import BeePixmapItem
from beeref.items import BeePixmapItem, BeeTextItem
def test_sqliteio_migrate_does_nothing_when_version_ok(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.ex('PRAGMA user_version=%s' % schema.USER_VERSION)
io.connection.commit()
del(io)
with patch('beeref.fileio.sql.SQLiteIO.ex') as ex_mock:
SQLiteIO(tmpfile, MagicMock())
ex_mock.assert_not_called()
@patch('beeref.fileio.sql.USER_VERSION', 3)
@patch('beeref.fileio.sql.MIGRATIONS', {
2: ['CREATE TABLE foo (col1 INT)',
'CREATE TABLE bar (baz INT)'],
3: ['ALTER TABLE foo ADD COLUMN col2 TEXT']})
def test_sqliteio_migrate_migrates(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.ex('PRAGMA user_version=1')
io.connection.commit()
del(io)
io = SQLiteIO(tmpfile, MagicMock())
io.ex('INSERT INTO foo (col1, col2) VALUES (22, "hello world")')
io.ex('INSERT INTO bar (baz) VALUES (55)')
result = io.fetchone('PRAGMA user_version')
assert result[0] == 3
@patch('beeref.fileio.sql.USER_VERSION', 3)
@patch('beeref.fileio.sql.MIGRATIONS', {
2: ['CREATE TABLE foo (col1 INT)',
'CREATE TABLE bar (baz INT)'],
3: ['ALTER TABLE foo ADD COLUMN col2 TEXT']})
def test_sqliteio_migrate_migrates_when_file_not_writable(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.ex('PRAGMA user_version=1')
io.connection.commit()
del(io)
os.chmod(tmpfile, stat.S_IREAD)
with pytest.raises(PermissionError):
open(tmpfile, 'w')
io = SQLiteIO(tmpfile, MagicMock(), readonly=True)
io.ex('INSERT INTO foo (col1, col2) VALUES (22, "hello world")')
io.ex('INSERT INTO bar (baz) VALUES (55)')
result = io.fetchone('PRAGMA user_version')
assert result[0] == 3
newdir = io._tmpdir.name
del(io)
assert os.path.exists(newdir) is False
def test_sqliteio_ẁrite_meta_application_id(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA application_id')
assert result[0] == SQLiteIO.APPLICATION_ID
assert result[0] == schema.APPLICATION_ID
def test_sqliteio_ẁrite_meta_user_version(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA user_version')
assert result[0] == SQLiteIO.USER_VERSION
assert result[0] == schema.USER_VERSION
def test_sqliteio_ẁrite_meta_foreign_keys(tmpfile):
@ -41,7 +95,9 @@ def test_sqliteio_create_schema_on_new_when_create_new(tmpfile):
scene_mock.clear_save_ids.assert_called_once()
def test_sqliteio_create_schema_on_new_when_not_create_new(tmpfile):
@patch('beeref.fileio.sql.SQLiteIO._migrate')
def test_sqliteio_create_schema_on_new_when_not_create_new(
migrate_mock, tmpfile):
scene_mock = MagicMock()
io = SQLiteIO(tmpfile, scene_mock, create_new=False)
io.create_schema_on_new()
@ -83,7 +139,36 @@ def test_sqliteio_write_calls_write_meta(tmpfile, view):
metamock.assert_called_once()
def test_sqliteio_write_inserts_new_item(tmpfile, view):
def test_sqliteio_write_inserts_new_text_item(tmpfile, view):
item = BeeTextItem(text='foo bar')
view.scene.addItem(item)
item.setScale(1.3)
item.setPos(44, 55)
item.setZValue(0.22)
item.setRotation(33)
item.do_flip()
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
assert item.save_id == 1
result = io.fetchone(
'SELECT x, y, z, scale, rotation, flip, items.data, type, '
'sqlar.data, sqlar.name '
'FROM items '
'LEFT OUTER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 44.0
assert result[1] == 55.0
assert result[2] == 0.22
assert result[3] == 1.3
assert result[4] == 33
assert result[5] == -1
assert json.loads(result[6]) == {'text': 'foo bar'}
assert result[7] == 'text'
assert result[8] is None
assert result[9] is None
def test_sqliteio_write_inserts_new_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.jpg')
view.scene.addItem(item)
item.setScale(1.3)
@ -97,7 +182,7 @@ def test_sqliteio_write_inserts_new_item(tmpfile, view):
assert item.save_id == 1
result = io.fetchone(
'SELECT x, y, z, scale, rotation, flip, filename, type, '
'SELECT x, y, z, scale, rotation, flip, items.data, type, '
'sqlar.data, sqlar.name '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
@ -107,26 +192,61 @@ def test_sqliteio_write_inserts_new_item(tmpfile, view):
assert result[3] == 1.3
assert result[4] == 33
assert result[5] == -1
assert result[6] == 'bee.jpg'
assert json.loads(result[6]) == {'filename': 'bee.jpg'}
assert result[7] == 'pixmap'
assert result[8] == b'abc'
assert result[9] == '0001-bee.png'
def test_sqliteio_write_inserts_new_item_without_filename(tmpfile, view, item):
def test_sqliteio_write_inserts_new_pixmap_item_without_filename(
tmpfile, view, item):
view.scene.addItem(item)
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
assert item.save_id == 1
result = io.fetchone(
'SELECT filename, sqlar.name FROM items '
'SELECT items.data, sqlar.name FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] is None
assert json.loads(result[0]) == {'filename': None}
assert result[1] == '0001.png'
def test_sqliteio_write_updates_existing_item(tmpfile, view):
def test_sqliteio_write_updates_existing_text_item(tmpfile, view):
item = BeeTextItem(text='foo bar')
view.scene.addItem(item)
item.setScale(1.3)
item.setPos(44, 55)
item.setZValue(0.22)
item.setRotation(33)
item.save_id = 1
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
item.setScale(0.7)
item.setPos(20, 30)
item.setZValue(0.33)
item.setRotation(100)
item.do_flip()
item.setPlainText('updated')
io.create_new = False
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
result = io.fetchone(
'SELECT x, y, z, scale, rotation, flip, items.data, sqlar.data '
'FROM items '
'LEFT OUTER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 20
assert result[1] == 30
assert result[2] == 0.33
assert result[3] == 0.7
assert result[4] == 100
assert result[5] == -1
assert json.loads(result[6]) == {'text': 'updated'}
assert result[7] is None
def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
view.scene.addItem(item)
item.setScale(1.3)
@ -149,7 +269,7 @@ def test_sqliteio_write_updates_existing_item(tmpfile, view):
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
result = io.fetchone(
'SELECT x, y, z, scale, rotation, flip, filename, sqlar.data '
'SELECT x, y, z, scale, rotation, flip, items.data, sqlar.data '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 20
@ -158,11 +278,27 @@ def test_sqliteio_write_updates_existing_item(tmpfile, view):
assert result[3] == 0.7
assert result[4] == 100
assert result[5] == -1
assert result[6] == 'new.png'
assert json.loads(result[6]) == {'filename': 'new.png'}
assert result[7] == b'abc'
def test_sqliteio_write_removes_nonexisting_item(tmpfile, view):
def test_sqliteio_write_removes_nonexisting_text_item(tmpfile, view):
item = BeeTextItem('foo bar')
item.setScale(1.3)
item.setPos(44, 55)
view.scene.addItem(item)
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
view.scene.removeItem(item)
io.create_new = False
io.write()
assert io.fetchone('SELECT COUNT(*) from items') == (0,)
assert io.fetchone('SELECT COUNT(*) from sqlar') == (0,)
def test_sqliteio_write_removes_nonexisting_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
item.setScale(1.3)
item.setPos(44, 55)
@ -215,13 +351,42 @@ def test_sqliteio_write_canceled(tmpfile, view):
worker.finished.emit.assert_called_once_with(tmpfile, [])
def test_sqliteio_read_reads_readonly(tmpfile, view, imgdata3x3):
def test_sqliteio_read_reads_readonly_text_item(tmpfile, view):
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items '
'(type, x, y, z, scale, rotation, flip, filename) '
'(type, x, y, z, scale, rotation, flip, data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1, 'bee.png'))
('text', 22.2, 33.3, 0.22, 3.4, 45, -1,
json.dumps({'text': 'foo bar'})))
io.connection.commit()
del(io)
io = SQLiteIO(tmpfile, view.scene, readonly=True)
io.read()
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
assert item.pos().y() == 33.3
assert item.zValue() == 0.22
assert item.scale() == 3.4
assert item.rotation() == 45
assert item.flip() == -1
assert item.toPlainText() == 'foo bar'
assert view.scene.items_to_add.empty() is True
def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items '
'(type, x, y, z, scale, rotation, flip, data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1,
json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)',
(1, imgdata3x3))
io.connection.commit()
@ -229,8 +394,10 @@ def test_sqliteio_read_reads_readonly(tmpfile, view, imgdata3x3):
io = SQLiteIO(tmpfile, view.scene, readonly=True)
io.read()
item, selected = view.scene.items_to_add.get()
assert selected is False
view.scene.add_queued_items()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
assert item.pos().y() == 33.3
@ -250,11 +417,12 @@ def test_sqliteio_read_updates_progress(tmpfile, view):
worker=worker)
io.create_schema_on_new()
io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
('pixmap', 0, 0, 0, 1, 'bee.png'))
('pixmap', 0, 0, 0, 1, json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
io.connection.commit()
io.read()
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
@ -264,17 +432,17 @@ def test_sqliteio_read_updates_progress(tmpfile, view):
def test_sqliteio_read_canceled(tmpfile, view):
worker = MagicMock(canceled=True)
io = SQLiteIO(tmpfile, view.scene, create_new=True, worker=worker)
io.create_schema_on_new()
io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
('pixmap', 0, 0, 0, 1, 'bee.png'))
('pixmap', 0, 0, 0, 1, json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
('pixmap', 50, 50, 0, 1, 'bee2.png'))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
('pixmap', 50, 50, 0, 1, json.dumps({'filename': 'bee2.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (2, b''))
io.connection.commit()
io.read()
worker.begin_processing.emit.assert_called_once_with(2)
worker.progress.emit.assert_called_once_with(0)

View file

@ -2,7 +2,11 @@ from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtCore, QtGui
from beeref.items import BeePixmapItem
from beeref.items import BeePixmapItem, item_registry
def test_in_item_registry():
assert item_registry['pixmap'] == BeePixmapItem
@patch('beeref.selection.SelectableMixin.init_selectable')
@ -48,6 +52,11 @@ def test_set_pos_center_when_rotated(qapp, item):
assert item.pos().y() == -100
def test_get_extra_save_data(item):
item.filename = 'foobar.png'
assert item.get_extra_save_data() == {'filename': 'foobar.png'}
def test_pixmap_to_bytes(qapp, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
assert item.pixmap_to_bytes().startswith(b'\x89PNG')
@ -114,6 +123,41 @@ def test_selection_action_items(qapp):
assert item.selection_action_items() == [item]
def test_update_from_data(item):
item.update_from_data(
save_id=3,
x=11,
y=22,
z=1.2,
scale=2.5,
rotation=45,
flip=-1)
assert item.save_id == 3
assert item.pos() == QtCore.QPointF(11, 22)
assert item.zValue() == 1.2
assert item.rotation() == 45
assert item.flip() == -1
def test_update_from_data_keeps_flip(item):
item.do_flip()
item.update_from_data(flip=-1)
assert item.flip() == -1
def test_update_from_data_keeps_unset_values(item):
item.setScale(3)
item.update_from_data(rotation=45)
assert item.scale() == 3
def test_create_from_data(item):
new_item = BeePixmapItem.create_from_data(
item=item, data={'filename': 'foobar.png'})
assert new_item is item
assert item.filename == 'foobar.png'
def test_create_copy(qapp, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3), 'foo.png')
item.setPos(20, 30)

View file

@ -0,0 +1,301 @@
from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtCore, QtWidgets
from PyQt6.QtCore import Qt
from beeref.items import BeeTextItem, item_registry
def test_in_items_registry():
assert item_registry['text'] == BeeTextItem
@patch('beeref.selection.SelectableMixin.init_selectable')
def test_init(selectable_mock, qapp):
item = BeeTextItem('foo bar')
assert item.save_id is None
assert item.width
assert item.height
assert item.scale() == 1
assert item.toPlainText() == 'foo bar'
assert item.is_editable is True
assert item.edit_mode is False
selectable_mock.assert_called_once()
def test_set_pos_center(qapp):
item = BeeTextItem('foo bar')
with patch('beeref.items.BeeTextItem.width',
new_callable=PropertyMock, return_value=200):
with patch('beeref.items.BeeTextItem.height',
new_callable=PropertyMock, return_value=100):
item.set_pos_center(QtCore.QPointF(0, 0))
assert item.pos().x() == -100
assert item.pos().y() == -50
def test_set_pos_center_when_scaled(qapp):
item = BeeTextItem('foo bar')
item.setScale(2)
with patch('beeref.items.BeeTextItem.width',
new_callable=PropertyMock, return_value=200):
with patch('beeref.items.BeeTextItem.height',
new_callable=PropertyMock, return_value=100):
item.set_pos_center(QtCore.QPointF(0, 0))
assert item.pos().x() == -200
assert item.pos().y() == -100
def test_set_pos_center_when_rotated(qapp):
item = BeeTextItem('foo bar')
item.setRotation(90)
with patch('beeref.items.BeeTextItem.width',
new_callable=PropertyMock, return_value=200):
with patch('beeref.items.BeeTextItem.height',
new_callable=PropertyMock, return_value=100):
item.set_pos_center(QtCore.QPointF(0, 0))
assert item.pos().x() == 50
assert item.pos().y() == -100
def test_get_extra_save_data(qapp):
item = BeeTextItem('foo bar')
assert item.get_extra_save_data() == {'text': 'foo bar'}
@patch('beeref.items.BeeTextItem.boundingRect')
def test_contains_when_inside_bounds(brect_mock, qapp):
brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
item = BeeTextItem('foo bar')
item.contains(QtCore.QPointF(33, 45)) is True
brect_mock.assert_called_once_with()
@patch('beeref.items.BeeTextItem.boundingRect')
def test_contains_when_outside_bounds(brect_mock, qapp):
brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
item = BeeTextItem('foo bar')
item.contains(QtCore.QPointF(19, 29)) is False
brect_mock.assert_called_once_with()
@patch('PyQt6.QtWidgets.QGraphicsTextItem.paint')
def test_paint(paint_mock, qapp):
item = BeeTextItem('foo bar')
item.paint_selectable = MagicMock()
painter = MagicMock()
option = MagicMock()
item.paint(painter, option, 'widget')
item.paint_selectable.assert_called_once()
painter.drawRect.assert_called_once()
assert option.state == QtWidgets.QStyle.StateFlag.State_Enabled
paint_mock.assert_called_once_with(painter, option, 'widget')
def test_has_selection_outline_when_not_selected(view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
item.setSelected(False)
item.has_selection_outline() is False
def test_has_selection_outline_when_selected(view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
item.setSelected(True)
item.has_selection_outline() is True
def test_has_selection_handles_when_not_selected(view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
item.setSelected(False)
item2 = BeeTextItem('baz')
view.scene.addItem(item2)
item2.setSelected(False)
item.has_selection_handles() is False
def test_has_selection_handles_when_selected_single(view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
item.setSelected(True)
item2 = BeeTextItem('baz')
view.scene.addItem(item2)
item2.setSelected(False)
item.has_selection_handles() is True
def test_has_selection_handles_when_selected_multi(view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
item.setSelected(True)
item2 = BeeTextItem('baz')
view.scene.addItem(item2)
item2.setSelected(True)
item.has_selection_handles() is False
def test_has_selection_handles_when_selected_single_and_edit_mode(view):
item = BeeTextItem('foo bar')
item.edit_mode = False
view.scene.addItem(item)
item.setSelected(True)
item2 = BeeTextItem('baz')
view.scene.addItem(item2)
item2.setSelected(False)
item.has_selection_handles() is False
def test_selection_action_items(qapp):
item = BeeTextItem('foo bar')
assert item.selection_action_items() == [item]
def test_update_from_data(qapp):
item = BeeTextItem('foo bar')
item.update_from_data(
save_id=3,
x=11,
y=22,
z=1.2,
scale=2.5,
rotation=45,
flip=-1)
assert item.save_id == 3
assert item.pos() == QtCore.QPointF(11, 22)
assert item.zValue() == 1.2
assert item.rotation() == 45
assert item.flip() == -1
def test_update_from_data_keeps_flip(qapp):
item = BeeTextItem('foo bar')
item.do_flip()
item.update_from_data(flip=-1)
assert item.flip() == -1
def test_update_from_data_keeps_unset_values(qapp):
item = BeeTextItem('foo bar')
item.setScale(3)
item.update_from_data(rotation=45)
assert item.scale() == 3
def test_create_from_data(qapp):
item = BeeTextItem.create_from_data(data={'text': 'hello world'})
item.toPlainText() == 'hello world'
def test_create_copy(qapp):
item = BeeTextItem('foo bar')
item.setPos(20, 30)
item.setRotation(33)
item.do_flip()
item.setZValue(0.5)
item.setScale(2.2)
copy = item.create_copy()
assert copy.toPlainText() == 'foo bar'
assert copy.pos() == QtCore.QPointF(20, 30)
assert copy.rotation() == 33
assert item.flip() == -1
assert item.zValue() == 0.5
assert item.scale() == 2.2
def test_enter_edit_mode(qapp):
item = BeeTextItem('foo bar')
item.enter_edit_mode()
assert item.edit_mode is True
flags = item.textInteractionFlags()
assert flags == Qt.TextInteractionFlag.TextEditorInteraction
@patch('PyQt6.QtGui.QTextCursor')
@patch('beeref.items.BeeTextItem.setTextCursor')
def test_exit_edit_mode(setcursor_mock, cursor_mock, qapp):
item = BeeTextItem('foo bar')
item.edit_mode = True
item.exit_edit_mode()
assert item.edit_mode is False
flags = item.textInteractionFlags()
assert flags == Qt.TextInteractionFlag.NoTextInteraction
cursor_mock.assert_called_once_with(item.document())
setcursor_mock.assert_called_once_with(cursor_mock.return_value)
@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
@patch('beeref.items.BeeTextItem.exit_edit_mode')
def test_key_press_event_any_key(exit_mock, key_press_mock, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
view.scene.edit_item = item
event = MagicMock()
event.key.return_value = Qt.Key.Key_T
event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
item.keyPressEvent(event)
key_press_mock.assert_called_once_with(event)
exit_mock.assert_not_called()
assert view.scene.edit_item == item
@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
@patch('beeref.items.BeeTextItem.exit_edit_mode')
def test_key_press_event_shift_return(exit_mock, key_press_mock, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
view.scene.edit_item = item
event = MagicMock()
event.key.return_value = Qt.Key.Key_Return
event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier
item.keyPressEvent(event)
key_press_mock.assert_called_once_with(event)
exit_mock.assert_not_called()
assert view.scene.edit_item == item
@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
@patch('beeref.items.BeeTextItem.exit_edit_mode')
def test_key_press_event_shift_enter(exit_mock, key_press_mock, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
view.scene.edit_item = item
event = MagicMock()
event.key.return_value = Qt.Key.Key_Enter
event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier
item.keyPressEvent(event)
key_press_mock.assert_called_once_with(event)
exit_mock.assert_not_called()
assert view.scene.edit_item == item
@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
@patch('beeref.items.BeeTextItem.exit_edit_mode')
def test_key_press_event_return(exit_mock, key_press_mock, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
view.scene.edit_item = item
event = MagicMock()
event.key.return_value = Qt.Key.Key_Return
event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
item.keyPressEvent(event)
key_press_mock.assert_not_called()
exit_mock.assert_called_once_with()
assert view.scene.edit_item is None
@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
@patch('beeref.items.BeeTextItem.exit_edit_mode')
def test_key_press_event_enter(exit_mock, key_press_mock, view):
item = BeeTextItem('foo bar')
view.scene.addItem(item)
view.scene.edit_item = item
event = MagicMock()
event.key.return_value = Qt.Key.Key_Enter
event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
item.keyPressEvent(event)
key_press_mock.assert_not_called()
exit_mock.assert_called_once_with()
assert view.scene.edit_item is None

View file

@ -7,7 +7,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref import commands
from beeref.items import BeePixmapItem
from beeref.items import BeePixmapItem, BeeTextItem
def test_add_remove_item(view, item):
@ -447,6 +447,66 @@ def test_mouse_press_event_when_left_click_over_item(mouse_mock, view, item):
assert view.scene.event_start == QtCore.QPointF(10, 20)
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
def test_mouse_press_event_when_left_click_over_item_in_edit_mode(
mouse_mock, view):
item = BeeTextItem('foo bar')
item.exit_edit_mode = MagicMock()
view.scene.addItem(item)
view.scene.edit_item = item
view.scene.itemAt = MagicMock(return_value=item)
event = MagicMock(
button=MagicMock(return_value=Qt.MouseButton.LeftButton),
)
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.edit_item == item
item.exit_edit_mode.assert_not_called()
assert view.scene.move_active is False
assert view.scene.rubberband_active is False
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
def test_mouse_press_event_when_left_click_over_diff_item_in_edit_mode(
mouse_mock, view, item):
txtitem = BeeTextItem('foo bar')
txtitem.exit_edit_mode = MagicMock()
view.scene.addItem(txtitem)
view.scene.edit_item = txtitem
view.scene.itemAt = MagicMock(return_value=item)
event = MagicMock(
button=MagicMock(return_value=Qt.MouseButton.LeftButton),
)
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.edit_item is None
txtitem.exit_edit_mode.assert_called_once_with()
assert view.scene.move_active is True
assert view.scene.rubberband_active is False
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
def test_mouse_press_event_when_left_click_over_no_item_in_edit_mode(
mouse_mock, view):
item = BeeTextItem('foo bar')
item.exit_edit_mode = MagicMock()
view.scene.addItem(item)
view.scene.edit_item = item
view.scene.itemAt = MagicMock(return_value=None)
event = MagicMock(
button=MagicMock(return_value=Qt.MouseButton.LeftButton),
)
view.scene.mousePressEvent(event)
event.accept.assert_not_called()
mouse_mock.assert_called_once_with(event)
assert view.scene.edit_item is None
item.exit_edit_mode.assert_called_once_with()
assert view.scene.move_active is False
assert view.scene.rubberband_active is True
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
def test_mouse_press_event_when_left_click_not_over_item(
mouse_mock, view, item):
@ -501,6 +561,33 @@ def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item):
mouse_mock.assert_not_called()
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent')
def test_mouse_doubleclick_event_when_over_editable_item(
double_mock, press_mock, view):
item = BeeTextItem('foo bar')
item.enter_edit_mode = MagicMock()
event = MagicMock()
view.scene.move_active = True
view.scene.addItem(item)
item.setPos(30, 40)
item.setSelected(True)
view.scene.itemAt = MagicMock(return_value=item)
view.fit_rect = MagicMock()
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=100):
view.scene.mouseDoubleClickEvent(event)
assert view.scene.move_active is False
item.enter_edit_mode.assert_called_once_with()
view.scene.edit_item == item
double_mock.assert_not_called()
press_mock.assert_called_once_with(event)
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent')
def test_mouse_doubleclick_event_when_item_not_selected(
mouse_mock, view, item):
@ -944,21 +1031,24 @@ def test_on_change_when_no_multi_select(view):
view.scene.multi_select_item.fit_selection_area.assert_not_called()
def test_add_queued_items_unselected(view, item):
item.setZValue(0.33)
view.scene.add_item_later(item, selected=False)
def test_add_queued_items_unselected(view):
data = {'type': 'text', 'z': 0.33, 'data': {'text': 'foo'}}
view.scene.add_item_later(data, selected=False)
view.scene.add_queued_items()
assert view.scene.items() == [item]
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert item.isSelected() is False
assert view.scene.max_z == 0.33
assert item.toPlainText() == 'foo'
def test_add_queued_items_selected(view, item):
def test_add_queued_items_selected(view):
view.scene.max_z = 0.6
item.setZValue(0.33)
view.scene.add_item_later(item, selected=True)
data = {'type': 'text', 'z': 0.33, 'data': {'text': 'foo'}}
view.scene.add_item_later(data, selected=True)
view.scene.add_queued_items()
assert view.scene.items() == [item]
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert item.isSelected() is True
assert item.zValue() > 0.6

View file

@ -1,4 +1,5 @@
import os.path
import shutil
import sqlite3
from unittest.mock import MagicMock, patch, mock_open
@ -244,7 +245,6 @@ def test_on_action_save_as_when_error(
view.scene.addItem(item)
view.on_saving_finished = MagicMock()
filename = os.path.join(tmpdir, 'test.bee')
assert os.path.exists(filename) is False
dialog_mock.return_value = (filename, None)
save_mock.side_effect = sqlite3.Error('foo')
view.on_action_save_as()
@ -256,8 +256,10 @@ def test_on_action_save(view, qtbot, imgfilename3x3, tmpdir):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
view.filename = os.path.join(tmpdir, 'test.bee')
root = os.path.dirname(__file__)
shutil.copyfile(os.path.join(root, 'assets', 'test1item.bee'),
view.filename)
view.on_saving_finished = MagicMock()
assert os.path.exists(view.filename) is False
view.on_action_save()
qtbot.waitUntil(lambda: view.on_saving_finished.called is True)
assert os.path.exists(view.filename) is True
@ -320,6 +322,16 @@ def test_on_action_insert_images_when_error(
'', ['iaeiae', 'trntrn'])
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
def test_on_action_insert_text(clear_mock, view):
view.on_action_insert_text()
clear_mock.assert_called_once_with()
assert len(view.scene.items()) == 1
item = view.scene.items()[0]
assert item.toPlainText() == 'Text'
assert item.isSelected() is True
@patch('PyQt6.QtWidgets.QApplication.clipboard')
def test_on_action_copy(clipboard_mock, view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
@ -359,9 +371,24 @@ def test_on_action_paste_internal(mimedata_mock, clear_mock, view):
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
@patch('PyQt6.QtGui.QClipboard.text')
@patch('PyQt6.QtGui.QClipboard.image')
def test_on_action_paste_when_empty(clipboard_mock, clear_mock, view):
clipboard_mock.return_value = QtGui.QImage()
def test_on_action_paste_when_text(img_mock, text_mock, clear_mock, view):
img_mock.return_value = QtGui.QImage()
text_mock.return_value = 'foo bar'
view.on_action_paste()
assert len(view.scene.items()) == 1
assert view.scene.items()[0].isSelected() is True
assert view.scene.items()[0].toPlainText() == 'foo bar'
clear_mock.assert_called_once_with()
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
@patch('PyQt6.QtGui.QClipboard.text')
@patch('PyQt6.QtGui.QClipboard.image')
def test_on_action_paste_when_empty(img_mock, text_mock, clear_mock, view):
img_mock.return_value = QtGui.QImage()
text_mock.return_value = ''
view.on_action_paste()
assert len(view.scene.items()) == 0
clear_mock.assert_not_called()