diff --git a/.gitignore b/.gitignore index 0db9e6a46b9fa4611784c4cce0d509c708f3a1d4..8015dc08236bd6b1e9c0b5261cd9ed9a862e9904 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ tags # Database soitool/database +soitool/testDatabase* __pycache__/ diff --git a/soitool/accept_reject_dialog.py b/soitool/accept_reject_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..0e67f84ad72a0d62c963c635c342d92677652c14 --- /dev/null +++ b/soitool/accept_reject_dialog.py @@ -0,0 +1,42 @@ +"""Provides a superclass to use for all dialogs needing accept and reject.""" + +from PySide2.QtWidgets import ( + QHBoxLayout, + QVBoxLayout, + QPushButton, + QDialog, +) + + +class AcceptRejectDialog(QDialog): + """Dialog with accept and reject buttons. + + The class is not meant to be used directly. It is meant to be the + superclass of all dialogs that include "accept" and "reject" -like buttons. + + Button texts have meaningful defaults, but can be overriden by subclass. + + Subclasses should include custom content by adding it to the + 'layout_content' layout. + """ + + def __init__(self): + super().__init__() + + self.button_ok = QPushButton("Ok") + self.button_cancel = QPushButton("Avbryt") + + self.button_ok.clicked.connect(self.accept) + self.button_cancel.clicked.connect(self.reject) + + self.layout_content = QVBoxLayout() + self.layout_accept_reject_buttons = QHBoxLayout() + self.layout_accept_reject_buttons.addWidget(self.button_ok) + self.layout_accept_reject_buttons.addWidget(self.button_cancel) + self.layout_content_and_buttons = QVBoxLayout() + self.layout_content_and_buttons.addLayout(self.layout_content) + self.layout_content_and_buttons.addLayout( + self.layout_accept_reject_buttons + ) + + self.setLayout(self.layout_content_and_buttons) diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index 8a6c9a85e417cb5b00e799a00efb341e2390328f..5bde3a8ba14af685065fa8200d96e2148e36651a 100644 --- a/soitool/inline_editable_soi_view.py +++ b/soitool/inline_editable_soi_view.py @@ -9,8 +9,15 @@ from PySide2.QtWidgets import ( QGraphicsScene, QGraphicsView, QGraphicsRectItem, + QGraphicsProxyWidget, +) +from PySide2.QtGui import ( + QFont, + QPixmap, + QBrush, + QPalette, + QPainter, ) -from PySide2.QtGui import QFont, QPixmap, QBrush, QPalette, QPainter from PySide2.QtPrintSupport import QPrinter from soitool.soi import ModuleLargerThanBinError from soitool.dialog_wrappers import exec_warning_dialog @@ -22,45 +29,153 @@ from soitool.serialize_export_import_soi import generate_soi_filename ATTACHMENT_NUMBERING_SCHEME = list(string.ascii_uppercase) -class InlineEditableSOIView(QScrollArea): - """Widget som kan byttes ut med view, edit etc.""" +class ProxyLabelWithCustomQPrintText(QGraphicsProxyWidget): + """QGraphicsItem that prints a custom text when printed onto QPrint. - def is_widget_in_scene(self, widget): - """Indicate wether given widget already has a proxy in the scene.""" - for proxy in self.proxies: - if proxy.widget() == widget: - return True - return False + Useful to have a piece of text be painted differently in a QGraphicsScene + depending on whether it's drawn to QPrint or not. - def ensure_proxies(self): - """Make sure all modules of the SOI have a proxy inside the scene.""" - for module in self.soi.modules + self.soi.attachments: - if not self.is_widget_in_scene(module["widget"]): - proxy = self.scene.addWidget(module["widget"]) - self.proxies.add(proxy) + Note that this class doesn't have to use a QLabel. It was done to KISS. - def mousePressEvent(self, _): - """Reorganize modules when pressed. + ## How it works - This is a temporary way to activate reorganization of widgets. Note - that will not be triggered by clicks on modules in the scene. + When the QGrahpicsScene wants to render it's items it first uses the + "bounding rects" of it's items to figure out which of them needs to be + redrawn. It then redraws items using their `paint` functions. By overriding + `paint` we can control how our item is drawn. One of the parameters to the + `paint` function is the QPainter that is being used, which we can use to + print custom text onto QPrinter. - If reorganization cannot occur because a module is too large the user - will be informed. It's worth noting that with the current - implementation this is the only way the user will be informed of this, - so reorganization that is not triggered here will not give feedback to - the user. + Parameters + ---------- + default_text : str + Text to be drawn for all QPainters except QPrint. + printing_text : str + Text to be drawn if the QPainter is QPrint. + """ + + def __init__(self, default_text, printing_text): + super(ProxyLabelWithCustomQPrintText, self).__init__() + + self.default_text = default_text + self.printing_text = printing_text + + # self.boundingRect is updated at the end of this function, so this + # default value is in practice never used. + self.bounding_rect = QRectF(0, 0, 0, 0) + + self.label = QLabel(self.default_text) + self.setWidget(self.label) + + self.update_bounding_rect() + + def update_bounding_rect(self): + """Update bounding rect property.""" + self.bounding_rect = self.determine_bounding_rect() + + def determine_bounding_rect(self): + """Calculate bounding rect that encapsulates both alternative strings. + + From the docs: "Although the item's shape can be arbitrary, the + bounding rect is always rectangular, and it is unaffected by the + items' transformation." Link: + https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect + Because of this we're returning a rectangle (starting at 0,0) that + encapsulates the self.widget(), seen from this items local coordinate + system. This class purposefully lets self.widget() have different + content depending on the QPainter, so we give a bounding rect that + encapsulates the largest content. + + Returns + ------- + QRectF + Bounding rect that enpasulates both alternative contents of + self.widget(). """ - try: - self.soi.reorganize() - except ModuleLargerThanBinError: - exec_warning_dialog( - text="Minst én av modulene er for store " - "for å passe på én side!", - informative_text="Programmet kan desverre ikke fikse dette " - "for deg. Se igjennom modulene og sjekk at " - "alle moduler er mindre enn én enkelt side.", - ) + bounding_rect_default_text = self.label.fontMetrics().boundingRect( + self.default_text + ) + bounding_rect_printing_text = self.label.fontMetrics().boundingRect( + self.printing_text + ) + largest_width = max( + bounding_rect_default_text.width(), + bounding_rect_printing_text.width(), + ) + largest_height = max( + bounding_rect_default_text.height(), + bounding_rect_printing_text.height(), + ) + return QRectF(0, 0, largest_width, largest_height) + + def paint(self, painter, option, widget): + """Overridden to paint text depending on `painter` parameter. + + Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint + + Parameter + --------- + painter : QPainter + QPainter that is painting the item. Used to determine which text + to draw. + option : QStyleOptionGraphicsItem + Passed on to superclass implementation. See source. + widget : QWidget + Passed on to superclass implementation. See source. + """ + if isinstance(painter.device(), QPrinter): + self.label.setText(self.printing_text) + else: + self.label.setText(self.default_text) + + # From Qt docs: "Prepares the item for a geometry change. Call this + # function before changing the bounding rect of an item to keep + # QGraphicsScene's index up to date." + # https://doc.qt.io/qt-5/qgraphicsitem.html#prepareGeometryChange + self.prepareGeometryChange() + + # QLabel doesn't adjust it's size automatically, so do it here + # https://stackoverflow.com/a/47037607/3545896 + self.label.adjustSize() + + # Let super handle the actual painting + super(ProxyLabelWithCustomQPrintText, self).paint( + painter, option, widget + ) + + def boundingRect(self): + """Give QRectF that bounds this item. Overridden. + + Overridden to provide custom bounding rect. Custom bounding rect is + needed because this item has two different contents depending on where + it is drawn, which Qt does not respect (or understand) out of the box. + Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect + + This function is called by Qt to figure out how much space it needs. + + See the docstring of `determine_bounding_rect` for how the bounding + rect is calculated. + + If either of self.default_text or self.printing_text changes, or if + font / style is updated on self.label, self.bounding_rect needs to be + updated using `update_bounding_rect`. + + Returns + ------- + QRectF + Bounding rect. + """ + return self.bounding_rect + + +class InlineEditableSOIView(QScrollArea): + """Widget that allows for "inline" editing of an SOI. Also prints to PDF. + + Parameters + ---------- + soi : soitool.soi.SOI + SOI to edit. + """ def __init__(self, soi): super().__init__() @@ -74,6 +189,16 @@ class InlineEditableSOIView(QScrollArea): self.number_of_non_attachment_pages = 1 self.proxies = set() + # NOTE: These variables are only included to support PDF output of the + # widget. When rendering self.scene onto a QPrinter this widget will + # use these variables to indicate that "copy number self.copy_current + # is being printed, out of a total of self.copy_total copies". By + # looping self.copy_total times, updating self.copy_current each time + # and rendering onto a QPrinter it is possible to print multiple + # copies of the SOI, each marked with a copy number. + self.copy_current = 1 + self.copy_total = 3 + # Necessary to make the scroll area fill the space it's given self.setWidgetResizable(True) @@ -94,7 +219,44 @@ class InlineEditableSOIView(QScrollArea): # self.launch_auto_zoom() - def produce_pdf(self, filename=None): + def is_widget_in_scene(self, widget): + """Indicate wether given widget already has a proxy in the scene.""" + for proxy in self.proxies: + if proxy.widget() == widget: + return True + return False + + def ensure_proxies(self): + """Make sure all modules of the SOI have a proxy inside the scene.""" + for module in self.soi.modules + self.soi.attachments: + if not self.is_widget_in_scene(module["widget"]): + proxy = self.scene.addWidget(module["widget"]) + self.proxies.add(proxy) + + def mousePressEvent(self, _): + """Reorganize modules when pressed. + + This is a temporary way to activate reorganization of widgets. Note + that will not be triggered by clicks on modules in the scene. + + If reorganization cannot occur because a module is too large the user + will be informed. It's worth noting that with the current + implementation this is the only way the user will be informed of this, + so reorganization that is not triggered here will not give feedback to + the user. + """ + try: + self.soi.reorganize() + except ModuleLargerThanBinError: + exec_warning_dialog( + text="Minst én av modulene er for store " + "for å passe på én side!", + informative_text="Programmet kan desverre ikke fikse dette " + "for deg. Se igjennom modulene og sjekk at " + "alle moduler er mindre enn én enkelt side.", + ) + + def produce_pdf(self, number_of_copies, resolution, filename=None): """Produce PDF using QGraphicsScene. Renders the QGraphicsScene-representation of the SOI as a PDF. This @@ -107,6 +269,11 @@ class InlineEditableSOIView(QScrollArea): Parameters ---------- + number_of_copies : int + Total number of copies to produce. + resolution : int + Resolution of PDF. Passed to + https://doc.qt.io/qt-5/qprinter.html#setResolution filename : str Name of file to store PDF in. If file exists it will be overwritten. Note that the filename should contain the extension @@ -123,6 +290,7 @@ class InlineEditableSOIView(QScrollArea): filename = generate_soi_filename(self.soi) + ".pdf" printer = QPrinter(QPrinter.HighResolution) + printer.setResolution(resolution) printer.setOutputFormat(QPrinter.PdfFormat) printer.setOutputFileName(filename) printer.setPageSize(QPrinter.A4) @@ -141,19 +309,32 @@ class InlineEditableSOIView(QScrollArea): "filename '{}'".format(filename) ) - # Render each page to own PDF page - for i in range(self.number_of_pages_total): + # Update total number of copies from parameter + self.copy_total = number_of_copies - x = 0 - y = self.soi.HEIGHT * i + self.soi.PADDING * i + for i in range(self.copy_total): - self.scene.render( - painter, - source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT), - ) + # Update copy number and redraw pages so that it is reflected + # in the scene + self.copy_current = i + 1 + self.draw_pages() + + # Render each page to own PDF page + for j in range(self.number_of_pages_total): + + x = 0 + y = self.soi.HEIGHT * j + self.soi.PADDING * j - # If there are more pages, newPage - if i + 1 < self.number_of_pages_total: + self.scene.render( + painter, + source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT), + ) + + # If there are more pages, newPage + if j + 1 < self.number_of_pages_total: + printer.newPage() + # If there are more copies, newPage + if i + 1 < self.copy_total: printer.newPage() finally: painter.end() @@ -279,19 +460,33 @@ class InlineEditableSOIView(QScrollArea): proxy.setRotation(-90) # Copy number - copy_number = QLabel("1 av N") - copy_number.setStyleSheet("background-color: rgba(0,0,0,0%)") - copy_number.setFont(QFont("Times New Roman", 40)) + + # Store for usage when placing copy number title and page number + copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 + + # NOTE: See __init__ for explanation on self.copy_current and + # self.copy_total + proxy = ProxyLabelWithCustomQPrintText( + "1 av N", f"{self.copy_current} av {self.copy_total}" + ) + proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)") + proxy.label.setFont(QFont("Times New Roman", 40)) + + # Need to call this when label of proxy changes. See function docstring + proxy.update_bounding_rect() + # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( - copy_number.fontMetrics().boundingRect(copy_number.text()).width() + proxy.label.fontMetrics().boundingRect(proxy.label.text()).width() ) - # Store for usage when placing copy number title and page number - copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 - copy_number.move(x + 15, copy_number_y_pos + label_width / 2) - proxy = self.scene.addWidget(copy_number) + + # NOTE that this position is only correct for the default text. During + # PDF printing the ProxyLabelWithCustomQPrintText class will paint + # itself with a different text, and thus not be perfectly centered. + proxy.setPos(x + 15, copy_number_y_pos + label_width / 2) proxy.setRotation(-90) + self.scene.addItem(proxy) # Copy number title label_copy_number_header = QLabel("Eksemplar") diff --git a/soitool/main_window.py b/soitool/main_window.py index 23d78eb6e067e9c03e8c461f5b92642d5b2504a3..ddaf71c3771215eb7eba64bd9b41efa47d8ca2b4 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -11,6 +11,7 @@ from PySide2.QtWidgets import ( QApplication, QAction, QFileDialog, + QDialog, ) from PySide2.QtGui import QIcon from PySide2.QtCore import QTimer @@ -23,6 +24,7 @@ from soitool.soi_db_widget import SOIDbWidget from soitool.soi_model_view import SOITableModel from soitool.database import Database, DBPATH from soitool.help_actions import ShortcutsHelpDialog, BasicUsageHelpDialog +from soitool.pdf_export_options_dialog import PdfExportOptionsDialog from soitool.serialize_export_import_soi import ( export_soi, import_soi, @@ -270,7 +272,7 @@ class MainWindow(QMainWindow): elif medium == ExportMedium.UNCOMPRESSED: export_soi(tab_widget.soi, False) elif medium == ExportMedium.PDF: - tab_widget.view.produce_pdf() + self.prompt_user_and_produce_pdf() else: raise ValueError(f"Unknown medium for export '{medium}'") else: @@ -280,6 +282,21 @@ class MainWindow(QMainWindow): "For å eksportere en SOI må riktig tab velges.", ) + def prompt_user_and_produce_pdf(self): + """Prompt user for PDF options and produce PDF.""" + dialog_pdf_options = PdfExportOptionsDialog() + dialog_code = dialog_pdf_options.exec() + if dialog_code == QDialog.DialogCode.Accepted: + chosen_number_of_copies = ( + dialog_pdf_options.spinbox_number_of_copies.value() + ) + chosen_resolution = dialog_pdf_options.spinbox_resolution.value() + + tab_widget = self.tabs.currentWidget() + tab_widget.view.produce_pdf( + chosen_number_of_copies, chosen_resolution + ) + def import_soi(self): """Import serialized SOI. diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py index c7e30afea04aa54c398d06e38191767e58ea860b..b28ce56b8a52199cafd27cce3fbd79c7447f6366 100644 --- a/soitool/new_module_dialog.py +++ b/soitool/new_module_dialog.py @@ -3,8 +3,6 @@ from PySide2.QtWidgets import ( QHBoxLayout, QVBoxLayout, - QPushButton, - QDialog, QListWidget, QLineEdit, QListWidgetItem, @@ -18,6 +16,7 @@ from soitool.modules.module_authentication_board import ( ) from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule +from soitool.accept_reject_dialog import AcceptRejectDialog # Constant holding all modules the user can choose from. This is intended as a @@ -45,7 +44,7 @@ class NewModuleDialogListItem(QListWidgetItem): self.widget_implementation = widget -class NewModuleDialog(QDialog): +class NewModuleDialog(AcceptRejectDialog): """Dialog to let user select module to be added to the SOI. When the dialog is closed the user's choice is fetched by directly @@ -66,16 +65,6 @@ class NewModuleDialog(QDialog): if module_choices is None: module_choices = MODULE_CHOICES - self.button_add = QPushButton("Legg til") - self.button_cancel = QPushButton("Avbryt") - - self.button_add.clicked.connect(self.accept) - self.button_cancel.clicked.connect(self.reject) - - self.layout_buttons = QHBoxLayout() - self.layout_buttons.addWidget(self.button_add) - self.layout_buttons.addWidget(self.button_cancel) - self.list_module_choices = QListWidget() self.list_module_choices.setViewMode(QListWidget.IconMode) self.list_module_choices.setIconSize(QSize(100, 100)) @@ -87,7 +76,11 @@ class NewModuleDialog(QDialog): ) # Set the first element as the default choice, whatever it is - self.list_module_choices.item(0).setSelected(True) + # Only relevant if there is at least one item + if self.list_module_choices.count() > 0: + self.list_module_choices.setCurrentItem( + self.list_module_choices.item(0) + ) self.line_edit_name = QLineEdit() self.line_edit_name.setPlaceholderText( @@ -112,6 +105,5 @@ class NewModuleDialog(QDialog): self.layout_wrapper.addWidget(self.list_module_choices) self.layout_wrapper.addLayout(self.layout_name) self.layout_wrapper.addLayout(self.layout_attachment) - self.layout_wrapper.addLayout(self.layout_buttons) - self.setLayout(self.layout_wrapper) + self.layout_content.addLayout(self.layout_wrapper) diff --git a/soitool/pdf_export_options_dialog.py b/soitool/pdf_export_options_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..7142b4f1caf5a097c528b7bd20a9bbdf0510a41c --- /dev/null +++ b/soitool/pdf_export_options_dialog.py @@ -0,0 +1,60 @@ +"""Provides a dialog where the user can select options for export to PDF.""" + +from PySide2.QtWidgets import ( + QVBoxLayout, + QLabel, + QSpinBox, + QFormLayout, +) +from soitool.accept_reject_dialog import AcceptRejectDialog + + +class PdfExportOptionsDialog(AcceptRejectDialog): + """Dialog with options for PDF export. + + When the dialog is closed, the users choices can be fetched by directly + accessing the child widgets. + """ + + def __init__(self): + super().__init__() + self.setWindowTitle("PDF-eksport") + + self.label_number_of_copies = QLabel("Antall eksemplarer") + + self.spinbox_number_of_copies = QSpinBox() + self.spinbox_number_of_copies.setMinimum(1) + self.spinbox_number_of_copies.setValue(2) + + self.label_resolution = QLabel("Oppløsning") + + self.spinbox_resolution = QSpinBox() + self.spinbox_resolution.setMinimum(50) + self.spinbox_resolution.setMaximum(1200) + self.spinbox_resolution.setSingleStep(50) + self.spinbox_resolution.setSuffix(" dpi") + self.spinbox_resolution.setValue(200) + + self.layout_settings = QFormLayout() + + self.layout_settings.addRow( + self.label_number_of_copies, self.spinbox_number_of_copies, + ) + + self.layout_settings.addRow( + self.label_resolution, self.spinbox_resolution, + ) + + self.label_resolution_explanation = QLabel( + "Oppløsning på 200 dpi vil gi lesbar PDF med akseptabel " + "størrelse. Merk at større oppløsning kan føre til veldig store " + "filer, som er vanskelig å håndtere, mens mindre oppløsning kan " + "resultere i uleselige PDFer." + ) + self.label_resolution_explanation.setWordWrap(True) + + self.layout_wrapper = QVBoxLayout() + self.layout_wrapper.addLayout(self.layout_settings) + self.layout_wrapper.addWidget(self.label_resolution_explanation) + + self.layout_content.addLayout(self.layout_wrapper) diff --git a/soitool/setup_settings.py b/soitool/setup_settings.py index bcc912b5048085e94d62913651082d5650e15ec1..2bfe2248a23bdefc365ec2dc17d53a5a4cad7e84 100644 --- a/soitool/setup_settings.py +++ b/soitool/setup_settings.py @@ -5,14 +5,11 @@ This dialog is called when a button in soi_workspace_widget is pressed """ from PySide2.QtWidgets import ( - QDialog, QVBoxLayout, - QHBoxLayout, QFormLayout, QLabel, QLineEdit, QRadioButton, - QPushButton, QComboBox, QGroupBox, ) @@ -21,9 +18,10 @@ from soitool.soi import ( STRING_TO_ALGORITHM_RECTPACK_PACK, STRING_TO_ALGORITHM_INITIAL_SORT, ) +from soitool.accept_reject_dialog import AcceptRejectDialog -class Setup(QDialog): # pylint: disable = R0902 +class Setup(AcceptRejectDialog): # pylint: disable = R0902 """Contains the settings for the SOI. This class is used to change the settings for the SOI. @@ -47,7 +45,6 @@ class Setup(QDialog): # pylint: disable = R0902 # Layouts self.layout_setup = QVBoxLayout() - self.layout_buttons = QHBoxLayout() self.layout_header = QFormLayout() self.layout_orientation_button = QVBoxLayout() self.layout_alg_button_bin = QVBoxLayout() @@ -153,24 +150,17 @@ class Setup(QDialog): # pylint: disable = R0902 self.layout_setup.addWidget(self.group_algorithm_pack) self.layout_setup.addWidget(self.group_algorithm_sort) - # Save and cancel - self.button_save = QPushButton("Lagre") - self.button_cancel = QPushButton("Avbryt") - self.layout_buttons.addWidget(self.button_save) - self.layout_buttons.addWidget(self.button_cancel) - self.layout_setup.addLayout(self.layout_buttons) + # Customize ok button from superclass to something more fitting + self.button_ok.setText("Lagre") - self.setLayout(self.layout_setup) + self.layout_content.addLayout(self.layout_setup) - self.button_cancel.clicked.connect(self.cancel) # esc-key (default) - self.button_save.clicked.connect(self.save) # enter-key (default) + def accept(self): + """Save and update the SOI with the given changes. - def cancel(self): - """Close the dialog without saving.""" - self.reject() - - def save(self): - """Save and update the SOI with the given changes.""" + Overriden to implement custom behavior on accept, executes superclass' + implementation as final step. + """ # Dict will contain all changes to make property_changes = {} property_changes["title"] = self.edit_title.text() @@ -213,7 +203,7 @@ class Setup(QDialog): # pylint: disable = R0902 # Pass changes as unpacked variable list self.soi.update_properties(**property_changes) - self.accept() + super().accept() def get_from_soi(self): """Get data from the SOI and update the dialog with the data.""" diff --git a/test/test_accept_reject_dialog.py b/test/test_accept_reject_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..b0ab9966ec8a1698b7f27c9db2900ee88cded988 --- /dev/null +++ b/test/test_accept_reject_dialog.py @@ -0,0 +1,79 @@ +"""Test dialog in accept_reject_dialog.py. + +Also provides a function 'dialog_code_test_helper' that can be used by tests of +subclasses of AcceptRejectDialog to ensure that dialog codes work as expected. +""" + +import unittest +from PySide2 import QtGui +from PySide2.QtWidgets import QApplication, QDialog +from PySide2.QtCore import QTimer, Qt +from PySide2.QtTest import QTest +from soitool.accept_reject_dialog import AcceptRejectDialog + + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +def dialog_code_test_helper(testcase, dialog_class): + """Test that a given dialog class returns dialog codes as expected. + + Parameters + ---------- + testcase : unittest.TestCase + Used to report status of test. + dialog_class : AcceptRejectDialog or derivative + The dialog class must be a derivative of AcceptRejectDialog, or + AcceptRejectDialog itself. This is because it's properties are used for + testing. Also accepts a callable that returns an instance of a + derivative of AcceptRejectDialog, or AcceptRejectDialog itself. This is + useful when the subclass takes custom init parameters. + """ + # Prepare all nested functions + def press_enter_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Enter) + + def press_escape_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Escape) + + def press_add_button_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.mouseClick(active_widget.button_ok, Qt.LeftButton) + + def press_cancel_button_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton) + + # Perform tests + dialog = dialog_class() + QTimer.singleShot(0, press_enter_on_active_dialog) + dialogcode = dialog.exec_() + testcase.assertEqual(dialogcode, QDialog.DialogCode.Accepted) + + dialog = dialog_class() + QTimer.singleShot(0, press_escape_on_active_dialog) + dialogcode = dialog.exec_() + testcase.assertEqual(dialogcode, QDialog.DialogCode.Rejected) + + dialog = dialog_class() + QTimer.singleShot(0, press_add_button_on_active_dialog) + dialogcode = dialog.exec_() + testcase.assertEqual(dialogcode, QDialog.DialogCode.Accepted) + + dialog = dialog_class() + QTimer.singleShot(0, press_cancel_button_on_active_dialog) + dialogcode = dialog.exec_() + testcase.assertEqual(dialogcode, QDialog.DialogCode.Rejected) + + +class TestAcceptRejectDialog(unittest.TestCase): + """Testcase for AcceptRejectDialog class.""" + + def test_dialog_codes(self): + """Test dialog codes using helper function.""" + dialog_code_test_helper(self, AcceptRejectDialog) diff --git a/test/test_new_module_dialog.py b/test/test_new_module_dialog.py index abc0b1cbedb3f3234a3e7b7b4539b3701a1d026d..759292483fd40e0b3b9fc4093357e82b1c5bbb8c 100644 --- a/test/test_new_module_dialog.py +++ b/test/test_new_module_dialog.py @@ -3,12 +3,18 @@ import unittest from PySide2 import QtGui from PySide2.QtGui import QIcon -from PySide2.QtWidgets import QApplication, QDialog +from PySide2.QtWidgets import QApplication from PySide2.QtCore import QTimer, Qt from PySide2.QtTest import QTest from soitool.new_module_dialog import NewModuleDialog from soitool.modules.module_base import ModuleBase +# The error being ignored here is pylint telling us that 'test' is a standard +# module, so the import should be placed further up. In our case we have an +# actual custom module called 'test', so pylint is confused. +# pylint: disable=C0411 +from test.test_accept_reject_dialog import dialog_code_test_helper + class SimpleTestModule(ModuleBase): """Simple module used to test NewModuleDialog only.""" @@ -64,43 +70,8 @@ class TestNewModuleDialog(unittest.TestCase): def test_dialog_code(self): """Test that the dialog returns dialog codes as expected.""" - # prepare all nested functions - def press_enter_on_active_dialog(): - active_widget = app.activeModalWidget() - QTest.keyClick(active_widget, Qt.Key_Enter) - - def press_escape_on_active_dialog(): - active_widget = app.activeModalWidget() - QTest.keyClick(active_widget, Qt.Key_Escape) - - def press_add_button_on_active_dialog(): - active_widget = app.activeModalWidget() - QTest.mouseClick(active_widget.button_add, Qt.LeftButton) - - def press_cancel_button_on_active_dialog(): - active_widget = app.activeModalWidget() - QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton) - - # perform tests - dialog = NewModuleDialog() - QTimer.singleShot(0, press_enter_on_active_dialog) - dialogcode = dialog.exec_() - self.assertEqual(dialogcode, QDialog.DialogCode.Accepted) - - dialog = NewModuleDialog() - QTimer.singleShot(0, press_escape_on_active_dialog) - dialogcode = dialog.exec_() - self.assertEqual(dialogcode, QDialog.DialogCode.Rejected) - - dialog = NewModuleDialog() - QTimer.singleShot(0, press_add_button_on_active_dialog) - dialogcode = dialog.exec_() - self.assertEqual(dialogcode, QDialog.DialogCode.Accepted) - - dialog = NewModuleDialog() - QTimer.singleShot(0, press_cancel_button_on_active_dialog) - dialogcode = dialog.exec_() - self.assertEqual(dialogcode, QDialog.DialogCode.Rejected) + # Makes use of helper function from other test case + dialog_code_test_helper(self, NewModuleDialog) def test_full_selection_scenario(self): """Test full usage of the module.""" @@ -140,7 +111,7 @@ class TestNewModuleDialog(unittest.TestCase): QTest.keyClicks(active_widget.line_edit_name, name_to_input) QTest.mouseClick(active_widget.checkbox_attachment, Qt.LeftButton) - QTest.mouseClick(active_widget.button_cancel, Qt.LeftButton) + QTest.mouseClick(active_widget.button_ok, Qt.LeftButton) dialog = NewModuleDialog(module_choices=module_choices) QTimer.singleShot(0, perform_selection_and_add) diff --git a/test/test_pdf_export_options_dialog.py b/test/test_pdf_export_options_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..f024551691e74fd52345c5078b3448a338e281bf --- /dev/null +++ b/test/test_pdf_export_options_dialog.py @@ -0,0 +1,96 @@ +"""Test dialog in pdf_export_options_dialog.py.""" + +import unittest +from PySide2 import QtGui +from PySide2.QtWidgets import QApplication +from PySide2.QtCore import QTimer, Qt +from PySide2.QtTest import QTest +from soitool.pdf_export_options_dialog import PdfExportOptionsDialog + +# The error being ignored here is pylint telling us that 'test' is a standard +# module, so the import should be placed further up. In our case we have an +# actual custom module called 'test', so pylint is confused. +# pylint: disable=C0411 +from test.test_accept_reject_dialog import dialog_code_test_helper + + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestPdfExportOptionsDialog(unittest.TestCase): + """Testcase for PdfExportOptionsDialog class.""" + + def test_starts_up(self): + """Test that the dialog can start. + + Also tests whether dialog can be closed with the enter key. + """ + dialog = PdfExportOptionsDialog() + + def assert_window_title_of_active_dialog(): + active_widget = app.activeModalWidget() + self.assertEqual("PDF-eksport", active_widget.windowTitle()) + QTest.keyClick(active_widget, Qt.Key_Enter) + + QTimer.singleShot(0, assert_window_title_of_active_dialog) + dialog.exec_() + + def test_dialog_codes(self): + """Test dialog codes using helper function.""" + dialog_code_test_helper(self, PdfExportOptionsDialog) + + def test_full_usage_scenario(self): + """Perform selection of desired settings an accept. + + This function is heavily inspired by soitool.test_new_module_dialog + .TestNewModuleDialog.test_full_usage_scenario + """ + test_copies = 10 + test_resolution = 300 + + def perform_selection_and_ok(): + """Perform selection. + + Select 'test_copies' copies and 'test_resolution' dpi resolution, + then click add. + """ + active_widget = app.activeModalWidget() + + # Double click selects text, so that we can replace it entirely + QTest.mouseDClick( + active_widget.spinbox_number_of_copies.lineEdit(), + Qt.LeftButton, + ) + QTest.keyClicks( + active_widget.spinbox_number_of_copies, str(test_copies) + ) + + # Triple click selects text, so that we can replace it entirely + # Triple click is needed because the spinbox includes a suffix + QTest.mouseDClick( + active_widget.spinbox_resolution.lineEdit(), Qt.LeftButton + ) + QTest.mouseClick( + active_widget.spinbox_resolution.lineEdit(), Qt.LeftButton + ) + QTest.keyClicks( + active_widget.spinbox_resolution, str(test_resolution) + ) + + QTest.mouseClick(active_widget.button_ok, Qt.LeftButton) + + dialog = PdfExportOptionsDialog() + QTimer.singleShot(0, perform_selection_and_ok) + dialog.exec() + + # Assert dialog was successfully filled + + self.assertEqual( + test_copies, dialog.spinbox_number_of_copies.value(), + ) + self.assertEqual( + test_resolution, dialog.spinbox_resolution.value(), + ) diff --git a/test/test_setup_settings.py b/test/test_setup_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..2c5ef1f49ffd3aa52fbcc3caa494c94d6ed5dc17 --- /dev/null +++ b/test/test_setup_settings.py @@ -0,0 +1,29 @@ +"""Test dialog in setup_settings.py.""" + +import unittest +from PySide2 import QtGui +from PySide2.QtWidgets import QApplication +from soitool.setup_settings import Setup +from soitool.soi import SOI + +# The error being ignored here is pylint telling us that 'test' is a standard +# module, so the import should be placed further up. In our case we have an +# actual custom module called 'test', so pylint is confused. +# pylint: disable=C0411 +from test.test_accept_reject_dialog import dialog_code_test_helper + + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestSetup(unittest.TestCase): + """TestCase for Setup dialog class.""" + + def test_dialog_code(self): + """Test that the dialog returns dialog codes as expected.""" + # Makes use of helper function from other test case + # The lambda is used to inject a required init parameter + dialog_code_test_helper(self, lambda: Setup(SOI()))