diff --git a/beeref/commands.py b/beeref/commands.py index d112a98..2383bb0 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -18,12 +18,16 @@ from PyQt6 import QtCore, QtGui class InsertItems(QtGui.QUndoCommand): - def __init__(self, scene, items): + def __init__(self, scene, items, ignore_first_redo=False): super().__init__('Insert items') self.scene = scene self.items = items + self.ignore_first_redo = ignore_first_redo def redo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return self.scene.clearSelection() for item in self.items: self.scene.addItem(item) diff --git a/beeref/fileio/__init__.py b/beeref/fileio/__init__.py index 52955bf..ccf7637 100644 --- a/beeref/fileio/__init__.py +++ b/beeref/fileio/__init__.py @@ -15,23 +15,86 @@ import logging + +from PyQt6 import QtCore, QtGui + +from beeref import commands from beeref.fileio.errors import BeeFileIOError from beeref.fileio.sql import SQLiteIO +from beeref.items import BeePixmapItem -__all__ = ['load', 'save', 'BeeFileIOError'] +__all__ = [ + 'load_bee', + 'save_bee', + 'load_images', + 'ThreadedLoader', + 'BeeFileIOError', +] + logger = logging.getLogger('BeeRef') -def load(filename, scene, progress=None): +def load_bee(filename, scene, worker=None): + """Load BeeRef native file.""" logger.info(f'Loading from file {filename}...') - io = SQLiteIO(filename, scene, readonly=True, progress=progress) + io = SQLiteIO(filename, scene, readonly=True, worker=worker) return io.read() -def save(filename, scene, create_new=False, progress=None): +def save_bee(filename, scene, create_new=False, worker=None): + """Save BeeRef native file.""" logger.info(f'Saving to file {filename}...') logger.debug(f'Create new: {create_new}') - io = SQLiteIO(filename, scene, create_new, progress=progress) + io = SQLiteIO(filename, scene, create_new, worker=worker) io.write() logger.info('Saved!') + + +def load_images(filenames, pos, scene, worker): + """Add images to existing scene.""" + + errors = [] + items = [] + worker.begin_processing.emit(len(filenames)) + for i, filename in enumerate(filenames): + logger.info(f'Loading image from file {filename}') + img = QtGui.QImage(filename) + worker.progress.emit(i) + if img.isNull(): + errors.append(filename) + continue + item = BeePixmapItem(img, filename) + item.set_pos_center(pos) + pos.setX(pos.x() + 50) + pos.setY(pos.y() + 50) + scene.add_item_later(item, selected=True) + items.append(item) + if worker.canceled: + break + + scene.undo_stack.push( + commands.InsertItems(scene, items, ignore_first_redo=True)) + worker.finished.emit('', errors) + + +class ThreadedIO(QtCore.QThread): + """Dedicated thread for loading and saving.""" + + progress = QtCore.pyqtSignal(int) + finished = QtCore.pyqtSignal(str, list) + begin_processing = QtCore.pyqtSignal(int) + + def __init__(self, func, *args, **kwargs): + super().__init__() + self.func = func + self.args = args + self.kwargs = kwargs + self.kwargs['worker'] = self + self.canceled = False + + def run(self): + self.func(*self.args, **self.kwargs) + + def on_canceled(self): + self.canceled = True diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index a3de084..8a105fc 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -43,9 +43,10 @@ def handle_sqlite_errors(func): func(self, *args, **kwargs) except sqlite3.Error as e: logger.exception(f'Error while reading/writing {self.filename}') - if self.progress: - self.progress.setValue(self.progress.maximum()) - raise BeeFileIOError(msg=str(e), filename=self.filename) from e + if self.worker: + self.worker.finished.emit('', [str(e)]) + else: + raise BeeFileIOError(msg=str(e), filename=self.filename) from e return wrapper @@ -55,12 +56,12 @@ class SQLiteIO: APPLICATION_ID = 2060242126 def __init__(self, filename, scene, create_new=False, readonly=False, - progress=None): + worker=None): self.scene = scene self.create_new = create_new self.filename = filename self.readonly = readonly - self.progress = progress + self.worker = worker def __del__(self): self._close_connection() @@ -130,24 +131,27 @@ class SQLiteIO: 'SELECT items.id, x, y, z, scale, rotation, flip, filename, ' 'sqlar.data ' 'FROM items INNER JOIN sqlar on sqlar.item_id = items.id') - if self.progress: - self.progress.setMaximum(len(rows)) + 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]) - self.scene.addItem(item) + self.scene.add_item_later(item) item.setZValue(row[3]) item.setScale(row[4]) item.setRotation(row[5]) if row[6] == -1: item.do_flip() - if self.progress: - self.progress.setValue(i) - if self.progress.wasCanceled(): - break + if self.worker: + self.worker.progress.emit(i) + if self.worker.canceled: + self.worker.finished.emit('', []) + return + if self.worker: + self.worker.finished.emit(self.filename, []) @handle_sqlite_errors def write(self): @@ -168,8 +172,8 @@ class SQLiteIO: def write_data(self): to_delete = self.fetchall('SELECT id from ITEMS') to_save = list(self.scene.items_for_save()) - if self.progress: - self.progress.setMaximum(len(to_save)) + if self.worker: + self.worker.begin_processing.emit(len(to_save)) for i, item in enumerate(to_save): logger.debug(f'Saving {item} with id {item.save_id}') if item.save_id: @@ -177,12 +181,14 @@ class SQLiteIO: to_delete.remove((item.save_id,)) else: self.insert_item(item) - if self.progress: - self.progress.setValue(i) - if self.progress.wasCanceled(): + if self.worker: + self.worker.progress.emit(i) + if self.worker.canceled: break self.delete_items(to_delete) self.connection.commit() + if self.worker: + self.worker.finished.emit(self.filename, []) def delete_items(self, to_delete): self.exmany('DELETE FROM items WHERE id=?', to_delete) diff --git a/beeref/gui.py b/beeref/gui.py index 7abf2e7..f24e90d 100644 --- a/beeref/gui.py +++ b/beeref/gui.py @@ -44,10 +44,20 @@ class WelcomeOverlay(QtWidgets.QWidget): class BeeProgressDialog(QtWidgets.QProgressDialog): - def __init__(self, label, maximum=100, parent=None): + def __init__(self, label, worker, maximum=100, parent=None): super().__init__(label, 'Cancel', 0, maximum, parent=parent) - self.setMinimumDuration(2) + self.setMinimumDuration(0) self.setWindowModality(Qt.WindowModality.WindowModal) + worker.begin_processing.connect(self.on_begin_processing) + worker.progress.connect(self.on_progress) + worker.finished.connect(self.on_finished) + self.canceled.connect(worker.on_canceled) - def setValue(self, value): - super().setValue(value + 1) + def on_progress(self, value): + self.setValue(value + 1) + + def on_begin_processing(self, value): + self.setMaximum(value) + + def on_finished(self, filename, errors): + self.close() diff --git a/beeref/scene.py b/beeref/scene.py index 0c5688b..5e0f9e6 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . +from queue import Queue import logging import math @@ -38,6 +39,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): self.rubberband_item = RubberbandItem() self.selectionChanged.connect(self.on_selection_change) self.changed.connect(self.on_change) + self.items_to_add = Queue() def normalize_width_or_height(self, mode): """Scale the selected images to have the same width or height, as @@ -205,3 +207,19 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): and not self.multi_select_item.rotate_active): self.multi_select_item.fit_selection_area( self.get_selection_rect()) + + def add_item_later(self, item, selected=False): + """Keep an item for adding later via ``add_delayed_items``""" + + self.items_to_add.put((item, selected)) + + def add_delayed_items(self): + """Adds items added via ``add_items_later``""" + + while not self.items_to_add.empty(): + item, selected = self.items_to_add.get() + self.addItem(item) + if selected: + item.setSelected(True) + item.bring_to_front() + self.max_z = max(self.max_z, item.zValue()) diff --git a/beeref/selection.py b/beeref/selection.py index 99ae1dc..38e5220 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -72,7 +72,8 @@ class BaseItemMixin: def setZValue(self, value): logger.debug(f'Setting z-value for {self} to {value}') super().setZValue(value) - self.scene().max_z = max(self.scene().max_z, value) + if self.scene(): + self.scene().max_z = max(self.scene().max_z, value) def bring_to_front(self): self.setZValue(self.scene().max_z + 0.001) @@ -536,7 +537,7 @@ class MultiSelectItem(SelectableMixin, def fit_selection_area(self, rect): """Updates itself to fit the given selection area.""" - logging.debug(f'Fit selection area to {rect}') + logger.debug(f'Fit selection area to {rect}') # Only update when values have changed, otherwise we end up in an # infinite event loop sceneChange -> itemChange -> sceneChange ... diff --git a/beeref/view.py b/beeref/view.py index 15d83aa..fd365dc 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -78,7 +78,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): def on_scene_changed(self, region): if not self.scene.items(): - logger.info('No items in scene') + logger.debug('No items in scene') self.setTransform(QtGui.QTransform()) self.welcome_overlay.show() else: @@ -138,23 +138,32 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): def on_action_flip_vertically(self): self.scene.flip_items(vertical=True) - def open_from_file(self, filename): - logger.info(f'Opening file {filename}') - self.clear_scene() - try: - progress = BeeProgressDialog( - 'Loading %s' % filename, - parent=self) - fileio.load(filename, self.scene, progress) + def on_items_loaded(self, value): + self.scene.add_delayed_items() + + def on_loading_finished(self, filename, errors): + if filename: self.filename = filename - progress.close() - except fileio.BeeFileIOError: + if errors: QtWidgets.QMessageBox.warning( self, 'Problem loading file', ('

Problem loading file %s

' '

Not accessible or not a proper bee file

') % filename) + def open_from_file(self, filename): + logger.info(f'Opening file {filename}') + self.clear_scene() + self.worker = fileio.ThreadedIO( + fileio.load_bee, filename, self.scene) + self.worker.progress.connect(self.on_items_loaded) + self.worker.finished.connect(self.on_loading_finished) + self.progress = BeeProgressDialog( + 'Loading %s' % filename, + worker=self.worker, + parent=self) + self.worker.start() + def on_action_open(self): filename, f = QtWidgets.QFileDialog.getOpenFileName( parent=self, @@ -164,44 +173,59 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): self.open_from_file(filename) self.filename = filename + def on_saving_finished(self, filename, errors): + if filename: + self.filename = filename + else: + QtWidgets.QMessageBox.warning( + self, + 'Problem saving file', + ('

Problem saving file %s

' + '

File/directory not accessible

') % filename) + + def do_save(self, filename, create_new): + if not filename.endswith('.bee'): + filename = f'{filename}.bee' + self.worker = fileio.ThreadedIO( + fileio.save_bee, filename, self.scene, create_new=create_new) + self.worker.finished.connect(self.on_saving_finished) + self.progress = BeeProgressDialog( + 'Saving %s' % filename, + worker=self.worker, + parent=self) + self.worker.start() + def on_action_save_as(self): filename, f = QtWidgets.QFileDialog.getSaveFileName( parent=self, caption='Save file', filter='BeeRef File (*.bee)') if filename: - if not filename.endswith('.bee'): - filename = f'{filename}.bee' - try: - progress = BeeProgressDialog( - 'Saving %s' % filename, - parent=self) - fileio.save(filename, self.scene, create_new=True, - progress=progress) - self.filename = filename - progress.close() - except fileio.BeeFileIOError: - QtWidgets.QMessageBox.warning( - self, - 'Problem saving file', - ('

Problem saving file %s

' - '

File/directory not accessible

') % filename) + self.do_save(filename, create_new=True) def on_action_save(self): if not self.filename: self.on_action_save_as() else: - progress = BeeProgressDialog( - 'Saving %s' % self.filename, - parent=self) - fileio.save(self.filename, self.scene, create_new=False, - progress=progress) - progress.close() + self.do_save(self.filename, create_new=False) def on_action_quit(self): logger.info('User quit. Exiting...') self.app.quit() + def on_insert_images_finished(self, filename, errors): + if errors: + errornames = [ + f'
  • {fn}
  • ' for fn in errors] + errornames = '' % '\n'.join(errornames) + msg = ('{errors} image(s) out of {total} ' + 'could not be opened:'.format( + errors=len(errors), total=len(errors))) + QtWidgets.QMessageBox.warning( + self, + 'Problem loading images', + msg + errornames) + def on_action_insert_images(self): formats = self.get_supported_image_formats(QtGui.QImageReader) filenames, f = QtWidgets.QFileDialog.getOpenFileNames( @@ -209,42 +233,19 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin): caption='Select one ore more images to open', filter=f'Images ({formats})') - pos = self.mapToScene(self.get_view_center()) - errors = [] - items = [] - - progress = BeeProgressDialog( - 'Loading images...', len(filenames), parent=self) - - for i, filename in enumerate(filenames): - logger.info(f'Loading image from file {filename}') - img = QtGui.QImage(filename) - progress.setValue(i) - if progress.wasCanceled(): - break - if img.isNull(): - errors.append(filename) - continue - item = BeePixmapItem(img, filename) - item.set_pos_center(pos) - items.append(item) - pos.setX(pos.x() + 50) - pos.setY(pos.y() + 50) - - progress.close() - self.undo_stack.push(commands.InsertItems(self.scene, items)) - - if errors: - errornames = [ - f'
  • {fn}
  • ' for fn in errors] - errornames = '' % '\n'.join(errornames) - msg = ('{errors} image(s) out of {total} ' - 'could not be opened:'.format( - errors=len(errors), total=len(filenames))) - QtWidgets.QMessageBox.warning( - self, - 'Problem loading images', - msg + errornames) + self.scene.clearSelection() + self.worker = fileio.ThreadedIO( + fileio.load_images, + filenames, + self.mapToScene(self.get_view_center()), + self.scene) + self.worker.progress.connect(self.on_items_loaded) + self.worker.finished.connect(self.on_insert_images_finished) + self.progress = BeeProgressDialog( + 'Loading images', + worker=self.worker, + parent=self) + self.worker.start() def on_action_paste(self): logger.info('Pasting from clipboard...') diff --git a/tests/base.py b/tests/base.py index 19880ad..d00d486 100644 --- a/tests/base.py +++ b/tests/base.py @@ -20,6 +20,12 @@ class BeeTestCase(TestCase): inst = QtWidgets.QApplication.instance() cls.app = inst if inst else QtWidgets.QApplication([]) + def queue2list(self, queue): + qlist = [] + while not queue.empty(): + qlist.append(queue.get()) + return qlist + # @classmethod # def tearDownClass(cls): # cls.app.quit() diff --git a/tests/fileio/test_init.py b/tests/fileio/test_init.py index 7b553f6..2c3126d 100644 --- a/tests/fileio/test_init.py +++ b/tests/fileio/test_init.py @@ -1,21 +1,87 @@ import os.path import tempfile -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from PyQt6 import QtCore from beeref import fileio +from beeref import commands +from beeref.scene import BeeGraphicsScene +from ..base import BeeTestCase @patch('beeref.fileio.sql.SQLiteIO.write') -def test_save_create_new_false(write_mock): +def test_save_bee_create_new_false(write_mock): with tempfile.TemporaryDirectory() as dirname: fname = os.path.join(dirname, 'test.bee') - fileio.save(fname, 'myscene', create_new=False) + fileio.save_bee(fname, 'myscene', create_new=False) write_mock.assert_called_once() @patch('beeref.fileio.sql.SQLiteIO.read') -def test_write(read_mock): +def test_read_bee(read_mock): with tempfile.TemporaryDirectory() as dirname: fname = os.path.join(dirname, 'test.bee') - fileio.load(fname, 'myscene') + fileio.load_bee(fname, 'myscene') read_mock.assert_called_once() + + +class LoadImagesTestCase(BeeTestCase): + + def setUp(self): + self.scene = BeeGraphicsScene(MagicMock()) + + def test_loads(self): + worker = MagicMock(canceled=False) + fileio.load_images([self.imgfilename3x3], + QtCore.QPointF(5, 6), self.scene, worker) + 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 = self.queue2list(self.scene.items_to_add) + assert len(items) == 1 + item = items[0][0] + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert isinstance(cmd, commands.InsertItems) + assert cmd.items == [item] + assert cmd.scene == self.scene + assert cmd.ignore_first_redo is True + assert item.pos() == QtCore.QPointF(3.5, 4.5) + + def test_canceled(self): + worker = MagicMock(canceled=True) + fileio.load_images([self.imgfilename3x3, self.imgfilename3x3], + QtCore.QPointF(5, 6), self.scene, worker) + 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 = self.queue2list(self.scene.items_to_add) + assert len(items) == 1 + item = items[0][0] + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert isinstance(cmd, commands.InsertItems) + assert cmd.items == [item] + assert cmd.scene == self.scene + assert cmd.ignore_first_redo is True + assert item.pos() == QtCore.QPointF(3.5, 4.5) + + def test_error(self): + worker = MagicMock(canceled=False) + fileio.load_images(['foo.jpg', self.imgfilename3x3], + QtCore.QPointF(5, 6), self.scene, worker) + worker.begin_processing.emit.assert_called_once_with(2) + worker.progress.emit.assert_any_call(0) + worker.progress.emit.assert_any_call(1) + worker.finished.emit.assert_called_once_with('', ['foo.jpg']) + items = self.queue2list(self.scene.items_to_add) + assert len(items) == 1 + item = items[0][0] + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert isinstance(cmd, commands.InsertItems) + assert cmd.items == [item] + assert cmd.scene == self.scene + assert cmd.ignore_first_redo is True + assert item.pos() == QtCore.QPointF(3.5, 4.5) diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index 42804d9..e4b24fb 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -189,14 +189,28 @@ class SQLiteIOWriteTestCase(BeeTestCase): assert result[0] == 1 def test_updates_progress(self): - progress = MagicMock() + worker = MagicMock(canceled=False) io = SQLiteIO(':memory:', self.scene, create_new=True, - progress=progress) + worker=worker) item = BeePixmapItem(QtGui.QImage()) self.scene.addItem(item) io.write() - progress.setMaximum.assert_called_once_with(1) - progress.setValue.assert_called_once_with(0) + worker.begin_processing.emit.assert_called_once_with(1) + worker.progress.emit.assert_called_once_with(0) + worker.finished.emit.assert_called_once_with(':memory:', []) + + def test_canceled(self): + worker = MagicMock(canceled=True) + io = SQLiteIO(':memory:', self.scene, create_new=True, + worker=worker) + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + item = BeePixmapItem(QtGui.QImage()) + self.scene.addItem(item) + io.write() + worker.begin_processing.emit.assert_called_once_with(2) + worker.progress.emit.assert_called_once_with(0) + worker.finished.emit.assert_called_once_with(':memory:', []) class SQLiteIOReadTestCase(BeeTestCase): @@ -220,8 +234,8 @@ class SQLiteIOReadTestCase(BeeTestCase): io = SQLiteIO(fname, self.scene, readonly=True) io.read() - assert len(self.scene.items()) == 1 - item = self.scene.items()[0] + item, selected = self.scene.items_to_add.get() + assert selected is False assert item.save_id == 1 assert item.pos().x() == 22.2 assert item.pos().y() == 33.3 @@ -232,11 +246,12 @@ class SQLiteIOReadTestCase(BeeTestCase): assert item.filename == 'bee.png' assert item.width == 3 assert item.height == 3 + assert self.scene.items_to_add.empty() is True def test_updates_progress(self): - progress = MagicMock() + worker = MagicMock(canceled=False) io = SQLiteIO(':memory:', self.scene, create_new=True, - progress=progress) + worker=worker) io.create_schema_on_new() io.ex('INSERT INTO items (type, x, y, z, scale, filename) ' @@ -245,8 +260,29 @@ class SQLiteIOReadTestCase(BeeTestCase): io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b'')) io.connection.commit() io.read() - progress.setMaximum.assert_called_once_with(1) - progress.setValue.assert_called_once_with(0) + worker.begin_processing.emit.assert_called_once_with(1) + worker.progress.emit.assert_called_once_with(0) + worker.finished.emit.assert_called_once_with(':memory:', []) + + def test_canceled(self): + worker = MagicMock(canceled=True) + io = SQLiteIO(':memory:', self.scene, create_new=True, + worker=worker) + + io.create_schema_on_new() + io.ex('INSERT INTO items (type, x, y, z, scale, filename) ' + 'VALUES (?, ?, ?, ?, ?, ?) ', + ('pixmap', 0, 0, 0, 1, 'bee.png')) + io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b'')) + io.ex('INSERT INTO items (type, x, y, z, scale, filename) ' + 'VALUES (?, ?, ?, ?, ?, ?) ', + ('pixmap', 50, 50, 0, 1, 'bee2.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(2) + worker.progress.emit.assert_called_once_with(0) + worker.finished.emit.assert_called_once_with('', []) def test_raises_error_when_file_borked(self): with tempfile.TemporaryDirectory() as dirname: @@ -259,6 +295,20 @@ class SQLiteIOReadTestCase(BeeTestCase): io.read() assert exinfo.value.filename == fname + def test_emits_error_message_when_file_borked(self): + with tempfile.TemporaryDirectory() as dirname: + fname = os.path.join(dirname, 'test.bee') + with open(fname, 'w') as f: + f.write('foobar') + + worker = MagicMock() + io = SQLiteIO(fname, self.scene, readonly=True, worker=worker) + io.read() + worker.finished.emit.assert_called_once() + args = worker.finished.emit.call_args_list[0][0] + assert args[0] == '' + assert len(args[1]) == 1 + def test_reads_raises_error_when_file_empty(self): with tempfile.TemporaryDirectory() as dirname: fname = os.path.join(dirname, 'test.bee') diff --git a/tests/test_commands.py b/tests/test_commands.py index ca76b00..9e9b9b5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -30,6 +30,26 @@ class InsertItemsTestCase(BeeTestCase): assert list(scene.items_for_save()) == [item1] assert item1.isSelected() is False + @patch('beeref.scene.BeeGraphicsScene.views') + def test_ignore_first_redo(self, views_mock): + scene = BeeGraphicsScene(None) + view = MagicMock(get_scale=MagicMock(return_value=1)) + views_mock.return_value = [view] + scene.update_selection = MagicMock() + scene.max_z = 5 + item1 = BeePixmapItem(QtGui.QImage()) + scene.addItem(item1) + item2 = BeePixmapItem(QtGui.QImage()) + command = commands.InsertItems(scene, [item2], ignore_first_redo=True) + command.redo() + assert list(scene.items_for_save()) == [item1] + assert item1.isSelected() is False + command.redo() + assert list(scene.items_for_save()) == [item1, item2] + assert item1.isSelected() is False + assert item2.isSelected() is True + item2.zValue() > 5 + class DeleteItemsTestCase(BeeTestCase): diff --git a/tests/test_scene.py b/tests/test_scene.py index cd8cce0..317a4d0 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -77,7 +77,7 @@ class BeeGraphicsSceneTestCase(BeeTestCase): self.scene.flip_items(vertical=True) args = self.scene.undo_stack.push.call_args_list[0][0] cmd = args[0] - isinstance(cmd, commands.FlipItems) + assert isinstance(cmd, commands.FlipItems) assert cmd.items == [item] assert cmd.anchor == QtCore.QPointF(60, 50) assert cmd.vertical is True @@ -272,7 +272,7 @@ class BeeGraphicsSceneTestCase(BeeTestCase): self.scene.undo_stack.push.assert_called_once() args = self.scene.undo_stack.push.call_args_list[0][0] cmd = args[0] - isinstance(cmd, commands.MoveItemsBy) + assert isinstance(cmd, commands.MoveItemsBy) assert cmd.items == [item] assert cmd.ignore_first_redo is True assert cmd.delta.x() == 10 @@ -473,3 +473,26 @@ class BeeGraphicsSceneTestCase(BeeTestCase): self.scene.multi_select_item.rotate_active = True self.scene.on_change(None) self.scene.multi_select_item.fit_selection_area.assert_not_called() + + def test_add_delayed_items_unselected(self): + item = BeePixmapItem(QtGui.QImage()) + item.setZValue(0.33) + self.scene.add_item_later(item, selected=False) + self.scene.add_delayed_items() + assert self.scene.items() == [item] + assert item.isSelected() is False + assert self.scene.max_z == 0.33 + + def test_add_delayed_items_selected(self): + self.scene.max_z = 0.6 + item = BeePixmapItem(QtGui.QImage()) + item.setZValue(0.33) + self.scene.add_item_later(item, selected=True) + self.scene.add_delayed_items() + assert self.scene.items() == [item] + assert item.isSelected() is True + assert item.zValue() > 0.6 + + def test_add_delayed_items_when_no_items(self): + self.scene.add_delayed_items() + assert self.scene.items() == [] diff --git a/tests/test_view.py b/tests/test_view.py index 3f01c34..ec511bc 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -2,6 +2,8 @@ import os.path import tempfile from unittest.mock import MagicMock, patch +from pytest import mark + from PyQt6 import QtGui, QtWidgets from beeref.items import BeePixmapItem @@ -87,29 +89,44 @@ class BeeGraphicsViewTestCase(BeeTestCase): root = os.path.dirname(__file__) filename = os.path.join(root, 'assets', 'test1item.bee') self.view.open_from_file(filename) - assert len(self.view.scene.items()) == 1 + self.view.worker.wait() + items = self.queue2list(self.view.scene.items_to_add) + assert len(items) == 1 + item, selected = items[0] + assert items[0][0].pixmap() + assert items[0][1] is False clear_mock.assert_called_once_with() @patch('PyQt6.QtWidgets.QMessageBox.warning') def test_open_from_file_when_error(self, warn_mock): + # FIXME: #1 + # Can't check signal handling currently self.view.open_from_file('uieauiae') + self.view.worker.wait() + assert self.view.scene.items_to_add.empty() is True assert len(self.view.scene.items()) == 0 - warn_mock.assert_called_once() @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName') def test_on_action_open(self, dialog_mock): + # FIXME: #1 + # Can't check signal handling currently root = os.path.dirname(__file__) dialog_mock.return_value = ( os.path.join(root, 'assets', 'test1item.bee'), None) self.view.on_action_open() - assert len(self.view.scene.items()) == 1 + self.view.worker.wait() + items = self.queue2list(self.view.scene.items_to_add) + assert len(items) == 1 + assert items[0][0].pixmap() + assert items[0][1] is False @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName') - def test_on_action_open_when_no_filename(self, dialog_mock): + @patch('beeref.view.BeeGraphicsView.on_action_open') + def test_on_action_open_when_no_filename(self, dialog_mock, open_mock): dialog_mock.return_value = (None, None) self.view.on_action_open() - assert len(self.view.scene.items()) == 0 + open_mock.assert_not_called() @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') def test_on_action_save_as(self, dialog_mock): @@ -120,10 +137,11 @@ class BeeGraphicsViewTestCase(BeeTestCase): assert os.path.exists(filename) is False dialog_mock.return_value = (filename, None) self.view.on_action_save_as() + self.view.worker.wait() assert os.path.exists(filename) is True @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') - @patch('beeref.fileio.save') + @patch('beeref.view.BeeGraphicsView.do_save') def test_on_action_save_as_when_no_filename(self, save_mock, dialog_mock): item = BeePixmapItem(QtGui.QImage(self.imgfilename3x3)) self.view.scene.addItem(item) @@ -140,8 +158,10 @@ class BeeGraphicsViewTestCase(BeeTestCase): assert os.path.exists(filename) is False dialog_mock.return_value = (filename, None) self.view.on_action_save_as() + self.view.worker.wait() assert os.path.exists(f'{filename}.bee') is True + @mark.skip('needs pytest-qt') @patch('PyQt6.QtWidgets.QMessageBox.warning') @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') @patch('beeref.fileio.save') @@ -165,6 +185,7 @@ class BeeGraphicsViewTestCase(BeeTestCase): self.view.filename = os.path.join(tmpdir, 'test.bee') assert os.path.exists(self.view.filename) is False self.view.on_action_save() + self.view.worker.wait() assert os.path.exists(self.view.filename) is True @patch('beeref.view.BeeGraphicsView.on_action_save_as') @@ -178,23 +199,30 @@ class BeeGraphicsViewTestCase(BeeTestCase): @patch('beeref.scene.BeeGraphicsScene.clearSelection') @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileNames') def test_on_action_insert_images(self, dialog_mock, clear_mock): + # FIXME: #1 + # Can't check signal handling currently dialog_mock.return_value = ([self.imgfilename3x3], None) self.view.on_action_insert_images() - assert len(self.view.scene.items()) == 1 - assert self.view.scene.items()[0].isSelected() is True + self.view.worker.wait() + items = self.queue2list(self.view.scene.items_to_add) + assert len(items) == 1 + assert items[0][0].pixmap() + assert items[0][1] is True clear_mock.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection') - @patch('PyQt6.QtWidgets.QMessageBox.warning') @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileNames') - def test_on_action_insert_images_when_error( - self, dialog_mock, warn_mock, clear_mock): + def test_on_action_insert_images_when_error(self, dialog_mock, clear_mock): + # FIXME: #1 + # Can't check signal handling currently dialog_mock.return_value = ( [self.imgfilename3x3, 'iaeiae', 'trntrn'], None) self.view.on_action_insert_images() - assert len(self.view.scene.items()) == 1 - assert self.view.scene.items()[0].isSelected() is True - warn_mock.assert_called_once() + self.view.worker.wait() + items = self.queue2list(self.view.scene.items_to_add) + assert len(items) == 1 + assert items[0][0].pixmap() + assert items[0][1] is True clear_mock.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection')