diff --git a/soitool/database.py b/soitool/database.py index b76c85a9adb83bb0a66483119e3d12debf1baec9..7d9ee22723a802dcfb587e9ac07d078c99f316e7 100644 --- a/soitool/database.py +++ b/soitool/database.py @@ -162,14 +162,50 @@ class Database: Returns ------- - List of strings - Categories + List + Containing categories (string). """ stmt = "SELECT DISTINCT Category FROM CategoryWords" queried = self.conn.execute(stmt) - categories = [] - for row in queried: - categories.append(row["Category"]) + categories = [row["Category"] for row in queried] + + return categories + + def get_random_category_word(self): + """Read a random word from database-table CategoryWords. + + Returns + ------- + String + Word (string). + """ + stmt = "SELECT Word FROM CategoryWords ORDER BY RANDOM() LIMIT 1" + word = self.conn.execute(stmt).fetchone()[0] + + return word + + def get_categories_from_codebook(self, small=False): + """Read categories from full or small codebook. + + Parameters + ---------- + small : bool, optional + Categories are from small codebook if True, full codebook if + False, by default False. + + Returns + ------- + List + Containing categories (string). + """ + stmt = "SELECT Category FROM Codebook" + if small: + stmt += " WHERE Type='Liten' GROUP BY Category" + else: + stmt += " GROUP BY Category" + + queried = self.conn.execute(stmt).fetchall() + categories = [row["Category"] for row in queried] return categories @@ -242,6 +278,31 @@ class Database: return codebook + def get_codebook_expressions_in_category(self, category, small=False): + """Read expressions, from full or small codebook, in a given category. + + Parameters + ---------- + category : string + Expressions in this category are returned. + small : bool, optional + Expressions are from small codebook if True, full codebook if + False, by default False. + + Returns + ------- + List + Containing expressions (string). + """ + stmt = "SELECT Word FROM Codebook WHERE Category=?" + if small: + stmt += " AND Type='Liten'" + queried = self.conn.execute(stmt, (category,)).fetchall() + + expressions = [row["Word"] for row in queried] + + return expressions + def update_codebook(self): """Update codes in DB.""" # Get all the words (PK) diff --git a/soitool/help_actions.py b/soitool/help_actions.py index 0d99369a6082c279ce8a5f4caa29ecef07c48739..7c30dba840d5279899f202b6e5b3e9ffd0d9e8eb 100644 --- a/soitool/help_actions.py +++ b/soitool/help_actions.py @@ -11,6 +11,7 @@ from PySide2.QtWidgets import ( QFormLayout, ) from PySide2.QtCore import Qt +from PySide2.QtGui import QFont class ShortcutsHelpDialog(QDialog): @@ -32,6 +33,15 @@ class ShortcutsHelpDialog(QDialog): self.button_ok = QPushButton("Ok") self.button_ok.clicked.connect(self.close) + self.header_main = QLabel("Hurtigtaster for applikasjonen:") + self.header_main.setFont(QFont("Verdana", 10)) + self.header_table = QLabel("Hurtigtaster for redigerbare tabeller:") + self.header_table.setFont(QFont("Verdana", 10)) + self.header_modules = QLabel("Hurtigtaster for noen gitte moduler:") + self.header_modules.setFont(QFont("Verdana", 10)) + + # Main/program shortcuts + self.layout_label.addRow(self.header_main) self.layout_label.addRow( QLabel("Opprett en ny SOI: "), QLabel("Ctrl + N") ) @@ -41,18 +51,41 @@ class ShortcutsHelpDialog(QDialog): self.layout_label.addRow( QLabel("Åpne SOI fra database: "), QLabel("Ctrl + D") ) + self.layout_label.addRow( + QLabel("Endre oppsett SOI: "), QLabel("Ctrl + I") + ) + self.layout_label.addRow( + QLabel("Legg til ny modul: "), QLabel("Ctrl + M") + ) self.layout_label.addRow(QLabel("Eksporter PDF: "), QLabel("Ctrl + P")) + self.layout_label.addRow( + QLabel("Eksporter komprimert SOI: "), QLabel("Ctrl + E") + ) self.layout_label.addRow( QLabel("Lagre i database: "), QLabel("Ctrl + S") ) + + # Table shortcuts + self.layout_label.addRow(QLabel()) + self.layout_label.addRow(self.header_table) + self.layout_label.addRow(QLabel("Legg til rad"), QLabel("Ctrl + +")) + self.layout_label.addRow(QLabel("Fjern rad"), QLabel("Ctrl + -")) self.layout_label.addRow( - QLabel("Eksporter komprimert SOI: "), QLabel("Ctrl + E") + QLabel("Legg til kolonne"), QLabel("Shift + +") + ) + self.layout_label.addRow(QLabel("Fjern kolonne"), QLabel("Shift + -")) + + # Other module-specific shortcuts + self.layout_label.addRow(QLabel()) + self.layout_label.addRow(self.header_modules) + self.layout_label.addRow( + QLabel("Rediger forhåndsavtalte koder: "), QLabel("Ctrl + R") ) self.layout_label.addRow( - QLabel("Legg til ny modul: "), QLabel("Ctrl + M") + QLabel("Rediger telefonliste: "), QLabel("Ctrl + R") ) self.layout_label.addRow( - QLabel("Endre oppsett SOI: "), QLabel("Ctrl + I") + QLabel("Rediger frekvenstabell: "), QLabel("Ctrl + R") ) self.layout_button.addWidget(self.button_ok, alignment=Qt.AlignRight) diff --git a/soitool/main_window.py b/soitool/main_window.py index e4460aedb5c550379110e1fabbd3cb49c022e944..5dc81632c957243ee2a1fc6e6560a7ca1d049e92 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -315,10 +315,10 @@ class MainWindow(QMainWindow): )[0] if len(file_path) > 0: - soi = import_soi(file_path) + soi = import_soi(file_path, self.database) # Create and select tab - tab = SOIWorkspaceWidget(soi) + tab = SOIWorkspaceWidget(self.database, soi) self.tabs.addTab(tab, soi.title) self.tabs.setCurrentWidget(tab) diff --git a/soitool/media/predefinedcodesmodule.PNG b/soitool/media/predefinedcodesmodule.PNG new file mode 100644 index 0000000000000000000000000000000000000000..dcf0cc1bbb7dd9924e27ef5b713069ed8c6d6170 Binary files /dev/null and b/soitool/media/predefinedcodesmodule.PNG differ diff --git a/soitool/modules/code_table_base.py b/soitool/modules/code_table_base.py index 478bc64449a55dc22a63336299d9ed83d5611253..7028a3d6ac7f04e2cca30160aff2abbcc3538847 100644 --- a/soitool/modules/code_table_base.py +++ b/soitool/modules/code_table_base.py @@ -94,11 +94,10 @@ class CodeTableBase(ModuleBase, QTableWidget, metaclass=Meta): # Set cell-items for i in range(self.rowCount()): for j in range(self.columnCount()): - item = QTableWidgetItem( - cells[i + 1][j] - ) # +1 skip headline + # + 1 skip headline + item = QTableWidgetItem(cells[i + 1][j]) + item.setTextAlignment(Qt.AlignCenter) if j == 2: - item.setTextAlignment(Qt.AlignCenter) item.setFont(self.code_font) self.setItem(i, j, item) diff --git a/soitool/modules/module_predefined_codes.py b/soitool/modules/module_predefined_codes.py new file mode 100644 index 0000000000000000000000000000000000000000..7c9ce9b4bc7b1ae6d9e4f89a3992a3261a11ccd2 --- /dev/null +++ b/soitool/modules/module_predefined_codes.py @@ -0,0 +1,629 @@ +"""SOI-module 'Forhåndsavtalte koder'.""" +import string +from random import sample +from PySide2.QtWidgets import ( + QWidget, + QTableWidget, + QTableWidgetItem, + QHBoxLayout, + QVBoxLayout, + QLabel, + QLineEdit, + QDialog, + QListWidget, + QListWidgetItem, + QAbstractItemView, + QFormLayout, + QSpinBox, +) +from PySide2 import QtGui +from PySide2.QtCore import Qt +from soitool.modules.module_base import ModuleBase, HEADLINE_FONT, resize_table +from soitool.accept_reject_dialog import AcceptRejectDialog + +ALPHABET = string.ascii_uppercase +HEADLINE = "FORHÅNDSAVTALTE KODER" +MAXIMUM_COLUMN_HEIGHT = 1000 +DEFAULT_COLUMN_HEIGHT = 200 + + +class Meta(type(ModuleBase), type(QWidget)): + """Used as a metaclass to enable multiple inheritance.""" + + +class PredefinedCodesModule(ModuleBase, QWidget, metaclass=Meta): + """QWidget representing SOI-module 'Forhåndsavtalte koder'. + + This widget has a headline, a warning-word and a layout with x amount of + PredefinedCodesTable-objects containing all expressions and categories from + small codebook ("Type" = "Liten" in database-table "Codebook"). + + If parameter "data" is not given, the widget reads all expressions and + categories from small codebook, and a warning-word is randomly chosen from + database-table "CategoryWords". It then launches a dialog where the user + can set up the module - headline, warning_word, maximum height and category + order. + If parameter "data" is given, the widget is built based on the contents. + + Finally, it creates one PredefinedCodesTable per category and inserts all + expressions in the category. + + The shortcut "Ctrl + R" launches the setup-dialog so that the user can + modify and update the module. If changes have been made to small codebook, + the module will reflect the changes, and it will also randomly sort all + expressions in each category (so that a new code is assigned to each one). + + The PredefinedCodesTables are placed in one or more QVBoxLayouts, which are + placed next to each other. The QVBoxLayouts are therefore referred to as + columns. The user can decide (through the dialog PredefinedCodesSettings) + the maximum column-height. Each time there is not enough room for all + tables in a column within the maximum height, a new column is created. + The class has an attribute 'minimum_column_height', which reflects the + height of the tallest table. The user is not allowed to set a maximum + column-height smaller than the minimum_column_height, because one or more + tables would not fit. + + The widget does not use more room than needed, and resizes dynamically. + + Parameters + ---------- + database : soitool.database.Database + Database-instance used to read from database. + data : dict, optional + { + "headline": string, + "warning_word": string, + "maximum_column_height": int, + "categories": [strings], + "tables": [{ + "table_headline": string, + "expressions": [strings] + ]} + } + By default None. + """ + + def __init__(self, database, data=None): + self.type = "PredefinedCodesModule" + QWidget.__init__(self) + ModuleBase.__init__(self) + + self.database = database + self.tables = [] + self.categories = [] + + if data is not None: + self.headline = QLabel(data["headline"]) + self.warning_word = QLabel(data["warning_word"]) + self.maximum_column_height = data["maximum_column_height"] + self.categories = data["categories"] + self.create_tables_from_data(data) + self.create_and_set_layout() + else: + # Get a random warning-word and categories from database + warning_word = self.database.get_random_category_word() + categories_unsorted = self.database.get_categories_from_codebook( + small=True + ) + # Set up module + self.run_setup(warning_word, categories_unsorted) + + def run_setup(self, warning_word, categories): + """Launch setup-dialog, read input, create tables and set layout.""" + # Calculate height of the tallest table + self.minimum_column_height = self.calculate_height_of_tallest_table( + categories + ) + # Launch dialog + dialog = PredefinedCodesSettings(HEADLINE, warning_word, categories) + dialog.edit_column_height.setMinimum(self.minimum_column_height) + dialog.exec_() + + # Read dialog-input + categories_and_expressions = self.read_from_dialog(dialog) + + # Create tables and set layout + self.create_tables(categories_and_expressions) + self.create_and_set_layout() + + def read_from_dialog(self, dialog): + """Read input from dialog PredefinedCodesSettings. + + Parameters + ---------- + dialog : PredefinedCodesSettings + Dialog to read from. + + Returns + ------- + dict + With categories as keys and list of expressions as values. + """ + # Read headline and warning word, + # create QLabel or set text on existing QLabel + if hasattr(self, "headline"): + self.headline.setText(dialog.edit_headline.text()) + else: + self.headline = QLabel(dialog.edit_headline.text()) + if hasattr(self, "warning_word"): + self.warning_word.setText(dialog.edit_warning_word.text()) + else: + self.warning_word = QLabel(dialog.edit_warning_word.text()) + + self.maximum_column_height = dialog.edit_column_height.value() + + # Read categories in order + self.categories.clear() + for i in range(dialog.list_category_order.count()): + self.categories.append(dialog.list_category_order.item(i).text()) + + # Create dict containing categories and their expressions + categories_and_expressions = {} + for category in self.categories: + expressions = self.database.get_codebook_expressions_in_category( + category, small=True + ) + # Add expressions sorted randomly + categories_and_expressions[category] = sample( + expressions, len(expressions) + ) + + return categories_and_expressions + + def create_tables(self, categories_and_expressions): + """Create PredefinedCodesTable-objects. + + Parameters + ---------- + categories_and_expressions : dict + With categories as keys and list of expressions as values. + """ + # Delete previous tables + del self.tables[:] + + # Create new tables + for i, category in enumerate(categories_and_expressions.keys()): + headline = " " + ALPHABET[i] + " " + category + table = PredefinedCodesTable( + headline, categories_and_expressions[category] + ) + self.tables.append(table) + + def create_tables_from_data(self, data): + """Create PredefinedCodesTable-objects from data. + + Parameters + ---------- + Dict + Contains data needed, see module description. + """ + for table_data in data["tables"]: + table_headline = table_data["table_headline"] + expressions = table_data["expressions"] + table = PredefinedCodesTable(table_headline, expressions) + self.tables.append(table) + + self.minimum_column_height = self.get_height_of_tallest_table() + + def create_and_set_layout(self): + """Create, fill and set layout.""" + self.headline.setFont(HEADLINE_FONT) + self.headline.setAlignment(Qt.AlignCenter) + self.warning_word.setFont(HEADLINE_FONT) + self.warning_word.setAlignment(Qt.AlignCenter) + + # Layout for tables + table_layout = self.create_table_layout() + + # Layout for warning_word + warning_word_layout = QHBoxLayout() + warning_word_label = QLabel("Varslingsord: ") + warning_word_layout.addWidget(warning_word_label) + warning_word_layout.addWidget(self.warning_word) + warning_word_layout.setAlignment(warning_word_label, Qt.AlignHCenter) + warning_word_layout.setAlignment(self.warning_word, Qt.AlignHCenter) + + # Main layout + self.main_layout = QVBoxLayout() + self.main_layout.addWidget(self.headline) + self.main_layout.setAlignment(self.headline, Qt.AlignHCenter) + self.main_layout.addLayout(warning_word_layout) + self.main_layout.setAlignment(warning_word_layout, Qt.AlignHCenter) + self.main_layout.addLayout(table_layout) + + # Set layout and adjust size + self.setLayout(self.main_layout) + self.adjustSize() + + def create_table_layout(self): + """Create and return a table-layout. + + Returns + ------- + QHBoxLayout + Layout containing x amount of QVBoxLayouts with tables. + """ + # Create table-layout + table_layout = QHBoxLayout() + + # Algorithm-explanation: Create a "column" and fill it with tables + # until adding the next table will overshoot maximum_column_height, + # then add a new column and repeat the process until all tables are + # added. + i = 0 + column_height = 0 + # While all tables are not added + while i < len(self.tables): + # Create a "column" + vbox_layout = QVBoxLayout() + max_table_width = 0 + # While there are more tables and there is room for the next table, + # add table to column and save it's width if it is the widest table + while ( + i < len(self.tables) + and column_height + self.tables[i].height() + <= self.maximum_column_height + ): + table = self.tables[i] + if table.width() > max_table_width: + max_table_width = table.width() + vbox_layout.addWidget(table) + # Increase column-height + column_height += table.height() + i += 1 + + # Column does not have room to fit the next table. + # Make all tables in column have equal width. + for j in range(vbox_layout.count()): + widget = vbox_layout.itemAt(j).widget() + column_one_width = widget.columnWidth(0) + widget.setFixedWidth(max_table_width) + widget.setColumnWidth(1, max_table_width - column_one_width) + + # Add a QSpacerItem so that tables in column are pushed to the top + vbox_layout.addStretch(1) + + # Add column to the table_layout and reset column-height. + table_layout.addLayout(vbox_layout) + column_height = 0 + + return table_layout + + def new_table_layout(self): + """Replace current table-layout with a new one.""" + # Get current layout and remove it from main-layout. + table_layout = self.main_layout.itemAt(2) + self.main_layout.removeItem(table_layout) + + # Loop through table-layout's columns (QVBoxLayouts) and delete them + # along with their widgets to make them disappear. + columns = [table_layout.itemAt(i) for i in range(table_layout.count())] + for column in columns: + tables = [column.itemAt(i).widget() for i in range(column.count())] + for table in tables: + if table is not None: + table.close() + del table + del column + del table_layout + + # Create new table-layout + table_layout = self.create_table_layout() + + # Add table-layout and adjust own size + self.main_layout.addLayout(table_layout) + self.adjustSize() + + def get_size(self): + """Return size of table. + + Returns + ------- + Tuple + width, height. + """ + return self.width(), self.height() + + def get_data(self): + """Return a dict containing all module-data. + + Returns + ------- + Dict + { + "headline": string, + "warning_word": string, + "maximum_column_height": int, + "categories": [strings], + "tables": [{ + "table_headline": string, + "expressions": [strings] + ]} + } + """ + tables = [] + # Create a dict for each table and them to tables-list + for table in self.tables: + # Table-headline + item_headline = table.item(0, 0) + if item_headline is not None: + table_headline = item_headline.text() + else: + table_headline = "" + # Table-expressions + expressions = [] + for i in range(1, table.rowCount()): + expressions.append(table.item(i, 1).text()) + # Create dict with table-data and add it to list + table = { + "table_headline": table_headline, + "expressions": expressions, + } + tables.append(table) + + # Create main dict and add tables-list + data = { + "headline": self.headline.text(), + "warning_word": self.warning_word.text(), + "maximum_column_height": self.maximum_column_height, + "categories": self.categories, + "tables": tables, + } + + return data + + def get_height_of_tallest_table(self): + """Get height of the tallest table. + + Returns + ------- + int + Height of the tallest table. + """ + tallest_height = 0 + for table in self.tables: + if table.height() > tallest_height: + tallest_height = table.height() + + return tallest_height + + def calculate_height_of_tallest_table(self, categories): + """Find what table will be tallest and return it's height. + + Parameters + ---------- + categories : List + Containing categories, is used to retrieve expressions in each + category. + + Returns + ------- + int + Height of the tallest table. + """ + # Get expressions in each category, while noting the index of the + # category with the most expressions + all_expressions = [] + max_length = 0 + max_index = None + for i, category in enumerate(categories): + expressions = self.database.get_codebook_expressions_in_category( + category, small=True + ) + all_expressions.append(expressions) + if len(expressions) > max_length: + max_index = i + max_length = len(expressions) + + # Create the tallest table and get it's height + tallest_table = PredefinedCodesTable("", all_expressions[max_index]) + tallest_height = tallest_table.height() + + return tallest_height + + def keyPressEvent(self, event): + """Launch PredefinedCodesSettings-dialog when "Ctrl + R" is pressed.""" + if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_R: + dialog = PredefinedCodesSettings( + self.headline.text(), self.warning_word.text(), self.categories + ) + + # Modify dialog + dialog.edit_column_height.setMinimum(self.minimum_column_height) + dialog.edit_column_height.setValue(self.maximum_column_height) + dialog.button_ok.setText("Oppdater") + dialog.button_cancel.show() + + dialog_code = dialog.exec_() + # If user accepted, read dialog, create tables and set a + # new table-layout + if dialog_code == QDialog.DialogCode.Accepted: + categories_and_expressions = self.read_from_dialog(dialog) + + self.create_tables(categories_and_expressions) + self.new_table_layout() + + super().keyPressEvent(event) + + @staticmethod + def get_user_friendly_name(): + """Get user-friendly name of module.""" + return "Forhåndsavtalte koder" + + @staticmethod + def get_icon(): + """Get icon of module.""" + return QtGui.QIcon("soitool/media/predefinedcodesmodule.png") + + +class PredefinedCodesTable(QTableWidget): + """Modified QTableWidget displaying predefined-codes in a category. + + This table has a headline and two columns. The headline should consist of + a letter followed by a category. Each row contains a unique letter and an + expression. Practically speaking, the letter from the headline + the letter + from the row is used as a code for the expression on that row. + + Parameters + ---------- + headline : string + Will be the headline of the table, should be a letter + followed by a category. + expressions : list + Containing expressions (string). + """ + + def __init__(self, headline, expressions): + QTableWidget.__init__(self) + + # Set focus-policy to prevent PredefinedCodesModule's + # keyPressEvent-function to be called twice when a cell is selected. + self.setFocusPolicy(Qt.NoFocus) + + # Set row- and columncount + self.setRowCount(len(expressions)) + self.setColumnCount(2) + + # Remove headers and scrollbars + self.horizontalHeader().hide() + self.verticalHeader().hide() + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Insert codes + self.insert_codes() + + # Insert expressions + for i, expression in enumerate(expressions): + item_expression = QTableWidgetItem(expression) + item_expression.setFlags( + item_expression.flags() ^ Qt.ItemIsEditable + ) + self.setItem(i, 1, item_expression) + + # Resize columns and rows to fit contents + self.resizeColumnsToContents() + self.resizeRowsToContents() + + # Insert headline and resize table + self.insert_headline(headline) + resize_table(self, rows=False, columns=False, has_headline=True) + + def insert_headline(self, text): + """Insert headline. + + Parameters + ---------- + text : string + Text of the headline. + """ + # Create QTableWidgetItem + item_headline = QTableWidgetItem(text) + item_headline.setFont(HEADLINE_FONT) + item_headline.setFlags(item_headline.flags() ^ Qt.ItemIsEditable) + + # Insert row, item and make it span all columns + self.insertRow(0) + self.setItem(0, 0, item_headline) + self.setSpan(0, 0, 1, self.columnCount()) + + def insert_codes(self): + """Insert codes A-Z in first column.""" + for i in range(self.rowCount()): + item_code = QTableWidgetItem(ALPHABET[i]) + item_code.setTextAlignment(Qt.AlignCenter) + item_code.setFlags(item_code.flags() ^ Qt.ItemIsEditable) + self.setItem(i, 0, item_code) + + +class PredefinedCodesSettings(AcceptRejectDialog): + """Dialog for setup and adjustment of PredefinedCodesModule. + + Parameters + ---------- + headline : string + Input-field for headline will be prefilled with this string. + warning_word : string + Input-field for warning-word will be prefilled with this string. + categories : list + Containing the categories (strings). + """ + + def __init__(self, headline, warning_word, categories): + super().__init__() + + # Hide help-button, disable close-button and set window title and width + self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowCloseButtonHint, False) + self.setWindowTitle("Forhåndsavtalte koder") + self.setFixedWidth(350) + + # Headline + self.label_headline = QLabel("Overskrift") + self.edit_headline = QLineEdit() + self.edit_headline.setText(headline) + + # Warning-word + self.label_warning_word = QLabel("Varslingsord") + self.edit_warning_word = QLineEdit() + self.edit_warning_word.setText(warning_word) + + # Maximum column-height + self.label_column_height = QLabel("Maksimal kolonnehøyde") + self.edit_column_height = QSpinBox() + self.edit_column_height.lineEdit().setReadOnly(True) + self.edit_column_height.setRange(50, MAXIMUM_COLUMN_HEIGHT) + self.edit_column_height.setSingleStep(50) + self.edit_column_height.setValue(DEFAULT_COLUMN_HEIGHT) + + # Category-order + self.label_category_order = QLabel( + "Kategori-rekkefølge\n(dra og slipp)" + ) + self.list_category_order = QListWidget() + # Enable drag-and-drop + self.list_category_order.setDragEnabled(True) + self.list_category_order.viewport().setAcceptDrops(True) + self.list_category_order.setDragDropMode( + QAbstractItemView.InternalMove + ) + # Remove horizontal scrollbar + self.list_category_order.setHorizontalScrollBarPolicy( + Qt.ScrollBarAlwaysOff + ) + # Add uneditable categories + for i, category in enumerate(categories): + item = QListWidgetItem(category) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.list_category_order.insertItem(i, item) + + self.button_ok.setText("Opprett") + + # Hide cancel-button, it is only used when modifying an existing + # PredefinedCodesModule + self.button_cancel.hide() + + self.create_and_set_layout() + + def create_and_set_layout(self): + """Create layouts, add widgets and set layout.""" + # Layout for input-widgets + self.form_layout = QFormLayout() + + # Add labels and their associated input-widgets + self.form_layout.addRow(self.label_headline, self.edit_headline) + self.form_layout.addRow( + self.label_warning_word, self.edit_warning_word + ) + self.form_layout.addRow( + self.label_column_height, self.edit_column_height + ) + self.form_layout.addRow( + self.label_category_order, self.list_category_order + ) + + # Main layout + self.main_layout = QVBoxLayout() + self.main_layout.addLayout(self.form_layout) + + self.layout_content.addLayout(self.main_layout) diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py index 5787cde5a3c016015e4b76f6ed1bbc4504b78b67..897172ead06d76cbd6b2ba901a04d2c792c8752f 100644 --- a/soitool/new_module_dialog.py +++ b/soitool/new_module_dialog.py @@ -17,6 +17,7 @@ from soitool.modules.module_authentication_board import ( from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule from soitool.modules.module_code_phrase import CodePhraseModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule from soitool.accept_reject_dialog import AcceptRejectDialog @@ -29,6 +30,7 @@ MODULE_CHOICES = [ SubtractorcodesModule, FreeTextModule, CodePhraseModule, + PredefinedCodesModule, ] diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py index 18ba00bcb79ab56917733edf7b7e078cc63aa265..c1889f78115fecff7fd30373d9dbd3e7b8cf2772 100644 --- a/soitool/serialize_export_import_soi.py +++ b/soitool/serialize_export_import_soi.py @@ -10,6 +10,7 @@ from soitool.modules.module_authentication_board import ( ) from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule # Valid schema for serialized SOI SERIALIZED_SOI_SCHEMA = Schema( @@ -173,7 +174,7 @@ def export_soi(soi, compressed=True): file.close() -def import_soi(file_path): +def import_soi(file_path, database): """Import compressed or uncompressed serialized SOI. Reads content of file and decompresses it for .txt-files. @@ -183,6 +184,8 @@ def import_soi(file_path): ---------- file_path : string Full path to a file containing serialized SOI. + database : soitool.database.Database + Database-instance passed to specific modules. Returns ------- @@ -200,18 +203,22 @@ def import_soi(file_path): serialized = file.read() if file_path.endswith(".txt"): - return construct_soi_from_serialized(serialized, compressed=True) + return construct_soi_from_serialized( + serialized, database, compressed=True + ) - return construct_soi_from_serialized(serialized) + return construct_soi_from_serialized(serialized, database) -def construct_soi_from_serialized(serialized, compressed=False): +def construct_soi_from_serialized(serialized, database, compressed=False): """Construct an SOI-object from a serialized SOI. Parameters ---------- serialized : string Serialized SOI. + database : soitool.database.Database + Database-instance passed to specific modules. compressed : bool, optional True if serialized SOI is compressed, by default False. @@ -236,8 +243,12 @@ def construct_soi_from_serialized(serialized, compressed=False): raise ValueError("Serialized SOI does not have correct format.") # Construct modules and attachments - modules = construct_modules_from_serialized(serialized["modules"]) - attachments = construct_modules_from_serialized(serialized["attachments"]) + modules = construct_modules_from_serialized( + serialized["modules"], database + ) + attachments = construct_modules_from_serialized( + serialized["attachments"], database + ) # Create SOI soi = SOI( @@ -261,13 +272,15 @@ def construct_soi_from_serialized(serialized, compressed=False): return soi -def construct_modules_from_serialized(serialized_modules): +def construct_modules_from_serialized(serialized_modules, database): """Instantiate modules from serialized format. Parameters ---------- serialized_modules : list Containing dicts with serialized modules or attachment-modules. + database : soitool.database.Database + Database-instance passed to specific modules. Returns ------- @@ -283,16 +296,14 @@ def construct_modules_from_serialized(serialized_modules): for module in serialized_modules: module_type = module["type"] + size = module["size"] + data = module["data"] if module_type == "TableModule": - size = module["size"] - data = module["data"] modules.append( {"widget": TableModule(size, data), "meta": module["meta"]} ) elif module_type == "AuthenticationBoardModule": - size = module["size"] - data = module["data"] modules.append( { "widget": AuthenticationBoardModule(size, data), @@ -300,8 +311,6 @@ def construct_modules_from_serialized(serialized_modules): } ) elif module_type == "SubtractorcodesModule": - size = module["size"] - data = module["data"] modules.append( { "widget": SubtractorcodesModule(size, data), @@ -309,11 +318,16 @@ def construct_modules_from_serialized(serialized_modules): } ) elif module_type == "FreeTextModule": - size = module["size"] - data = module["data"] modules.append( {"widget": FreeTextModule(size, data), "meta": module["meta"]} ) + elif module_type == "PredefinedCodesModule": + modules.append( + { + "widget": PredefinedCodesModule(database, data), + "meta": module["meta"], + } + ) else: raise TypeError( "Module-type '{}' is not recognized.".format(module_type) diff --git a/soitool/soi_model_view.py b/soitool/soi_model_view.py index 680d27502fdefbec63c274fcd4c4c4ae1084b7b7..9f77f8b5189de45853732dd02e77da2be003966e 100644 --- a/soitool/soi_model_view.py +++ b/soitool/soi_model_view.py @@ -26,7 +26,8 @@ class SOITableView(QTableView): Parameters ---------- database : soitool.database.Database - Is used to create a QSqlDatabase from the database-file. + Is used to create a QSqlDatabase from the database-file, + and to instantiate SOIWorkspaceWidget. tab_widget : QTabWidget Is used to open a new tab. @@ -38,8 +39,10 @@ class SOITableView(QTableView): def __init__(self, database, tab_widget): super().__init__() + self.database = database + db = QSqlDatabase.addDatabase(DBTYPE, CONNAME) - db.setDatabaseName(database.db_path) + db.setDatabaseName(self.database.db_path) self.tab_widget = tab_widget if not db.open(): @@ -91,8 +94,10 @@ class SOITableView(QTableView): compressed_soi = self.model().index(row, 2).data() # Construct SOI and create SOIWorkspaceWidget - soi = construct_soi_from_serialized(compressed_soi, compressed=True) - tab = SOIWorkspaceWidget(soi) + soi = construct_soi_from_serialized( + compressed_soi, self.database, compressed=True + ) + tab = SOIWorkspaceWidget(self.database, soi) # Add and select tab self.tab_widget.addTab(tab, soi.title) diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py index 2b25d75300ceac65780b4a2f56f28babb054ef43..19f8effaa7bd1b1a0c19726a98322c9f32ff8805 100644 --- a/soitool/soi_workspace_widget.py +++ b/soitool/soi_workspace_widget.py @@ -22,6 +22,10 @@ from soitool.modules.module_code_phrase import ( CodePhraseModule, NoMoreAvailableCategories, ) +from soitool.modules.module_predefined_codes import PredefinedCodesModule + +# List of SOI-modules that require a database as parameter +DATABASE_MODULES = [PredefinedCodesModule, CodePhraseModule] class SOIWorkspaceWidget(QWidget): @@ -29,12 +33,13 @@ class SOIWorkspaceWidget(QWidget): Creates a new SOI by default, but can receive an existing SOI through it's init parameter 'soi'. + The parameter 'database' is passed to instantiate specific modules. The widget is used inside tabs in our application, and contains a sidebar with a module list along with a view of the SOI. """ - def __init__(self, database=None, soi=None): + def __init__(self, database, soi=None): super().__init__() self.database = database @@ -124,15 +129,14 @@ class SOIWorkspaceWidget(QWidget): ) try: - # Some modules require the database in their init - if module_widget_implementation == CodePhraseModule: - module_widget_instance = module_widget_implementation( + if module_widget_implementation in DATABASE_MODULES: + module = module_widget_implementation( database=self.database ) else: - module_widget_instance = module_widget_implementation() + module = module_widget_implementation() self.soi.add_module( - module_name, module_widget_instance, is_attachment, + module_name, module, is_attachment, ) except ModuleNameTaken: exec_warning_dialog( diff --git a/soitool/testdata/long_codebook.json b/soitool/testdata/long_codebook.json index 1c5a0fe6bcbc8081944a64f9b9cb849abd6434a1..5423c81cf1c4ae9de2cc9032fff3970a1d516601 100644 --- a/soitool/testdata/long_codebook.json +++ b/soitool/testdata/long_codebook.json @@ -1,9 +1,64 @@ [ + { + "word": "0", + "category": "Tall", + "type": "Liten" + }, + { + "word": "1", + "category": "Tall", + "type": "Liten" + }, + { + "word": "2", + "category": "Tall", + "type": "Liten" + }, + { + "word": "3", + "category": "Tall", + "type": "Liten" + }, + { + "word": "4", + "category": "Tall", + "type": "Liten" + }, { "word": "40 mm", "category": "V\u00e5penteknisk", "type": "Stor" }, + { + "word": "5", + "category": "Tall", + "type": "Liten" + }, + { + "word": "6", + "category": "Tall", + "type": "Liten" + }, + { + "word": "7", + "category": "Tall", + "type": "Liten" + }, + { + "word": "8", + "category": "Tall", + "type": "Liten" + }, + { + "word": "9", + "category": "Tall", + "type": "Liten" + }, + { + "word": "A", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "AG3", "category": "V\u00e5penteknisk", @@ -34,6 +89,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "B", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "BV-206", "category": "Kj\u00f8ret\u00f8y", @@ -47,12 +107,17 @@ { "word": "Bombe", "category": "V\u00e5penteknisk", - "type": "Stor" + "type": "Liten" }, { "word": "Bro", "category": "Landemerker", - "type": "Stor" + "type": "Liten" + }, + { + "word": "C", + "category": "Bokstaver", + "type": "Liten" }, { "word": "CV 90", @@ -84,6 +149,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "D", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Dal", "category": "Landemerker", @@ -104,6 +174,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "E", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "ERYX", "category": "V\u00e5penteknisk", @@ -117,7 +192,7 @@ { "word": "Elv", "category": "Landemerker", - "type": "Stor" + "type": "Liten" }, { "word": "Er i rute", @@ -129,11 +204,26 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "F", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Ferdigstilling", "category": "Straff", "type": "Stor" }, + { + "word": "Fram", + "category": "Retninger", + "type": "Liten" + }, + { + "word": "G", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "G-notes", "category": "Testkategori", @@ -145,29 +235,24 @@ "type": "Stor" }, { - "word": "HK-416", - "category": "V\u00e5penteknisk", - "type": "Stor" - }, - { - "word": "Hadza", - "category": "Testkategori", - "type": "Stor" + "word": "H", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Heron", - "category": "Testkategori", - "type": "Stor" + "word": "HK-416", + "category": "V\u00e5penteknisk", + "type": "Liten" }, { - "word": "Hindu", - "category": "Testkategori", - "type": "Stor" + "word": "Helikopter", + "category": "St\u00f8tte", + "type": "Liten" }, { "word": "H\u00e5ndgranat", "category": "V\u00e5penteknisk", - "type": "Stor" + "type": "Liten" }, { "word": "H\u00f8yde", @@ -175,15 +260,30 @@ "type": "Stor" }, { - "word": "Jacqui", - "category": "Testkategori", - "type": "Stor" + "word": "I", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "J", + "category": "Bokstaver", + "type": "Liten" }, { "word": "Jamnagar", "category": "Testkategori", "type": "Stor" }, + { + "word": "K", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "K9", + "category": "St\u00f8tte", + "type": "Liten" + }, { "word": "Khronos", "category": "Testkategori", @@ -205,18 +305,13 @@ "type": "Stor" }, { - "word": "Leopard 2", - "category": "Kj\u00f8ret\u00f8y", - "type": "Stor" - }, - { - "word": "Louis Quatorze", - "category": "Testkategori", - "type": "Stor" + "word": "L", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Lucifer", - "category": "Testkategori", + "word": "Leopard 2", + "category": "Kj\u00f8ret\u00f8y", "type": "Stor" }, { @@ -224,6 +319,11 @@ "category": "V\u00e5penteknisk", "type": "Stor" }, + { + "word": "M", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "M04", "category": "Bekledning", @@ -255,19 +355,14 @@ "type": "Stor" }, { - "word": "MP-5", - "category": "V\u00e5penteknisk", + "word": "MP", + "category": "St\u00f8tte", "type": "Liten" }, { - "word": "Maeander", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Manila", - "category": "Testkategori", - "type": "Stor" + "word": "MP-5", + "category": "V\u00e5penteknisk", + "type": "Liten" }, { "word": "Meldingsblankett (blokker)", @@ -275,14 +370,14 @@ "type": "Stor" }, { - "word": "Mendoza", - "category": "Testkategori", + "word": "Mine", + "category": "V\u00e5penteknisk", "type": "Stor" }, { - "word": "Midwest", - "category": "Testkategori", - "type": "Stor" + "word": "N", + "category": "Bokstaver", + "type": "Liten" }, { "word": "Netting under over", @@ -290,19 +385,19 @@ "type": "Stor" }, { - "word": "Nikkei index", - "category": "Testkategori", - "type": "Stor" + "word": "Nord", + "category": "Retninger", + "type": "Liten" }, { - "word": "Orthodox Jew", - "category": "Testkategori", - "type": "Stor" + "word": "O", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "P-80", - "category": "V\u00e5penteknisk", - "type": "Stor" + "word": "P", + "category": "Bokstaver", + "type": "Liten" }, { "word": "PACE-batteri/BA-3090 (stk)", @@ -329,6 +424,16 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "Q", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "R", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Regnt\u00f8y", "category": "Bekledning", @@ -350,32 +455,22 @@ "type": "Stor" }, { - "word": "Saaniches", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Sarah", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Scylla", - "category": "Testkategori", - "type": "Stor" + "word": "S", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Senate", + "word": "Saaniches", "category": "Testkategori", "type": "Stor" }, { - "word": "Sephardim", - "category": "Testkategori", - "type": "Stor" + "word": "Sanitet", + "category": "St\u00f8tte", + "type": "Liten" }, { - "word": "Serpentes", + "word": "Sarah", "category": "Testkategori", "type": "Stor" }, @@ -390,44 +485,39 @@ "type": "Stor" }, { - "word": "Silicon Wadi", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Siralun", - "category": "Testkategori", + "word": "Stans, -e, -et", + "category": "Uttrykk/tiltak/oppdrag", "type": "Stor" }, { - "word": "Somerset Island", - "category": "Testkategori", - "type": "Stor" + "word": "Sti", + "category": "Landemerker", + "type": "Liten" }, { - "word": "Squalicum's", + "word": "Surinamese", "category": "Testkategori", "type": "Stor" }, { - "word": "Stans, -e, -et", - "category": "Uttrykk/tiltak/oppdrag", - "type": "Stor" + "word": "S\u00f8r", + "category": "Retninger", + "type": "Liten" }, { - "word": "Sti", - "category": "Landemerker", - "type": "Stor" + "word": "T", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Surinamese", - "category": "Testkategori", - "type": "Stor" + "word": "Tilbake", + "category": "Retninger", + "type": "Liten" }, { - "word": "Tsvetaeva", - "category": "Testkategori", - "type": "Stor" + "word": "U", + "category": "Bokstaver", + "type": "Liten" }, { "word": "U-235", @@ -444,6 +534,11 @@ "category": "Personlig utrustning", "type": "Stor" }, + { + "word": "V", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Vann (l)", "category": "Etterforsyninger", @@ -460,94 +555,29 @@ "type": "Stor" }, { - "word": "Vest plate", - "category": "Personnlig utrustning", - "type": "Stor" - }, - { - "word": "Vicar of Christ", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Visayan", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Winterfest", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "acanthocephalan", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "acceptress", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "accountants", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "advertisers", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aestivates", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aetiologies", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "after-damp", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "after-dinner", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "alalia", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "alanyl", - "category": "Testkategori", - "type": "Stor" + "word": "Vest", + "category": "Retninger", + "type": "Liten" }, { - "word": "alcoholometry", - "category": "Testkategori", - "type": "Stor" + "word": "W", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "alfresco", - "category": "Testkategori", - "type": "Stor" + "word": "X", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "algolagnic", - "category": "Testkategori", - "type": "Stor" + "word": "Y", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "allured", - "category": "Testkategori", - "type": "Stor" + "word": "Z", + "category": "Bokstaver", + "type": "Liten" }, { "word": "angleworm", @@ -574,26 +604,6 @@ "category": "Testkategori", "type": "Stor" }, - { - "word": "antitragicus", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "apama", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aphoristic", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "archpriest", - "category": "Testkategori", - "type": "Stor" - }, { "word": "areola", "category": "Testkategori", @@ -609,21 +619,6 @@ "category": "Testkategori", "type": "Stor" }, - { - "word": "assubjugated", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "athanasy", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "atony", - "category": "Testkategori", - "type": "Stor" - }, { "word": "attapulgite", "category": "Testkategori", @@ -3374,9 +3369,14 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "\u00d8st", + "category": "Retninger", + "type": "Liten" + }, { "word": "\u00e6nigmata", "category": "Testkategori", "type": "Stor" } -] +] \ No newline at end of file diff --git a/test/test_database.py b/test/test_database.py index 08dc93f079ba024302b4289c6bdff9389451161a..91378d02897dec218179ec99ef0bb523bddeaa9d 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -179,6 +179,48 @@ class DatabaseTest(unittest.TestCase): categories_db = self.database.get_categories() self.assertEqual(categories_file, categories_db) + def test_get_random_category_word(self): + """Assert function get_random_category_word works as expected.""" + # Read all category-words + stmt = "SELECT Word from CategoryWords" + queried = self.database.conn.execute(stmt).fetchall() + category_words = [row["Word"] for row in queried] + + # Generate 5 random category-words and assert that they are + # from table CategoryWords + generated_category_words = [] + for _ in range(5): + category_word = self.database.get_random_category_word() + self.assertTrue(category_word in category_words) + generated_category_words.append(category_word) + + # Assert that all words are not equal + self.assertTrue(len(set(generated_category_words)) > 1) + + def test_get_categories_from_codebook(self): + """Assert function get_categories_from_codebook works as expected.""" + # Get categories in full and small codebook + stmt = "SELECT Category FROM Codebook GROUP BY Category" + queried = self.database.conn.execute(stmt).fetchall() + expected_categories_full = [row["Category"] for row in queried] + + stmt = ( + "SELECT Category FROM Codebook " + "WHERE Type='Liten' GROUP BY Category" + ) + queried = self.database.conn.execute(stmt).fetchall() + expected_categories_small = [row["Category"] for row in queried] + + # Get categories in full and small codebook through function + actual_categories_full = self.database.get_categories_from_codebook() + actual_categories_small = self.database.get_categories_from_codebook( + small=True + ) + + # Assert actual categories matches expected categories + self.assertEqual(actual_categories_full, expected_categories_full) + self.assertEqual(actual_categories_small, expected_categories_small) + def test_get_codebook(self): """Assert function get_codebook returns full codebook.""" # Load full codebook @@ -226,6 +268,37 @@ class DatabaseTest(unittest.TestCase): self.assertRegex(actual[i]["code"], "[A-Z]{" + str(code_len) + "}") self.assertEqual(entry["type"], actual[i]["type"]) + def test_get_codebook_expressions_in_category(self): + """Test function get_codebook_expressions_in_category.""" + # Get a category in full and small codebook + category_full = self.database.get_categories_from_codebook()[0] + category_small = self.database.get_categories_from_codebook( + small=True + )[0] + + # Get expressions from category + stmt = "SELECT Word FROM Codebook WHERE Category=?" + queried = self.database.conn.execute(stmt, (category_full,)).fetchall() + expected_expressions_full = [row["Word"] for row in queried] + + stmt += " AND Type='Liten'" + queried = self.database.conn.execute( + stmt, (category_small,) + ).fetchall() + expected_expressions_small = [row["Word"] for row in queried] + + # Get expressions from function + actual_expr_full = self.database.get_codebook_expressions_in_category( + category_full + ) + actual_expr_small = self.database.get_codebook_expressions_in_category( + category_small, small=True + ) + + # Assert actual expressions matches expected expressions + self.assertEqual(actual_expr_full, expected_expressions_full) + self.assertEqual(actual_expr_small, expected_expressions_small) + def test_update_codebook(self): """Test that the codes get updated.""" # Get entries before and after update @@ -281,7 +354,7 @@ class DatabaseTest(unittest.TestCase): ) else: - print("ERROR: Database is not 675 entries long, cant run test") + print("ERROR: Database is not 676 entries long, cant run test") self.assertTrue(False) # pylint: disable=W1503 def test_seconds_to_next_update(self): diff --git a/test/test_module_predefined_codes.py b/test/test_module_predefined_codes.py new file mode 100644 index 0000000000000000000000000000000000000000..d18fa647fb13d5e94cdbe809ec8ceaccb2dda837 --- /dev/null +++ b/test/test_module_predefined_codes.py @@ -0,0 +1,155 @@ +"""Tests for PredefinedCodesModule.""" +import unittest +import string +from test.test_database import TESTDBPATH +from PySide2.QtCore import Qt, QTimer +from PySide2 import QtGui +from PySide2.QtTest import QTest +from PySide2.QtWidgets import QApplication +from soitool.database import Database +from soitool.modules.module_predefined_codes import ( + PredefinedCodesModule, + HEADLINE, + DEFAULT_COLUMN_HEIGHT, +) + +ALPHABET = string.ascii_uppercase + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestDefaultPredefinedCodesModule(unittest.TestCase): + """TestCase for PredefinedCodesModule.""" + + def setUp(self): + """Create database and PredefinedCodesModule.""" + self.database = Database(db_path=TESTDBPATH) + + # Get categories in small codebook + self.categories = self.database.get_categories_from_codebook( + small=True + ) + + def press_enter_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Enter) + + QTimer.singleShot(0, press_enter_on_active_dialog) + self.module = PredefinedCodesModule(self.database) + + def test_default_dialog(self): + """Test default values in dialog.""" + + def test_and_close_dialog(): + dialog = app.activeModalWidget() + + # Assert prefilled headline is correct + self.assertEqual(dialog.edit_headline.text(), HEADLINE) + + # Assert prefilled warning-word is not empty + warning_word = dialog.edit_warning_word.text() + self.assertTrue(len(warning_word) > 0) + + # Assert prefilled maximum column-height is correct + maximum_column_height_value = dialog.edit_column_height.value() + self.assertTrue( + maximum_column_height_value >= DEFAULT_COLUMN_HEIGHT + ) + + # Assert categories are correct + for i, category in enumerate(self.categories): + dialog_category = dialog.list_category_order.item(i).text() + self.assertEqual(category, dialog_category) + + # Close dialog + dialog.accept() + + # Use shortcut to open dialog + QTimer.singleShot(0, test_and_close_dialog) + QTest.keyClicks(self.module, "R", Qt.ControlModifier) + + def test_default_module(self): + """Test default module.""" + # Assert headline is correct + self.assertEqual(self.module.headline.text(), HEADLINE) + + # Assert warning-word is not empty + self.assertTrue(len(self.module.warning_word.text()) > 0) + + # Assert module has one table per category + self.assertEqual(len(self.module.tables), len(self.categories)) + + # Assert each table has the correct headline and contents + for i, table in enumerate(self.module.tables): + expected_headline = " " + ALPHABET[i] + " " + self.categories[i] + actual_headline = table.item(0, 0).text() + self.assertEqual(actual_headline, expected_headline) + + expressions = self.database.get_codebook_expressions_in_category( + self.categories[i], small=True + ) + # Assert codes are correct and read expressions in table + actual_expressions = [] + for j in range(1, table.rowCount()): + expected_code = ALPHABET[j - 1] + actual_code = table.item(j, 0).text() + self.assertEqual(actual_code, expected_code) + + actual_expressions.append(table.item(j, 1).text()) + # Assert expressions are correct + for actual_expression in actual_expressions: + self.assertTrue(actual_expression in expressions) + + +class TestDefaultPredefinedCodesModuleFromData(unittest.TestCase): + """TestCase for initializing PredefinedCodesModule from data.""" + + def test_create_from_data(self): + """Test creating PredefinedCodesModule from data.""" + test_data = { + "headline": "HeadlineText", + "warning_word": "WarningWordText", + "maximum_column_height": 500, + "categories": ["CategoryOne", "CategoryTwo"], + "tables": [ + { + "table_headline": "TableHeadlineOne", + "expressions": ["expressionOne", "expressionTwo"], + }, + { + "table_headline": "TableHeadlineTwo", + "expressions": ["expressionOne", "expressionTwo"], + }, + ], + } + # Create PredefinedCodesModule from data + module = PredefinedCodesModule(Database(db_path=TESTDBPATH), test_data) + + # Assert headline is correct + self.assertEqual(module.headline.text(), "HeadlineText") + + # Assert warning-word is correct + self.assertEqual(module.warning_word.text(), "WarningWordText") + + # Assert module has two tables + self.assertEqual(len(module.tables), 2) + + # Assert each table has the correct headline and expressions + table_one = module.tables[0] + table_two = module.tables[1] + + # Assert correct headlines + self.assertEqual(table_one.item(0, 0).text(), "TableHeadlineOne") + self.assertEqual(table_two.item(0, 0).text(), "TableHeadlineTwo") + + # Assert correct codes and expressions + self.assertEqual(table_one.item(1, 1).text(), "expressionOne") + self.assertEqual(table_one.item(2, 1).text(), "expressionTwo") + self.assertEqual(table_two.item(1, 1).text(), "expressionOne") + self.assertEqual(table_two.item(2, 1).text(), "expressionTwo") + for i in range(1, 3): + self.assertEqual(table_one.item(i, 0).text(), ALPHABET[i - 1]) + self.assertEqual(table_two.item(i, 0).text(), ALPHABET[i - 1]) diff --git a/test/test_resolution_smoke_test.py b/test/test_resolution_smoke_test.py index 3bbcb60fab656e4497f9d65a913735fe8881ed76..3bc6fcbd0662307bc26cbfb840408c2339ced370 100644 --- a/test/test_resolution_smoke_test.py +++ b/test/test_resolution_smoke_test.py @@ -19,13 +19,30 @@ from soitool.modules.module_authentication_board import ( AuthenticationBoardModule, ) from soitool.modules.module_subtractorcodes import SubtractorcodesModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule from soitool.new_module_dialog import MODULE_CHOICES +from soitool.soi_workspace_widget import DATABASE_MODULES +from soitool.database import Database + +# 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_database import TESTDBPATH + if isinstance(QtGui.qApp, type(None)): app = QApplication([]) else: app = QtGui.qApp +# Modules with a popup as part of their __init__ +POPUP_MODULES = [ + AuthenticationBoardModule, + SubtractorcodesModule, + PredefinedCodesModule, +] + def screen_information(): """Get string with information about the screen. @@ -87,20 +104,28 @@ class TestModulesAcrossResolutions(unittest.TestCase): deleted, or changed. It will fail until it is updated. """ expected_result = [ - {"x": 0, "y": 0, "page": 1, "name": "AuthenticationBoardModule"}, - {"x": 407.5, "y": 0, "page": 1, "name": "SubtractorcodesModule"}, - {"x": 602.0, "y": 0, "page": 1, "name": "FreeTextModule"}, - {"x": 702.0, "y": 0, "page": 1, "name": "TableModule"}, + {"x": 0, "y": 0, "page": 1, "name": "PredefinedCodesModule"}, + {"x": 654, "y": 0, "page": 1, "name": "AuthenticationBoardModule"}, + {"x": 1061.5, "y": 0, "page": 1, "name": "SubtractorcodesModule"}, + {"x": 1287.0, "y": 0, "page": 1, "name": "FreeTextModule"}, + {"x": 1387.0, "y": 0, "page": 1, "name": "TableModule"}, ] + # For use with modules that require a database + database = Database(db_path=TESTDBPATH) + def press_enter(): active_widget = app.activeModalWidget() - # triple click to select existing text for overwrite - QTest.mouseDClick(active_widget.edit_headline, Qt.LeftButton) - QTest.mouseClick(active_widget.edit_headline, Qt.LeftButton) - # need to overwrite text because title otherwise contains - # unpredictable text - QTest.keyClicks(active_widget.edit_headline, "TestTitle") + + # AuthenticationBoardModule needs special treatment because the + # title can contain random info + if isinstance(active_widget, AuthenticationBoardModule): + # triple click to select existing text for overwrite + QTest.mouseDClick(active_widget.edit_headline, Qt.LeftButton) + QTest.mouseClick(active_widget.edit_headline, Qt.LeftButton) + # need to overwrite text because title otherwise contains + # unpredictable text + QTest.keyClicks(active_widget.edit_headline, "TestTitle") QTest.keyClick(active_widget, Qt.Key_Enter) soi = SOI() @@ -108,9 +133,13 @@ class TestModulesAcrossResolutions(unittest.TestCase): for module in MODULE_CHOICES: # If we're adding one of the modules with a popup as part of it's # constructor we need to singleShot pressing enter to close it - if module in (AuthenticationBoardModule, SubtractorcodesModule): + if module in POPUP_MODULES: QTimer.singleShot(0, press_enter) - soi.add_module(module.__name__, module()) + + if module in DATABASE_MODULES: + soi.add_module(module.__name__, module(database=database)) + else: + soi.add_module(module.__name__, module()) soi.reorganize() diff --git a/test/test_serialize_export_import.py b/test/test_serialize_export_import.py index 143c31635110ed0a69270a28e3124aba99960d80..c55f3b565db59ed040833ef8377c42a300d8b237 100644 --- a/test/test_serialize_export_import.py +++ b/test/test_serialize_export_import.py @@ -4,9 +4,11 @@ import unittest import json from pathlib import Path from datetime import datetime +from test.test_database import TESTDBPATH from PySide2.QtWidgets import QApplication from soitool.soi import SOI from soitool.modules.module_table import TableModule +from soitool.database import Database from soitool.serialize_export_import_soi import ( serialize_soi, export_soi, @@ -116,6 +118,7 @@ class SerializeTest(unittest.TestCase): self.file_path_compressed = os.path.join( SOITOOL_ROOT_PATH, file_name_compressed ) + self.database = Database(db_path=TESTDBPATH) def test_serialize_soi(self): """Serialize SOI and check its validity against schema.""" @@ -149,7 +152,7 @@ class SerializeTest(unittest.TestCase): # Assert import throws error with self.assertRaises(ValueError) as error: - import_soi(invalid_soi_file_path) + import_soi(invalid_soi_file_path, self.database) self.assertEqual( str(error.exception), "Serialized SOI does not have correct format.", @@ -160,8 +163,10 @@ class SerializeTest(unittest.TestCase): def test_import_soi(self): """Import serialized SOI's, check content and delete SOI-files.""" # Import uncompressed and compressed SOI's - soi_uncompressed = import_soi(self.file_path_uncompressed) - soi_compressed = import_soi(self.file_path_compressed) + soi_uncompressed = import_soi( + self.file_path_uncompressed, self.database + ) + soi_compressed = import_soi(self.file_path_compressed, self.database) # Assert SOI content is correct self.check_soi_content(soi_uncompressed)