Many hyperlinks are disabled.
Use anonymous login
to enable hyperlinks.
Changes In Branch custom_card_import_dialog Excluding Merge-Ins
This is equivalent to a diff from a0bb946382 to bd1ca5c600
2025-04-25
| ||
11:56 | Tests: Add fixture page_layout, returning a PageLayoutSettings instance created from settings. Use it everywhere, where code previously explicitly instantiated a PageLayuoutSettings. Validate the instance against the default settings. Leaf check-in: bd1ca5c600 user: thomas tags: custom_card_import_dialog | |
11:37 | Remove a broken test case and add additional validation to test_page_layout_compute_page_row_count(). Somewhere, a test alters the document settings without changing them back check-in: dfab6f5fde user: thomas tags: custom_card_import_dialog | |
2025-04-03
| ||
14:20 | Remove unused attributes in the PageConfigWidget and PageConfigContainer classes. check-in: 2cbbc31007 user: thomas tags: trunk | |
2025-04-01
| ||
20:51 | Merge with trunk. check-in: d8a4c9160d user: thomas tags: custom_card_import_dialog | |
13:36 | Break long lines in README.md check-in: a0bb946382 user: thomas tags: trunk | |
12:39 | Implemented saving custom cards in the native save file format. Major improvement for custom card support check-in: 3467478e43 user: thomas tags: trunk | |
Changes to .fossil-settings/ignore-glob.
︙ | ︙ | |||
26 27 28 29 30 31 32 | *.spec *.pdf *.mtgproxies mtg_proxy_printer/resources/translations/*.qm mtg_proxy_printer/resources/translations/mtgproxyprinter_sources.ts Screenshots/*.txt *.png | > | 26 27 28 29 30 31 32 33 | *.spec *.pdf *.mtgproxies mtg_proxy_printer/resources/translations/*.qm mtg_proxy_printer/resources/translations/mtgproxyprinter_sources.ts Screenshots/*.txt *.png requirements*.txt |
Changes to doc/changelog.md.
1 2 3 4 | # Changelog # Next version (in development) | | > > > > > > > | > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | # Changelog # Next version (in development) ## New features - Improved custom card support - Adding custom cards via drag & drop now opens a dialog to customize the import - Allows setting the number of copies to add for each card. Vastly improves the workflow when you want to print multiple copies. - Shown card name is now derived from the file name, instead of defaulting to "Custom Card" - The import dialog can also be accessed from the File menu. Access to printing custom cards no longer requires the use of drag & drop. - It is now possible to save custom cards and empty slots in the apps save file format. Custom cards are no longer lost when saving. ## Changed features - The card table in the deck import wizard now has an editable Copies column to state the number of copies per card, instead of duplicating the card for that many rows. This makes it possible to edit the number of copies per card - When splitting exported PDFs, zero-pad the sequence numbers appended to the file name so that all have the same length. This gives a more consistent sorting of output files. - This avoids having output files sorted like "1.pdf", "11.pdf", "12.pdf", …, "2.pdf", "21.pdf", … - The page content table no longer uses a fancy multi selection behavior, as it interfered with editing entries. The new behavior is in line with how other applications allow selections in tables. # Version 0.30.1 (2025-03-11) <a name="v0_30_1"></a> ## Fixed issues - Fixed that some start-up tasks were not run on the Windows 10+ build. Fixes that the deck list translation and default card language setting in the application settings did not offer any language choices. |
︙ | ︙ |
Changes to mtg-proxy-printer-runner.py.
︙ | ︙ | |||
23 24 25 26 27 28 29 | import sys # Make sure to find this checkout, and not any system- or user-wide installed versions that may be present root_path = pathlib.Path(__file__).parent.absolute().resolve() sys.path.insert(0, str(root_path)) import mtg_proxy_printer.model.carddb | | | 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | import sys # Make sure to find this checkout, and not any system- or user-wide installed versions that may be present root_path = pathlib.Path(__file__).parent.absolute().resolve() sys.path.insert(0, str(root_path)) import mtg_proxy_printer.model.carddb from mtg_proxy_printer.__main__ import main # These methods are wrapped by the profile() function # if this script is run using the kernprof line profiler. to_be_profiled_functions = { mtg_proxy_printer.model.carddb.CardDatabase: [ "get_all_languages", |
︙ | ︙ |
Changes to mtg_proxy_printer/decklist_parser/common.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 | from abc import abstractmethod import typing from PyQt5.QtCore import QObject, pyqtSignal as Signal | | > | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | from abc import abstractmethod import typing from PyQt5.QtCore import QObject, pyqtSignal as Signal from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card import Card, AnyCardType from mtg_proxy_printer.model.imagedb import ImageDatabase import mtg_proxy_printer.settings from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ |
︙ | ︙ | |||
38 39 40 41 42 43 44 | # noinspection PyUnresolvedReferences,PyUnboundLocalVariable profile except NameError: # If not defined, use this identity decorator as a replacement def profile(func): return func | | | 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | # noinspection PyUnresolvedReferences,PyUnboundLocalVariable profile except NameError: # If not defined, use this identity decorator as a replacement def profile(func): return func CardCounter = typing.Counter[AnyCardType] ParsedDeck = typing.Tuple[CardCounter, typing.List[str]] class ParserBase(QObject): @staticmethod def supported_file_types() -> typing.Dict[str, typing.List[str]]: |
︙ | ︙ |
Changes to mtg_proxy_printer/decklist_parser/csv_parsers.py.
︙ | ︙ | |||
17 18 19 20 21 22 23 | import abc import collections import csv import typing from PyQt5.QtCore import QObject, QCoreApplication | | > | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | import abc import collections import csv import typing from PyQt5.QtCore import QObject, QCoreApplication from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from ..model.card import Card from mtg_proxy_printer.model.imagedb import ImageDatabase from .common import ParsedDeck, ParserBase from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger |
︙ | ︙ |
Changes to mtg_proxy_printer/decklist_parser/re_parsers.py.
︙ | ︙ | |||
17 18 19 20 21 22 23 | from collections import Counter import re import typing from PyQt5.QtCore import QObject, QCoreApplication from mtg_proxy_printer.decklist_parser.common import ParsedDeck, ParserBase | | > | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | from collections import Counter import re import typing from PyQt5.QtCore import QObject, QCoreApplication from mtg_proxy_printer.decklist_parser.common import ParsedDeck, ParserBase from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card import Card from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger MatchType = typing.Dict[str, str] |
︙ | ︙ |
Changes to mtg_proxy_printer/document_controller/_interface.py.
︙ | ︙ | |||
66 67 68 69 70 71 72 | @abstractmethod def undo(self, document: "Document") -> Self: """ Reverses the application of the action to the given document, undoing its effects. For this to work properly, this action must have been the most recent action applied to the document. """ | | | 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | @abstractmethod def undo(self, document: "Document") -> Self: """ Reverses the application of the action to the given document, undoing its effects. For this to work properly, this action must have been the most recent action applied to the document. """ return self def __eq__(self, other) -> bool: return isinstance(other, self.__class__) and all( map( operator.eq, map((partial(getattr, self)), self.COMPARISON_ATTRIBUTES), map((partial(getattr, other)), self.COMPARISON_ATTRIBUTES) |
︙ | ︙ |
Changes to mtg_proxy_printer/document_controller/card_actions.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 | import functools import itertools import math import typing | | > > | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | import functools import itertools import math import typing from ..model.card import Card, AnyCardType if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import Page from mtg_proxy_printer.natsort import to_list_of_ranges from ._interface import DocumentAction, IllegalStateError, Self, split_iterable from .page_actions import ActionNewPage, ActionRemovePage from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ |
︙ | ︙ | |||
214 215 216 217 218 219 220 | def as_str(self): card_count = sum(upper-lower+1 for lower, upper in self.card_ranges_to_remove) page_number = self.page_number+1 return self.translate( "ActionRemoveCards", "Remove %n card(s) from page {page_number}", "Undo/redo tooltip text", card_count ).format(page_number=page_number) | < < < < < < < < < < < < < < | 216 217 218 219 220 221 222 | def as_str(self): card_count = sum(upper-lower+1 for lower, upper in self.card_ranges_to_remove) page_number = self.page_number+1 return self.translate( "ActionRemoveCards", "Remove %n card(s) from page {page_number}", "Undo/redo tooltip text", card_count ).format(page_number=page_number) |
Added mtg_proxy_printer/document_controller/edit_custom_card.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import functools import typing from PyQt5.QtCore import QModelIndex, Qt if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import CardContainer, PageColumns from ._interface import DocumentAction, Self from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ "ActionEditCustomCard", ] ItemDataRole = Qt.ItemDataRole class ActionEditCustomCard(DocumentAction): """ Compacts a document by filling as many empty slots as possible on pages that are not at the end of the document. Scans the document for pages that are not completely filled and for each such page, moves cards from the last page with items to it. This fills all (but the last) pages up to the capacity limit to help reduce possible waste during printing. """ COMPARISON_ATTRIBUTES = ["old_value", "new_value", "page", "row", "column"] def __init__(self, index: QModelIndex, value): self.page = index.parent().row() self.row = index.row() self.column = PageColumns(index.column()) self.old_value = index.data(ItemDataRole.EditRole) self.new_value = value self.new_display_value = None document = index.model() self.header_text = document.headerData(self.column, Qt.Orientation.Horizontal, ItemDataRole.DisplayRole) def apply(self, document: "Document") -> Self: self._set_data_for_custom_card(document, self.new_value) index = document.index(self.row, self.column, document.index(self.page, 0)) self.new_display_value = index.data(ItemDataRole.DisplayRole) return super().apply(document) def undo(self, document: "Document") -> Self: self._set_data_for_custom_card(document, self.old_value) return super().undo(document) def _set_data_for_custom_card(self, document: "Document", value: typing.Any): row, column = self.row, self.column index = document.index(row, column, document.index(self.page, 0)) container: CardContainer = index.internalPointer() card = container.card logger.debug(f"Setting page data on custom card for {column=} to {value}") if column == PageColumns.CardName: # This also affects the page overview. find_relevant_index_ranges() # takes care of that by also returning relevant Page indices card.name = value elif column == PageColumns.CollectorNumber: card.collector_number = value elif column == PageColumns.Language: card.language = value elif column == PageColumns.IsFront: card.is_front = value card.face_number = int(not value) elif column == PageColumns.Set: card.set = value for lower, upper in document.find_relevant_index_ranges(card, column): document.dataChanged.emit(lower, upper, [ItemDataRole.DisplayRole, ItemDataRole.EditRole]) @functools.cached_property def as_str(self): return self.translate( "ActionEditCustomCard", "Edit custom card, set {column_header_text} to {new_value}", "Undo/redo tooltip text").format(column_header_text=self.header_text, new_value=self.new_display_value) |
Changes to mtg_proxy_printer/document_controller/load_document.py.
︙ | ︙ | |||
18 19 20 21 22 23 24 | import pathlib import typing if typing.TYPE_CHECKING: from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.model.document import Document | | | 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | import pathlib import typing if typing.TYPE_CHECKING: from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.card import CardList from ._interface import DocumentAction, IllegalStateError, ActionList, Self from .page_actions import ActionNewPage from .card_actions import ActionAddCard from .new_document import ActionNewDocument from .edit_document_settings import ActionEditDocumentSettings from mtg_proxy_printer.logger import get_logger |
︙ | ︙ |
Changes to mtg_proxy_printer/document_controller/page_actions.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 | import functools import typing if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import Page | | | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | import functools import typing if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import Page from ..model.card import AnyCardType from ._interface import DocumentAction, IllegalStateError, Self from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ "ActionNewPage", |
︙ | ︙ |
Changes to mtg_proxy_printer/document_controller/replace_card.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 | import functools import typing from PyQt5.QtCore import Qt | > | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | import functools import typing from PyQt5.QtCore import Qt from ..model.card import Card if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document_page import CardContainer from mtg_proxy_printer.model.document import Document from ._interface import DocumentAction, Self, ActionList from .card_actions import ActionRemoveCards, ActionAddCard |
︙ | ︙ |
Changes to mtg_proxy_printer/document_controller/save_document.py.
︙ | ︙ | |||
24 25 26 27 28 29 30 | if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from ._interface import DocumentAction, Self from mtg_proxy_printer.sqlite_helpers import open_database, cached_dedent from mtg_proxy_printer.units_and_sizes import CardSizes, UUID from mtg_proxy_printer.model.page_layout import PageLayoutSettings | | | 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | if typing.TYPE_CHECKING: from mtg_proxy_printer.model.document import Document from ._interface import DocumentAction, Self from mtg_proxy_printer.sqlite_helpers import open_database, cached_dedent from mtg_proxy_printer.units_and_sizes import CardSizes, UUID from mtg_proxy_printer.model.page_layout import PageLayoutSettings from ..model.card import AnyCardType, CustomCard from mtg_proxy_printer.model.document_loader import CardType from mtg_proxy_printer.save_file_migrations import migrate_database from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger |
︙ | ︙ | |||
50 51 52 53 54 55 56 | def __init__(self, file_path: Path): super().__init__() self.file_path = file_path def apply(self, document: "Document") -> Self: logger.debug(f"About to save document to {self.file_path}") layout = document.page_layout | | | | | > | | | | | | > > > > > > | > < | | | < > | < < < < < < < < < < < < | < | | | < > | | > | > > > > > > > > > | | | < | | < < > | | < < < < < < < < > | | | | 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | def __init__(self, file_path: Path): super().__init__() self.file_path = file_path def apply(self, document: "Document") -> Self: logger.debug(f"About to save document to {self.file_path}") layout = document.page_layout with open_database(self.file_path, "document-v7") as db: db.execute("BEGIN IMMEDIATE TRANSACTION -- apply()\n") migrate_database(db, layout) self._clear_cards_and_pages(db) self._save_pages(db, document) self._save_cards(db, document) self.save_settings(db, layout) self._clean_unused_custom_cards(db) db.commit() if db.execute(cached_dedent("""\ SELECT cast(freelist_count AS real)/page_count > 0.1 AS "should vacuum" -- apply() FROM pragma_page_count INNER JOIN pragma_freelist_count """)).fetchone()[0]: db.execute("VACUUM -- apply()\n") logger.debug("Database saved and closed.") @staticmethod def save_settings(save_file: sqlite3.Connection, layout: PageLayoutSettings): settings, dimensions = layout.to_save_file_data() save_file.executemany( 'INSERT OR REPLACE INTO DocumentSettings ("key", value) VALUES (?, ?) -- save_settings()\n', settings) save_file.executemany( 'INSERT OR REPLACE INTO DocumentDimensions ("key", value) VALUES (?, ?) -- save_settings()\n', dimensions) logger.debug("Written document settings") @staticmethod def _clear_cards_and_pages(save_file: sqlite3.Connection): save_file.execute("DELETE FROM Card -- _clear_cards_and_pages()\n") save_file.execute("DELETE FROM Page -- _clear_cards_and_pages()\n") @staticmethod def _save_pages(save_file: sqlite3.Connection, document: "Document"): pages = ( (number, CardSizes.for_page_type(page.page_type()).to_save_data()) for number, page in enumerate(document.pages, start=1) if page ) save_file.executemany( "INSERT INTO Page (page, image_size) VALUES (?, ?) -- _save_pages()\n", pages ) @staticmethod def _save_cards(save_file: sqlite3.Connection, document: "Document"): empty_slot = cached_dedent("""\ INSERT INTO Card (page, slot, is_front, type) -- _save_cards() VALUES (?, ? ,?, ?)\n""") official_card = cached_dedent("""\ INSERT INTO Card (page, slot, is_front, type, scryfall_id) -- _save_cards() VALUES (?, ?, ? ,?, ?)\n""") for page_number, page in enumerate(document.pages, start=1): for slot, container in enumerate(page, start=1): card = container.card if card.image_file is document.image_db.get_blank(card.size): # Empty slot save_file.execute( empty_slot,(page_number, slot, card.is_front, CardType.from_card(card)) ) elif card.is_custom_card: ActionSaveDocument._save_custom_card(save_file, page_number, slot, card) else: save_file.execute( official_card, (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id) ) logger.debug(f"Written {save_file.execute('SELECT count(1) FROM Card').fetchone()[0]} cards.") @staticmethod def _save_custom_card(save_file: sqlite3.Connection, page_number: int, slot: int, card: AnyCardType): custom_card_data = ( card.scryfall_id, card.source_image_file, card.name, card.set.name, card.set_code, card.collector_number, card.is_front) save_file.execute(cached_dedent("""\ INSERT INTO CustomCardData -- _save_custom_card() (card_id, image, name, set_name, set_code, collector_number, is_front) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (card_id) DO UPDATE SET name = excluded.name, set_name = excluded.set_name, set_code = excluded.set_code, collector_number = excluded.collector_number, is_front = excluded.is_front WHERE name <> excluded.name OR set_name <> excluded.set_name OR set_code <> excluded.set_code OR collector_number <> excluded.collector_number OR is_front <> excluded.is_front """), custom_card_data ) card_data = (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id) save_file.execute(cached_dedent("""\ INSERT INTO Card (page, slot, is_front, type, custom_card_id) -- _save_custom_card() VALUES (?, ?, ? ,?, ?)\n"""), card_data ) @staticmethod def _clean_unused_custom_cards(save_file: sqlite3.Connection): save_file.execute(cached_dedent("""\ DELETE FROM CustomCardData -- _clean_unused_custom_cards() WHERE card_id NOT IN ( SELECT custom_card_id FROM Card WHERE custom_card_id IS NOT NULL) """)) def undo(self, document: "Document") -> Self: raise NotImplementedError("Undoing saving to disk is unsupported.") @functools.cached_property def as_str(self): return self.translate( "ActionSaveDocument", "Save document to '{save_file_path}'." ).format(save_file_path=self.file_path) |
Changes to mtg_proxy_printer/document_controller/shuffle_document.py.
︙ | ︙ | |||
22 23 24 25 26 27 28 | from secrets import token_bytes as randbytes import typing from PyQt5.QtCore import Qt, QModelIndex from ._interface import DocumentAction, IllegalStateError, Self | | | | | 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | from secrets import token_bytes as randbytes import typing from PyQt5.QtCore import Qt, QModelIndex from ._interface import DocumentAction, IllegalStateError, Self from ..model.card import Card from mtg_proxy_printer.model.document_page import CardContainer, PageColumns from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.units_and_sizes import PageType __all__ = [ "ActionShuffleDocument", ] IndexedCards = typing.List[typing.Tuple[int, Card]] ModelIndexList = typing.List[QModelIndex] |
︙ | ︙ |
Added mtg_proxy_printer/model/card.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import hashlib import enum import functools import typing from PyQt5.QtCore import QRect, QPoint, QSize, Qt, QPointF from PyQt5.QtGui import QPixmap, QColor, QColorConstants, QPainter, QTransform from mtg_proxy_printer.units_and_sizes import CardSize, PageType, CardSizes, UUID ItemDataRole = Qt.ItemDataRole RenderHint = QPainter.RenderHint SmoothTransformation = Qt.TransformationMode.SmoothTransformation @dataclasses.dataclass(frozen=True) class MTGSet: code: str name: str def data(self, role: ItemDataRole): """data getter used for Qt Model API based access""" if role == ItemDataRole.EditRole: return self elif role == ItemDataRole.DisplayRole: return f"{self.name} ({self.code.upper()})" elif role == ItemDataRole.ToolTipRole: return self.name else: return None @enum.unique class CardCorner(enum.Enum): """ The four corners of a card. Values are relative image positions in X and Y. These are fractions so that they work properly for both regular and oversized cards Values are tuned to return the top-left corner of a 10x10 area centered around (20,20) away from the respective corner. """ TOP_LEFT = (15/745, 15/1040) TOP_RIGHT = (1-25/745, 15/1040) BOTTOM_LEFT = (15/745, 1-25/1040) BOTTOM_RIGHT = (1-25/745, 1-25/1040) @dataclasses.dataclass(unsafe_hash=True) class Card: name: str = dataclasses.field(compare=True) set: MTGSet = dataclasses.field(compare=True) collector_number: str = dataclasses.field(compare=True) language: str = dataclasses.field(compare=True) scryfall_id: str = dataclasses.field(compare=True) is_front: bool = dataclasses.field(compare=True) oracle_id: str = dataclasses.field(compare=True) image_uri: str = dataclasses.field(compare=True) highres_image: bool = dataclasses.field(compare=False) size: CardSize = dataclasses.field(compare=False) face_number: int = dataclasses.field(compare=True) is_dfc: bool = dataclasses.field(compare=True) image_file: typing.Optional[QPixmap] = dataclasses.field(default=None, compare=False) def set_image_file(self, image: QPixmap): self.image_file = image self.corner_color.cache_clear() def requested_page_type(self) -> PageType: if self.image_file is None: return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR return PageType.OVERSIZED if self.image_file.size() == CardSizes.OVERSIZED.as_qsize_px() else PageType.REGULAR @functools.lru_cache(maxsize=len(CardCorner)) def corner_color(self, corner: CardCorner) -> QColor: """Returns the color of the card at the given corner. """ if self.image_file is None: return QColorConstants.Transparent sample_area = self.image_file.copy(QRect( QPoint( round(self.image_file.width() * corner.value[0]), round(self.image_file.height() * corner.value[1])), QSize(10, 10) )) average_color = sample_area.scaled( 1, 1, transformMode=SmoothTransformation).toImage().pixelColor(0, 0) return average_color def display_string(self): return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]' @property def set_code(self): # Compatibility with CardIdentificationData return self.set.code @property def is_custom_card(self) -> bool: return False @property def is_oversized(self) -> bool: return self.size == CardSizes.OVERSIZED @dataclasses.dataclass(unsafe_hash=True) class CustomCard: name: str = dataclasses.field(compare=True) set: MTGSet = dataclasses.field(compare=True) collector_number: str = dataclasses.field(compare=True) language: str = dataclasses.field(compare=True) is_front: bool = dataclasses.field(compare=True) image_uri: str = dataclasses.field(compare=True) highres_image: bool = dataclasses.field(compare=False) size: CardSize = dataclasses.field(compare=False) face_number: int = dataclasses.field(compare=True) is_dfc: bool = dataclasses.field(compare=True) source_image_file: bytes = dataclasses.field(default=None, compare=False) def requested_page_type(self) -> PageType: if self.image_file is None: return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR return PageType.OVERSIZED if self.image_file.size() == CardSizes.OVERSIZED.as_qsize_px() else PageType.REGULAR @functools.lru_cache(maxsize=len(CardCorner)) def corner_color(self, corner: CardCorner) -> QColor: """Returns the color of the card at the given corner. """ if self.image_file is None: return QColorConstants.Transparent sample_area = self.image_file.copy(QRect( QPoint( round(self.image_file.width() * corner.value[0]), round(self.image_file.height() * corner.value[1])), QSize(10, 10) )) average_color = sample_area.scaled( 1, 1, transformMode=SmoothTransformation).toImage().pixelColor(0, 0) return average_color def display_string(self): return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]' @property def oracle_id(self): return "" @property def set_code(self): # Compatibility with CardIdentificationData return self.set.code @property def is_oversized(self) -> bool: return self.size == CardSizes.OVERSIZED @property def is_custom_card(self) -> bool: return True @functools.cached_property def image_file(self) -> QPixmap: source = QPixmap() source.loadFromData(self.source_image_file) target_size = self.size.as_qsize_px() return source if source.size() == target_size else source.scaled(target_size, transformMode=SmoothTransformation) @functools.cached_property def scryfall_id(self) -> UUID: hd = hashlib.md5(self.source_image_file).hexdigest() # TODO: Maybe use something else instead of md5? return UUID(f"{hd[:8]}-{hd[8:12]}-{hd[12:16]}-{hd[16:20]}-{hd[20:]}") @dataclasses.dataclass(unsafe_hash=True) class CheckCard: front: Card back: Card @property def name(self) -> str: return f"{self.front.name} // {self.back.name}" @property def set(self) -> MTGSet: return self.front.set @set.setter def set(self, value: MTGSet): self.front.set = value self.back.set = value @property def collector_number(self) -> str: return self.front.collector_number @property def language(self) -> str: return self.front.language @property def scryfall_id(self) -> str: return self.front.scryfall_id @property def is_front(self) -> bool: return True @property def oracle_id(self) -> str: return self.front.oracle_id @property def size(self): return self.front.size @property def image_uri(self) -> str: return "" @property def set_code(self): return self.front.set_code @property def highres_image(self) -> bool: return self.front.highres_image and self.back.highres_image @property def is_oversized(self): return self.front.is_oversized @property def face_number(self) -> int: return 1 @property def is_dfc(self) -> bool: return False @property def is_custom_card(self): return self.front.is_custom_card @property def image_file(self) -> typing.Optional[QPixmap]: if self.front.image_file is None or self.back.image_file is None: return None card_size = self.front.image_file.size() # Unlike metric paper sizes, the MTG card aspect ratio does not follow the golden ratio. # Cards thus can’t be scaled using a singular factor of sqrt(2) on both axis. # The scaled cards get a bit compressed horizontally. vertical_scaling_factor = card_size.width() / card_size.height() horizontal_scaling_factor = card_size.height() / (2 * card_size.width()) combined_image = QPixmap(card_size) combined_image.fill(QColorConstants.Transparent) painter = QPainter(combined_image) painter.setRenderHints(RenderHint.SmoothPixmapTransform | RenderHint.HighQualityAntialiasing) transformation = QTransform() transformation.rotate(90) transformation.scale(horizontal_scaling_factor, vertical_scaling_factor) painter.setTransform(transformation) painter.drawPixmap(QPointF(card_size.width(), -card_size.height()), self.back.image_file) painter.drawPixmap(QPointF(0, -card_size.height()), self.front.image_file) return combined_image def requested_page_type(self) -> PageType: return self.front.requested_page_type() @functools.lru_cache(maxsize=len(CardCorner)) def corner_color(self, corner: CardCorner) -> QColor: """Returns the color of the card at the given corner. """ if corner == CardCorner.TOP_LEFT: return self.front.corner_color(CardCorner.BOTTOM_LEFT) elif corner == CardCorner.TOP_RIGHT: return self.front.corner_color(CardCorner.TOP_LEFT) elif corner == CardCorner.BOTTOM_LEFT: return self.back.corner_color(CardCorner.BOTTOM_RIGHT) elif corner == CardCorner.BOTTOM_RIGHT: return self.back.corner_color(CardCorner.TOP_RIGHT) return QColorConstants.Transparent def display_string(self): return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]' AnyCardType = typing.Union[Card, CheckCard, CustomCard] CardList = typing.List[AnyCardType] OptionalCard = typing.Optional[AnyCardType] # Py3.8 compatibility hack, because isinstance(a, AnyCardType) fails on 3.8 AnyCardTypeForTypeCheck = typing.get_args(AnyCardType) |
Changes to mtg_proxy_printer/model/card_list.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 | from collections import Counter import dataclasses import enum import itertools import typing | | > | > | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | from collections import Counter import dataclasses import enum import itertools import typing from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSignal as Signal, QItemSelection from PyQt5.QtGui import QIcon from mtg_proxy_printer.ui.common import get_card_image_tooltip from mtg_proxy_printer.decklist_parser.common import CardCounter from mtg_proxy_printer.model.carddb import CardIdentificationData, CardDatabase from mtg_proxy_printer.model.card import Card, AnyCardType from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger ItemDataRole = Qt.ItemDataRole ItemFlag = Qt.ItemFlag __all__ = [ "CardListColumns", "CardListModel", ] INVALID_INDEX = QModelIndex() @dataclasses.dataclass class CardListModelRow: card: AnyCardType copies: int class CardListColumns(enum.IntEnum): Copies = 0 CardName = enum.auto() Set = enum.auto() |
︙ | ︙ | |||
92 93 94 95 96 97 98 | if role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): if column == CardListColumns.Copies: return self.rows[row].copies elif column == CardListColumns.CardName: return card.name elif column == CardListColumns.Set: if role == ItemDataRole.EditRole: | | > | | > | | | | | > > | > > > > | > > | | > | < < < < | | | | | | | | | | | | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | < | 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | if role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): if column == CardListColumns.Copies: return self.rows[row].copies elif column == CardListColumns.CardName: return card.name elif column == CardListColumns.Set: if role == ItemDataRole.EditRole: return card.set else: set_ = card.set return f"{set_.name} ({set_.code.upper()})" elif column == CardListColumns.CollectorNumber: return card.collector_number elif column == CardListColumns.Language: return card.language elif column == CardListColumns.IsFront: if role == ItemDataRole.EditRole: return card.is_front return self.tr("Front") if card.is_front else self.tr("Back") if card.is_custom_card and column == CardListColumns.CardName and role == ItemDataRole.ToolTipRole: return get_card_image_tooltip(card.source_image_file) elif card.is_oversized and role == ItemDataRole.ToolTipRole: return self.tr("Beware: Potentially oversized card!\nThis card may not fit in your deck.") if card.is_oversized and role == ItemDataRole.DecorationRole: return self._oversized_icon def flags(self, index: QModelIndex) -> Qt.ItemFlags: flags = super().flags(index) if index.column() in self.EDITABLE_COLUMNS or self.rows[index.row()].card.is_custom_card: flags |= ItemFlag.ItemIsEditable return flags def setData(self, index: QModelIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool: row, column = index.row(), index.column() container = self.rows[row] card = container.card if not card.is_custom_card and role == ItemDataRole.EditRole and column in self.EDITABLE_COLUMNS: return self._set_data_for_official_card(index, value) elif card.is_custom_card and role == ItemDataRole.EditRole: return self._set_data_for_custom_card(index, value) return False def _set_data_for_official_card(self, index: QModelIndex, value: typing.Any) -> bool: row, column = index.row(), index.column() container = self.rows[row] card = container.card logger.debug(f"Setting card list model data on official card for column {column} to {value}") if column == CardListColumns.Copies: return self._set_copies_value(container, card, value) elif column == CardListColumns.CollectorNumber: card_data = CardIdentificationData( card.language, card.name, card.set_code, value, is_front=card.is_front) elif column == CardListColumns.Set: card_data = CardIdentificationData( card.language, card.name, value.code, is_front=card.is_front ) else: card_data = self.card_db.translate_card(card, value) if card_data == card: return False return self._request_replacement_card(index, card_data) def _set_data_for_custom_card(self, index: QModelIndex, value: typing.Any) -> bool: row, column = index.row(), index.column() container = self.rows[row] card = container.card logger.debug(f"Setting card list model data on custom card for column {column} to {value}") if column == CardListColumns.Copies: return self._set_copies_value(container, card, value) elif column == CardListColumns.CardName: card.name = value return True elif column == CardListColumns.CollectorNumber: card.collector_number = value return True elif column == CardListColumns.Language: card.language = value return True elif column == CardListColumns.IsFront: card.is_front = value card.face_number = int(not value) return True elif column == CardListColumns.Set: card.set = value return True return False def _set_copies_value(self, container: CardListModelRow, card: Card, value: int) -> bool: old_value, container.copies = container.copies, value if card.is_oversized and (difference := value - old_value): self.oversized_card_count += difference self.oversized_card_count_changed.emit(self.oversized_card_count) return value != old_value def _request_replacement_card( self, index: QModelIndex, card_data: typing.Union[CardIdentificationData, AnyCardType]): row, column = index.row(), index.column() if isinstance(card_data, CardIdentificationData): logger.debug(f"Requesting replacement for {card_data}") result = self.card_db.get_cards_from_data(card_data) else: result = [card_data] if result: # Simply choose the first match. The user can’t make a choice at this point, so just use one of the results. new_card = result[0] logger.debug(f"Replacing with {new_card}") top_left = index.sibling(row, column) bottom_right = top_left.siblingAtColumn(len(CardListColumns)-1) old_row = self.rows[row] self.rows[row] = new_row = CardListModelRow(new_card, old_row.copies) self.dataChanged.emit( |
︙ | ︙ | |||
190 191 192 193 194 195 196 | self.oversized_card_count_changed.emit(self.oversized_card_count) def _remove_card_handle_oversized_flag(self, row: CardListModelRow): if row.card.is_oversized: self.oversized_card_count -= row.copies self.oversized_card_count_changed.emit(self.oversized_card_count) | < < | 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | self.oversized_card_count_changed.emit(self.oversized_card_count) def _remove_card_handle_oversized_flag(self, row: CardListModelRow): if row.card.is_oversized: self.oversized_card_count -= row.copies self.oversized_card_count_changed.emit(self.oversized_card_count) def remove_multi_selection(self, indices: QItemSelection) -> int: """ Remove all cards in the given multi-selection. :return: Number of cards removed """ selected_ranges = sorted( (selected_range.top(), selected_range.bottom()) for selected_range in indices ) # This both minimizes the number of model changes needed and de-duplicates the data received from the # selection model. If the user selects a row, the UI returns a range for each cell selected, creating many # duplicates that have to be removed. selected_ranges = self._merge_ranges(selected_ranges) |
︙ | ︙ | |||
292 293 294 295 296 297 298 | (index, index) for index, row in enumerate(self.rows) if row.card.oracle_id in basic_land_oracle_ids ) merged = reversed(self._merge_ranges(to_remove_rows)) removed_cards = sum(itertools.starmap(self.remove_cards, merged)) logger.info(f"User requested removal of basic lands, removed {removed_cards} cards") | > > > > > > > | 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | (index, index) for index, row in enumerate(self.rows) if row.card.oracle_id in basic_land_oracle_ids ) merged = reversed(self._merge_ranges(to_remove_rows)) removed_cards = sum(itertools.starmap(self.remove_cards, merged)) logger.info(f"User requested removal of basic lands, removed {removed_cards} cards") def set_all_copies_to(self, value: int): top = self.index(0, CardListColumns.Copies) bottom = self.index(self.rowCount()-1, CardListColumns.Copies) for item in self.rows: item.copies = value self.dataChanged.emit(top, bottom, [ItemDataRole.DisplayRole, ItemDataRole.EditRole]) |
Changes to mtg_proxy_printer/model/carddb.py.
︙ | ︙ | |||
13 14 15 16 17 18 19 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import atexit import dataclasses import datetime | < | | < | > | < < < > > > < < < < < < < | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | | | | < < < < < < < < < | 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import atexit import dataclasses import datetime import itertools import functools from pathlib import Path import sqlite3 import threading from typing import Literal, Union, Dict, List, Tuple, NamedTuple, TypeVar, Optional, Set, Sequence, Any from PyQt5.QtWidgets import QApplication from PyQt5.QtCore import QObject, pyqtSignal as Signal, pyqtSlot as Slot from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, OptionalCard, CardList, CustomCard from mtg_proxy_printer.model.imagedb_files import CacheContent import mtg_proxy_printer.app_dirs from mtg_proxy_printer.natsort import natural_sorted import mtg_proxy_printer.meta_data from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema import mtg_proxy_printer.settings from mtg_proxy_printer.units_and_sizes import StringList, OptStr, CardSizes, CardSize, UUID from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger OLD_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardDataCache.sqlite3" DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_data_path / "CardDatabase.sqlite3" SCHEMA_NAME = "carddb" # The card data is mostly stable, Scryfall recommends fetching the card bulk data only in larger intervals, like # once per month or so. MINIMUM_REFRESH_DELAY = datetime.timedelta(days=14) T = TypeVar("T", Card, CheckCard, CustomCard) write_semaphore = threading.BoundedSemaphore() __all__ = [ "CardIdentificationData", "CardDatabase", "OLD_DATABASE_LOCATION", "DEFAULT_DATABASE_LOCATION", "with_database_write_lock", "SCHEMA_NAME", ] @dataclasses.dataclass class CardIdentificationData: language: OptStr = None name: OptStr = None set_code: OptStr = None collector_number: OptStr = None scryfall_id: OptStr = None is_front: Optional[bool] = None oracle_id: OptStr = None class ImageDatabaseCards(NamedTuple): visible: List[Tuple[Card, CacheContent]] = [] hidden: List[Tuple[Card, CacheContent]] = [] unknown: List[CacheContent] = [] def with_database_write_lock(semaphore: threading.BoundedSemaphore = write_semaphore): """Decorator managing the database lock. Used to serialize database write transactions.""" def decorator(func): @functools.wraps(func) def wrapped(*args, **kwargs): |
︙ | ︙ | |||
307 308 309 310 311 312 313 314 | class CardDatabase(QObject): """ Holds the connection to the local SQLite database that contains the relevant card data. Provides methods for data access. """ card_data_updated = Signal() | > | | | | 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | class CardDatabase(QObject): """ Holds the connection to the local SQLite database that contains the relevant card data. Provides methods for data access. """ card_data_updated = Signal() custom_cards: Dict[UUID, CustomCard] = {} def __init__(self, db_path: Union[Literal[":memory:"], Path] = DEFAULT_DATABASE_LOCATION, parent: QObject = None, check_same_thread: bool = True, register_exit_hooks: bool = True): """ :param db_path: Path to the database file. May be “:memory:” to create an in-memory database for testing purposes. """ super().__init__(parent) logger.info(f"Creating {self.__class__.__name__} instance.") self._db_check_same_thread = check_same_thread self.db_path = db_path self.db: sqlite3.Connection = None self._db_is_temporary = False self.reopen_database() self._exit_hook = None if db_path != ":memory:" and register_exit_hooks: self._register_exit_hook() @Slot() def reopen_database(self) -> None: logger.info(f"About to open card database from {self.db_path}") db = open_database(self.db_path, SCHEMA_NAME, check_same_thread=self._db_check_same_thread) outdated_on_disk = mtg_proxy_printer.sqlite_helpers.check_database_schema_version(db, SCHEMA_NAME) > 0 |
︙ | ︙ | |||
383 384 385 386 387 388 389 | logger.info("Starting new read transaction") self.db.execute("BEGIN DEFERRED TRANSACTION; --begin_transaction()\n") def has_data(self) -> bool: result, = self.db.execute("SELECT EXISTS(SELECT * FROM Card)\n").fetchone() return bool(result) | | | 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | logger.info("Starting new read transaction") self.db.execute("BEGIN DEFERRED TRANSACTION; --begin_transaction()\n") def has_data(self) -> bool: result, = self.db.execute("SELECT EXISTS(SELECT * FROM Card)\n").fetchone() return bool(result) def get_last_card_data_update_timestamp(self) -> Optional[datetime.datetime]: """Returns the last card data update timestamp, or None, if no card data was ever imported""" query = "SELECT MAX(update_timestamp) FROM LastDatabaseUpdate -- get_last_card_data_update_timestamp\n" result = self._read_optional_scalar_from_db(query, []) return datetime.datetime.fromisoformat(result) if result else None def allow_updating_card_data(self) -> bool: """ |
︙ | ︙ | |||
434 435 436 437 438 439 440 | self.db.execute( query, parameters ) ] return result def get_basic_land_oracle_ids( | | | 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | self.db.execute( query, parameters ) ] return result def get_basic_land_oracle_ids( self, include_wastes: bool = False, include_snow_basics: bool = False) -> Set[str]: """Returns the oracle ids of all Basic lands.""" names = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] # Ordering matters: If WotC ever prints "Snow-Covered Wastes" (as of writing, those don’t exist), # this order does support them in the case include_wastes=False, include_snow_basics=True. if include_wastes: names.append("Wastes") if include_snow_basics: |
︙ | ︙ | |||
614 615 616 617 618 619 620 | for related_oracle_id, in related_card_ids: # Prefer same set over other sets, which is important for multi-component cards like Meld cards. If it # isn't available, take from any other set. As a last-ditch fallback, resort to English printings. # The last case is most likely hit with non-English token-producing cards, # as long as Scryfall does not provide localized tokens. related_cards = \ self.get_cards_from_data( | | | 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | for related_oracle_id, in related_card_ids: # Prefer same set over other sets, which is important for multi-component cards like Meld cards. If it # isn't available, take from any other set. As a last-ditch fallback, resort to English printings. # The last case is most likely hit with non-English token-producing cards, # as long as Scryfall does not provide localized tokens. related_cards = \ self.get_cards_from_data( CardIdentificationData(card.language, set_code=card.set_code, oracle_id=related_oracle_id), order_by_print_count=True) or \ self.get_cards_from_data( CardIdentificationData(card.language, oracle_id=related_oracle_id), order_by_print_count=True) or \ self.get_cards_from_data( CardIdentificationData("en", oracle_id=related_oracle_id), order_by_print_count=True) |
︙ | ︙ | |||
656 657 658 659 660 661 662 | AND set_code = ? AND card_name = ? ''') return natural_sorted(item for item, in self.db.execute(query, (language, set_abbr, card_name))) def find_sets_matching( self, card_name: str, language: str, set_name_filter: str = None, | | | 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 | AND set_code = ? AND card_name = ? ''') return natural_sorted(item for item, in self.db.execute(query, (language, set_abbr, card_name))) def find_sets_matching( self, card_name: str, language: str, set_name_filter: str = None, *, is_front: bool = None) -> List[MTGSet]: """ Finds all matching sets that the given card was printed in. :param card_name: Card name, matched exactly :param language: card language, matched exactly :param set_name_filter: If provided, only return sets with set code or full name beginning with this. Used as a LIKE pattern, supporting SQLite wildcards. |
︙ | ︙ | |||
708 709 710 711 712 713 714 | size = CardSizes.from_bool(is_oversized) return Card( name, MTGSet(set_abbr, set_name), collector_number, language, scryfall_id, bool(is_front), oracle_id, image_uri, bool(highres_image), size, face_number, bool(is_dfc), ) | | | 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 | size = CardSizes.from_bool(is_oversized) return Card( name, MTGSet(set_abbr, set_name), collector_number, language, scryfall_id, bool(is_front), oracle_id, image_uri, bool(highres_image), size, face_number, bool(is_dfc), ) def get_all_cards_from_image_cache(self, cache_content: List[CacheContent]) -> ImageDatabaseCards: """ Partitions the content of the ImageDatabase disk cache into three lists: - All visible card printings - All hidden card printings - All unknown images Visible and invisible printings are returned as lists containing tuples (Card, CacheContent), |
︙ | ︙ | |||
757 758 759 760 761 762 763 | SELECT scryfall_id, is_front FROM Printing JOIN CardFace USING (printing_id) ) ''') cards = ImageDatabaseCards([], [], []) cards.unknown[:] = ( | | | | | 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 | SELECT scryfall_id, is_front FROM Printing JOIN CardFace USING (printing_id) ) ''') cards = ImageDatabaseCards([], [], []) cards.unknown[:] = ( CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), Path(abs_path)) for scryfall_id, is_front, highres_on_disk, abs_path in db.execute(unknown_images_query)) for scryfall_id, is_front, highres_on_disk, abs_path, \ name, set_code, set_name, collector_number, language, image_uri, oracle_id, \ is_oversized, face_number, is_dfc, is_hidden \ in db.execute(known_images_query): cache_item = CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), Path(abs_path)) size = CardSizes.from_bool(is_oversized) card = Card( name, MTGSet(set_code, set_name), collector_number, language, cache_item.scryfall_id, cache_item.is_front, oracle_id, image_uri, bool(highres_on_disk), size, face_number, is_dfc ) if is_hidden: cards.hidden.append((card, cache_item)) else: cards.visible.append((card, cache_item)) db.execute("ROLLBACK TRANSACTION TO SAVEPOINT 'partition_image_cache' -- get_all_cards_from_image_cache()\n") return cards def get_opposing_face(self, card) -> OptionalCard: """ Returns the opposing face for double faced cards, or None for single-faced cards. """ return self.get_card_with_scryfall_id(card.scryfall_id, not card.is_front) def guess_language_from_name(self, name: str) -> Optional[str]: """Guesses the card language from the card name. Returns None, if no result was found.""" query = cached_dedent('''\ SELECT "language" -- guess_language_from_name() FROM FaceName JOIN PrintLanguage USING (language_id) WHERE card_name = ? -- Assume English by default to not match other languages in case their entry misses the proper |
︙ | ︙ | |||
818 819 820 821 822 823 824 | query = cached_dedent(''' SELECT is_dfc -- is_dfc() FROM AllPrintings WHERE "scryfall_id" = ? ''') return bool(self._read_optional_scalar_from_db(query, (scryfall_id,))) | | | 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 | query = cached_dedent(''' SELECT is_dfc -- is_dfc() FROM AllPrintings WHERE "scryfall_id" = ? ''') return bool(self._read_optional_scalar_from_db(query, (scryfall_id,))) def translate_card_name(self, card_data: Union[CardIdentificationData, Card], target_language: str, include_hidden_names: bool = False) -> OptStr: """ Translates a card into the target_language. Uses the language in the card data as the source language, if given. If not, card names across all languages are searched. :return: String with the translated card name, or None, if either unknown or unavailable in the target language. """ |
︙ | ︙ | |||
894 895 896 897 898 899 900 | ) ORDER BY language ASC; """) parameters = card.language, card.oracle_id result = [item for item, in self.db.execute(query, parameters)] return result | | | 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 | ) ORDER BY language ASC; """) parameters = card.language, card.oracle_id result = [item for item, in self.db.execute(query, parameters)] return result def get_available_sets_for_card(self, card: Card) -> List[MTGSet]: """ Returns a list of MTG sets the card with the given Oracle ID is in, ordered by release date from old to new. """ query = cached_dedent("""\ SELECT DISTINCT set_code, set_name FROM ( -- get_available_sets_for_card() SELECT set_code, set_name, release_date FROM MTGSet |
︙ | ︙ | |||
918 919 920 921 922 923 924 | UNION ALL SELECT set_code, set_name, release_date FROM MTGSet WHERE set_code = ? ) ORDER BY release_date ASC """) | | | 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 | UNION ALL SELECT set_code, set_name, release_date FROM MTGSet WHERE set_code = ? ) ORDER BY release_date ASC """) parameters = card.oracle_id, card.language, card.set_code result = [MTGSet(code, name) for code, name in self.db.execute(query, parameters)] if not result: result.append(card.set) return result def get_available_collector_numbers_for_card_in_set(self, card: Card) -> StringList: query = cached_dedent("""\ |
︙ | ︙ | |||
943 944 945 946 947 948 949 | WHERE Printing.is_hidden IS FALSE AND FaceName.is_hidden IS FALSE AND oracle_id = ? AND set_code = ? AND language = ? ) """) | | | | | | 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 | WHERE Printing.is_hidden IS FALSE AND FaceName.is_hidden IS FALSE AND oracle_id = ? AND set_code = ? AND language = ? ) """) parameters = (card.collector_number, card.oracle_id, card.set_code, card.language) result = natural_sorted((number for number, in self.db.execute(query, parameters))) return result def _read_optional_scalar_from_db(self, query: str, parameters: Sequence[Any] = None): if result := self.db.execute(query, parameters).fetchone(): return result[0] else: return None def is_removed_printing(self, scryfall_id: str) -> bool: logger.debug(f"Query RemovedPrintings table for scryfall id {scryfall_id}") parameters = scryfall_id, query = cached_dedent("""\ SELECT oracle_id -- is_removed_printing() FROM RemovedPrintings WHERE scryfall_id = ? """) return bool(self._read_optional_scalar_from_db(query, parameters)) def cards_not_used_since(self, keys: List[Tuple[str, bool]], date: datetime.date) -> List[int]: """ Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices into the input list that correspond to cards that were not used since the given date. """ query = cached_dedent("""\ SELECT last_use_date < ? AS last_use_was_before_threshold -- cards_not_used_since() FROM LastImageUseTimestamps WHERE scryfall_id = ? AND is_front = ? """) cards_not_used_since = [] for index, (scryfall_id, is_front) in enumerate(keys): result = self._read_optional_scalar_from_db(query, (date.isoformat(), scryfall_id, is_front)) if result is None or result: cards_not_used_since.append(index) return cards_not_used_since def cards_used_less_often_then(self, keys: List[Tuple[str, bool]], count: int) -> List[int]: """ Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices into the input list that correspond to cards that are used less often than the given count. If count is zero or less, returns an empty list. """ if count <= 0: return [] |
︙ | ︙ | |||
1063 1064 1065 1066 1067 1068 1069 | return None size = CardSizes.from_bool(is_oversized) return Card( name, MTGSet(set_code, set_name), collector_number, language_override, scryfall_id, card.is_front, card.oracle_id, image_uri, bool(highres_image), size, face_number, bool(is_dfc), ) | > > > > > > > > > > > | 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 | return None size = CardSizes.from_bool(is_oversized) return Card( name, MTGSet(set_code, set_name), collector_number, language_override, scryfall_id, card.is_front, card.oracle_id, image_uri, bool(highres_image), size, face_number, bool(is_dfc), ) def get_custom_card( self, name: str, set_code: str, set_name: str, collector_number: str, size: CardSize, is_front: bool, image: bytes) -> CustomCard: card = CustomCard( name, MTGSet(set_code, set_name), collector_number, "en", is_front, "", True, size, 1 + (not is_front), False, image) custom_card_id = card.scryfall_id card = self.custom_cards.get(custom_card_id, card) self.custom_cards[custom_card_id] = card return card |
Changes to mtg_proxy_printer/model/document-v7.sql.
︙ | ︙ | |||
25 26 27 28 29 30 31 | card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'), image BLOB NOT NULL, -- The raw image content name TEXT NOT NULL DEFAULT '', set_name TEXT NOT NULL DEFAULT '', set_code TEXT NOT NULL DEFAULT '', collector_number TEXT NOT NULL DEFAULT '', is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE, | < | 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'), image BLOB NOT NULL, -- The raw image content name TEXT NOT NULL DEFAULT '', set_name TEXT NOT NULL DEFAULT '', set_code TEXT NOT NULL DEFAULT '', collector_number TEXT NOT NULL DEFAULT '', is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE, other_face TEXT REFERENCES CustomCardData(card_id) -- If this is a DFC, this references the other side ); CREATE TABLE Page ( page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0), image_size TEXT NOT NULL CHECK(image_size <> '') ); |
︙ | ︙ |
Changes to mtg_proxy_printer/model/document.py.
︙ | ︙ | |||
21 22 23 24 25 26 27 | import pathlib import sys import typing from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \ QPersistentModelIndex | > > | | > < < < < < < < < < < | 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | import pathlib import sys import typing from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \ QPersistentModelIndex from mtg_proxy_printer.natsort import to_list_of_ranges from mtg_proxy_printer.document_controller.edit_custom_card import ActionEditCustomCard from mtg_proxy_printer.model.document_page import CardContainer, Page, PageColumns from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.model.document_loader import DocumentLoader from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.logger import get_logger from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.document_controller.replace_card import ActionReplaceCard from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument logger = get_logger(__name__) del get_logger if sys.version_info[:2] >= (3, 9): Counter = collections.Counter else: Counter = typing.Counter __all__ = [ "Document", ] class DocumentColumns(enum.IntEnum): Page = 0 INVALID_INDEX = QModelIndex() ActionStack = typing.Deque[DocumentAction] AnyIndex = typing.Union[QModelIndex, QPersistentModelIndex] ItemDataRole = Qt.ItemDataRole Orientation = Qt.Orientation ItemFlag = Qt.ItemFlag |
︙ | ︙ | |||
107 108 109 110 111 112 113 | self.redo_stack: ActionStack = collections.deque() self.save_file_path: typing.Optional[pathlib.Path] = None self.card_db = card_db self.image_db = image_db self.loader = DocumentLoader(self) self.loader.loading_state_changed.connect(self.loading_state_changed) self.loader.load_requested.connect(self.apply) | | | 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | self.redo_stack: ActionStack = collections.deque() self.save_file_path: typing.Optional[pathlib.Path] = None self.card_db = card_db self.image_db = image_db self.loader = DocumentLoader(self) self.loader.loading_state_changed.connect(self.loading_state_changed) self.loader.load_requested.connect(self.apply) self.pages: typing.List[Page] = [first_page := Page()] # Mapping from page id() to list index in the page list self.page_index_cache: typing.Dict[int, int] = {id(first_page): 0} self.currently_edited_page = first_page self.page_layout = PageLayoutSettings.create_from_settings() logger.debug(f"Loaded document settings from configuration file: {self.page_layout}") logger.info(f"Created {self.__class__.__name__} instance") |
︙ | ︙ | |||
229 230 231 232 233 234 235 | else: # Page return self._data_page(index, role) def flags(self, index: AnyIndex) -> Qt.ItemFlags: index = self._to_index(index) data = index.internalPointer() flags = super().flags(index) | | | > > | > | > | | < | | 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | else: # Page return self._data_page(index, role) def flags(self, index: AnyIndex) -> Qt.ItemFlags: index = self._to_index(index) data = index.internalPointer() flags = super().flags(index) if isinstance(data, CardContainer) and (index.column() in self.EDITABLE_COLUMNS or data.card.is_custom_card): flags |= ItemFlag.ItemIsEditable return flags def setData(self, index: AnyIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool: index = self._to_index(index) data: CardContainer = index.internalPointer() if not isinstance(data, CardContainer) or role != ItemDataRole.EditRole: return False column = index.column() card = data.card if card.is_custom_card: self.apply(ActionEditCustomCard(index, value)) return True elif column in self.EDITABLE_COLUMNS: logger.debug(f"Setting page data on official card for {column=} to {value}") if column == PageColumns.CollectorNumber: card_data = CardIdentificationData( card.language, card.name, card.set.code, value, is_front=card.is_front) elif column == PageColumns.Set: card_data = CardIdentificationData( card.language, card.name, value.code, is_front=card.is_front ) else: replacement = self.card_db.translate_card(card, value) if replacement != card: action = ActionReplaceCard(replacement, index.parent().row(), index.row()) self.request_fill_image_for_action.emit(action) return True |
︙ | ︙ | |||
425 426 427 428 429 430 431 | )) def recreate_page_index_cache(self): self.page_index_cache.clear() self.page_index_cache.update( (id(page), index) for index, page in enumerate(self.pages) ) | > > > > > > > > > > > > > > | 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 | )) def recreate_page_index_cache(self): self.page_index_cache.clear() self.page_index_cache.update( (id(page), index) for index, page in enumerate(self.pages) ) def find_relevant_index_ranges(self, to_find: AnyCardType, column: PageColumns): """Finds all indices relevant for the given card.""" for page_row, page in enumerate(self.pages): instance_rows = to_list_of_ranges( # Use is to find exact same instances (row for row, container in enumerate(page) if container.card is to_find) ) if instance_rows: parent = self.index(page_row, 0) if column == PageColumns.CardName: yield parent, parent for lower, upper in instance_rows: yield self.index(lower, column, parent), self.index(upper, column, parent) |
Changes to mtg_proxy_printer/model/document_loader.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 | import collections import enum import functools import itertools import pathlib import sqlite3 import textwrap | > | | < | | | | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | import collections import enum import functools import itertools import pathlib import sqlite3 import textwrap from pathlib import Path from typing import Counter, Dict, Iterable, List, NamedTuple, Optional, Tuple, TYPE_CHECKING, TypeVar, Union, Literal from unittest.mock import patch import pint from PyQt5.QtCore import QObject, pyqtSignal as Signal, QThreadPool, Qt from PyQt5.QtGui import QPixmap from hamcrest import assert_that, all_of, instance_of, greater_than_or_equal_to, matches_regexp, is_in, \ has_properties, is_, any_of, none, has_item, has_property, equal_to try: from hamcrest import contains_exactly except ImportError: # Compatibility with PyHamcrest < 1.10 from hamcrest import contains as contains_exactly import mtg_proxy_printer.settings from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema from mtg_proxy_printer.model.carddb import CardIdentificationData, SCHEMA_NAME, CardDatabase from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, CardList, AnyCardType, CustomCard from mtg_proxy_printer.model.imagedb import ImageDownloader from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.logger import get_logger from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UUID, CardSizes, OptStr, unit_registry, CardSize from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.runner import Runnable from mtg_proxy_printer.save_file_migrations import migrate_database if TYPE_CHECKING: from mtg_proxy_printer.model.document import Document logger = get_logger(__name__) |
︙ | ︙ | |||
64 65 66 67 68 69 70 | class CardType(str, enum.Enum): REGULAR = "r" CHECK_CARD = "d" @classmethod def from_card(cls, card: AnyCardType) -> "CardType": | | | | 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | class CardType(str, enum.Enum): REGULAR = "r" CHECK_CARD = "d" @classmethod def from_card(cls, card: AnyCardType) -> "CardType": if isinstance(card, (Card, CustomCard)): return cls.REGULAR elif isinstance(card, CheckCard): return cls.CHECK_CARD else: raise NotImplementedError() class DatabaseLoadResult(NamedTuple): card: AnyCardType was_migrated: bool class CardRow(NamedTuple): is_front: bool card_type: CardType scryfall_id: Optional[UUID] custom_card_id: Optional[UUID] sqlite3.register_adapter(CardType, lambda item: item.value) CustomCards = Dict[str, Card] T = TypeVar("T") def split_iterable(iterable: Iterable[T], chunk_size: int, /) -> Iterable[Tuple[T, ...]]: """Split the given iterable into chunks of size chunk_size. Does not add padding values to the last item.""" iterable = iter(iterable) |
︙ | ︙ | |||
123 124 125 126 127 128 129 | This class uses an internal worker to push that work off the GUI thread to keep the application responsive during a loading process. """ loading_state_changed = Signal(bool) | | < | 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | This class uses an internal worker to push that work off the GUI thread to keep the application responsive during a loading process. """ loading_state_changed = Signal(bool) def __init__(self, document: "Document"): super().__init__(None) self.document = document disable_loading_state_on_completion = functools.partial(self.loading_state_changed.emit, False) self.finished.connect(disable_loading_state_on_completion, Qt.ConnectionType.DirectConnection) def load_document(self, save_file_path: pathlib.Path): logger.info(f"Loading document from {save_file_path}") self.loading_state_changed.emit(True) QThreadPool.globalInstance().start(LoaderRunner(save_file_path, self)) |
︙ | ︙ | |||
163 164 165 166 167 168 169 | self.worker.load_document() finally: self.release_instance() def _create_worker(self): parent = self.parent worker = Worker(parent.document, self.path) | < < | 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | self.worker.load_document() finally: self.release_instance() def _create_worker(self): parent = self.parent worker = Worker(parent.document, self.path) # The blocking connection causes the worker to wait for the document in the main thread to complete the loading worker.load_requested.connect(parent.load_requested, Qt.ConnectionType.BlockingQueuedConnection) worker.loading_file_failed.connect(parent.loading_file_failed) worker.unknown_scryfall_ids_found.connect(parent.unknown_scryfall_ids_found) worker.loading_file_successful.connect(parent.on_loading_file_successful) worker.network_error_occurred.connect(parent.network_error_occurred) worker.finished.connect(parent.finished) |
︙ | ︙ | |||
193 194 195 196 197 198 199 | """ loading_file_successful = Signal(pathlib.Path) def __init__(self, document: "Document", path: pathlib.Path): super().__init__(None) self.document = document self.save_path = path | | < | | | | | | | < < < | > | | | 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """ loading_file_successful = Signal(pathlib.Path) def __init__(self, document: "Document", path: pathlib.Path): super().__init__(None) self.document = document self.save_path = path self.card_db = self._open_carddb(document) self.image_db = image_db = document.image_db # Create our own ImageDownloader, instead of using the ImageDownloader embedded in the ImageDatabase. # That one lives in its own thread and runs asynchronously and is thus unusable for loading documents. # So create a separate instance and use it synchronously inside this worker thread. self.image_loader = image_loader = ImageDownloader(image_db, self) image_loader.download_begins.connect(image_db.card_download_starting) image_loader.download_finished.connect(image_db.card_download_finished) image_loader.download_progress.connect(image_db.card_download_progress) image_loader.network_error_occurred.connect(self.on_network_error_occurred) self.network_errors_during_load: Counter[str] = collections.Counter() self.finished.connect(self.propagate_errors_during_load) self.should_run: bool = True self.unknown_ids = 0 self.migrated_ids = 0 self.current_progress = 0 self.prefer_already_downloaded = mtg_proxy_printer.settings.settings["decklist-import"].getboolean( "prefer-already-downloaded-images") def _open_carddb(self, document: "Document") -> CardDatabase: db_path = document.card_db.db_path card_db = CardDatabase(db_path, self, register_exit_hooks=False) if db_path == ":memory:": # For testing, copy the in-memory database of the passed card database instance document.card_db.db.backup(card_db.db) return card_db def propagate_errors_during_load(self): if error_count := sum(self.network_errors_during_load.values()): logger.warning(f"{error_count} errors occurred during document load, reporting to the user") self.network_error_occurred.emit( f"Some cards may be missing images, proceed with caution.\n" f"Error count: {error_count}. Most common error message:\n" |
︙ | ︙ | |||
247 248 249 250 251 252 253 | self._load_document() except (AssertionError, sqlite3.DatabaseError) as e: logger.exception( "Selected file is not a known MTGProxyPrinter document or contains invalid data. Not loading it.") self.loading_file_failed.emit(self.save_path, str(e)) self.finished.emit() # Release UI in failure case. _load_document() emits this during regular operation finally: | > | | < | | | | | | | > > | | > | | | | | > > > > < < | < < | 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | self._load_document() except (AssertionError, sqlite3.DatabaseError) as e: logger.exception( "Selected file is not a known MTGProxyPrinter document or contains invalid data. Not loading it.") self.loading_file_failed.emit(self.save_path, str(e)) self.finished.emit() # Release UI in failure case. _load_document() emits this during regular operation finally: self.card_db.db.rollback() self.card_db.db.close() self.card_db = None def _complete_loading(self): if self.unknown_ids or self.migrated_ids: self.unknown_scryfall_ids_found.emit(self.unknown_ids, self.migrated_ids) self.unknown_ids = self.migrated_ids = 0 self.loading_file_successful.emit(self.save_path) self.finished.emit() def _load_document(self): # Imported here to break a circular import. TODO: Investigate a better fix from mtg_proxy_printer.document_controller.load_document import ActionLoadDocument additional_steps = 2 save_db = self._open_validate_and_migrate_save_file(self.save_path) total_cards = save_db.execute("SELECT count(1) FROM Card").fetchone()[0] self.begin_loading_loop.emit(total_cards+additional_steps, "Loading document:") page_layout = self._load_document_settings(save_db) self._advance_progress() logger.debug(f"About to load {total_cards} cards.") pages = self._load_cards(save_db) if total_cards else [] save_db.rollback() save_db.close() self._fix_mixed_pages(pages, page_layout) self._advance_progress() action = ActionLoadDocument(self.save_path, pages, page_layout) self.load_requested.emit(action) self._complete_loading() def _advance_progress(self): self.current_progress += 1 self.progress_loading_loop.emit(self.current_progress) @staticmethod def _open_validate_and_migrate_save_file(save_path: pathlib.Path) -> sqlite3.Connection: """ Opens the save database, validates the schema and migrates the content to the newest save file version. :param save_path: File system path to open :return: The opened database connection.""" db = open_database(save_path, f"document-v7") try: user_version = Worker._validate_database_schema(db) if user_version not in range(2, 8): raise AssertionError(f"Unknown database schema version: {user_version}") logger.info(f"Save file version is {user_version}") migrate_database(db, PageLayoutSettings.create_from_settings()) except Exception: db.rollback() db.close() raise return db def _load_cards(self, save_db: sqlite3.Connection) -> List[CardList]: custom_cards: CustomCards = {} assert_that( save_db.execute("SELECT min(page) FROM Page").fetchone(), contains_exactly(all_of(instance_of(int), greater_than_or_equal_to(1)) )) pages: List[CardList] = [] allowed_sizes = {CardSizes.REGULAR.to_save_data(), CardSizes.OVERSIZED.to_save_data()} for page, expected_size in save_db.execute( "SELECT page, image_size FROM Page ORDER BY page ASC").fetchall(): # type: int, str assert_that(page, is_(instance_of(int))) assert_that(expected_size, is_in(allowed_sizes)) pages.append(self._load_cards_on_page(save_db, page, expected_size, custom_cards)) return pages def _load_cards_on_page( self, save_db: sqlite3.Connection, page: int, expected_size: str, custom_cards: CustomCards) -> CardList: query = textwrap.dedent("""\ SELECT slot, is_front, type, scryfall_id, custom_card_id -- _load_cards_on_page() FROM Card WHERE page = ? ORDER BY page ASC, slot ASC""") db_data: Iterable[Tuple[int, bool, str, OptStr, OptStr]] = save_db.execute(query, (page,)) valid_card_types = {v.value for v in CardType} is_positive_int = all_of(instance_of(int), greater_than_or_equal_to(1)) result: CardList = [] card_size = CardSizes.REGULAR if expected_size == CardSizes.REGULAR.to_save_data() else CardSizes.OVERSIZED for item in db_data: self._validate_save_db_card_row(is_positive_int, item, valid_card_types) slot, is_front, card_type_str, scryfall_id, custom_card_id = item card_row = CardRow(is_front, CardType(card_type_str), scryfall_id, custom_card_id) if custom_card_id: if custom_card_id in custom_cards: result.append(custom_cards[custom_card_id]) else: card = self._load_custom_card_from_save(save_db, card_size, card_row) if card.image_file: result.append(card) custom_cards[custom_card_id] = card else: logger.warning("Skipping loading custom card with invalid image") continue elif scryfall_id: loaded = self._load_official_card_from_card_db(card_row) result.append(loaded.card) if loaded.was_migrated: self.migrated_ids += 1 else: result.append(self.document.get_empty_card_for_size(card_size)) self._advance_progress() return result @staticmethod def _validate_save_db_card_row(is_positive_int, item, valid_card_types): assert_that(item, contains_exactly( is_positive_int, is_in({True, False}), is_in(valid_card_types), any_of(none(), matches_regexp(UUID.uuid_re.pattern)), any_of(none(), matches_regexp(UUID.uuid_re.pattern)), )) _, _, card_type_str, scryfall_id, custom_card_id = item card_type = CardType(card_type_str) if card_type == CardType.CHECK_CARD and custom_card_id: raise AssertionError("Check cards for custom DFCs currently not supported.") assert_that( (scryfall_id, custom_card_id), has_item(none()), "Scryfall ID and custom card ID must not be both present") def _load_official_card_from_card_db(self, data: CardRow) -> Optional[DatabaseLoadResult]: if data.card_type == CardType.CHECK_CARD: return self._load_check_card(data) else: return self._load_official_card(data) |
︙ | ︙ | |||
432 433 434 435 436 437 438 | filtered_choices = [] if prefer_already_downloaded: filtered_choices = self.image_db.filter_already_downloaded(choices) card = filtered_choices[0] if filtered_choices else choices[0] logger.info(f"Found suitable replacement card: {card}") return card | < | | | < > | < > | < < | | < | 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 | filtered_choices = [] if prefer_already_downloaded: filtered_choices = self.image_db.filter_already_downloaded(choices) card = filtered_choices[0] if filtered_choices else choices[0] logger.info(f"Found suitable replacement card: {card}") return card def _load_custom_card_from_save(self, save_db: sqlite3.Connection, card_size: CardSize, card_row: CardRow) -> CustomCard: query = cached_dedent("""\ SELECT name, set_code, set_name, collector_number, image FROM CustomCardData WHERE card_id = ? AND is_front = ? """) name, set_code, set_name, collector_number, image_bytes = save_db.execute( query, (card_row.custom_card_id, card_row.is_front) ).fetchone() # type: str, str, str, str, bytes return self.card_db.get_custom_card( name, set_code, set_name, collector_number, card_size, card_row.is_front, image_bytes) def _fix_mixed_pages(self, pages: List[CardList], page_settings: PageLayoutSettings): """ Documents saved with older versions (or specifically crafted save files) can contain images with mixed sizes on the same page. This method is called when the document loading finishes and moves cards away from these mixed pages so that all pages only contain a single image size. |
︙ | ︙ | |||
508 509 510 511 512 513 514 | f"'{key}'" for key, value in settings.__annotations__.items() if value is QuantityT) document_dimensions_query = textwrap.dedent(f"""\ SELECT "key", value FROM DocumentDimensions WHERE "key" in ({keys}) """) settings.update(db.execute(document_dimensions_query)) | > | > > | | | | | | | | | | | | < < | 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 | f"'{key}'" for key, value in settings.__annotations__.items() if value is QuantityT) document_dimensions_query = textwrap.dedent(f"""\ SELECT "key", value FROM DocumentDimensions WHERE "key" in ({keys}) """) settings.update(db.execute(document_dimensions_query)) is_distance = all_of( instance_of(pint.Quantity), has_property("dimensionality", equal_to(unit_registry.mm.dimensionality))) is_bool_str = is_in(("True", "False")) assert_that( settings, has_properties( card_bleed=is_distance, page_height=is_distance, page_width=is_distance, margin_top=is_distance, margin_bottom=is_distance, margin_left=is_distance, margin_right=is_distance, row_spacing=is_distance, column_spacing=is_distance, draw_cut_markers=is_bool_str, draw_sharp_corners=is_bool_str, draw_page_numbers=is_bool_str, document_name=instance_of(str), ), "Document settings contain invalid data or data types" ) for key, annotated_type in PageLayoutSettings.__annotations__.items(): value = getattr(settings, key) if annotated_type is bool: value = mtg_proxy_printer.settings.settings._convert_to_boolean(value) elif annotated_type is QuantityT: # Ensure all floats are within the allowed bounds. value = mtg_proxy_printer.settings.clamp_to_supported_range( value, mtg_proxy_printer.settings.MIN_SIZE, mtg_proxy_printer.settings.MAX_SIZE) elif annotated_type is str: pass setattr(settings, key, value) assert_that( |
︙ | ︙ |
Changes to mtg_proxy_printer/model/document_page.py.
︙ | ︙ | |||
11 12 13 14 15 16 17 18 19 20 | # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses from functools import partial import typing | > | > > > > > > > > > | 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import enum from functools import partial import typing from mtg_proxy_printer.model.card import AnyCardType, AnyCardTypeForTypeCheck from mtg_proxy_printer.units_and_sizes import PageType class PageColumns(enum.IntEnum): CardName = 0 Set = enum.auto() CollectorNumber = enum.auto() Language = enum.auto() IsFront = enum.auto() Image = enum.auto() @dataclasses.dataclass class CardContainer: parent: "Page" card: AnyCardType |
︙ | ︙ | |||
65 66 67 68 69 70 71 | super().insert(__index, container) return container def append(self, __object: AnyCardType) -> CardContainer: container = CardContainer(self, __object) super().append(container) return container | < < < | 75 76 77 78 79 80 81 | super().insert(__index, container) return container def append(self, __object: AnyCardType) -> CardContainer: container = CardContainer(self, __object) super().append(container) return container |
Changes to mtg_proxy_printer/model/imagedb.py.
︙ | ︙ | |||
38 39 40 41 42 43 44 | from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList from mtg_proxy_printer.document_controller import DocumentAction from .imagedb_files import ImageKey, CacheContent import mtg_proxy_printer.app_dirs import mtg_proxy_printer.downloader_base import mtg_proxy_printer.http_file from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize | | | 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList from mtg_proxy_printer.document_controller import DocumentAction from .imagedb_files import ImageKey, CacheContent import mtg_proxy_printer.app_dirs import mtg_proxy_printer.downloader_base import mtg_proxy_printer.http_file from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize from .card import Card, CheckCard, AnyCardType from mtg_proxy_printer.runner import Runnable from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger ItemDataRole = Qt.ItemDataRole DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardImages" |
︙ | ︙ |
Changes to mtg_proxy_printer/model/page_layout.py.
︙ | ︙ | |||
134 135 136 137 138 139 140 | def compute_page_column_count(self, page_type: PageType = PageType.REGULAR) -> int: """Returns the total number of card columns that fit on this page.""" card_size: CardSize = CardSizes.for_page_type(page_type) card_width: QuantityT = card_size.width.to("mm", "print") available_width: QuantityT = self.page_width - (self.margin_left + self.margin_right) | | | | 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | def compute_page_column_count(self, page_type: PageType = PageType.REGULAR) -> int: """Returns the total number of card columns that fit on this page.""" card_size: CardSize = CardSizes.for_page_type(page_type) card_width: QuantityT = card_size.width.to("mm", "print") available_width: QuantityT = self.page_width - (self.margin_left + self.margin_right) if available_width <= card_width: return 0 cards = 1 + math.floor( (available_width - card_width) / (card_width + self.column_spacing)) return cards def compute_page_row_count(self, page_type: PageType = PageType.REGULAR) -> int: """Returns the total number of card rows that fit on this page.""" card_size: CardSize = CardSizes.for_page_type(page_type) card_height: QuantityT = card_size.height.to("mm", "print") available_height: QuantityT = self.page_height - (self.margin_top + self.margin_bottom) if available_height <= card_height: return 0 cards = 1 + math.floor( (available_height - card_height) / (card_height + self.row_spacing) ) return cards |
︙ | ︙ |
Changes to mtg_proxy_printer/model/string_list.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import typing from PyQt5.QtCore import QAbstractListModel, Qt, QObject, QModelIndex | | < | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import typing from PyQt5.QtCore import QAbstractListModel, Qt, QObject, QModelIndex from mtg_proxy_printer.model.card import MTGSet __all__ = [ "PrettySetListModel", ] INVALID_INDEX = QModelIndex() ItemDataRole = Qt.ItemDataRole Orientation = Qt.Orientation |
︙ | ︙ |
Changes to mtg_proxy_printer/natsort.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | # along with this program. If not, see <http://www.gnu.org/licenses/>. """ Natural sorting for lists or other iterables of strings. """ import re import typing from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex __all__ = [ "natural_sorted", "str_less_than", "NaturallySortedSortFilterProxyModel", ] _NUMBER_GROUP_REG_EXP = re.compile(r"(\d+)") def try_convert_int(s: str): try: | > > | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | # along with this program. If not, see <http://www.gnu.org/licenses/>. """ Natural sorting for lists or other iterables of strings. """ import itertools import re import typing from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex __all__ = [ "natural_sorted", "str_less_than", "NaturallySortedSortFilterProxyModel", "to_list_of_ranges" ] _NUMBER_GROUP_REG_EXP = re.compile(r"(\d+)") def try_convert_int(s: str): try: |
︙ | ︙ | |||
75 76 77 78 79 80 81 | return super().lessThan(left, right) def row_sort_order(self) -> typing.List[int]: """Returns the row numbers of the source model in the current sort order.""" return [ self.mapToSource(self.index(row, 0)).row() for row in range(self.rowCount()) ] | > > > > > > > > > > > > > > | 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | return super().lessThan(left, right) def row_sort_order(self) -> typing.List[int]: """Returns the row numbers of the source model in the current sort order.""" return [ self.mapToSource(self.index(row, 0)).row() for row in range(self.rowCount()) ] def to_list_of_ranges(sequence: typing.Iterable[int]) -> typing.List[typing.Tuple[int, int]]: sequence = sorted(sequence) ranges: typing.List[typing.Tuple[int, int]] = [] sequence = itertools.chain(sequence, (sentinel := object(),)) lower = upper = next(sequence) for item in sequence: if item is sentinel or upper != item-1: ranges.append((lower, upper)) lower = upper = item else: upper = item return ranges |
Changes to mtg_proxy_printer/resources/ui/central_widget/columnar.ui.
︙ | ︙ | |||
43 44 45 46 47 48 49 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> <property name="selectionBehavior"> <enum>QAbstractItemView::SelectRows</enum> </property> <attribute name="verticalHeaderVisible"> <bool>false</bool> </attribute> </widget> |
︙ | ︙ |
Changes to mtg_proxy_printer/resources/ui/central_widget/grouped.ui.
︙ | ︙ | |||
75 76 77 78 79 80 81 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> <property name="selectionBehavior"> <enum>QAbstractItemView::SelectRows</enum> </property> <attribute name="verticalHeaderVisible"> <bool>false</bool> </attribute> </widget> |
︙ | ︙ |
Changes to mtg_proxy_printer/resources/ui/central_widget/tabbed_vertical.ui.
︙ | ︙ | |||
68 69 70 71 72 73 74 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> <property name="selectionBehavior"> <enum>QAbstractItemView::SelectRows</enum> </property> <attribute name="verticalHeaderVisible"> <bool>false</bool> </attribute> </widget> |
︙ | ︙ |
Added mtg_proxy_printer/resources/ui/custom_card_import_dialog.ui.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> <class>CustomCardImportDialog</class> <widget class="QDialog" name="CustomCardImportDialog"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>900</width> <height>500</height> </rect> </property> <property name="windowTitle"> <string>Import custom cards</string> </property> <layout class="QGridLayout" name="gridLayout"> <item row="6" column="3"> <widget class="QPushButton" name="set_copies_to"> <property name="text"> <string>Set Copies to …</string> </property> <property name="icon"> <iconset theme="document-edit"/> </property> </widget> </item> <item row="6" column="4"> <widget class="QSpinBox" name="card_copies"> <property name="minimum"> <number>1</number> </property> </widget> </item> <item row="1" column="2" rowspan="11"> <widget class="CardListTableView" name="card_table"/> </item> <item row="3" column="3" colspan="2"> <widget class="QPushButton" name="remove_selected"> <property name="text"> <string>Remove selected</string> </property> <property name="icon"> <iconset theme="edit-delete"/> </property> </widget> </item> <item row="2" column="3" colspan="2"> <widget class="QPushButton" name="add_cards"> <property name="text"> <string>Load images</string> </property> <property name="icon"> <iconset theme="document-import"/> </property> </widget> </item> <item row="8" column="3" colspan="2"> <widget class="QDialogButtonBox" name="button_box"> <property name="sizePolicy"> <sizepolicy hsizetype="Minimum" vsizetype="Maximum"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="orientation"> <enum>Qt::Vertical</enum> </property> <property name="standardButtons"> <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> </property> </widget> </item> <item row="7" column="3"> <spacer name="verticalSpacer"> <property name="orientation"> <enum>Qt::Vertical</enum> </property> <property name="sizeHint" stdset="0"> <size> <width>20</width> <height>40</height> </size> </property> </spacer> </item> </layout> </widget> <customwidgets> <customwidget> <class>CardListTableView</class> <extends>QTableView</extends> <header>mtg_proxy_printer.ui.card_list_table_view</header> </customwidget> </customwidgets> <resources/> <connections> <connection> <sender>button_box</sender> <signal>accepted()</signal> <receiver>CustomCardImportDialog</receiver> <slot>accept()</slot> <hints> <hint type="sourcelabel"> <x>248</x> <y>254</y> </hint> <hint type="destinationlabel"> <x>157</x> <y>274</y> </hint> </hints> </connection> <connection> <sender>button_box</sender> <signal>rejected()</signal> <receiver>CustomCardImportDialog</receiver> <slot>reject()</slot> <hints> <hint type="sourcelabel"> <x>316</x> <y>260</y> </hint> <hint type="destinationlabel"> <x>286</x> <y>274</y> </hint> </hints> </connection> </connections> </ui> |
Changes to mtg_proxy_printer/resources/ui/deck_import_wizard/parser_result_page.ui.
︙ | ︙ | |||
35 36 37 38 39 40 41 | <widget class="QLabel" name="parsed_cards_label"> <property name="text"> <string>These cards were successfully identified:</string> </property> </widget> </item> <item> | | | 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | <widget class="QLabel" name="parsed_cards_label"> <property name="text"> <string>These cards were successfully identified:</string> </property> </widget> </item> <item> <widget class="CardListTableView" name="parsed_cards_table"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> <horstretch>0</horstretch> <verstretch>65</verstretch> </sizepolicy> </property> <property name="sizeAdjustPolicy"> |
︙ | ︙ | |||
87 88 89 90 91 92 93 94 95 96 | <property name="openLinks"> <bool>false</bool> </property> </widget> </item> </layout> </widget> <resources/> <connections/> </ui> | > > > > > > > | 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | <property name="openLinks"> <bool>false</bool> </property> </widget> </item> </layout> </widget> <customwidgets> <customwidget> <class>CardListTableView</class> <extends>QTableView</extends> <header>mtg_proxy_printer.ui.card_list_table_view</header> </customwidget> </customwidgets> <resources/> <connections/> </ui> |
Changes to mtg_proxy_printer/resources/ui/main_window.ui.
︙ | ︙ | |||
19 20 21 22 23 24 25 | <widget class="CentralWidget" name="central_widget"/> <widget class="QMenuBar" name="menubar"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>1050</width> | | > | 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | <widget class="CentralWidget" name="central_widget"/> <widget class="QMenuBar" name="menubar"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>1050</width> <height>28</height> </rect> </property> <widget class="QMenu" name="menu_file"> <property name="title"> <string>Fi&le</string> </property> <addaction name="action_new_document"/> <addaction name="action_load_document"/> <addaction name="action_save_document"/> <addaction name="action_save_as"/> <addaction name="action_print_preview"/> <addaction name="action_print"/> <addaction name="action_print_pdf"/> <addaction name="separator"/> <addaction name="action_import_deck_list"/> <addaction name="action_add_custom_cards"/> <addaction name="separator"/> <addaction name="action_quit"/> </widget> <widget class="QMenu" name="menu_settings"> <property name="title"> <string>Settings</string> </property> |
︙ | ︙ | |||
269 270 271 272 273 274 275 | </property> </action> <action name="action_import_deck_list"> <property name="icon"> <iconset theme="document-import"/> </property> <property name="text"> | | | 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | </property> </action> <action name="action_import_deck_list"> <property name="icon"> <iconset theme="document-import"/> </property> <property name="text"> <string>Import deck list</string> </property> <property name="toolTip"> <string>Import a deck list from online sources</string> </property> </action> <action name="action_cleanup_local_image_cache"> <property name="icon"> |
︙ | ︙ | |||
353 354 355 356 357 358 359 360 361 362 363 364 365 366 | </property> <property name="text"> <string>Add empty card to page</string> </property> <property name="toolTip"> <string>Add an empty spacer filling a card slot</string> </property> </action> </widget> <customwidgets> <customwidget> <class>CentralWidget</class> <extends>QWidget</extends> <header>mtg_proxy_printer.ui.central_widget</header> | > > > > > > > > | 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 | </property> <property name="text"> <string>Add empty card to page</string> </property> <property name="toolTip"> <string>Add an empty spacer filling a card slot</string> </property> </action> <action name="action_add_custom_cards"> <property name="icon"> <iconset theme="list-add"/> </property> <property name="text"> <string>Add custom cards</string> </property> </action> </widget> <customwidgets> <customwidget> <class>CentralWidget</class> <extends>QWidget</extends> <header>mtg_proxy_printer.ui.central_widget</header> |
︙ | ︙ |
Added mtg_proxy_printer/resources/ui/set_editor_widget.ui.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> <class>SetEditor</class> <widget class="QWidget" name="SetEditor"> <layout class="QHBoxLayout" name="horizontal_layout"> <property name="spacing"> <number>0</number> </property> <property name="leftMargin"> <number>0</number> </property> <property name="topMargin"> <number>0</number> </property> <property name="rightMargin"> <number>0</number> </property> <item> <widget class="QLineEdit" name="name_editor"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> <horstretch>15</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="placeholderText"> <string>Set name</string> </property> </widget> </item> <item> <widget class="QLabel" name="opening_parenthesis"> <property name="text"> <string>(</string> </property> </widget> </item> <item> <widget class="QLineEdit" name="code_edit"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> <horstretch>7</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> <property name="inputMethodHints"> <set>Qt::ImhUppercaseOnly</set> </property> <property name="maxLength"> <number>6</number> </property> <property name="placeholderText"> <string>CODE</string> </property> </widget> </item> <item> <widget class="QLabel" name="closing_parenthesis"> <property name="text"> <string>)</string> </property> </widget> </item> </layout> </widget> <tabstops> <tabstop>name_editor</tabstop> <tabstop>code_edit</tabstop> </tabstops> <resources/> <connections/> </ui> |
Changes to mtg_proxy_printer/save_file_migrations.py.
︙ | ︙ | |||
220 221 222 223 224 225 226 | card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'), image BLOB NOT NULL, -- The raw image content name TEXT NOT NULL DEFAULT '', set_name TEXT NOT NULL DEFAULT '', set_code TEXT NOT NULL DEFAULT '', collector_number TEXT NOT NULL DEFAULT '', is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE, | < | 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'), image BLOB NOT NULL, -- The raw image content name TEXT NOT NULL DEFAULT '', set_name TEXT NOT NULL DEFAULT '', set_code TEXT NOT NULL DEFAULT '', collector_number TEXT NOT NULL DEFAULT '', is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE, other_face TEXT REFERENCES CustomCardData(card_id) -- If this is a DFC, this references the other side )"""), "ALTER TABLE Card RENAME TO Card_old", textwrap.dedent("""\ CREATE TABLE Page ( page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0), image_size TEXT NOT NULL CHECK(image_size <> '') |
︙ | ︙ |
Changes to mtg_proxy_printer/sqlite_helpers.py.
︙ | ︙ | |||
20 21 22 23 24 25 26 | import re import sqlite3 import sys import textwrap import typing from hamcrest import assert_that, contains_exactly | < | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | import re import sqlite3 import sys import textwrap import typing from hamcrest import assert_that, contains_exactly from mtg_proxy_printer.units_and_sizes import unit_registry from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/add_card.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | from typing import Union, Type, Optional from PyQt5.QtCore import QStringListModel, pyqtSlot as Slot, pyqtSignal as Signal, Qt, QItemSelectionModel, QItemSelection from PyQt5.QtWidgets import QWidget, QDialogButtonBox from PyQt5.QtGui import QIcon from mtg_proxy_printer.document_controller.card_actions import ActionAddCard import mtg_proxy_printer.model.string_list import mtg_proxy_printer.model.carddb import mtg_proxy_printer.model.document import mtg_proxy_printer.settings from mtg_proxy_printer.ui.common import load_ui_from_file from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger try: | > > | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | from typing import Union, Type, Optional from PyQt5.QtCore import QStringListModel, pyqtSlot as Slot, pyqtSignal as Signal, Qt, QItemSelectionModel, QItemSelection from PyQt5.QtWidgets import QWidget, QDialogButtonBox from PyQt5.QtGui import QIcon import mtg_proxy_printer.model.card from mtg_proxy_printer.document_controller.card_actions import ActionAddCard import mtg_proxy_printer.model.string_list import mtg_proxy_printer.model.carddb import mtg_proxy_printer.model.document import mtg_proxy_printer.settings from mtg_proxy_printer.model.card import MTGSet from mtg_proxy_printer.ui.common import load_ui_from_file from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger try: |
︙ | ︙ | |||
142 143 144 145 146 147 148 | self.ui.collector_number_list.selectionModel().clearSelection() return logger.debug("Currently selected set changed.") current_model_index = current.indexes()[0] valid = current_model_index.isValid() self.ui.collector_number_box.setEnabled(valid) if valid: | | | | | 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | self.ui.collector_number_list.selectionModel().clearSelection() return logger.debug("Currently selected set changed.") current_model_index = current.indexes()[0] valid = current_model_index.isValid() self.ui.collector_number_box.setEnabled(valid) if valid: mtg_set: MTGSet = current_model_index.data(ItemDataRole.EditRole) collector_numbers = self.card_database.find_collector_numbers_matching( self.current_card_name, mtg_set.code, self.current_language ) logger.debug( f'Selected: "{mtg_set.code}", language: {self.current_language}, matching {len(collector_numbers)} prints') self.collector_number_model.setStringList(collector_numbers) self.ui.collector_number_list.selectionModel().select( self.collector_number_model.createIndex(0, 0), QItemSelectionModel.ClearAndSelect) @Slot(QItemSelection) def collector_number_list_selection_changed(self, current: QItemSelection): self.ui.button_box.button(StandardButton.Ok).setEnabled(bool(current.indexes())) |
︙ | ︙ | |||
233 234 235 236 237 238 239 | if add_opposing_faces_enabled and (opposing_face := self.card_database.get_opposing_face(card)) is not None: logger.info( "Card is double faced and adding opposing faces is enabled, automatically adding the other face.") self._log_added_card(opposing_face, copies) self.request_action.emit(ActionAddCard(opposing_face, copies)) @staticmethod | | | 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | if add_opposing_faces_enabled and (opposing_face := self.card_database.get_opposing_face(card)) is not None: logger.info( "Card is double faced and adding opposing faces is enabled, automatically adding the other face.") self._log_added_card(opposing_face, copies) self.request_action.emit(ActionAddCard(opposing_face, copies)) @staticmethod def _log_added_card(card: mtg_proxy_printer.model.card.Card, copies: int): logger.debug(f"Adding {copies}× [{card.set.code.upper()}:{card.collector_number}] {card.name}") @Slot() def reset(self): logger.info("User hit the Reset button, resetting…") self.ui.collector_number_list.clearSelection() self.collector_number_model.setStringList([]) |
︙ | ︙ | |||
264 265 266 267 268 269 270 | else: return None @property def current_set_name(self) -> Optional[str]: selected = self.ui.set_name_list.selectedIndexes() if selected: | | | 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | else: return None @property def current_set_name(self) -> Optional[str]: selected = self.ui.set_name_list.selectedIndexes() if selected: return selected[0].data(ItemDataRole.EditRole).code else: return None @property def current_collector_number(self) -> Optional[str]: selected = self.ui.collector_number_list.selectedIndexes() if selected: |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/cache_cleanup_wizard.py.
︙ | ︙ | |||
13 14 15 16 17 18 19 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import datetime import enum | < | | | > | | 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import datetime import enum import math import pathlib import typing from PyQt5.QtCore import QAbstractTableModel, Qt, QModelIndex, QObject, QItemSelectionModel, QSize from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget, QWizard, QWizardPage import mtg_proxy_printer.settings from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import MTGSet, Card from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.imagedb_files import CacheContent as ImageCacheContent, ImageKey from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase, get_card_image_tooltip from mtg_proxy_printer.units_and_sizes import OptStr from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger try: from mtg_proxy_printer.ui.generated.cache_cleanup_wizard.card_filter_page import Ui_CardFilterPage |
︙ | ︙ | |||
50 51 52 53 54 55 56 | "CacheCleanupWizard", ] INVALID_INDEX = QModelIndex() SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows ItemDataRole = Qt.ItemDataRole Orientation = Qt.Orientation | < < < < < < < < < < < < < < < < | 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | "CacheCleanupWizard", ] INVALID_INDEX = QModelIndex() SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows ItemDataRole = Qt.ItemDataRole Orientation = Qt.Orientation class KnownCardColumns(enum.IntEnum): Name = 0 Set = enum.auto() CollectorNumber = enum.auto() IsHidden = enum.auto() IsFront = enum.auto() |
︙ | ︙ | |||
100 101 102 103 104 105 106 | def __post_init__(self): super().__init__(self._parent) # Call QObject.__init__() without interfering with the dataclass internals def data(self, column: int, role: ItemDataRole): if column == KnownCardColumns.Name and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): data = self.name elif column == KnownCardColumns.Name and role == ItemDataRole.ToolTipRole: | | | 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | def __post_init__(self): super().__init__(self._parent) # Call QObject.__init__() without interfering with the dataclass internals def data(self, column: int, role: ItemDataRole): if column == KnownCardColumns.Name and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): data = self.name elif column == KnownCardColumns.Name and role == ItemDataRole.ToolTipRole: data = get_card_image_tooltip(self.path, self.preferred_language_name) elif column == KnownCardColumns.Set: data = self.set.data(role) elif column == KnownCardColumns.CollectorNumber and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): data = self.collector_number elif column == KnownCardColumns.IsHidden and role == ItemDataRole.DisplayRole: data = self.tr("Yes") if self.is_hidden else self.tr("No") elif column == KnownCardColumns.IsHidden and role == ItemDataRole.ToolTipRole and self.is_hidden: |
︙ | ︙ | |||
236 237 238 239 240 241 242 | image.absolute_path.stat().st_size, image.absolute_path ) def data(self, column: int, role: ItemDataRole): if column == UnknownCardColumns.ScryfallId and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): data = self.scryfall_id elif column == UnknownCardColumns.ScryfallId and role == ItemDataRole.ToolTipRole: | | | 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | image.absolute_path.stat().st_size, image.absolute_path ) def data(self, column: int, role: ItemDataRole): if column == UnknownCardColumns.ScryfallId and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole): data = self.scryfall_id elif column == UnknownCardColumns.ScryfallId and role == ItemDataRole.ToolTipRole: data = get_card_image_tooltip(self.path) elif column == UnknownCardColumns.IsFront and role == ItemDataRole.DisplayRole: data = self.tr("Front") if self.is_front else self.tr("Back") elif column == UnknownCardColumns.IsFront and role == ItemDataRole.EditRole: data = self.is_front elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.EditRole: data = self.has_high_resolution elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.DisplayRole: |
︙ | ︙ | |||
486 487 488 489 490 491 492 | def reject(self) -> None: super().reject() logger.info("User canceled the cache cleanup.") self._clear_tooltip_cache() @staticmethod def _clear_tooltip_cache(): | | | | 470 471 472 473 474 475 476 477 478 479 | def reject(self) -> None: super().reject() logger.info("User canceled the cache cleanup.") self._clear_tooltip_cache() @staticmethod def _clear_tooltip_cache(): logger.debug(f"Tooltip cache efficiency: {get_card_image_tooltip.cache_info()}") # Free memory by clearing the cached, base64 encoded PNGs used for tooltip display get_card_image_tooltip.cache_clear() |
Added mtg_proxy_printer/ui/card_list_table_view.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import math from PyQt5.QtCore import Qt, pyqtSignal as Signal, pyqtSlot as Slot from PyQt5.QtWidgets import QTableView, QWidget from mtg_proxy_printer.model.card_list import CardListColumns, CardListModel from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel from mtg_proxy_printer.ui.item_delegates import CollectorNumberEditorDelegate, BoundedCopiesSpinboxDelegate, \ CardSideSelectionDelegate, SetEditorDelegate, LanguageEditorDelegate from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger ItemDataRole = Qt.ItemDataRole class CardListTableView(QTableView): """ This table view shows a CardListModel, and sets up all item delegates used for proper display and validation. """ changed_selection_is_empty = Signal(bool) def __init__(self, parent: QWidget = None): super().__init__(parent) self._column_delegates = ( self._setup_combo_box_item_delegate(), self._setup_language_delegate(), self._setup_copies_delegate(), self._setup_side_delegate(), self._setup_set_delegate(), ) self.sort_model = NaturallySortedSortFilterProxyModel(self) def setModel(self, model: CardListModel): self.sort_model.setSourceModel(model) super().setModel(self.sort_model) # Has to be set up here, because setModel() implicitly creates the QItemSelectionModel self.selectionModel().selectionChanged.connect(self._on_selection_changed) # Now that the model is set and columns are discovered, set the column widths to reasonable values. self._setup_default_column_widths() @Slot() def _on_selection_changed(self): selection = self.selectionModel().selection() is_empty = selection.isEmpty() logger.debug(f"Selection changed: Currently selected cells: {selection.count()}") self.changed_selection_is_empty.emit(is_empty) def _setup_language_delegate(self): delegate = LanguageEditorDelegate(self) self.setItemDelegateForColumn(CardListColumns.Language, delegate) return delegate def _setup_combo_box_item_delegate(self) -> CollectorNumberEditorDelegate: delegate = CollectorNumberEditorDelegate(self) self.setItemDelegateForColumn(CardListColumns.CollectorNumber, delegate) return delegate def _setup_copies_delegate(self) -> BoundedCopiesSpinboxDelegate: delegate = BoundedCopiesSpinboxDelegate(self) self.setItemDelegateForColumn(CardListColumns.Copies, delegate) return delegate def _setup_side_delegate(self) -> CardSideSelectionDelegate: delegate = CardSideSelectionDelegate(self) self.setItemDelegateForColumn(CardListColumns.IsFront, delegate) return delegate def _setup_set_delegate(self) -> SetEditorDelegate: delegate = SetEditorDelegate(self) self.setItemDelegateForColumn(CardListColumns.Set, delegate) return delegate def _setup_default_column_widths(self): # These factors are empirically determined to give reasonable column sizes for column, scaling_factor in ( (CardListColumns.Copies, 0.9), (CardListColumns.CardName, 2), (CardListColumns.Set, 2.75), (CardListColumns.CollectorNumber, 0.95), (CardListColumns.Language, 0.9)): new_size = math.floor(self.columnWidth(column) * scaling_factor) self.setColumnWidth(column, new_size) |
Changes to mtg_proxy_printer/ui/common.py.
︙ | ︙ | |||
9 10 11 12 13 14 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. | | | | | | > < > | | > > > > > > > > > > > > > > > > > > > > > > > | 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import functools from pathlib import Path import platform from typing import Union, Dict from PyQt5.QtCore import QFile, QUrl, QObject, QSize, QCoreApplication, Qt, QBuffer, QIODevice from PyQt5.QtWidgets import QLabel, QWizard, QWidget, QGraphicsColorizeEffect, QTextEdit from PyQt5.QtGui import QIcon, QPixmap # noinspection PyUnresolvedReferences from PyQt5 import uic from mtg_proxy_printer.units_and_sizes import OptStr from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger __all__ = [ "RESOURCE_PATH_PREFIX", "ICON_PATH_PREFIX", "HAS_COMPILED_RESOURCES", "highlight_widget", "BlockedSignals", "load_ui_from_file", "format_size", "WizardBase", "get_card_image_tooltip", ] try: import mtg_proxy_printer.ui.compiled_resources except ModuleNotFoundError: RESOURCE_PATH_PREFIX = str(Path(__file__).resolve().parent.with_name("resources")) ICON_PATH_PREFIX = str(Path(__file__).resolve().parent.with_name("resources") / "icons") HAS_COMPILED_RESOURCES = False else: import atexit # Compiled resources found, so use it. RESOURCE_PATH_PREFIX = ":" ICON_PATH_PREFIX = ":/icons" HAS_COMPILED_RESOURCES = True atexit.register(mtg_proxy_printer.ui.compiled_resources.qCleanupResources) @functools.lru_cache(maxsize=256) def get_card_image_tooltip(image: Union[bytes, Path], card_name: OptStr = None, scaling_factor: int = 3) -> str: """ Returns a tooltip string showing a scaled down image for the given path. :param image: Filesystem path to the image file or raw image content as bytes :param card_name: Optional card name. If given, it is centered above the image :param scaling_factor: Scales the source by factor to 1/scaling_factor :return: HTML fragment with the image embedded as a base64 encoded PNG """ if isinstance(image, bytes): source = QPixmap() source.loadFromData(image) else: source = QPixmap(str(image)) pixmap = source.scaledToWidth(source.width() // scaling_factor, Qt.TransformationMode.SmoothTransformation) buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, "PNG", quality=100) image = buffer.data().toBase64().data().decode() card_name = f'<p style="text-align:center">{card_name}</p><br>' if card_name else "" return f'{card_name}<img src="data:image/png;base64,{image}">' def highlight_widget(widget: QWidget) -> None: """Sets a visual highlight on the given widget to make it stand out""" palette = widget.palette() highlight_color = palette.color(palette.currentColorGroup(), palette.ColorRole.Highlight) effect = QGraphicsColorizeEffect(widget) effect.setColor(highlight_color) |
︙ | ︙ | |||
75 76 77 78 79 80 81 | def __enter__(self): self.qt_object.blockSignals(True) def __exit__(self, exc_type, exc_val, exc_tb): self.qt_object.blockSignals(False) | < < < < < < < < < < < < < | 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | def __enter__(self): self.qt_object.blockSignals(True) def __exit__(self, exc_type, exc_val, exc_tb): self.qt_object.blockSignals(False) def load_ui_from_file(name: str): """ Returns the Ui class type from uic.loadUiType(), loading the ui file with the given name. :param name: Path to the UI file :return: class implementing the requested Ui |
︙ | ︙ | |||
131 132 133 134 135 136 137 | return template.format(size=f"{size:3.2f}", unit=unit) size /= 1024 return template.format(size=f"{size:.2f}", unit="YiB") class WizardBase(QWizard): """Base class for wizards based on QWizard""" | | | 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | return template.format(size=f"{size:3.2f}", unit=unit) size /= 1024 return template.format(size=f"{size:.2f}", unit="YiB") class WizardBase(QWizard): """Base class for wizards based on QWizard""" BUTTON_ICONS: Dict[QWizard.WizardButton, str] = {} def __init__(self, window_size: QSize, parent: QWidget, flags): super().__init__(parent, flags) if platform.system() == "Windows": # Avoid Aero style on Windows, which does not support dark mode target_style = QWizard.WizardStyle.ModernStyle logger.debug(f"Creating a QWizard on Windows, explicitly setting style to {target_style}") |
︙ | ︙ |
Added mtg_proxy_printer/ui/custom_card_import_dialog.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | # Copyright (C) 2020-2024 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from collections import Counter from pathlib import Path import typing from PyQt5.QtCore import Qt, QSize, pyqtSignal as Signal, pyqtSlot as Slot from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QPixmap from PyQt5.QtWidgets import QDialog, QWidget, QFileDialog, QPushButton from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import MTGSet, Card, CustomCard from mtg_proxy_printer.units_and_sizes import CardSizes try: from mtg_proxy_printer.ui.generated.custom_card_import_dialog import Ui_CustomCardImportDialog except ModuleNotFoundError: from mtg_proxy_printer.ui.common import load_ui_from_file Ui_CustomCardImportDialog = load_ui_from_file("custom_card_import_dialog") from mtg_proxy_printer.model.card_list import CardListModel import mtg_proxy_printer.units_and_sizes from mtg_proxy_printer.app_dirs import data_directories from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger TransformationMode = Qt.TransformationMode EventTypes = typing.Union[QDragEnterEvent, QDropEvent] class CustomCardImportDialog(QDialog): request_action = Signal(DocumentAction) def __init__(self, card_db: CardDatabase, parent: QWidget = None, flags=Qt.WindowFlags()): super().__init__(parent, flags) self.ui = ui = Ui_CustomCardImportDialog() ui.setupUi(self) self.ok_button.setEnabled(False) ui.remove_selected.setDisabled(True) self.model = CardListModel(card_db) ui.card_table.setModel(self.model) ui.card_table.selectionModel().selectionChanged.connect(self.on_card_table_selection_changed) self.model.rowsInserted.connect(self.on_rows_inserted) self.model.rowsRemoved.connect(self.on_rows_removed) self.model.modelReset.connect(self.on_rows_removed) logger.info(f"Created {self.__class__.__name__} instance") @property def currently_selected_cards(self): return self.ui.card_table.selectionModel().selection() @property def ok_button(self) -> QPushButton: return self.ui.button_box.button(self.ui.button_box.StandardButton.Ok) @staticmethod def dragdrop_acceptable(event: EventTypes) -> bool: urls = event.mimeData().urls() local_paths = [Path(url.toLocalFile()) for url in urls] acceptable = local_paths and all((path.is_file() for path in local_paths)) return acceptable @Slot() def on_card_table_selection_changed(self): cards_selected = self.currently_selected_cards.isEmpty() self.ui.remove_selected.setDisabled(cards_selected) @Slot() def on_rows_inserted(self): self.ok_button.setEnabled(True) @Slot() def on_rows_removed(self): has_cards = bool(self.model.rowCount()) self.ok_button.setEnabled(has_cards) @Slot() def on_add_cards_clicked(self): logger.info("User about to add additional card images") default_path = getattr(data_directories, "user_pictures_dir", str(Path.home())) files, _ = QFileDialog.getOpenFileNames(self, self.tr("Import custom cards"), default_path) logger.debug(f"User selected {len(files)} paths") file_paths = list(map(Path, files)) cards = self.create_cards(file_paths) self.model.add_cards(cards) logger.info(f"Added {len(cards)} cards from the selected files.") @Slot() def on_remove_selected_clicked(self): logger.info("User about to delete all selected cards from the card table") self.model.remove_multi_selection(self.currently_selected_cards) @Slot() def on_set_copies_to_clicked(self): value = self.ui.card_copies.value() self.model.set_all_copies_to(value) logger.info(f"All copy counts set to {value}") def show_from_drop_event(self, event: QDropEvent): urls = event.mimeData().urls() local_paths = [Path(url.toLocalFile()) for url in urls] cards = self.create_cards(local_paths) self.model.add_cards(cards) self.show() def create_cards(self, paths: typing.List[Path]) -> typing.Counter[CustomCard]: result: typing.Counter[CustomCard] = Counter() regular = mtg_proxy_printer.units_and_sizes.CardSizes.REGULAR card_db = self.model.card_db for path in paths: if not QPixmap(str(path)).isNull(): # This read should stay guarded by the Pixmap constructor to prevent accidental DoS by reading huge files pixmap_bytes = path.read_bytes() card = card_db.get_custom_card( path.stem, "" , "", "", regular, True, pixmap_bytes) result[card] += 1 return result def accept(self): action = ActionImportDeckList(self.model.as_cards(), False) self.request_action.emit(action) super().accept() |
Changes to mtg_proxy_printer/ui/deck_import_wizard.py.
︙ | ︙ | |||
12 13 14 15 16 17 18 | # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import itertools | < | | < | 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import itertools import pathlib import re import typing import urllib.error import urllib.parse from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, pyqtProperty as Property, QStringListModel, Qt, \ QSize, QUrl from PyQt5.QtGui import QValidator, QIcon, QDesktopServices from PyQt5.QtWidgets import QWizard, QFileDialog, QMessageBox, QWizardPage, QWidget, QRadioButton from mtg_proxy_printer.units_and_sizes import SectionProxy import mtg_proxy_printer.settings from mtg_proxy_printer.decklist_parser import re_parsers, common, csv_parsers from mtg_proxy_printer.decklist_downloader import IsIdentifyingDeckUrlValidator, AVAILABLE_DOWNLOADERS, \ get_downloader_class, ParserBase from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.card_list import CardListModel from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase, markdown_to_html from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList try: from mtg_proxy_printer.ui.generated.deck_import_wizard.load_list_page import Ui_LoadListPage from mtg_proxy_printer.ui.generated.deck_import_wizard.parser_result_page import Ui_SummaryPage from mtg_proxy_printer.ui.generated.deck_import_wizard.select_deck_parser_page import Ui_SelectDeckParserPage except ModuleNotFoundError: |
︙ | ︙ | |||
436 437 438 439 440 441 442 443 444 | # here. self.selected_parser.incompatible_file_format.connect(self.wizard().on_incompatible_deck_file_selected) logger.info(f"Created parser: {self.selected_parser.__class__.__name__}") return self.isComplete() class SummaryPage(QWizardPage): def __init__(self, card_db: CardDatabase, *args, **kwargs): super().__init__(*args, **kwargs) | > > > > > > > > | | < | < < | | 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 | # here. self.selected_parser.incompatible_file_format.connect(self.wizard().on_incompatible_deck_file_selected) logger.info(f"Created parser: {self.selected_parser.__class__.__name__}") return self.isComplete() class SummaryPage(QWizardPage): # Give the generic enum constants a semantic name BasicLandRemovalOption = WizardOption.HaveCustomButton1 BasicLandRemovalButton = WizardButton.CustomButton1 SelectedRemovalOption = WizardOption.HaveCustomButton2 SelectedRemovalButton = WizardButton.CustomButton2 def __init__(self, card_db: CardDatabase, *args, **kwargs): super().__init__(*args, **kwargs) self.ui = ui = Ui_SummaryPage() ui.setupUi(self) self.setCommitPage(True) self.card_list = CardListModel(card_db, self) self.card_list.oversized_card_count_changed.connect(self._update_accept_button_on_oversized_card_count_changed) ui.parsed_cards_table.setModel(self.card_list) self.registerField("should_replace_document", self.ui.should_replace_document) ui.should_replace_document.toggled[bool].connect( self._update_accept_button_on_replace_document_option_toggled) logger.info(f"Created {self.__class__.__name__} instance.") def _create_sort_model(self, source_model: CardListModel) -> NaturallySortedSortFilterProxyModel: proxy_model = NaturallySortedSortFilterProxyModel(self) proxy_model.setSourceModel(source_model) proxy_model.setSortRole(Qt.ItemDataRole.EditRole) |
︙ | ︙ | |||
488 489 490 491 492 493 494 | if enabled: accept_button.setIcon(QIcon.fromTheme("document-replace")) accept_button.setToolTip(self.tr("Replace document content with the identified cards")) else: accept_button.setIcon(QIcon.fromTheme("dialog-ok")) accept_button.setToolTip(self.tr("Append identified cards to the document")) | < < < < < < < < < < < < < < < < < < < | 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 | if enabled: accept_button.setIcon(QIcon.fromTheme("document-replace")) accept_button.setToolTip(self.tr("Replace document content with the identified cards")) else: accept_button.setIcon(QIcon.fromTheme("dialog-ok")) accept_button.setToolTip(self.tr("Append identified cards to the document")) def initializePage(self) -> None: super().initializePage() parser: common.ParserBase = self.field("selected_parser") decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"] logger.debug(f"About to parse the deck list using parser {parser.__class__.__name__}") if self.field("translate-deck-list-enable"): language_override = self.field("translate-deck-list-target-language") logger.info(f"Language override enabled. Will translate deck list to language {language_override}") else: |
︙ | ︙ | |||
534 535 536 537 538 539 540 | logger.info("Automatically remove basic lands") self._remove_basic_lands() logger.debug(f"Initialized {self.__class__.__name__}") def _initialize_custom_buttons(self, decklist_import_section: SectionProxy): wizard = self.wizard() wizard.customButtonClicked.connect(self.custom_button_clicked) | > | | | | | > > > | | > > > < < < < < < < | | < > | | 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 | logger.info("Automatically remove basic lands") self._remove_basic_lands() logger.debug(f"Initialized {self.__class__.__name__}") def _initialize_custom_buttons(self, decklist_import_section: SectionProxy): wizard = self.wizard() wizard.customButtonClicked.connect(self.custom_button_clicked) # When basic lands are stripped fully automatically, there is no need to have a non-functional button. should_offer_basic_land_removal = not decklist_import_section.getboolean("automatically-remove-basic-lands") wizard.setOption(self.BasicLandRemovalOption, should_offer_basic_land_removal) remove_basic_lands_button = wizard.button(self.BasicLandRemovalButton) remove_basic_lands_button.setEnabled(self.card_list.has_basic_lands( decklist_import_section.getboolean("remove-basic-wastes"), decklist_import_section.getboolean("remove-snow-basics"))) remove_basic_lands_button.setText(self.tr("Remove basic lands")) remove_basic_lands_button.setToolTip(self.tr("Remove all basic lands in the deck list above")) remove_basic_lands_button.setIcon(QIcon.fromTheme("edit-delete")) wizard.setOption(self.SelectedRemovalOption, True) remove_selected_cards_button = wizard.button(self.SelectedRemovalButton) remove_selected_cards_button.setEnabled(False) remove_selected_cards_button.setText(self.tr("Remove selected")) remove_selected_cards_button.setToolTip(self.tr("Remove all selected cards in the deck list above")) remove_selected_cards_button.setIcon(QIcon.fromTheme("edit-delete")) self.ui.parsed_cards_table.changed_selection_is_empty.connect( remove_selected_cards_button.setDisabled ) def cleanupPage(self): self.card_list.clear() super().cleanupPage() wizard = self.wizard() wizard.customButtonClicked.disconnect(self.custom_button_clicked) wizard.setOption(self.BasicLandRemovalOption, False) wizard.setOption(self.SelectedRemovalOption, False) self.ui.parsed_cards_table.changed_selection_is_empty.disconnect( wizard.button(self.SelectedRemovalButton).setDisabled ) logger.debug(f"Cleaned up {self.__class__.__name__}") @Slot() def isComplete(self) -> bool: return self.card_list.rowCount() > 0 @Slot(int) def custom_button_clicked(self, button_id: int): button = WizardButton(button_id) self.wizard().button(button).setEnabled(False) if button == self.BasicLandRemovalButton: logger.info("User requests to remove all basic lands") self._remove_basic_lands() elif button == self.SelectedRemovalButton: self._remove_selected_cards() def _remove_basic_lands(self): decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"] self.card_list.remove_all_basic_lands( decklist_import_section.getboolean("remove-basic-wastes"), decklist_import_section.getboolean("remove-snow-basics")) def _remove_selected_cards(self): logger.info("User removes the selected cards") sort_model = self.ui.parsed_cards_table.sort_model selection_mapped_to_source = sort_model.mapSelectionToSource( self.ui.parsed_cards_table.selectionModel().selection()) self.card_list.remove_multi_selection(selection_mapped_to_source) if not self.card_list.rowCount(): # User deleted everything, so nothing left to complete the wizard. This’ll disable the Finish button. self.completeChanged.emit() |
︙ | ︙ | |||
627 628 629 630 631 632 633 634 | logger.info("Aborting accept(), because oversized cards are present " "in the deck list and the user chose to go back.") return super().accept() logger.info("User finished the import wizard, performing the requested actions") if replace_document := self.field("should_replace_document"): logger.info("User chose to replace the current document content, clearing it") action = ActionImportDeckList( | > | | 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 | logger.info("Aborting accept(), because oversized cards are present " "in the deck list and the user chose to go back.") return super().accept() logger.info("User finished the import wizard, performing the requested actions") if replace_document := self.field("should_replace_document"): logger.info("User chose to replace the current document content, clearing it") sort_order = self.summary_page.ui.parsed_cards_table.sort_model.row_sort_order() action = ActionImportDeckList( self.summary_page.card_list.as_cards(sort_order), replace_document ) logger.info(f"User loaded a deck list with {action.card_count()} cards, adding these to the document") self.request_action.emit(action) def _ask_about_oversized_cards(self) -> bool: oversized_count = self.summary_page.card_list.oversized_card_count |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/dialogs.py.
︙ | ︙ | |||
20 21 22 23 24 25 26 | from PyQt5.QtCore import QFile, pyqtSlot as Slot, QThreadPool, QObject, QEvent, Qt from PyQt5.QtWidgets import QFileDialog, QWidget, QTextBrowser, QDialogButtonBox, QDialog from PyQt5.QtGui import QIcon from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrintDialog, QPrinter import mtg_proxy_printer.app_dirs | | > | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | from PyQt5.QtCore import QFile, pyqtSlot as Slot, QThreadPool, QObject, QEvent, Qt from PyQt5.QtWidgets import QFileDialog, QWidget, QTextBrowser, QDialogButtonBox, QDialog from PyQt5.QtGui import QIcon from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrintDialog, QPrinter import mtg_proxy_printer.app_dirs from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import Card import mtg_proxy_printer.model.document import mtg_proxy_printer.model.imagedb import mtg_proxy_printer.print import mtg_proxy_printer.settings import mtg_proxy_printer.ui.common import mtg_proxy_printer.meta_data from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX, ConfigParser |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/item_delegates.py.
︙ | ︙ | |||
10 11 12 13 14 15 16 17 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import typing from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QSortFilterProxyModel | > | | < | > > > > > > > | > | | | > > > > > > > > > > > > > > | > | < < < < | | > > < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < > > > > > > > > > > > | > > > | > > > > > > | > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import typing from typing import Union from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QSortFilterProxyModel from PyQt5.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QComboBox, QSpinBox, QLineEdit from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.logger import get_logger try: from mtg_proxy_printer.ui.generated.set_editor_widget import Ui_SetEditor except ModuleNotFoundError: from mtg_proxy_printer.ui.common import load_ui_from_file Ui_SetEditor = load_ui_from_file("set_editor_widget") logger = get_logger(__name__) del get_logger __all__ = [ "CollectorNumberEditorDelegate", "BoundedCopiesSpinboxDelegate", "CardSideSelectionDelegate", "SetEditorDelegate", "LanguageEditorDelegate", ] ItemDataRole = Qt.ItemDataRole def get_document_from_index(index: QModelIndex) -> Document: """ Returns the Document instance associated with the given index. Resolves any chain of layered sort/filter models, to grant access to non-Qt-API Document methods. """ model: typing.Union[Document, QSortFilterProxyModel, None] = index.model() if model is None: raise RuntimeError("Invalid index without attached model passed") while hasattr(model, "sourceModel"): model = model.sourceModel() source_model: Document = model return source_model class BoundedCopiesSpinboxDelegate(QStyledItemDelegate): """A QSpinBox delegate bounded to the inclusive range (1-100). Used for card copies.""" def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox: editor = QSpinBox(parent) editor.setMinimum(1) editor.setMaximum(100) return editor class CardSideSelectionDelegate(QStyledItemDelegate): """A QComboBox delegate used to switch between Front and Back face of cards""" def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox: editor = QComboBox(parent) editor.addItem(self.tr("Front"), True) editor.addItem(self.tr("Back"), False) return editor def setModelData(self, editor: QComboBox, model: QAbstractItemModel, index: QModelIndex) -> None: new_value = editor.currentData(ItemDataRole.UserRole) previous_value = index.data(ItemDataRole.EditRole) if new_value != previous_value: logger.debug(f"Setting data for column {index.column()} to {new_value}") model.setData(index, new_value, ItemDataRole.EditRole) class SetEditorDelegate(QStyledItemDelegate): """ A set editor. For official cards, use a QComboBox with valid set choices for the given card. For custom cards, use the embedded editor widget to allow free-form text entry. """ class CustomCardSetEditor(QWidget): """A widget holding two line edits, allowing the user to freely edit the set name & code of custom cards.""" def __init__(self, parent: QWidget = None, flags=Qt.WindowFlags()): super().__init__(parent, flags) self.ui = ui = Ui_SetEditor() ui.setupUi(self) def set_data(self, mtg_set: MTGSet): self.ui.name_editor.setText(mtg_set.name) self.ui.code_edit.setText(mtg_set.code) def to_mtg_set(self): return MTGSet(self.ui.code_edit.text(), self.ui.name_editor.text()) def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex): card: AnyCardType = index.data(ItemDataRole.UserRole) # Use a locked-down choice-based editor for official cards, and a free-form editor for custom cards return self.CustomCardSetEditor(parent) if card.is_custom_card else QComboBox(parent) def setEditorData(self, editor: Union[QComboBox, CustomCardSetEditor], index: QModelIndex): card: AnyCardType = index.data(ItemDataRole.UserRole) if card.is_custom_card: current_data: MTGSet = index.data(ItemDataRole.EditRole) editor.set_data(current_data) else: model = get_document_from_index(index) matching_sets = model.card_db.get_available_sets_for_card(card) current_set_code = card.set.code for position, set_data in enumerate(matching_sets): editor.addItem(set_data.data(ItemDataRole.DisplayRole), set_data) if set_data.code == current_set_code: editor.setCurrentIndex(position) def setModelData( self, editor: Union[QComboBox, CustomCardSetEditor], model: QAbstractItemModel, index: QModelIndex) -> None: card: AnyCardType = index.data(ItemDataRole.UserRole) data = editor.to_mtg_set() if card.is_custom_card else editor.currentData(ItemDataRole.UserRole) model.setData(index, data, ItemDataRole.EditRole) @staticmethod def _is_official_card(editor: Union[QComboBox, CustomCardSetEditor]): return isinstance(editor, QComboBox) class LanguageEditorDelegate(QStyledItemDelegate): """ A language editor. For official cards, use a QComboBox with valid language choices for the given card. For custom cards, populate the combo box with all known languages and also enable the edit functionality to allow free-form text entry. """ MAX_LENGTH = 5 def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QComboBox: return QComboBox(parent) def setEditorData(self, editor: QComboBox, index: QModelIndex): model = get_document_from_index(index) card: Card = index.data(ItemDataRole.UserRole) current_language = card.language is_custom_card = card.is_custom_card editor.setEditable(is_custom_card) # Allow custom languages for custom cards only if is_custom_card: editor.lineEdit().setMaxLength(self.MAX_LENGTH) languages = model.card_db.get_all_languages() else: languages = model.card_db.get_available_languages_for_card(card) for language in languages: editor.addItem(language, language) if current_language in languages: # This is only false for custom cards and user-entered, unknown languages editor.setCurrentIndex(languages.index(index.data(ItemDataRole.EditRole))) def setModelData(self, editor: QComboBox, model: QAbstractItemModel, index: QModelIndex) -> None: new_value = editor.lineEdit().text() if editor.isEditable() else editor.currentData(ItemDataRole.UserRole) previous_value = index.data(ItemDataRole.EditRole) if new_value != previous_value: logger.debug(f"Setting data for column {index.column()} to {new_value}") model.setData(index, new_value, ItemDataRole.EditRole) class CollectorNumberEditorDelegate(QStyledItemDelegate): """ Editor for collector numbers. Allows free-form editing for custom cards, and uses a locked-down choice-based combo box for official cards """ def createEditor( self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex ) -> typing.Union[QLineEdit, QComboBox]: card: AnyCardType = index.data(ItemDataRole.UserRole) # Use a locked-down choice-based editor for official cards, and a free-form editor for custom cards return QLineEdit(parent) if card.is_custom_card else QComboBox(parent) def setEditorData(self, editor: typing.Union[QLineEdit, QComboBox], index: QModelIndex) -> None: model = get_document_from_index(index) card: Card = index.data(ItemDataRole.UserRole) if card.is_custom_card: editor.setText(card.collector_number) else: matching_collector_numbers = model.card_db.get_available_collector_numbers_for_card_in_set(card) for collector_number in matching_collector_numbers: editor.addItem(collector_number, collector_number) # Store the key in the UserData role if matching_collector_numbers: editor.setCurrentIndex(matching_collector_numbers.index(index.data(ItemDataRole.EditRole))) def setModelData( self, editor: typing.Union[QLineEdit, QComboBox], model: QAbstractItemModel, index: QModelIndex) -> None: card: Card = index.data(ItemDataRole.UserRole) new_value = editor.text() if card.is_custom_card else editor.currentData(ItemDataRole.UserRole) previous_value = card.collector_number if new_value != previous_value: logger.debug(f"Setting collector number from {previous_value} to {new_value}") model.setData(index, new_value, ItemDataRole.EditRole) |
Changes to mtg_proxy_printer/ui/main_window.py.
︙ | ︙ | |||
20 21 22 23 24 25 26 | from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, QStringListModel, QUrl, Qt, QSize from PyQt5.QtGui import QCloseEvent, QKeySequence, QDesktopServices, QDragEnterEvent, QDropEvent, QPixmap from PyQt5.QtWidgets import QApplication, QMessageBox, QAction, QWidget, QMainWindow, QDialog from mtg_proxy_printer.missing_images_manager import MissingImagesManager from mtg_proxy_printer.card_info_downloader import CardInfoDownloader | | > | | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, QStringListModel, QUrl, Qt, QSize from PyQt5.QtGui import QCloseEvent, QKeySequence, QDesktopServices, QDragEnterEvent, QDropEvent, QPixmap from PyQt5.QtWidgets import QApplication, QMessageBox, QAction, QWidget, QMainWindow, QDialog from mtg_proxy_printer.missing_images_manager import MissingImagesManager from mtg_proxy_printer.card_info_downloader import CardInfoDownloader from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.document_controller.compact_document import ActionCompactDocument from mtg_proxy_printer.document_controller.page_actions import ActionNewPage, ActionRemovePage from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument from mtg_proxy_printer.document_controller.new_document import ActionNewDocument from mtg_proxy_printer.document_controller.card_actions import ActionAddCard from mtg_proxy_printer.ui.custom_card_import_dialog import CustomCardImportDialog from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX import mtg_proxy_printer.settings import mtg_proxy_printer.print from mtg_proxy_printer.ui.dialogs import SavePDFDialog, SaveDocumentAsDialog, LoadDocumentDialog, \ AboutDialog, PrintPreviewDialog, PrintDialog, DocumentSettingsDialog from mtg_proxy_printer.ui.cache_cleanup_wizard import CacheCleanupWizard from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard from mtg_proxy_printer.ui.progress_bar import ProgressBar |
︙ | ︙ | |||
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | @Slot() def on_action_import_deck_list_triggered(self): logger.info(f"User imports a deck list.") wizard = DeckImportWizard(self.card_database, self.image_db, self.language_model, parent=self) wizard.request_action.connect(self.image_db.fill_batch_document_action_images) wizard.show() @Slot() def on_action_print_triggered(self): logger.info(f"User prints the current document.") action_str = self.tr( "printing", "This is passed as the {action} when asking the user about compacting the document if that can save pages") if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel: return | > > > > > > > > | | | | | | | | | | 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | @Slot() def on_action_import_deck_list_triggered(self): logger.info(f"User imports a deck list.") wizard = DeckImportWizard(self.card_database, self.image_db, self.language_model, parent=self) wizard.request_action.connect(self.image_db.fill_batch_document_action_images) wizard.show() @Slot() def on_action_add_custom_cards_triggered(self): logger.info(f"User adds custom cards.") self.current_dialog = dialog = CustomCardImportDialog(self.card_database, self) dialog.finished.connect(self.on_dialog_finished) dialog.request_action.connect(self.document.apply) dialog.show() @Slot() def on_action_print_triggered(self): logger.info(f"User prints the current document.") action_str = self.tr( "printing", "This is passed as the {action} when asking the user about compacting the document if that can save pages") if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel: return self.current_dialog = dialog = PrintDialog(self.document, self) dialog.finished.connect(self.on_dialog_finished) self.missing_images_manager.obtain_missing_images(dialog.open) @Slot() def on_action_print_preview_triggered(self): logger.info(f"User views the print preview.") action_str = self.tr( "printing", "This is passed as the {action} when asking the user about compacting the document if that can save pages") if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel: return self.current_dialog = dialog = PrintPreviewDialog(self.document, self) dialog.finished.connect(self.on_dialog_finished) self.missing_images_manager.obtain_missing_images(dialog.open) @Slot() def on_action_print_pdf_triggered(self): logger.info(f"User prints the current document to PDF.") action_str = self.tr( "exporting as a PDF", "This is passed as the {action} when asking the user about compacting the document if that can save pages") if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel: return self.current_dialog = dialog = SavePDFDialog(self, self.document) dialog.finished.connect(self.on_dialog_finished) self.missing_images_manager.obtain_missing_images(dialog.open) @Slot() def on_action_add_empty_card_triggered(self): empty_card = self.document.get_empty_card_for_current_page() action = ActionAddCard(empty_card) self.document.apply(action) |
︙ | ︙ | |||
355 356 357 358 359 360 361 | logger.debug("About to save the document") self.document.save_to_disk() logger.debug("Saved.") @Slot() def on_action_edit_document_settings_triggered(self): logger.info("User wants to edit the document settings. Showing the editor dialog") | | | | | | | | | | | | 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 | logger.debug("About to save the document") self.document.save_to_disk() logger.debug("Saved.") @Slot() def on_action_edit_document_settings_triggered(self): logger.info("User wants to edit the document settings. Showing the editor dialog") self.current_dialog = dialog = DocumentSettingsDialog(self.document, self) dialog.finished.connect(self.on_dialog_finished) dialog.open() @Slot() def on_action_download_missing_card_images_triggered(self): logger.info("User wants to download missing card images") self.missing_images_manager.obtain_missing_images() @Slot() def on_action_save_as_triggered(self): self.current_dialog = dialog = SaveDocumentAsDialog(self.document, self) dialog.finished.connect(self.on_dialog_finished) dialog.open() @Slot() def on_action_load_document_triggered(self): self.current_dialog = dialog = LoadDocumentDialog(self, self.document) dialog.accepted.connect(self.ui.central_widget.select_first_page) dialog.finished.connect(self.on_dialog_finished) dialog.open() def on_document_loading_failed(self, failed_path: pathlib.Path, reason: str): function_text = self.ui.action_import_deck_list.text() QMessageBox.critical( self, self.tr("Document loading failed"), self.tr('Loading file "{failed_path}" failed. The file was not recognized as a ' '{program_name} document. If you want to load a deck list, use the ' |
︙ | ︙ | |||
478 479 480 481 482 483 484 | mtg_proxy_printer.settings.write_settings_to_file() logger.debug("Written settings to disk.") def dragEnterEvent(self, event: QDragEnterEvent) -> None: if self._to_save_file_path(event): logger.info("User drags a saved MTGProxyPrinter document onto the main window, accepting event") event.acceptProposedAction() | | | | | < < < < < | > > | 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 | mtg_proxy_printer.settings.write_settings_to_file() logger.debug("Written settings to disk.") def dragEnterEvent(self, event: QDragEnterEvent) -> None: if self._to_save_file_path(event): logger.info("User drags a saved MTGProxyPrinter document onto the main window, accepting event") event.acceptProposedAction() elif CustomCardImportDialog.dragdrop_acceptable(event): logger.info(f"User drags {len(event.mimeData().urls())} images onto the main window, accepting event") event.acceptProposedAction() else: logger.debug("Rejecting drag&drop action for unknown or invalid data") def dropEvent(self, event: QDropEvent) -> None: if path := self._to_save_file_path(event): logger.info("User dropped save file onto the main window, loading the dropped document") self.document.loader.load_document(path) elif CustomCardImportDialog.dragdrop_acceptable(event): self.current_dialog = dialog = CustomCardImportDialog(self.card_database, self) dialog.request_action.connect(self.document.apply) dialog.finished.connect(self.on_dialog_finished) dialog.show_from_drop_event(event) @staticmethod def _to_save_file_path(event: typing.Union[QDragEnterEvent, QDropEvent]) -> typing.Optional[pathlib.Path]: """ Returns a Path instance to a file, if the drag&drop event contains a reference to exactly 1 document save file, None otherwise. """ |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/page_card_table_view.py.
︙ | ︙ | |||
22 23 24 25 26 27 28 | from PyQt5.QtCore import QPoint, Qt, pyqtSignal as Signal, pyqtSlot as Slot, QPersistentModelIndex from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QTableView, QWidget, QMenu, QAction, QInputDialog, QFileDialog from mtg_proxy_printer.app_dirs import data_directories from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards | | > | > | > | > > > < | > > > > | > > > > | | | 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | from PyQt5.QtCore import QPoint, Qt, pyqtSignal as Signal, pyqtSlot as Slot, QPersistentModelIndex from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QTableView, QWidget, QMenu, QAction, QInputDialog, QFileDialog from mtg_proxy_printer.app_dirs import data_directories from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import Card, CheckCard, CardList, AnyCardType, AnyCardTypeForTypeCheck from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import PageColumns from mtg_proxy_printer.ui.item_delegates import CollectorNumberEditorDelegate, SetEditorDelegate, LanguageEditorDelegate from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger ItemDataRole = Qt.ItemDataRole class PageCardTableView(QTableView): request_action = Signal(DocumentAction) obtain_card_image = Signal(ActionAddCard) changed_selection_is_empty = Signal(bool) def __init__(self, parent: QWidget = None): super().__init__(parent) self.customContextMenuRequested.connect(self.page_table_context_menu_requested) self._column_delegates = ( self._setup_combo_box_item_delegate(), self._setup_language_delegate(), self._setup_set_delegate(), ) self.card_db: CardDatabase = None def set_data(self, document: Document, card_db: CardDatabase): self.card_db = card_db self.setModel(document) self.request_action.connect(document.apply) document.current_page_changed.connect(self.on_current_page_changed) # Has to be set up here, because setModel() implicitly creates the QItemSelectionModel self.selectionModel().selectionChanged.connect(self._on_selection_changed) @Slot() def _on_selection_changed(self): is_empty = self.selectionModel().selection().isEmpty() self.changed_selection_is_empty.emit(is_empty) def _setup_combo_box_item_delegate(self): combo_box_delegate = CollectorNumberEditorDelegate(self) self.setItemDelegateForColumn(PageColumns.CollectorNumber, combo_box_delegate) return combo_box_delegate def _setup_language_delegate(self): delegate = LanguageEditorDelegate(self) self.setItemDelegateForColumn(PageColumns.Language, delegate) return delegate def _setup_set_delegate(self): delegate = SetEditorDelegate(self) self.setItemDelegateForColumn(PageColumns.Set, delegate) return delegate @Slot(QPoint) def page_table_context_menu_requested(self, pos: QPoint): if not (index := self.indexAt(pos)).isValid(): logger.debug("Right clicked empty space in the page card table view, ignoring event") return logger.info(f"Page card table requests context menu at x={pos.x()}, y={pos.y()}, row={index.row()}") |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/page_config_preview_area.py.
︙ | ︙ | |||
23 24 25 26 27 28 29 | from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize from mtg_proxy_printer.model.document_page import PageType from mtg_proxy_printer.model.document import Document | | | 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize from mtg_proxy_printer.model.document_page import PageType from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.card import MTGSet, Card from mtg_proxy_printer.ui.common import load_ui_from_file from mtg_proxy_printer.logger import get_logger try: from mtg_proxy_printer.ui.generated.page_config_preview_area import Ui_PageConfigPreviewArea except ModuleNotFoundError: Ui_PageConfigPreviewArea = load_ui_from_file("page_config_preview_area") |
︙ | ︙ |
Changes to mtg_proxy_printer/ui/page_scene.py.
︙ | ︙ | |||
21 22 23 24 25 26 27 | from PyQt5.QtCore import Qt, QSizeF, QPointF, QRectF, pyqtSignal as Signal, QObject, pyqtSlot as Slot, \ QPersistentModelIndex, QModelIndex, QRect, QPoint, QSize from PyQt5.QtGui import QPen, QColorConstants, QBrush, QColor, QPalette, QFontMetrics, QPixmap, QTransform, QPolygonF from PyQt5.QtWidgets import QGraphicsItemGroup, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, \ QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsScene, QGraphicsPolygonItem | | | > | 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | from PyQt5.QtCore import Qt, QSizeF, QPointF, QRectF, pyqtSignal as Signal, QObject, pyqtSlot as Slot, \ QPersistentModelIndex, QModelIndex, QRect, QPoint, QSize from PyQt5.QtGui import QPen, QColorConstants, QBrush, QColor, QPalette, QFontMetrics, QPixmap, QTransform, QPolygonF from PyQt5.QtWidgets import QGraphicsItemGroup, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, \ QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsScene, QGraphicsPolygonItem from mtg_proxy_printer.model.card import CardCorner, Card from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import PageColumns from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.settings import settings from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, RESOLUTION, CardSizes, CardSize, QuantityT from mtg_proxy_printer.logger import get_logger logger = get_logger(__name__) del get_logger |
︙ | ︙ | |||
533 534 535 536 537 538 539 | if page.row() == self.selected_page.row(): self.update_card_positions() if self.document.page_layout.draw_cut_markers: self.remove_cut_markers() self.draw_cut_markers() def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[ItemDataRole]): | | > > > > > > | 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 | if page.row() == self.selected_page.row(): self.update_card_positions() if self.document.page_layout.draw_cut_markers: self.remove_cut_markers() self.draw_cut_markers() def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[ItemDataRole]): if (top_left.parent().row() == self.selected_page.row() and ItemDataRole.DisplayRole in roles # Multiple columns changed means card replaced. # Editing custom cards only changes single columns. # Thes cases can be ignored, as the pixmap never changes and top_left.column() < bottom_right.column() ): page_type: PageType = top_left.parent().data(ItemDataRole.UserRole) card_items = self.card_items for row in range(top_left.row(), bottom_right.row()+1): logger.debug(f"Card {row} on the current page was replaced, replacing image.") current_item = card_items[row] self.draw_card(row, page_type, current_item) self.removeItem(current_item) |
︙ | ︙ |
Changes to mtg_proxy_printer/units_and_sizes.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # along with this program. If not, see <http://www.gnu.org/licenses/>. """Contains some constants, type definitions and the unit parsing support code""" import configparser import enum import re import typing try: from typing import NotRequired except ImportError: # Compatibility with Python < 3.11 from typing_extensions import NotRequired import pint | > | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # along with this program. If not, see <http://www.gnu.org/licenses/>. """Contains some constants, type definitions and the unit parsing support code""" import configparser import enum import re import sqlite3 import typing try: from typing import NotRequired except ImportError: # Compatibility with Python < 3.11 from typing_extensions import NotRequired import pint |
︙ | ︙ | |||
103 104 105 106 107 108 109 110 111 112 113 114 115 116 | return cls.OVERSIZED if page_type == PageType.OVERSIZED else cls.REGULAR @classmethod def from_bool(cls, value: bool) -> CardSize: return cls.OVERSIZED if value else cls.REGULAR @enum.unique class PageType(enum.Enum): """ This enum can be used to indicate what kind of images are placed on a Page. A page that only contains regular-sized images is REGULAR, a page only containing oversized images is OVERSIZED. An empty page has an UNDETERMINED image size and can be used for both oversized or regular sized cards A page containing both is MIXED. This should never happen. A page being MIXED indicates a bug in the code. | > > > | 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | return cls.OVERSIZED if page_type == PageType.OVERSIZED else cls.REGULAR @classmethod def from_bool(cls, value: bool) -> CardSize: return cls.OVERSIZED if value else cls.REGULAR sqlite3.register_adapter(CardSize, lambda item: item.to_save_data()) sqlite3.register_adapter(CardSizes, lambda item: item.to_save_data()) @enum.unique class PageType(enum.Enum): """ This enum can be used to indicate what kind of images are placed on a Page. A page that only contains regular-sized images is REGULAR, a page only containing oversized images is OVERSIZED. An empty page has an UNDETERMINED image size and can be used for both oversized or regular sized cards A page containing both is MIXED. This should never happen. A page being MIXED indicates a bug in the code. |
︙ | ︙ |
Changes to pyproject.toml.
︙ | ︙ | |||
77 78 79 80 81 82 83 | Translations = "https://crowdin.com/project/mtgproxyprinter" [project.gui-scripts] mtg-proxy-printer = "mtg_proxy_printer.__main__:main" [tool.pytest.ini_options] | | | 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | Translations = "https://crowdin.com/project/mtgproxyprinter" [project.gui-scripts] mtg-proxy-printer = "mtg_proxy_printer.__main__:main" [tool.pytest.ini_options] # timeout = 10 [tool.setuptools] exclude-package-data = {"mtg_proxy_printer" = ["resources", "resources.*"]} [tool.setuptools.packages.find] include = [ |
︙ | ︙ |
Changes to scripts/update_translations.py.
︙ | ︙ | |||
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | """ import argparse import itertools import pathlib import re import subprocess from typing import Callable, NamedTuple # Mapping between source locales, as provided by Crowdin, and the target, as expected/loaded by Qt. # TODO: Investigate, how systems behave in locales requiring the country as disambiguation, like en or zh. LOCALES = { "de-DE": "de", # "en-GB": "en_GB", "en-US": "en_US", "es-ES": "es", "fr-FR": "fr", "it-IT": "it", "ja-JP": "ja", "ko-KR": "ko", "pt-PT": "pt", "ru-RU": "ru", "zh-CN": "zh_CN", "zh-TW": "zh_TW", } | > > > > | | 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | """ import argparse import itertools import pathlib import re import subprocess import sys from typing import Callable, NamedTuple sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.resolve())) from mtg_proxy_printer.settings import VALID_LANGUAGES # Mapping between source locales, as provided by Crowdin, and the target, as expected/loaded by Qt. # TODO: Investigate, how systems behave in locales requiring the country as disambiguation, like en or zh. LOCALES = { "de-DE": "de", # "en-GB": "en_GB", "en-US": "en_US", "es-ES": "es", "fr-FR": "fr", "it-IT": "it", "ja-JP": "ja", "ko-KR": "ko", "pt-PT": "pt", "ru-RU": "ru", "zh-CN": "zh_CN", "zh-TW": "zh_TW", } TRANSLATIONS_DIR = pathlib.Path(__file__, "..", "..", "mtg_proxy_printer/resources/translations/").resolve() crowdin_yml_path = pathlib.Path(__file__).parent.parent/"crowdin.yml" SOURCES_PATH = pathlib.Path( # Fetch the name of the sources .ts file from crowdin.yml. # (Since Python does not come with a YAML parser, use a simple RE for data extraction) re.search( r'"source":\s*"(?P<path>.+)",', crowdin_yml_path.read_text("utf-8") |
︙ | ︙ | |||
119 120 121 122 123 124 125 | "crowdin", "upload" ]) def download_new_translations(args: Namespace): """Downloads translated .ts files from Crowdin via the API""" verify_crowdin_cli_present() | | | | | > > > > | 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | "crowdin", "upload" ]) def download_new_translations(args: Namespace): """Downloads translated .ts files from Crowdin via the API""" verify_crowdin_cli_present() #subprocess.call([ # "crowdin", "download" #]) # Strip all translations that are not registered as valid locale setting for file in TRANSLATIONS_DIR.glob("*.ts"): # type: pathlib.Path # Use get() to keep the mtgproxyprinter_sources.ts file by mapping it to the "System locale" value (empty str) if LOCALES.get(file.stem.split("_")[1], "") not in VALID_LANGUAGES: file.unlink() def get_lrelease(): """ Determine the lrelease binary name. Tries to find in on $PATH, or falls back to using the PySide2-supplied binary from the virtual environment. """ try: |
︙ | ︙ |
Changes to tests/conftest.py.
︙ | ︙ | |||
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | import itertools import sqlite3 import unittest.mock from pathlib import Path from PyQt5.QtGui import QColorConstants, QPixmap import pytest import mtg_proxy_printer.sqlite_helpers import mtg_proxy_printer.settings from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.units_and_sizes import CardSizes from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.imagedb_files import ImageKey | > > | | 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | import itertools import sqlite3 import unittest.mock from pathlib import Path from PyQt5.QtGui import QColorConstants, QPixmap import pytest from hamcrest import assert_that, is_ import mtg_proxy_printer.sqlite_helpers import mtg_proxy_printer.settings from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.units_and_sizes import CardSizes from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.imagedb_files import ImageKey from tests.helpers import fill_card_database_with_json_cards, is_dataclass_equal_to @pytest.fixture(params=[False, True]) def card_db(request) -> CardDatabase: section = mtg_proxy_printer.settings.settings["card-filter"] settings_to_use = {filter_name: "False" for filter_name in section.keys()} with unittest.mock.patch.dict(section, settings_to_use): |
︙ | ︙ | |||
104 105 106 107 108 109 110 | mock_card_db = unittest.mock.NonCallableMagicMock() mock_card_db.db = mtg_proxy_printer.sqlite_helpers.create_in_memory_database( "carddb", check_same_thread=False) document = Document(mock_card_db, mock_imagedb) document.loader.db = mock_card_db.db yield document document.__dict__.clear() | > > > > > > > > | 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | mock_card_db = unittest.mock.NonCallableMagicMock() mock_card_db.db = mtg_proxy_printer.sqlite_helpers.create_in_memory_database( "carddb", check_same_thread=False) document = Document(mock_card_db, mock_imagedb) document.loader.db = mock_card_db.db yield document document.__dict__.clear() @pytest.fixture def page_layout() -> PageLayoutSettings: layout = PageLayoutSettings.create_from_settings() defaults = PageLayoutSettings.create_from_settings(mtg_proxy_printer.settings.DEFAULT_SETTINGS) assert_that(layout, is_dataclass_equal_to(defaults)) return layout |
Changes to tests/decklist_parser/test_generic_re_parser.py.
︙ | ︙ | |||
12 13 14 15 16 17 18 | # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import unittest.mock | | > | 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import unittest.mock from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import Card from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.decklist_parser.re_parsers import GenericRegularExpressionDeckParser from tests.helpers import fill_card_database_with_json_cards import pytest from hamcrest import * |
︙ | ︙ |
Changes to tests/decklist_parser/test_scryfall_csv_parser.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 | import typing import unittest.mock import pytest from hamcrest import * | | > | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | import typing import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card import Card from mtg_proxy_printer.decklist_parser.csv_parsers import ScryfallCSVParser from mtg_proxy_printer.decklist_downloader import DecklistDownloader from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS StringList = typing.List[str] DECK_LIST_CSV_HEADER = "section,count,name,mana_cost,type,set,set_code,collector_number,lang,rarity," \ |
︙ | ︙ |
Changes to tests/decklist_parser/test_tappedout_csv_parser.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 | import typing import unittest.mock import pytest from hamcrest import * | | > | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | import typing import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card import Card from mtg_proxy_printer.decklist_parser.csv_parsers import TappedOutCSVParser from mtg_proxy_printer.decklist_downloader import TappedOutDownloader from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS StringList = typing.List[str] CSV_HEADER = "Board,Qty,Name,Printing,Foil,Alter,Signed,Condition,Language" |
︙ | ︙ |
Changes to tests/document_controller/helpers.py.
︙ | ︙ | |||
20 21 22 23 24 25 26 | import itertools from PyQt5.QtGui import QPixmap import hamcrest.core.base_matcher from hamcrest import has_properties, same_instance, all_of, instance_of, assert_that, is_, equal_to, has_property | | | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | import itertools from PyQt5.QtGui import QPixmap import hamcrest.core.base_matcher from hamcrest import has_properties, same_instance, all_of, instance_of, assert_that, is_, equal_to, has_property from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType from mtg_proxy_printer.model.document_page import CardContainer, Page from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize __all__ = [ "append_new_pages", "verify_page_index_cache_is_valid", "create_card", |
︙ | ︙ |
Changes to tests/document_controller/test_action_add_card.py.
︙ | ︙ | |||
16 17 18 19 20 21 22 | import copy import unittest.mock import pytest from hamcrest import * | | | 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | import copy import unittest.mock import pytest from hamcrest import * from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard from mtg_proxy_printer.model.document_page import PageType from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.document_controller import IllegalStateError from mtg_proxy_printer.document_controller.card_actions import ActionAddCard from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.units_and_sizes import CardSizes |
︙ | ︙ |
Changes to tests/document_controller/test_action_new_page.py.
︙ | ︙ | |||
15 16 17 18 19 20 21 | from unittest.mock import MagicMock from hamcrest import * import pytest | | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | from unittest.mock import MagicMock from hamcrest import * import pytest from mtg_proxy_printer.model.card import Card from mtg_proxy_printer.model.document_page import CardContainer, Page from mtg_proxy_printer.document_controller import IllegalStateError from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.units_and_sizes import CardSizes from .helpers import append_new_card_in_page, card_container_with, append_new_pages, verify_page_index_cache_is_valid, \ create_card |
︙ | ︙ |
Changes to tests/document_controller/test_action_save_document.py.
︙ | ︙ | |||
19 20 21 22 23 24 25 26 | import textwrap from hamcrest import * from PyQt5.QtCore import QModelIndex, Qt import pytest from pytestqt.qtbot import QtBot from mtg_proxy_printer.sqlite_helpers import open_database, create_in_memory_database | > | | < | | | > | | | | > > > | | | < < | < > | > > > | < < < < < | | | < < < < | < | 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | import textwrap from hamcrest import * from PyQt5.QtCore import QModelIndex, Qt import pytest from pytestqt.qtbot import QtBot from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.sqlite_helpers import open_database, create_in_memory_database from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT from mtg_proxy_printer.model.card import CheckCard from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_loader import CardType from mtg_proxy_printer.document_controller.card_actions import ActionAddCard from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument from tests.model.test_document import document_custom_layout from tests.helpers import quantity_close_to ItemDataRole = Qt.ItemDataRole mm: UnitT = unit_registry.mm def validate_qt_model_signal_parameter( expected_first: int, expected_last: int, parent: QModelIndex, first: int, last: int) -> bool: return not parent.isValid() and first == expected_first and last == expected_last @pytest.mark.parametrize("source_version", [2, 3, 4, 5, 6]) def test_save_migration(tmp_path: Path, document: Document, source_version: int): """Tests migration of existing saves to the newest schema revision on save.""" card = document.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True) capacity = document.page_layout.compute_page_card_capacity(card.requested_page_type()) document.apply(ActionAddCard(card, capacity)) action = ActionSaveDocument(_create_save_file(Path(tmp_path), source_version)) action.apply(document) _validate_database_schema(action.file_path) _validate_saved_document_settings(document.page_layout, action.file_path) def test_create_save(tmp_path: Path, document_custom_layout: Document): """Tests that saving a new document uses the newest database schema version""" layout = document_custom_layout.page_layout card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True) capacity = layout.compute_page_card_capacity(card.requested_page_type()) document_custom_layout.apply(ActionAddCard(card, capacity)) save_file = tmp_path / "test.mtgproxies" action = ActionSaveDocument(save_file) action.apply(document_custom_layout) _validate_database_schema(save_file) _validate_saved_document_settings(layout, save_file) @pytest.mark.parametrize("is_front", [True, False]) def test_save_as_saves_regular_card(tmp_path: Path, document: Document, is_front: bool): card = document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front) document.apply(ActionAddCard(card)) save_file = tmp_path/"test.mtgproxies" action = ActionSaveDocument(save_file) action.apply(document) with open_database(save_file, "document-v7") as con: content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall() del con assert_that( content, contains_exactly( contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front, CardType.REGULAR.value) ) ) def test_save_as_saves_check_card(tmp_path: Path, document: Document): card = CheckCard( document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True), document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", False), ) document.apply(ActionAddCard(card)) save_file = tmp_path / "test.mtgproxies" action = ActionSaveDocument(save_file) action.apply(document) with open_database(save_file, "document-v7") as con: content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall() del con assert_that( content, contains_exactly( contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True, CardType.CHECK_CARD.value) ) ) def test_subsequent_save_updates_settings(tmp_path: Path, qtbot: QtBot, document_custom_layout: Document): save_file = tmp_path / "test.mtgproxies" save_action = ActionSaveDocument(save_file) save_action.apply(document_custom_layout) modified_layout = copy.copy(document_custom_layout.page_layout) modified_layout.page_height = modified_layout.page_width = 1000*mm modified_layout.margin_top = modified_layout.margin_bottom = 13*mm modified_layout.margin_left = modified_layout.margin_right= 14*mm modified_layout.column_spacing = modified_layout.row_spacing = 2*mm modified_layout.draw_page_numbers = not modified_layout.draw_page_numbers modified_layout.draw_cut_markers = not modified_layout.draw_cut_markers modified_layout.draw_sharp_corners = not modified_layout.draw_sharp_corners modified_layout.document_name = "New" with qtbot.waitSignal(document_custom_layout.page_layout_changed, timeout=100): document_custom_layout.apply(ActionEditDocumentSettings(modified_layout)) save_action.apply(document_custom_layout) _validate_saved_document_settings(modified_layout, save_file) def _create_save_file(temp_path: Path, source_version: int): """Creates an empty document save file at the given path and using the given schema version.""" save_file_path = temp_path/"test.mtgproxies" open_database(save_file_path, f"document-v{source_version}").close() return save_file_path |
︙ | ︙ | |||
184 185 186 187 188 189 190 | "Given save file inconsistent: Unexpected tables or views") assert_that( db_unsafe.execute(indices_query).fetchall(), contains_exactly(*db_known_good.execute(indices_query).fetchall()), "Given save file inconsistent: Unexpected indices") | | < | 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | "Given save file inconsistent: Unexpected tables or views") assert_that( db_unsafe.execute(indices_query).fetchall(), contains_exactly(*db_known_good.execute(indices_query).fetchall()), "Given save file inconsistent: Unexpected indices") def _validate_saved_document_settings(layout: PageLayoutSettings, save_file: Path): with open_database(save_file, "document-v7") as save: assert_that( save.execute(textwrap.dedent(""" SELECT sum(cnt) FROM ( SELECT COUNT(1) AS cnt FROM DocumentSettings UNION ALL SELECT COUNT(1) AS cnt FROM DocumentDimensions |
︙ | ︙ |
Changes to tests/document_controller/test_action_shuffle_document.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from mtg_proxy_printer.units_and_sizes import PageType, CardSizes | | | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from mtg_proxy_printer.units_and_sizes import PageType, CardSizes from mtg_proxy_printer.model.card import CardList from mtg_proxy_printer.model.document_page import Page from mtg_proxy_printer.document_controller import IllegalStateError from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument from .helpers import append_new_card_in_page |
︙ | ︙ |
Changes to tests/helpers.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import functools import json import os import typing from numbers import Real from unittest.mock import patch, MagicMock from hamcrest.core.base_matcher import BaseMatcher from hamcrest import assert_that, is_, empty, contains_inanyorder, has_properties, equal_to, any_of, instance_of, \ close_to, all_of, greater_than_or_equal_to, less_than_or_equal_to from hamcrest.core.description import Description from hamcrest.core.matcher import Matcher from pytestqt.qtbot import QtBot import mtg_proxy_printer.model import mtg_proxy_printer.model.carddb import mtg_proxy_printer.card_info_downloader from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater | > > > > > > | | < | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import dataclasses import functools import json import os import sqlite3 import typing from numbers import Real from pathlib import Path from typing import List, Tuple, Union, Literal from unittest.mock import patch, MagicMock from hamcrest.core.base_matcher import BaseMatcher from hamcrest import assert_that, is_, empty, contains_inanyorder, has_properties, equal_to, any_of, instance_of, \ close_to, all_of, greater_than_or_equal_to, less_than_or_equal_to from hamcrest.core.description import Description from hamcrest.core.matcher import Matcher from pytestqt.qtbot import QtBot import mtg_proxy_printer.model import mtg_proxy_printer.model.carddb import mtg_proxy_printer.card_info_downloader from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument from mtg_proxy_printer.model.document_loader import CardType from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater from mtg_proxy_printer.units_and_sizes import CardDataType, StrDict, QuantityT, CardSize import mtg_proxy_printer.logger import mtg_proxy_printer.settings from mtg_proxy_printer.sqlite_helpers import read_resource_text, open_database def _should_skip_network_tests() -> bool: result = os.getenv("MTGPROXYPRINTER_RUN_NETWORK_TESTS", "0") try: result = int(result) except ValueError: |
︙ | ︙ | |||
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | dw = mtg_proxy_printer.card_info_downloader.DatabaseImportWorker(card_db) dw._db = card_db.db # Explicitly share the in-memory database connection section = mtg_proxy_printer.settings.settings["card-filter"] with qtbot.assertNotEmitted(dw.other_error_occurred), qtbot.assertNotEmitted(dw.network_error_occurred): settings_to_use = update_database_printing_filters(card_db, filter_settings) with patch.dict(section, settings_to_use): dw.populate_database(data) def update_database_printing_filters( card_db: mtg_proxy_printer.model.carddb.CardDatabase, filter_settings: StrDict) -> StrDict: section = mtg_proxy_printer.settings.settings["card-filter"] settings_to_use = {filter_name: "False" for filter_name in section.keys()} if filter_settings: settings_to_use.update(filter_settings) section = mtg_proxy_printer.settings.settings["card-filter"] with patch.dict(section, settings_to_use): updater = PrintingFilterUpdater(card_db, card_db.db, force_update_hidden_column=True) updater.run() return settings_to_use @functools.lru_cache() def load_json(name: str) -> CardDataType: data = read_resource_text("tests.json_samples", f"{name}.json") return json.loads(data) | > > | 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | dw = mtg_proxy_printer.card_info_downloader.DatabaseImportWorker(card_db) dw._db = card_db.db # Explicitly share the in-memory database connection section = mtg_proxy_printer.settings.settings["card-filter"] with qtbot.assertNotEmitted(dw.other_error_occurred), qtbot.assertNotEmitted(dw.network_error_occurred): settings_to_use = update_database_printing_filters(card_db, filter_settings) with patch.dict(section, settings_to_use): dw.populate_database(data) def update_database_printing_filters( card_db: mtg_proxy_printer.model.carddb.CardDatabase, filter_settings: StrDict) -> StrDict: section = mtg_proxy_printer.settings.settings["card-filter"] settings_to_use = {filter_name: "False" for filter_name in section.keys()} if filter_settings: settings_to_use.update(filter_settings) section = mtg_proxy_printer.settings.settings["card-filter"] with patch.dict(section, settings_to_use): updater = PrintingFilterUpdater(card_db, card_db.db, force_update_hidden_column=True) updater.run() return settings_to_use @functools.lru_cache() def load_json(name: str) -> CardDataType: data = read_resource_text("tests.json_samples", f"{name}.json") return json.loads(data) |
︙ | ︙ | |||
125 126 127 128 129 130 131 132 133 134 135 136 137 138 | def fill_card_database_with_json_card( qtbot: QtBot, card_db: mtg_proxy_printer.model.carddb.CardDatabase, json_file_or_name: typing.Union[str, CardDataType], filter_settings: typing.Dict[str, str] = None) -> mtg_proxy_printer.model.carddb.CardDatabase: return fill_card_database_with_json_cards(qtbot, card_db, [json_file_or_name], filter_settings) def assert_relation_is_empty(card_db: mtg_proxy_printer.model.carddb.CardDatabase, name: str): assert_that( card_db.db.execute(f'SELECT * FROM "{name}"').fetchall(), is_(empty()), f"{name} contains unexpected data" ) | > > > > > > > > > > > > > > > > > > | 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | def fill_card_database_with_json_card( qtbot: QtBot, card_db: mtg_proxy_printer.model.carddb.CardDatabase, json_file_or_name: typing.Union[str, CardDataType], filter_settings: typing.Dict[str, str] = None) -> mtg_proxy_printer.model.carddb.CardDatabase: return fill_card_database_with_json_cards(qtbot, card_db, [json_file_or_name], filter_settings) def create_save_database_with( path_or_connection: Union[Path, Literal[":memory:"], sqlite3.Connection], pages: List[Tuple[int, CardSize]], cards: List[Tuple[int, int, bool, str, CardType]], settings: PageLayoutSettings) -> sqlite3.Connection: save = path_or_connection if isinstance(path_or_connection, sqlite3.Connection) \ else open_database(path_or_connection, "document-v7") ActionSaveDocument.save_settings(save, settings) save.executemany( "INSERT INTO Page (page, image_size) VALUES (?, ?)", pages ) save.executemany( 'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', cards ) return save def assert_relation_is_empty(card_db: mtg_proxy_printer.model.carddb.CardDatabase, name: str): assert_that( card_db.db.execute(f'SELECT * FROM "{name}"').fetchall(), is_(empty()), f"{name} contains unexpected data" ) |
︙ | ︙ |
Added tests/model/__init__.py.
Added tests/model/test_card.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import copy import pytest from PyQt5.QtCore import QBuffer, QIODevice from PyQt5.QtGui import QPixmap, QColorConstants, QColor from pytestqt.qtbot import QtBot from mtg_proxy_printer.model.card import Card, MTGSet, CardCorner, CustomCard, CheckCard from mtg_proxy_printer.units_and_sizes import CardSize, CardSizes, PageType, UUID from hamcrest import * # noinspection PyUnusedLocal @pytest.fixture() def card(qtbot: QtBot) -> Card: # QPixmap() requires a QApplication to function size = CardSizes.REGULAR pixmap = QPixmap(size.as_qsize_px()) pixmap.fill(QColorConstants.Red) return Card( "Name", MTGSet("CODE", "Set name"), "collector number", "en", "11112222-3333-4444-5555-666677778888", True, "aaaabbbb-cccc-dddd-eeee-ffff00001111", "/nonexistent", True, size, 1, False, pixmap ) # noinspection PyUnusedLocal @pytest.fixture() def oversized(qtbot: QtBot) -> Card: # QPixmap() requires a QApplication to function size = CardSizes.OVERSIZED pixmap = QPixmap(size.as_qsize_px()) pixmap.fill(QColorConstants.Red) return Card( "Name", MTGSet("CODE", "Set name"), "collector number", "en", "11112222-3333-4444-5555-666677778888", True, "aaaabbbb-cccc-dddd-eeee-ffff00001111", "/nonexistent", True, size, 1, False, pixmap ) def test_card_corner_color_is_cached(card: Card): assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red))) new_pixmap = card.image_file.copy() new_pixmap.fill(QColorConstants.Blue) card.image_file = new_pixmap # Direct assignment does not clear the cache assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red))) def test_card_set_image_file_resets_corner_color(card: Card): assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red))) new_pixmap = card.image_file.copy() new_pixmap.fill(QColorConstants.Blue) card.set_image_file(new_pixmap) assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Blue))) def test_card_set_code(card: Card): assert_that(card.set_code, is_("CODE")) def test_card_is_custom_card(card: Card): assert_that(card.is_custom_card, is_(False)) def test_card_is_oversized(card: Card, oversized: Card): assert_that(card.is_oversized, is_(False)) assert_that(oversized.is_oversized, is_(True)) def test_card_requested_page_type(card: Card, oversized: Card): assert_that(card.requested_page_type(), is_(PageType.REGULAR)) assert_that(oversized.requested_page_type(), is_(PageType.OVERSIZED)) def test_card_display_string(card: Card): assert_that(card.display_string(), is_('"Name" [CODE:collector number]')) def image_as_bytes(color: QColor, size: CardSize) -> bytes: pixmap = QPixmap(size.as_qsize_px()) pixmap.fill(color) buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, "PNG", quality=100) return buffer.data().data() # noinspection PyUnusedLocal @pytest.fixture() def custom_card(qtbot: QtBot) -> CustomCard: # QPixmap() requires a QApplication to function size = CardSizes.REGULAR image = image_as_bytes(QColorConstants.Red, size) return CustomCard( "Name", MTGSet("CODE", "Set name"), "collector number", "en", True, "/nonexistent", True, size, 1, False, image ) # noinspection PyUnusedLocal @pytest.fixture() def custom_oversized(qtbot: QtBot) -> CustomCard: # QPixmap() requires a QApplication to function size = CardSizes.OVERSIZED image = image_as_bytes(QColorConstants.Red, size) return CustomCard( "Name", MTGSet("CODE", "Set name"), "collector number", "en", True, "/nonexistent", True, size, 1, False, image ) def test_custom_card_corner_color(custom_card: CustomCard): assert_that(custom_card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red))) def test_custom_card_image_file(custom_card: CustomCard): image = custom_card.image_file.toImage() test_px = image.pixelColor(image.width()//2, image.height()//2) assert_that(test_px, is_(equal_to(QColorConstants.Red))) def test_custom_card_scryfall_id_is_uuid(custom_card: CustomCard): assert_that(custom_card.scryfall_id, instance_of(UUID), "Scryfall ID not a UUID") def test_custom_card_set_code(custom_card: CustomCard): assert_that(custom_card.set_code, is_("CODE")) def test_custom_card_is_custom_card(custom_card: CustomCard): assert_that(custom_card.is_custom_card, is_(True)) def test_custom_card_is_oversized(custom_card: CustomCard, custom_oversized: CustomCard): assert_that(custom_card.is_oversized, is_(False)) assert_that(custom_oversized.is_oversized, is_(True)) def test_custom_card_requested_page_type(custom_card: CustomCard, custom_oversized: CustomCard): assert_that(custom_card.requested_page_type(), is_(PageType.REGULAR)) assert_that(custom_oversized.requested_page_type(), is_(PageType.OVERSIZED)) def test_custom_card_display_string(custom_card: CustomCard): assert_that(custom_card.display_string(), is_('"Name" [CODE:collector number]')) def test_custom_card_oracle_id_is_empty(custom_card: CustomCard): assert_that(custom_card.oracle_id, is_(empty())) @pytest.mark.parametrize("property_name", Card.__annotations__) def test_custom_card_has_all_card_attributes(custom_card: CustomCard, property_name: str): assert_that(custom_card, has_property(property_name)) def _create_back(front: Card) -> Card: back = copy.copy(front) image = front.image_file.copy() image.fill(QColorConstants.Green) back.set_image_file(image) back.name = "Back" back.is_front = False back.face_number = front.face_number + 1 return back @pytest.fixture() def check_card(card: Card) -> CheckCard: back = _create_back(card) return CheckCard(card, back) @pytest.fixture() def oversized_check_card(oversized: Card) -> CheckCard: back = _create_back(oversized) return CheckCard(oversized, back) def test_check_card_corner_color(check_card: CheckCard): assert_that(check_card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red))) assert_that(check_card.corner_color(CardCorner.BOTTOM_LEFT), is_(equal_to(QColorConstants.Green))) def test_check_card_image_file(check_card: CheckCard): image = check_card.image_file.toImage() test_px_top = image.pixelColor(image.width()//2, image.height()//3) assert_that(test_px_top, is_(equal_to(QColorConstants.Red))) test_px_bottom = image.pixelColor(image.width()//2, image.height()-image.height()//3) assert_that(test_px_bottom, is_(equal_to(QColorConstants.Green))) def test_check_card_scryfall_id_is_uuid(check_card: CheckCard): assert_that(check_card.scryfall_id, is_(check_card.front.scryfall_id)) def test_check_card_set_code(check_card: CheckCard): assert_that(check_card.set_code, is_("CODE")) def test_check_card_is_custom_card(check_card: CheckCard): assert_that(check_card.is_custom_card, is_(False)) def test_check_card_is_oversized(check_card: CheckCard, oversized_check_card: CheckCard): assert_that(check_card.is_oversized, is_(False)) assert_that(oversized_check_card.is_oversized, is_(True)) def test_check_card_requested_page_type(check_card: CheckCard, oversized_check_card: CheckCard): assert_that(check_card.requested_page_type(), is_(PageType.REGULAR)) assert_that(oversized_check_card.requested_page_type(), is_(PageType.OVERSIZED)) def test_check_card_display_string(check_card: CheckCard): assert_that(check_card.display_string(), is_('"Name // Back" [CODE:collector number]')) def test_check_card_oracle_id_is_empty(check_card: CheckCard): assert_that(check_card.oracle_id, is_(check_card.front.oracle_id)) @pytest.mark.parametrize("property_name", Card.__annotations__) def test_check_card_has_all_card_attributes(check_card: CheckCard, property_name: str): assert_that(check_card, has_property(property_name)) |
Added tests/model/test_card_list.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from collections import Counter import typing from hamcrest import * import pytest from pytestqt.qtbot import QtBot from PyQt5.QtCore import QItemSelectionModel from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData from mtg_proxy_printer.model.card_list import CardListModel, CardListModelRow, CardListColumns from tests.helpers import fill_card_database_with_json_cards OVERSIZED_ID = "650722b4-d72b-4745-a1a5-00a34836282b" REGULAR_ID = "0000579f-7b35-4ed3-b44c-db2a538066fe" FOREST_ID = "7ef83f4c-d3ff-4905-a16d-f2bae673a5b2" WASTES_ID = "9cc070d3-4b83-4684-9caf-063e5c473a77" SNOW_FOREST_ID = "ca17acea-f079-4e53-8176-a2f5c5c408a1" def _populate_card_db_and_create_model(qtbot, card_db: CardDatabase) -> CardListModel: fill_card_database_with_json_cards( qtbot, card_db, ["oversized_card", "regular_english_card", "english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"]) model = CardListModel(card_db) return model @pytest.mark.parametrize("count", [1, 2, 10]) def test_add_oversized_card_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase, count: int): model = _populate_card_db_and_create_model(qtbot, card_db) oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True) with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == count)): model.add_cards(Counter({oversized: count})) assert_that(model.oversized_card_count, is_(equal_to(count))) @pytest.mark.parametrize("count, expected", [ (-1, 1), (0, 1), (1, 1), (99, 99), (100, 100), (101, 100), ]) def test_add_cards_with_invalid_count_clamped_to_valid_range( qtbot: QtBot, card_db: CardDatabase, count: int, expected: int): model = _populate_card_db_and_create_model(qtbot, card_db) card = card_db.get_card_with_scryfall_id(REGULAR_ID, True) model.add_cards(Counter({card: count})) assert_that(model.rowCount(), is_(1)) index = model.index(0, CardListColumns.Copies) assert_that(model.data(index), is_(expected)) @pytest.mark.parametrize("new_count", [5, 15]) def test_update_oversized_card_count_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase, new_count: int): model = _populate_card_db_and_create_model(qtbot, card_db) oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True) model.add_cards(Counter({oversized: 10})) assert_that(model.oversized_card_count, is_(equal_to(10))) index = model.index(0, CardListColumns.Copies) with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == new_count)): model.setData(index, new_count) assert_that(model.oversized_card_count, is_(equal_to(new_count))) def test_remove_oversized_card_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase): model = _populate_card_db_and_create_model(qtbot, card_db) oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True) model.add_cards(Counter({oversized: 10})) assert_that(model.oversized_card_count, is_(equal_to(10))) with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == 0)): model.remove_cards(0, 1) assert_that(model.oversized_card_count, is_(equal_to(0))) def test_replace_oversized_with_regular_card_decrements_oversized_count(qtbot: QtBot, card_db: CardDatabase): model = _populate_card_db_and_create_model(qtbot, card_db) regular = card_db.get_card_with_scryfall_id(REGULAR_ID, True) oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True) regular_data = CardIdentificationData( regular.language, scryfall_id=regular.scryfall_id, is_front=regular.is_front) with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)): model.add_cards(Counter({oversized: 1, regular: 1})) oversized_index = model.index(0, 0) regular_index = model.index(1, 0) assert_that(model.rows[0].card.is_oversized, is_(True)) assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True)) assert_that(model.rows[1].card.is_oversized, is_(False)) assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False)) assert_that(model.oversized_card_count, is_(1)) with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000): assert_that(model._request_replacement_card(oversized_index, regular_data), is_(True)) assert_that(model.rows[0].card.is_oversized, is_(False)) assert_that(model.rows[1].card.is_oversized, is_(False)) assert_that(model.oversized_card_count, is_(0)) def test_replace_regular_with_oversized_card_increments_oversized_count(qtbot: QtBot, card_db: CardDatabase): model = _populate_card_db_and_create_model(qtbot, card_db) regular = card_db.get_card_with_scryfall_id(REGULAR_ID, True) oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True) oversized_data = CardIdentificationData( oversized.language, scryfall_id=oversized.scryfall_id, is_front=oversized.is_front) with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)): model.add_cards(Counter({oversized: 1, regular: 1})) oversized_index = model.index(0, 0) regular_index = model.index(1, 0) assert_that(model.rows[0].card.is_oversized, is_(True)) assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True)) assert_that(model.rows[1].card.is_oversized, is_(False)) assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False)) assert_that(model.oversized_card_count, is_(1)) with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000): assert_that(model._request_replacement_card(regular_index, oversized_data), is_(True)) assert_that(model.rows[0].card.is_oversized, is_(True)) assert_that(model.rows[1].card.is_oversized, is_(True)) assert_that(model.oversized_card_count, is_(2)) @pytest.mark.parametrize("ranges, merged", [ ([], []), ([(2, 3)], [(2, 3)]), ([(0, 0), (0, 0)], [(0, 0)]), ([(0, 0), (0, 1)], [(0, 1)]), ([(0, 1), (2, 3)], [(0, 3)]), ([(0, 1), (3, 4)], [(0, 1), (3, 4)]), ]) def test__merge_ranges(ranges: typing.List[typing.Tuple[int, int]], merged: typing.List[typing.Tuple[int, int]]): assert_that( CardListModel._merge_ranges(ranges), contains_exactly(*merged), "Wrong merge result" ) def test_remove_multi_selection(qtbot: QtBot, card_db: CardDatabase): model = _populate_card_db_and_create_model(qtbot, card_db) regular = CardListModelRow(card_db.get_card_with_scryfall_id(REGULAR_ID, True), 1) oversized = CardListModelRow(card_db.get_card_with_scryfall_id(OVERSIZED_ID, True), 1) model.add_cards(Counter({ oversized.card: 1, regular.card: 1, })) model.add_cards(Counter({ oversized.card: 1, })) selection_model = QItemSelectionModel(model) selection_model.select(model.index(0, 0), QItemSelectionModel.Select) selection_model.select(model.index(2, 0), QItemSelectionModel.Select) assert_that( model.remove_multi_selection(selection_model.selection()), is_(equal_to(2)) ) assert_that(model.rows, contains_exactly(regular)) assert_that(model.rowCount(), is_(equal_to(1))) @pytest.mark.parametrize("include_wastes, include_snow_basics, present_cards, expected", [ (False, False, [], False), (False, True, [], False), (True, False, [], False), (True, True, [], False), (False, False, [REGULAR_ID], False), (False, True, [REGULAR_ID], False), (True, False, [REGULAR_ID], False), (True, True, [REGULAR_ID], False), (False, False, [REGULAR_ID, OVERSIZED_ID], False), (False, True, [REGULAR_ID, OVERSIZED_ID], False), (True, False, [REGULAR_ID, OVERSIZED_ID], False), (True, True, [REGULAR_ID, OVERSIZED_ID], False), (False, False, [FOREST_ID], True), (False, True, [FOREST_ID], True), (True, False, [FOREST_ID], True), (True, True, [FOREST_ID], True), (False, False, [WASTES_ID], False), (False, True, [WASTES_ID], False), (True, False, [WASTES_ID], True), (True, True, [WASTES_ID], True), (False, False, [SNOW_FOREST_ID], False), (False, True, [SNOW_FOREST_ID], True), (True, False, [SNOW_FOREST_ID], False), (True, True, [SNOW_FOREST_ID], True), (False, False, [SNOW_FOREST_ID, WASTES_ID], False), (False, True, [SNOW_FOREST_ID, WASTES_ID], True), (True, False, [SNOW_FOREST_ID, WASTES_ID], True), (True, True, [SNOW_FOREST_ID, WASTES_ID], True), ]) def test_has_basic_lands( qtbot: QtBot, card_db: CardDatabase, include_wastes: bool, include_snow_basics: bool, present_cards: typing.List[str], expected: bool): model = _populate_card_db_and_create_model(qtbot, card_db) model.add_cards(Counter( {card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards} )) assert_that( model.has_basic_lands(include_wastes, include_snow_basics), is_(expected) ) @pytest.mark.parametrize("remove_wastes, remove_snow_basics, present_cards, expected_remaining", [ (False, False, [], []), (False, True, [], []), (True, False, [], []), (True, True, [], []), (False, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]), (False, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]), (True, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]), (True, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]), (False, False, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID, SNOW_FOREST_ID]), (False, True, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID]), (True, False, [WASTES_ID, SNOW_FOREST_ID], [SNOW_FOREST_ID]), (True, True, [WASTES_ID, SNOW_FOREST_ID], []), (False, False, [FOREST_ID], []), (False, True, [FOREST_ID], []), (True, False, [FOREST_ID], []), (True, True, [FOREST_ID], []), ]) def test_remove_all_basic_lands( qtbot: QtBot, card_db: CardDatabase, remove_wastes: bool, remove_snow_basics: bool, present_cards: typing.List[str], expected_remaining: typing.List[str]): model = _populate_card_db_and_create_model(qtbot, card_db) model.add_cards(Counter( {card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards} )) model.remove_all_basic_lands(remove_wastes, remove_snow_basics) remaining = [row.card.scryfall_id for row in model.rows] assert_that( remaining, contains_exactly(*expected_remaining) ) |
Added tests/model/test_carddb.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import datetime import itertools from pathlib import Path import textwrap import typing import unittest.mock from unittest.mock import MagicMock from hamcrest import * import pytest import mtg_proxy_printer.settings from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData, MINIMUM_REFRESH_DELAY from mtg_proxy_printer.model.card import MTGSet, Card, CardList from mtg_proxy_printer.model.imagedb_files import CacheContent from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.print_count_updater import PrintCountUpdater from mtg_proxy_printer.document_controller.card_actions import ActionAddCard from mtg_proxy_printer.units_and_sizes import UUID from ..helpers import assert_model_is_empty, fill_card_database_with_json_card, \ fill_card_database_with_json_cards, is_dataclass_equal_to, matches_type_annotation, update_database_printing_filters from ..test_card_info_downloader import TestCaseData StringList = typing.List[str] OptString = typing.Optional[str] def test_has_data_on_empty_database_returns_false(card_db: CardDatabase): assert_model_is_empty(card_db) assert_that(card_db.has_data(), is_(False)) def test_has_data_on_filled_database_returns_true(qtbot, card_db: CardDatabase): fill_card_database_with_json_card(qtbot, card_db, "regular_english_card") assert_that(card_db.has_data(), is_(True)) def test_get_all_languages_without_data(card_db: CardDatabase): assert_that( card_db.get_all_languages(), is_(empty()) ) def test_get_all_languages_with_data(qtbot, card_db: CardDatabase): fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "german_Coercion_with_faulty_translation", "spanish_basic_Forest", "german_Duress", ], ) assert_that( card_db.get_all_languages(), contains_exactly("de", "en", "es") ) @pytest.mark.parametrize("language, prefix, expected_names", [ ("en", None, ["Forest", "Future Sight", "Duress", "Coercion"]), ("en", "Fu", ["Future Sight"]), ("en", "%or", ["Forest"]), ("en", "AAAAAAAA", []), ("en", "F%t", ["Forest", "Future Sight"]), ("de", None, ["Wald", "Zwang"]), # noqa # A German Forest and Duress ("es", None, ["Bosque"]), # noqa # A Spanish Forest ("Nonexisting language", None, []), ]) def test_get_card_names(qtbot, card_db: CardDatabase, language: str, prefix: OptString, expected_names: StringList): fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "english_basic_Forest", "english_basic_Forest_2", "english_card_Future_Sight_MH1", "english_card_Future_Sight_MTGO_promo", "german_Coercion_with_faulty_translation", "german_basic_Forest", "spanish_basic_Forest", "german_Duress", ], ) assert_that( card_db.get_card_names(language, prefix), contains_inanyorder(*expected_names) ) @pytest.mark.parametrize("name, expected", [ ("Forest", "en"), ("Future Sight", "en"), ("Wald", "de"), ("Bosque", "es"), ("Unknown", None), ("Mentor Corrosivo", "pt"), ("Mentor corrosivo", "es"), ]) def test_guess_language_from_name(qtbot, card_db: CardDatabase, name: str, expected: OptString): fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "english_basic_Forest", "english_basic_Forest_2", "english_card_Future_Sight_MH1", "english_card_Future_Sight_MTGO_promo", "german_Coercion_with_faulty_translation", "german_basic_Forest", "spanish_basic_Forest", "german_Duress", "korean_Forest_with_placeholder_name", "portuguese_Corrosive_Mentor", "spanish_Corrosive_Mentor", ], ) assert_that( card_db.guess_language_from_name(name), is_(equal_to(expected)) ) @pytest.mark.parametrize("language, expected", [ ("en", True), ("de", True), ("es", True), ("", False), ("Unknown", False), ]) def test_is_known_language(qtbot, card_db: CardDatabase, language: str, expected: bool): fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "english_basic_Forest", "english_basic_Forest_2", "english_card_Future_Sight_MH1", "english_card_Future_Sight_MTGO_promo", "german_Coercion_with_faulty_translation", "german_basic_Forest", "spanish_basic_Forest", "german_Duress", ], ) assert_that( card_db.is_known_language(language), is_(equal_to(expected)) ) @pytest.fixture def card_db_with_cards(qtbot, card_db: CardDatabase): fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "english_basic_Forest", "english_basic_Forest_2", "english_card_Future_Sight_MH1", "english_card_Future_Sight_MTGO_promo", "german_Coercion_with_faulty_translation", "german_basic_Forest", "spanish_basic_Forest", "german_Duress", "german_Duress_2", "english_Ironroot_Treefolk_1", "english_Ironroot_Treefolk_2", "english_Ironroot_Treefolk_3", "german_Ironroot_Treefolk_1", "german_Ironroot_Treefolk_2", "german_Ironroot_Treefolk_3", "oversized_card", "regular_english_card", "english_double_faced_card", "english_double_faced_art_series_card", "Flowerfoot_Swordmaster_card", "Flowerfoot_Swordmaster_token", ], ) yield card_db card_db.__dict__.clear() def generate_test_cases_for_test_translate_card_name(): """Yields tuples with card data, target language and expected result.""" # Same-language identity translation yield CardIdentificationData("en", "Forest"), "en", "Forest" yield CardIdentificationData("de", "Wald"), "de", "Wald" yield CardIdentificationData("es", "Bosque"), "es", "Bosque" # Guess source language yield CardIdentificationData(None, "Forest"), "en", "Forest" yield CardIdentificationData(None, "Wald"), "en", "Forest" yield CardIdentificationData(None, "Bosque"), "en", "Forest" yield CardIdentificationData(None, "Bosque"), "de", "Wald" yield CardIdentificationData(None, "Forest"), "de", "Wald" # translation with source language yield CardIdentificationData("de", "Wald"), "en", "Forest" yield CardIdentificationData("es", "Bosque"), "en", "Forest" # wrong source language. Returns no result yield CardIdentificationData("wrong_source", "Wald"), "en", None yield CardIdentificationData("wrong_source", "Forest"), "de", None yield CardIdentificationData("wrong_source", "Bosque"), "en", None yield CardIdentificationData("wrong_source", "Bosque"), "es", None # Card with name clash. Tests majority voting yield CardIdentificationData("de", "Zwang"), "en", "Duress" yield CardIdentificationData(None, "Zwang"), "en", "Duress" # Card with name clash. Tests using context information yields the expected name yield CardIdentificationData("de", "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress" yield CardIdentificationData(None, "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress" yield CardIdentificationData("de", "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion" yield CardIdentificationData(None, "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion" yield CardIdentificationData("de", "Zwang", "7ed"), "en", "Duress" yield CardIdentificationData(None, "Zwang", "7ed"), "en", "Duress" yield CardIdentificationData("de", "Zwang", "6ed"), "en", "Coercion" yield CardIdentificationData(None, "Zwang", "6ed"), "en", "Coercion" # Card with updated, localized name. Tests that all names can be a source name. yield CardIdentificationData("de", "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk" yield CardIdentificationData(None, "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk" yield CardIdentificationData("de", "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk" yield CardIdentificationData(None, "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk" yield CardIdentificationData("de", "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk" yield CardIdentificationData(None, "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk" # Card with updated, localized name. Tests returning the newest name without context information yield CardIdentificationData("en", "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler" yield CardIdentificationData(None, "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler" # Card with updated, localized name. Tests returning the correct name for the source set with context information yield CardIdentificationData("en", "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler" yield CardIdentificationData(None, "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler" yield CardIdentificationData("en", "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk" yield CardIdentificationData(None, "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk" yield CardIdentificationData("en", "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes" yield CardIdentificationData(None, "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes" yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler" yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler" yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk" yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk" yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes" yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes" # double-faced art series card. Same name on both sides yield CardIdentificationData("en", "Clearwater Pathway"), "en", "Clearwater Pathway" @pytest.mark.parametrize("card_data, target_language, expected", generate_test_cases_for_test_translate_card_name()) def test_translate_card_name( card_db_with_cards: CardDatabase, card_data: CardIdentificationData, target_language: str, expected: OptString): assert_that( card_db_with_cards.translate_card_name(card_data, target_language), is_(equal_to(expected)) ) @pytest.mark.parametrize("usage_count, expected", [ (-1, []), (0, []), (1, [2]), (2, [1, 2]), (3, [0, 1, 2]), (100, [0, 1, 2]), ]) def test_cards_used_less_often_then(qtbot, card_db: CardDatabase, usage_count: int, expected: typing.List[int]): # Setup fill_card_database_with_json_cards( qtbot, card_db, [ "english_Coercion", "english_Duress", "english_basic_Forest", "english_basic_Forest_2", "english_card_Future_Sight_MH1", "english_card_Future_Sight_MTGO_promo", "german_Coercion_with_faulty_translation", "german_basic_Forest", "spanish_basic_Forest", "german_Duress", ], ) document = Document(card_db, MagicMock()) document.apply(ActionAddCard( _get_card_from_model(card_db, "e2ef9b74-481b-424b-8e33-f0b910f66370", True), 1) ) PrintCountUpdater(document, card_db.db).run() document.apply(ActionAddCard( _get_card_from_model(card_db, "ffa13d4c-6c5e-44bd-859e-38e79d47a916", True), 1) ) PrintCountUpdater(document, card_db.db).run() # Test assert_that( result := card_db.cards_used_less_often_then([ ("e2ef9b74-481b-424b-8e33-f0b910f66370", True), ("ffa13d4c-6c5e-44bd-859e-38e79d47a916", True), ("cd4cf73d-a408-48f1-9931-54707553c5d5", True), ], usage_count), contains_exactly(*expected), f"Result: {result}" ) def _get_card_from_model(card_db: CardDatabase, scryfall_id: str, is_front: bool): card = card_db.get_card_with_scryfall_id(scryfall_id, is_front) assert_that(card, has_properties({ "scryfall_id": equal_to(scryfall_id), "is_front": equal_to(is_front), }), "Wrong card returned") return card @pytest.mark.parametrize("json_name, scryfall_id, expected", [ ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False), ("oversized_card", "650722b4-d72b-4745-a1a5-00a34836282b", True) ]) def test_card_is_oversized(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool): """ Tests that all methods creating Card instances correctly set is_oversized attribute. """ fill_card_database_with_json_card(qtbot, card_db, json_name) assert_that( card_db.get_card_with_scryfall_id(scryfall_id, True), has_property("is_oversized", is_(expected)) ) def generate_test_cases_for_test_get_cards_from_data(): case = TestCaseData("regular_english_card") yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),] yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),] case = TestCaseData("oversized_card") yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),] yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),] # Tests effect of is_front on double-faced cards case = TestCaseData("english_double_faced_card") yield CardIdentificationData(scryfall_id=case.scryfall_id), [ case.as_card(1), case.as_card(2), ] yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), [ case.as_card(1), ] yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), [ case.as_card(2), ] # Tests identification based on oracle_id alone. Also tests highres_image boolean forest_en_1 = TestCaseData("english_basic_Forest") forest_en_2 = TestCaseData("english_basic_Forest_2") forest_de = TestCaseData("german_basic_Forest") forest_es = TestCaseData("spanish_basic_Forest") yield CardIdentificationData(oracle_id="b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"), [ forest_en_1.as_card(), forest_en_2.as_card(), forest_de.as_card(), forest_es.as_card(), ] # Tests other attribute combinations yield CardIdentificationData(name="Bosque"), [ forest_es.as_card() ] yield CardIdentificationData(set_code="anb"), [ forest_en_1.as_card() ] yield CardIdentificationData("de", set_code="znr"), [ forest_de.as_card() ] yield CardIdentificationData(set_code="znr", collector_number="280"), [ forest_en_2.as_card(), forest_es.as_card(), ] # Empty result set yield CardIdentificationData(scryfall_id="invalid"), [] # Prefer cards to tokens with the same name yield CardIdentificationData(name="Flowerfoot Swordmaster"), [ TestCaseData("Flowerfoot_Swordmaster_card").as_card(), TestCaseData("Flowerfoot_Swordmaster_token").as_card(), ] @pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_cards_from_data()) def test_get_cards_from_data( card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: CardList): cards = card_db_with_cards.get_cards_from_data(card_data) for card in cards: assert_that(card, matches_type_annotation()) assert_that( cards, contains_inanyorder( *map(is_dataclass_equal_to, expected) ) ) @pytest.mark.parametrize("card_data, expected", [ (CardIdentificationData(name="Flowerfoot Swordmaster"), [ TestCaseData("Flowerfoot_Swordmaster_card").as_card(), TestCaseData("Flowerfoot_Swordmaster_token").as_card(), ]) ]) def test_get_cards_from_data_always_prefers_card_over_token( card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: CardList): cards = card_db_with_cards.get_cards_from_data(card_data) assert_that( cards, contains_exactly( *map(is_dataclass_equal_to, expected) ) ) def generate_test_cases_for_test_get_card_with_scryfall_id() -> \ typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]: # Regular card case = TestCaseData("regular_english_card") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card() # Back side of regular card returns None yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), None # Unknown scryfall_id returns None yield CardIdentificationData(scryfall_id="ueueueue-abcd-1234-5678-abcdefabcdef", is_front=True), None case = TestCaseData("oversized_card") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card() case = TestCaseData("german_basic_Forest") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card() case = TestCaseData("spanish_basic_Forest") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card() # Double-faced with high-res image case = TestCaseData("english_double_faced_card") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1) yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2) # Art series card case = TestCaseData("english_double_faced_art_series_card") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1) yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2) # Digital card case = TestCaseData("english_basic_Forest") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card() @pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_card_with_scryfall_id()) def test_get_card_with_scryfall_id( card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]): assert_that( card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front), is_(any_of( all_of( none(), instance_of(type(expected)) # None if and only if expected is None ), all_of( is_(instance_of(Card)), matches_type_annotation(), has_properties({ # Verifies that the expected card matches the given card identification data. # Not strictly required, but ensures that the test data is consistent "scryfall_id": card_data.scryfall_id, "is_front": card_data.is_front, }), is_dataclass_equal_to(expected), ))) ) @pytest.mark.parametrize("language", ["en", None]) @pytest.mark.parametrize("card_count_data, expected_index, identification_data", [ ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 2), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 1)], 0, CardIdentificationData(name="Forest")), ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 1), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 2)], 1, CardIdentificationData(name="Forest")), ]) def test_get_cards_from_data_order_by_print_count_enabled( qtbot, card_db: CardDatabase, language: OptString, card_count_data, expected_index: int, identification_data: CardIdentificationData): fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "english_basic_Forest_2"]) card_db.db.executemany( "INSERT INTO LastImageUseTimestamps (scryfall_id, is_front, usage_count) VALUES (?, 1, ?)", card_count_data ) identification_data.language = language cards = card_db.get_cards_from_data(identification_data, order_by_print_count=True) other_index = int(not expected_index) assert_that( cards, contains_exactly( has_property("scryfall_id", equal_to( card_count_data[expected_index][0] )), has_property("scryfall_id", equal_to( card_count_data[other_index][0] )), ) ) def test_get_replacement_card( qtbot, card_db: CardDatabase): fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "german_basic_Forest"]) card_db.db.executemany( textwrap.dedent("""\ INSERT INTO RemovedPrintings (scryfall_id, language, oracle_id) VALUES (?, ?, ?) """), [ ("english-id", "en", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"), ("german-id", "de", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc"), ("non-english-id", "invalid", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"), ]) card_db.get_replacement_card_for_unknown_printing(CardIdentificationData(scryfall_id="english-id", language="en")) def generate_test_cases_for_test__translate_card(): # Same-language translation for case in (TestCaseData("regular_english_card"), TestCaseData("regular_english_card")): yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card() for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")): yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card(1) yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=False), case.as_card(2) # Translate single-faced card forests = TestCaseData("english_basic_Forest_2"), TestCaseData("german_basic_Forest"), TestCaseData("spanish_basic_Forest") for source, target in itertools.product(forests, repeat=2): # type: TestCaseData, TestCaseData yield CardIdentificationData(target.language, scryfall_id=source.scryfall_id, is_front=True), target.as_card() # treefolk = ( (TestCaseData("german_Ironroot_Treefolk_1"), TestCaseData("english_Ironroot_Treefolk_1")), (TestCaseData("german_Ironroot_Treefolk_2"), TestCaseData("english_Ironroot_Treefolk_2")), (TestCaseData("german_Ironroot_Treefolk_3"), TestCaseData("english_Ironroot_Treefolk_3")), ) for card_1, card_2 in treefolk: yield CardIdentificationData(card_2.language, scryfall_id=card_1.scryfall_id, is_front=True), card_2.as_card() yield CardIdentificationData(card_1.language, scryfall_id=card_2.scryfall_id, is_front=True), card_1.as_card() @pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test__translate_card()) def test__translate_card(card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: Card): is_front = card_data.is_front is None or card_data.is_front to_translate = card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, is_front) # Use the private method to skip the internal shortcut in translate_card() # that skips requested same-language translations. assert_that( card_db_with_cards._translate_card(to_translate, expected.language), all_of( is_(Card), is_not(same_instance(to_translate)), # No shortcut taken, is actually a new instance matches_type_annotation(), is_dataclass_equal_to(expected), ) ) def generate_test_cases_for_test_get_opposing_face() -> \ typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]: # Single-faced cards for case in (TestCaseData("regular_english_card"), TestCaseData("oversized_card")): # The back side of a regular card does not exist, Expect None yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None # The other side of a non-existing back side of a regular card returns the existing front yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card() case = TestCaseData("split_card") yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None # FIXME: This returns None, but should return the first face of the front # yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1) # Double-faced cards for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")): yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(2) yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1) @pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_opposing_face()) def test_get_opposing_face( card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]): result = card_db_with_cards.get_opposing_face(card_data) if expected is None: assert_that(result, is_(none())) else: assert_that( result, is_(all_of( is_(instance_of(Card)), matches_type_annotation(), has_properties({ # Verifies that the expected card matches the given card identification data. # Not strictly required, but ensures that the test data is consistent "scryfall_id": card_data.scryfall_id, "is_front": not card_data.is_front, # Negation here }), is_dataclass_equal_to(expected), )) ) def test_allow_updating_card_data_on_empty_database_returns_true(card_db: CardDatabase): assert_that(card_db.allow_updating_card_data(), is_(True)) def test_allow_updating_card_data_on_freshly_populated_database_returns_false(qtbot, card_db: CardDatabase): fill_card_database_with_json_card(qtbot, card_db, "regular_english_card") assert_that(card_db.allow_updating_card_data(), is_(False)) @pytest.mark.parametrize("delta_days", [-2, -1, 0, 1, 2]) def test_allow_updating_card_data_on_stale_populated_database_returns_true( qtbot, card_db: CardDatabase, delta_days: int): fill_card_database_with_json_card(qtbot, card_db, "regular_english_card") today = datetime.datetime.today() now = today + MINIMUM_REFRESH_DELAY + datetime.timedelta(delta_days) fromisoformat = datetime.datetime.fromisoformat with unittest.mock.patch("mtg_proxy_printer.model.carddb.datetime.datetime") as mock_date: mock_date.today.return_value = now mock_date.fromisoformat = fromisoformat assert_that(datetime.datetime.today(), is_not(today)) assert_that( card_db.allow_updating_card_data(), is_(delta_days >= 0) ) def test_is_removed_printing_with_removed_printing_returns_true(qtbot, card_db: CardDatabase): fill_card_database_with_json_card(qtbot, card_db, "missing_image_double_faced_card") assert_that( card_db.is_removed_printing("b120e3c2-21b1-43e3-b685-9cf62bd7aa07"), is_(True) ) @pytest.mark.parametrize("filter_value", [True, False]) def test_is_removed_printing_with_included_printing_returns_false(qtbot, card_db: CardDatabase, filter_value: bool): fill_card_database_with_json_card(qtbot, card_db, "oversized_card", {"hide-oversized-cards": str(filter_value)}) assert_that( card_db.is_removed_printing("650722b4-d72b-4745-a1a5-00a34836282b"), is_(filter_value) ) @pytest.mark.parametrize("order_printings", [True, False]) @pytest.mark.parametrize("cards_to_import, filter_name, card_data, expected_replacement", [ (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData("en", scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"), (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData(scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"), (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData("de", scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"), (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"), ]) def test_get_replacement_card_for_unknown_printing( qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, card_data: CardIdentificationData, expected_replacement: str, order_printings: bool): fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"}) assert_that( card_db.get_replacement_card_for_unknown_printing(card_data, order_by_print_count=order_printings), all_of( not_(empty()), contains_exactly( has_property("scryfall_id", equal_to(expected_replacement)), ) ) ) @pytest.mark.parametrize("cards_to_import, filter_name, printing, expected", [ (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "b120e3c2-21b1-43e3-b685-9cf62bd7aa07", True), (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "d9131fc3-018a-4975-8795-47be3956160d", False), (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", True), (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "0600d6c2-0f72-4e79-a55d-1f06dffa48c2", False), ]) def test_is_removed_printing( qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, printing: str, expected: bool): fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"}) assert_that( card_db.is_removed_printing(printing), is_(expected) ) @pytest.mark.timeout(1) @pytest.mark.parametrize("include_wastes, include_snow_basics, expected_oracle_ids", [ (False, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"]), (True, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4"]), (False, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]), (True, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]), ]) def test_get_basic_land_oracle_ids( qtbot, card_db: CardDatabase, include_wastes: bool, include_snow_basics: bool, expected_oracle_ids: StringList): fill_card_database_with_json_cards( qtbot, card_db, ["english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"]) assert_that( card_db.get_basic_land_oracle_ids(include_wastes, include_snow_basics), contains_inanyorder(*expected_oracle_ids) ) @pytest.mark.parametrize("source_id, expected_cards_names", [ ("2c6e5b25-b721-45ee-894a-697de1310b8c", ["Food"]), # Bake into a Pie ("37e32ba6-108a-421f-9dad-3d03f7ebe239", []), # Food token ("e4b7e3b5-2f3c-4eb7-abc9-322a049a9e1a", []), # Food Token # Both printings of Asmoranomardicadaistinaculdacar ("d99a9a7d-d9ca-4c11-80ab-e39d5943a315", ["The Underworld Cookbook", "Food"]), ("2879f780-e17f-4e68-931e-6e45f9df28e1", ["The Underworld Cookbook", "Food"]), # The Underworld Cookbook ("4f24504e-b397-4b98-b8e8-8166457f7a2e", ["Asmoranomardicadaistinaculdacar", "Food"]), # Ring ("7215460e-8c06-47d0-94e5-d1832d0218af", []), # The Ring itself ("e3bb16a8-b248-4ad5-ba45-1ed499ca1411", ["The Ring"]), # Elrond ("fbc88c94-adf6-4699-a11e-24ebd16aac0c", ["The Ring"]), # Samwise # Venture ("6f509dbe-6ec7-4438-ab36-e20be46c9922", []), # Dungeon of the Mad Mage ("d4dbed36-190c-4748-b282-409a2fb5d134", ["Dungeon of the Mad Mage"]), # Zombie Ogre ("b9b1e53f-1384-4860-9944-e68922afc65c", ["Dungeon of the Mad Mage"]), # Bar the Gate # Initiative ("2c65185b-6cf0-451d-985e-56aa45d9a57d", []), # The Undercity ("0c4f76ae-e93b-4ca1-ac62-753707f6319e", ["Undercity"]), # Trailblazer's Torch ("0cbf06f5-d1c7-474c-8f09-72f5ad0c8120", ["Undercity"]), # Explore the Underdark ]) def test_find_related_printings(qtbot, card_db: CardDatabase, source_id: str, expected_cards_names: StringList): fill_card_database_with_json_cards( qtbot, card_db, [ "The_Underworld_Cookbook", "Food_Token", "Asmoranomardicadaistinaculdacar", "Bake_into_a_Pie", "Asmoranomardicadaistinaculdacar_2", "Food_Token_2", # The Ring emblem and "The Ring tempts you" "The_Ring", "Samwise_the_Stouthearted", "Elrond_Lord_of_Rivendell", # A Dungeon and "Venture into the dungeon" "Dungeon_of_the_Mad_Mage", "Bar_the_Gate", "Zombie_Ogre", # The "Undercity" dungeon and "Take the initiative." "Undercity", "Explore_the_Underdark", "Trailblazers_Torch", ]) source_card = card_db.get_card_with_scryfall_id(source_id, True) assert_that(source_card, is_(not_none()), "Setup failed") related = card_db.find_related_cards(source_card) assert_that( related, contains_inanyorder( *[has_property("name", equal_to(expected)) for expected in expected_cards_names] ), f"Found cards do not match {expected_cards_names}" ) def test_get_all_cards_from_image_cache(qtbot, card_db): fill_card_database_with_json_cards( qtbot, card_db, ["regular_english_card", "oversized_card"], {"hide-oversized-cards": str(True)}) cache_content = [ CacheContent("650722b4-d72b-4745-a1a5-00a34836282b", True, True, Path()), # Atraxa CacheContent("0000579f-7b35-4ed3-b44c-db2a538066fe", True, True, Path()), # Fury Sliver CacheContent("abcdeabc-abcd-abcd-abcd-efghijklmnop", True, True, Path()), # Non-existing ] assert_that( card_db.get_all_cards_from_image_cache(cache_content), contains_exactly( contains_exactly(contains_exactly( has_property("name", equal_to("Fury Sliver")), cache_content[1])), contains_exactly(contains_exactly( has_property("name", equal_to("Atraxa, Praetors' Voice")), cache_content[0])), contains_exactly(cache_content[-1]), ) ) @pytest.mark.parametrize("json_name, scryfall_id, expected", [ ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False), ("english_double_faced_card", "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True), ]) def test_is_dfc(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool): fill_card_database_with_json_card(qtbot, card_db, json_name) assert_that( card_db.is_dfc(scryfall_id), is_(equal_to(expected)) ) @pytest.mark.parametrize("card_data, filter_enabled, expected", [ # Forests. All source languages return all available languages (CardIdentificationData(scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True), False, ["de", "en", "es"]), (CardIdentificationData(scryfall_id="ffa13d4c-6c5e-44bd-859e-38e79d47a916", is_front=True), False, ["de", "en", "es"]), (CardIdentificationData(scryfall_id="cd4cf73d-a408-48f1-9931-54707553c5d5", is_front=True), False, ["de", "en", "es"]), # The mis-translated German Coercion cannot be translated, as the English Coercion is not in the imported test data (CardIdentificationData(scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0", is_front=True), False, ["de"]), # English/German Duress can be translated (CardIdentificationData(scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3", is_front=True), False, ["de", "en"]), (CardIdentificationData(scryfall_id="15c8d82e-6e65-4d36-bf09-b24dde016581", is_front=True), False, ["de", "en"]), # English Back to Basics only finds English if card filters are active (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), True, ["en"]), (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), False, ["de", "en"]), # German, hidden printing of Back to Basics also round-trips the current language (CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), True, ["de", "en"]), ]) def test_get_available_languages_for_card( qtbot, card_db, card_data: CardIdentificationData, filter_enabled: bool, expected: StringList): fill_card_database_with_json_cards(qtbot, card_db, [ "english_basic_Forest", "german_basic_Forest", "spanish_basic_Forest", "german_Coercion_with_faulty_translation", "german_Duress", "english_Duress", "english_Back_to_Basics", "german_Back_to_Basics", ]) card = card_db.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front) assert_that(card, is_(not_none()), "Setup failed, card not found") if filter_enabled: filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]} update_database_printing_filters(card_db, filters) assert_that( card_db.get_available_languages_for_card(card), all_of(has_length(len(expected)), contains_exactly(*expected)) ) def test_get_card_from_data_prefers_highres_images_over_newer_lowres_printings(qtbot, card_db): fill_card_database_with_json_cards( qtbot, card_db, ["english_basic_Forest_2", "English_basic_Forest_newest_and_low_res"] ) assert_that( card_db.get_cards_from_data(CardIdentificationData(name="Forest")), contains_exactly( has_properties( language="en", name="Forest", set=has_property("name", "Zendikar Rising"), scryfall_id="e2ef9b74-481b-424b-8e33-f0b910f66370", is_front=True, highres_image=True, ), has_properties( language="en", name="Forest", set=has_property("name", "Doctor Who"), scryfall_id="15b3f35e-451e-4de6-a4f7-249287566964", is_front=True, highres_image=False, ), ) ) @pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [ # Result set with size > 1. Return sets in release order. # Also, these three cards have three different printed names (["german_Ironroot_Treefolk_1", "german_Ironroot_Treefolk_2", "german_Ironroot_Treefolk_3"], "2520cb2b-47f2-4fb3-a9e7-17ad135562c8", False, [MTGSet("3ed", "Revised Edition"), MTGSet("4ed", "Fourth Edition"), MTGSet("5ed", "Fifth Edition")]), # De-duplicate results (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"], "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False, [MTGSet("mh2", "Modern Horizons 2")]), # Only offer sets the card is available in the same language as the source (["english_Back_to_Basics", "german_Back_to_Basics"], "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", False, [MTGSet("usg", "Urza's Saga")]), # 1/1 colorless Spirit token offers both TNEO and TC16 (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"], "5009729f-6365-42ca-979f-d854a10e463b", False, [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]), (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"], "ca20548f-6324-4858-adbe-87303ff1ca52", False, [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]), # 4/5 green Spirit token from TNEO only offers TNEO (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"], "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False, [MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]), # The first of these has placeholder images, making it affected by a printing filter (["german_Duress", "german_Duress_2"], "920e8a8f-3cb4-4f33-8a71-f2524cf63aaf", True, # ID of the second printing from MID [MTGSet("mid", "Innistrad: Midnight Hunt")]), # Data of hidden printings present in the document must round-trip. # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it. (["german_Duress", "german_Duress_2"], "51c6ec30-afb2-41e6-895b-92e070aa86f3", True, # ID of the first printing from 7th Edition [MTGSet("7ed", "Seventh Edition"), MTGSet("mid", "Innistrad: Midnight Hunt")]), (["german_Duress"], "51c6ec30-afb2-41e6-895b-92e070aa86f3", True, [MTGSet("7ed", "Seventh Edition")]), ]) def test_get_available_sets_for_card( qtbot, card_db, jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: typing.List[MTGSet]): fill_card_database_with_json_cards(qtbot, card_db, jsons) card = card_db.get_card_with_scryfall_id(scryfall_id, True) if filter_enabled: filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]} update_database_printing_filters(card_db, filters) assert_that(card, is_(not_none()), "Test setup failed, card not found") fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty() assert_that(card_db.get_available_sets_for_card(card), fulfills_matcher) @pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [ # Actual two variants in the same set (regular & extended art) (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"], "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False, ["186", "463"]), # With enabled filters, the extended art variant is unavailable, thus should not be suggested (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"], "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", True, ["186"]), # The German, regular card should not find the collector number of the English extended art variant. (["Asmoranomardicadaistinaculdacar_German", "Asmoranomardicadaistinaculdacar_2"], "e710a21a-65eb-4106-a379-57a86fb9e6c6", False, ["186"]), # The 1/1 Spirit token in TNEO has number 2 (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"], "ca20548f-6324-4858-adbe-87303ff1ca52", False, ["2"]), # 4/5 green Spirit token in TNEO has number 11 (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"], "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False, ["11"]), # Data of hidden printings present in the document must round-trip. # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it. (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"], "2879f780-e17f-4e68-931e-6e45f9df28e1", True, ["186", "463"]), (["german_Duress", "german_Duress_2"], "51c6ec30-afb2-41e6-895b-92e070aa86f3", True, ["131"]), (["german_Duress"], "51c6ec30-afb2-41e6-895b-92e070aa86f3", True, ["131"]), ]) def test_get_available_collector_numbers_for_card_in_set( qtbot, card_db, jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: StringList): fill_card_database_with_json_cards(qtbot, card_db, jsons) card = card_db.get_card_with_scryfall_id(scryfall_id, True) assert_that(card, is_(not_none()), "Setup failed. Card not found") if filter_enabled: filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]} update_database_printing_filters(card_db, filters) fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty() assert_that(card_db.get_available_collector_numbers_for_card_in_set(card), fulfills_matcher) |
Added tests/model/test_document.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import copy import typing import unittest.mock from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap from hamcrest import * from hamcrest import contains_exactly import pytest from pytestqt.qtbot import QtBot from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, CardSize from mtg_proxy_printer.model.card import MTGSet, Card from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import PageColumns from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.document_controller import DocumentAction from mtg_proxy_printer.document_controller.new_document import ActionNewDocument from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.document_controller.card_actions import ActionAddCard from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings from tests.document_controller.helpers import append_new_card_in_page from ..document_controller.helpers import insert_card_in_page, create_card ItemDataRole = Qt.ItemDataRole mm: UnitT = unit_registry.mm REGULAR = CardSizes.REGULAR OVERSIZED = CardSizes.OVERSIZED class DummyAction(DocumentAction): """A dummy DocumentAction that does nothing. apply() and undo() are replaced with MagicMock instances.""" apply: unittest.mock.MagicMock undo: unittest.mock.MagicMock COMPARISON_ATTRIBUTES = ["value"] def __init__(self, value: int = 0): super().__init__() self.value = value self.apply = unittest.mock.MagicMock(return_value=self) self.undo = unittest.mock.MagicMock(return_value=self) @property def as_str(self): return f"Value: {self.value}" @pytest.mark.parametrize("first, second, matcher", [ (1, 1, is_), (0, 1, is_not), ]) def test_dummy_action_eq(first: int, second: int, matcher): a_1 = DummyAction(first) a_2 = DummyAction(second) assert_that(a_1, is_(equal_to(a_1))) assert_that(a_2, is_(equal_to(a_2))) assert_that(a_1, matcher(equal_to(a_2))) def assert_unused(action: DummyAction): action.apply.assert_not_called() action.undo.assert_not_called() def assert_applied(action: DummyAction, document: Document): action.apply.assert_called_once_with(document) action.undo.assert_not_called() def assert_undone(action: DummyAction, document: Document): action.apply.assert_not_called() action.undo.assert_called_once_with(document) def test_apply_on_empty_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document): action = DummyAction() with qtbot.wait_signals([document_light.undo_available_changed, document_light.action_applied], timeout=1000), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.apply(action) assert_that(document_light.redo_stack, is_(empty())) assert_that(document_light.undo_stack, contains_exactly(action)) assert_applied(action, document_light) def test_apply_on_empty_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(redo_dummy := DummyAction(1)) action = DummyAction(0) with qtbot.wait_signals([ document_light.undo_available_changed, document_light.redo_available_changed, document_light.action_applied], timeout=1000), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.apply(action) assert_that(document_light.redo_stack, is_(empty())) assert_that(document_light.undo_stack, contains_exactly(action)) assert_unused(redo_dummy) assert_applied(action, document_light) def test_apply_on_filled_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document): document_light.undo_stack.append(previous_action := DummyAction()) action = DummyAction() with qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone), \ qtbot.wait_signal(document_light.action_applied, timeout=1000): document_light.apply(action) assert_that(document_light.redo_stack, is_(empty())) assert_that(document_light.undo_stack, contains_exactly(previous_action, action)) assert_unused(previous_action) assert_applied(action, document_light) def test_apply_on_filled_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document): document_light.undo_stack.append(previous_action := DummyAction()) document_light.redo_stack.append(redo_dummy := DummyAction(1)) action = DummyAction(0) expected_signals = [document_light.redo_available_changed, document_light.action_applied] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.apply(action) assert_that(document_light.redo_stack, is_(empty())) assert_that(document_light.undo_stack, contains_exactly(previous_action, action)) assert_unused(redo_dummy) assert_unused(previous_action) assert_applied(action, document_light) def test_apply_same_action_as_on_redo_stack_does_keep_remaining_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(should_stay := DummyAction(2)) document_light.redo_stack.append(DummyAction(1)) action = DummyAction(1) expected_signals = [document_light.undo_available_changed, document_light.action_applied] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.apply(action) assert_that(document_light.redo_stack, contains_exactly(should_stay)) assert_that(document_light.undo_stack, contains_exactly(action)) def test_undo_on_empty_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document): document_light.undo_stack.append(first := DummyAction()) document_light.undo_stack.append(second := DummyAction()) expected_signals = [document_light.redo_available_changed, document_light.action_undone,] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.action_applied): document_light.undo() assert_that(document_light.undo_stack, contains_exactly(first)) assert_that(document_light.redo_stack, contains_exactly(second)) assert_unused(first) assert_undone(second, document_light) def test_undo_on_empty_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document): document_light.undo_stack.append(first := DummyAction()) expected_signals = [ document_light.redo_available_changed, document_light.undo_available_changed, document_light.action_undone, ] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.action_applied): document_light.undo() assert_that(document_light.undo_stack, is_(empty())) assert_that(document_light.redo_stack, contains_exactly(first)) assert_undone(first, document_light) def test_undo_on_filled_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(redo_dummy := DummyAction()) document_light.undo_stack.append(first := DummyAction()) expected_signals = [document_light.undo_available_changed, document_light.action_undone,] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_applied): document_light.undo() assert_that(document_light.undo_stack, is_(empty())) assert_that(document_light.redo_stack, contains_exactly(redo_dummy, first)) assert_unused(redo_dummy) assert_undone(first, document_light) def test_undo_on_filled_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(redo_dummy := DummyAction()) document_light.undo_stack.append(first := DummyAction()) document_light.undo_stack.append(second := DummyAction()) with qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_applied), \ qtbot.wait_signal(document_light.action_undone, timeout=1000): document_light.undo() assert_that(document_light.undo_stack, contains_exactly(first)) assert_that(document_light.redo_stack, contains_exactly(redo_dummy, second)) assert_unused(redo_dummy) assert_unused(first) assert_undone(second, document_light) def test_redo_on_empty_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(first := DummyAction()) expected_signals = [ document_light.undo_available_changed, document_light.redo_available_changed, document_light.action_applied ] with qtbot.wait_signals( expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.redo() assert_that(document_light.redo_stack, is_(empty())) assert_that(document_light.undo_stack, contains_exactly(first)) assert_applied(first, document_light) def test_redo_on_empty_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(first := DummyAction()) document_light.redo_stack.append(second := DummyAction()) expected_signals = [document_light.undo_available_changed, document_light.action_applied] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.redo() assert_that(document_light.redo_stack, contains_exactly(first)) assert_that(document_light.undo_stack, contains_exactly(second)) assert_unused(first) assert_applied(second, document_light) def test_redo_on_filled_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(first := DummyAction()) document_light.undo_stack.append(undo_dummy := DummyAction()) expected_signals = [document_light.redo_available_changed, document_light.action_applied] with qtbot.wait_signals(expected_signals, timeout=1000), \ qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone): document_light.redo() assert_that(document_light.undo_stack, contains_exactly(undo_dummy, first)) assert_that(document_light.redo_stack, is_(empty())) assert_unused(undo_dummy) assert_applied(first, document_light) def test_redo_on_filled_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document): document_light.redo_stack.append(first := DummyAction()) document_light.redo_stack.append(second := DummyAction()) document_light.undo_stack.append(undo_dummy := DummyAction()) with qtbot.assert_not_emitted(document_light.undo_available_changed), \ qtbot.assert_not_emitted(document_light.redo_available_changed), \ qtbot.assert_not_emitted(document_light.action_undone), \ qtbot.wait_signal(document_light.action_applied, timeout=1000): document_light.redo() assert_that(document_light.undo_stack, contains_exactly(undo_dummy, second)) assert_that(document_light.redo_stack, contains_exactly(first)) assert_unused(undo_dummy) assert_unused(first) assert_applied(second, document_light) @pytest.mark.parametrize("additional_pages", range(3)) def test_rowCount_without_index_parameter_return_page_count(document_light, additional_pages: int): if additional_pages: document_light.apply(ActionNewPage(count=additional_pages)) assert_that(document_light.pages, has_length(1+additional_pages), "Test setup failed") assert_that(document_light.rowCount(), is_(1+additional_pages), "Wrong rowCount() returned") def test_rowCount_with_valid_index_returns_card_count_on_page_given_by_index(document_light): document_light.apply(ActionNewPage(count=3)) for count in range(1, 4): document_light.set_currently_edited_page(document_light.pages[count]) card = Card("", MTGSet("", ""), "", "", "", True, "", "", True, REGULAR, 0, None) document_light.apply(ActionAddCard(card, count=count)) assert_that(document_light.currently_edited_page, has_length(count), "Test setup failed") for page in range(4): assert_that( document_light.rowCount(document_light.index(page, 0)), is_(equal_to(page)), f"Wrong rowCount() returned for page {page}" ) @pytest.mark.parametrize("page_type, parent_row, child_rows", [ (PageType.REGULAR, 0, [0]), (PageType.OVERSIZED, 2, [0, 1]), ]) def test_get_card_indices_of_type(document_light, page_type: PageType, parent_row: int, child_rows: typing.List[int]): ActionNewPage(count=2).apply(document_light) append_new_card_in_page(document_light.pages[0], "Normal", REGULAR) append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED) append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED) indices = list(document_light.get_card_indices_of_type(page_type)) assert_that(indices, has_length(len(child_rows))) for index, expected_row in zip(indices, child_rows): assert_that(index.row(), is_(expected_row)) assert_that(index.parent().row(), is_(parent_row)) card: Card = index.data(ItemDataRole.UserRole) assert_that(card.requested_page_type(), is_(page_type)) @pytest.fixture def document_custom_layout(document: Document) -> Document: custom_layout = PageLayoutSettings( page_height=300*mm, page_width=200*mm, margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm, row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm, draw_cut_markers=True, draw_sharp_corners=False, ) document.apply(ActionEditDocumentSettings(custom_layout)) yield document document.__dict__.clear() def test_document_reset_clears_modified_page_layout(qtbot: QtBot, page_layout: PageLayoutSettings, document_custom_layout: Document): assert_that( document_custom_layout, has_property("page_layout", not_(equal_to(page_layout))) ) assert_that( document_custom_layout.page_layout.compute_page_row_count(), is_not(equal_to(page_layout.compute_page_card_capacity())), "Test setup failed." ) with qtbot.waitSignal(document_custom_layout.page_layout_changed, timeout=1000): document_custom_layout.apply(ActionNewDocument()) assert_that( document_custom_layout, has_property("page_layout", equal_to(page_layout)) ) def test_document_is_created_empty(document_light: Document): capacity = document_light.page_layout.compute_page_card_capacity() assert_that(capacity, is_(greater_than_or_equal_to(1))) assert_that(document_light.rowCount(), is_(equal_to(1)), "Expected creation of a single, empty page.") assert_that( document_light.pages, contains_exactly(empty()), "Expected creation of a single, empty page." ) assert_that( document_light.rowCount(document_light.index(0, 0)), is_(equal_to(0)), "Expected empty page, but it is not empty") assert_that( document_light.pages[0].page_type(), is_(PageType.UNDETERMINED), "Empty page should have an undetermined page type" ) @pytest.mark.parametrize("size", [REGULAR, OVERSIZED]) def test_get_missing_image_cards(document_light: Document, size: CardSize): blank_image = document_light.image_db.get_blank(size) expected = create_card("Placeholder Image", size, "https://someurl", blank_image) # Create a new, distinct image by copying the blank image unexpected = create_card("Other Image", size, "", QPixmap(blank_image)) document_light.apply(ActionAddCard(expected, 2)) document_light.apply(ActionAddCard(unexpected, 2)) assert_that( result := list(document_light.get_missing_image_cards()), has_length(2) ) cards = [i.data(ItemDataRole.UserRole) for i in result] assert_that(cards, only_contains(expected)) @pytest.mark.parametrize("size", [REGULAR, OVERSIZED]) @pytest.mark.parametrize("result", [True, False]) def test_has_missing_images(document_light: Document, result: bool, size: CardSize): blank_image = document_light.image_db.get_blank(CardSizes.REGULAR) blank_image_card = create_card("Placeholder Image", size, "https://someurl", blank_image) # Create a new, distinct image by copying the blank image other_card = create_card("Other Image", size, "", QPixmap(blank_image)) if result: document_light.apply(ActionAddCard(blank_image_card, 2)) document_light.apply(ActionAddCard(other_card, 2)) assert_that( document_light.has_missing_images(), is_(result) ) @pytest.mark.parametrize("pages_content, expected", [ ([], 0), ([None, None], 1), ([create_card("Regular", REGULAR)], 0), ([create_card("Regular", REGULAR)]*2, 1), ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)], 0), ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)]*2, 2), ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED), None]*2, 4), ]) def test_compute_pages_saved_by_compacting( document_light: Document, pages_content: typing.List[typing.Optional[Card]], expected: int): if len(pages_content) > 1: document_light.apply(ActionNewPage(count=len(pages_content)-1)) for page, card in zip(document_light.pages, pages_content): if card is not None: insert_card_in_page(page, card) assert_that( document_light.compute_pages_saved_by_compacting(), is_(equal_to(expected)) ) def test_update_page_layout_copies_the_passed_in_instance(document_light: Document): layout = copy.copy(document_light.page_layout) layout.row_spacing = 1*mm document_light.apply(ActionEditDocumentSettings(layout)) layout.row_spacing = 2*mm assert_that(document_light.page_layout, has_property("row_spacing", equal_to(1*mm))) @pytest.mark.parametrize("invalid_page_row", [2]) def test_document__data_page_logs_error_on_invalid_index(document_light, invalid_page_row: int): index = document_light.createIndex(invalid_page_row, 0, None) with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock: assert_that(document_light._data_page(index), is_(None)) logger_mock.assert_called_once() @pytest.mark.parametrize("invalid_card_row", [2]) def test_document__data_card_logs_error_on_invalid_index_row(document_light, invalid_card_row: int): append_new_card_in_page(document_light.pages[0], "Card") index = document_light.createIndex(invalid_card_row, 0, document_light.pages[0][0]) with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock: assert_that(document_light._data_card(index), is_(None)) logger_mock.assert_called_once() @pytest.mark.parametrize("invalid_card_column", [len(PageColumns)]) def test_document__data_card_logs_error_on_invalid_index_column(document_light, invalid_card_column: int): append_new_card_in_page(document_light.pages[0], "Card") index = document_light.createIndex(0, invalid_card_column, document_light.pages[0][0]) with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock: assert_that(document_light._data_card(index), is_(None)) logger_mock.assert_called_once() |
Added tests/model/test_document_loader.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import contextlib from itertools import chain, repeat, product from pathlib import Path import sqlite3 import unittest.mock import textwrap import pint from pytestqt.qtbot import QtBot import pytest from hamcrest import * import mtg_proxy_printer.model.document_loader from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument from tests.helpers import quantity_close_to from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, QuantityT from mtg_proxy_printer.model.card import CheckCard import mtg_proxy_printer.sqlite_helpers from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.document_page import PageColumns from mtg_proxy_printer.model.page_layout import PageLayoutSettings from tests.helpers import create_save_database_with CardType = mtg_proxy_printer.model.document_loader.CardType mm: UnitT = unit_registry.mm @pytest.fixture() def page_layout() -> PageLayoutSettings: page_layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings( page_height=300*mm, page_width=200*mm, margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm, row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm, draw_cut_markers=True, draw_sharp_corners=False, draw_page_numbers=True ) assert_that( page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)), "Setup failed" ) return page_layout @pytest.mark.parametrize("user_version", [-1, 0, 1, 8, 9]) def test_unknown_save_version_raises_exception(empty_save_database: sqlite3.Connection, user_version: int): empty_save_database.execute(f"PRAGMA user_version = {user_version};") assert_that(empty_save_database.execute("PRAGMA user_version").fetchone()[0], is_(user_version)) worker = mtg_proxy_printer.model.document_loader.Worker with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock: mock.return_value = empty_save_database assert_that( calling(worker._open_validate_and_migrate_save_file).with_args(Path()), raises(AssertionError) ) mock.assert_called_once() def assert_document_is_empty(document: Document): assert_that(document.rowCount(), is_(equal_to(1))) page_index = document.index(0, 0) assert_that(page_index.isValid()) assert_that(document.rowCount(page_index), is_(0)) @contextlib.contextmanager def disabled_check_constraints(db: sqlite3.Connection): """ Instruct SQLite3 to ignore the SQL CHECK constraints defined in the database schema for a limited timeframe. """ db.execute("PRAGMA ignore_check_constraints = TRUE;") yield db db.execute("PRAGMA ignore_check_constraints = FALSE;") def test_document_with_card_loads_correctly( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection, page_layout: PageLayoutSettings): create_save_database_with( empty_save_database, [(1, CardSizes.REGULAR)], [(1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR)], page_layout ) loader = document.loader save_path = Path("/tmp/invalid.mtgproxies") with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database) as open_database, \ qtbot.waitSignals( [loader.loading_state_changed]*2, check_params_cbs=[(lambda value: value), (lambda value: not value)]),\ qtbot.waitSignals([loader.finished, loader.load_requested, document.page_layout_changed]): loader.load_document(save_path) open_database.assert_called_once() assert_that(document.rowCount(), is_(equal_to(1))) page_index = document.index(0, 0) assert_that(page_index.isValid()) assert_that(document.rowCount(page_index), is_(1)) assert_that( document.index(0, PageColumns.CardName, page_index).data(), is_("Fury Sliver") ) assert_that(document.save_file_path, is_(equal_to(save_path))) assert_that(document.page_layout, is_(equal_to(page_layout))) def test_empty_document_loads_correctly( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection, page_layout: PageLayoutSettings): create_save_database_with(empty_save_database, [], [], page_layout) loader = document.loader save_path = Path("/tmp/invalid.mtgproxies") with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock: mock.return_value = empty_save_database with qtbot.waitSignals([loader.loading_state_changed]*2, check_params_cbs=[(lambda value: value), (lambda value: not value)]), \ qtbot.waitSignals([loader.load_requested, document.page_layout_changed]), \ qtbot.assert_not_emitted(loader.loading_file_failed): loader.load_document(save_path) mock.assert_called_once() assert_that(document.rowCount(), is_(equal_to(1))) page_index = document.index(0, 0) assert_that(page_index.isValid()) assert_that(document.rowCount(page_index), is_(0)) assert_that(document.save_file_path, is_(equal_to(save_path))) assert_that(document.page_layout, is_(equal_to(page_layout))) def test_document_with_mixed_pages_distributes_cards_based_on_size( qtbot: QtBot, document: Document, page_layout: PageLayoutSettings, empty_save_database: sqlite3.Connection): create_save_database_with( empty_save_database, [(1, CardSizes.REGULAR)], [ (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR), (1, 2, True, "650722b4-d72b-4745-a1a5-00a34836282b", CardType.REGULAR), ], page_layout ) loader = document.loader save_path = Path("/tmp/invalid.mtgproxies") with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database) as open_database, \ qtbot.waitSignals( [loader.loading_state_changed] * 2, check_params_cbs=[(lambda value: value), (lambda value: not value)]), \ qtbot.waitSignals([loader.load_requested]): loader.load_document(save_path) open_database.assert_called_once() assert_that(document.rowCount(), is_(2)) total_cards = 0 for page in document.pages: assert_that(page.page_type(), is_in([PageType.OVERSIZED, PageType.REGULAR])) total_cards += len(page) assert_that(total_cards, is_(2)) @pytest.mark.parametrize("data", chain( # Syntactically invalid zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)), zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)), zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)), zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.REGULAR)), zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)), zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)), zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)), zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.CHECK_CARD)), # Semantically invalid, as type "d" it means generating a DFC check card for a single sided card. zip(repeat(1), repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD]), zip(repeat(1), repeat(1), repeat(0), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD]), )) def test_invalid_data_in_card_columns_raises_exception( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection, data): # Replace the Card table with one that has no implicit type casting empty_save_database.execute("DROP TABLE Card") empty_save_database.execute("CREATE TABLE Card (page BLOB, slot BLOB, is_front BLOB, scryfall_id BLOB, type BLOB)") empty_save_database.execute('INSERT INTO Card (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', data) assert_that( empty_save_database.execute("SELECT page, slot, is_front, scryfall_id, type FROM Card").fetchall(), contains_exactly(equal_to(data)), "Setup failed: Data mismatch" ) loader = document.loader with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database) as open_database, \ qtbot.waitSignal(loader.loading_file_failed, raising=True), \ qtbot.assertNotEmitted(loader.load_requested): loader.load_document(Path("/tmp/invalid.mtgproxies")) open_database.assert_called_once() assert_document_is_empty(document) assert_that(document.save_file_path, is_(none())) def test_protects_against_infinite_save_data( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection): empty_save_database.execute("DROP TABLE Card") # LIMIT clause in the definition below is a safety measure. empty_save_database.execute(textwrap.dedent("""\ CREATE VIEW Card (page, slot, scryfall_id, is_front) AS WITH RECURSIVE card_gen (page, slot, scryfall_id, is_front) AS ( SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1 UNION ALL SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1 FROM card_gen LIMIT 100000 ) SELECT * FROM card_gen """)) loader = document.loader with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database) as open_database, \ qtbot.waitSignal(loader.loading_file_failed, raising=True), \ qtbot.assertNotEmitted(loader.load_requested): loader.load_document(Path("/tmp/invalid.mtgproxies")) open_database.assert_called_once() assert_document_is_empty(document) assert_that(document.save_file_path, is_(none())) def generate_test_cases_for_test_protects_against_infinite_settings_data(): # LIMIT clause in the definitions below are safety measures. yield 4, textwrap.dedent("""\ CREATE VIEW DocumentSettings ( rowid, page_height, page_width, margin_top, margin_bottom, margin_left, margin_right, image_spacing_horizontal, image_spacing_vertical, draw_cut_markers) AS WITH RECURSIVE settings_gen ( rowid, page_height, page_width, margin_top, margin_bottom, margin_left, margin_right, image_spacing_horizontal, image_spacing_vertical, draw_cut_markers ) AS ( SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1 UNION ALL SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1 FROM settings_gen LIMIT 100000 ) SELECT * FROM settings_gen """) yield 5, textwrap.dedent("""\ CREATE VIEW DocumentSettings ( rowid, page_height, page_width, margin_top, margin_bottom, margin_left, margin_right, image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners) AS WITH RECURSIVE settings_gen ( rowid, page_height, page_width, margin_top, margin_bottom, margin_left, margin_right, image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners ) AS ( SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1 UNION ALL SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1 FROM settings_gen LIMIT 100000 ) SELECT * FROM settings_gen """) yield 6, textwrap.dedent("""\ CREATE VIEW DocumentSettings (key, value) AS WITH RECURSIVE settings_gen ( key, value ) AS ( SELECT 'key', 'something' UNION ALL SELECT 'key', 'something' FROM settings_gen LIMIT 100000 ) SELECT * FROM settings_gen """) @pytest.mark.parametrize("user_version, script", generate_test_cases_for_test_protects_against_infinite_settings_data()) def test_protects_against_infinite_settings_data( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection, user_version: int, script: str): empty_save_database.execute(f"PRAGMA user_version = {user_version}") empty_save_database.execute("DROP TABLE DocumentSettings") empty_save_database.execute(script) loader = document.loader with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock: mock.return_value = empty_save_database with qtbot.waitSignal(loader.loading_file_failed, raising=True), \ qtbot.assertNotEmitted(loader.load_requested): loader.load_document(Path("/tmp/invalid.mtgproxies")) mock.assert_called_once() assert_document_is_empty(document) assert_that(document.save_file_path, is_(none())) def test_cancelling_loading_does_not_crash( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection): create_save_database_with( empty_save_database, [(1, CardSizes.REGULAR)], [ (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR), (1, 2, True, "650722b4-d72b-4745-a1a5-00a34836282b", CardType.REGULAR), ], document.page_layout ) loader = document.loader loader.begin_loading_loop.connect(loader.cancel) with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database) as open_database, \ qtbot.wait_signals([ loader.begin_loading_loop, loader.progress_loading_loop, loader.loading_state_changed, loader.finished], timeout=100): loader.load_document(Path("/tmp/invalid.mtgproxies")) open_database.assert_called_once() def test_loads_check_card( qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection): create_save_database_with( empty_save_database, [(1, CardSizes.REGULAR)], [(1, 1, True, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", CardType.CHECK_CARD)], document.page_layout ) loader = document.loader with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as open_database: open_database.return_value = empty_save_database with qtbot.wait_signal(document.action_applied), \ qtbot.assert_not_emitted(loader.loading_file_failed): loader.load_document(Path("/tmp/invalid.mtgproxies")) assert_that( document.pages, contains_exactly( contains_exactly(has_property("card", all_of( instance_of(CheckCard), has_properties({ "image_file": not_none(), "name": "Growing Rites of Itlimoc // Itlimoc, Cradle of the Sun", "is_front": True, "is_dfc": False, }) ))) ) ) @pytest.fixture(params=[ (4, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1]), (5, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1, 0]), # Only old image spacing keys present (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0), ("image_spacing_horizontal", 2), ("image_spacing_vertical", 3), ("margin_top", 4), ("margin_bottom", 5), ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150)]), # Old and new image spacing keys present. This should never exist in the wild. Ensure that the new keys are used. (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0), ("image_spacing_horizontal", 8), ("image_spacing_vertical", 9), ("margin_top", 4), ("margin_bottom", 5), ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150), ("row_spacing", 2), ("column_spacing", 3)]), ]) def legacy_save_file(request, tmp_path: Path): save = tmp_path/"save.mtxproxies" save_version, settings = request.param # type: int, list db = mtg_proxy_printer.sqlite_helpers.open_database(save, f"document-v{save_version}", False) db.execute("BEGIN IMMEDIATE TRANSACTION") if save_version < 6: db.execute(f"INSERT INTO DocumentSettings VALUES ({', '.join('?'*len(settings))})", settings) elif save_version == 6: db.executemany("INSERT INTO DocumentSettings (key, value) VALUES (?, ?)", settings) else: pass db.commit() db.close() return save def test_load_settings_from_legacy_save_file_is_successful( qtbot: QtBot, legacy_save_file: Path, document: Document): loader = document.loader with qtbot.wait_signal(document.action_applied), \ qtbot.assert_not_emitted(loader.loading_file_failed): loader.load_document(legacy_save_file) annotations = document.page_layout.__annotations__ assert_that( document.page_layout, has_properties({ item: instance_of(pint.Quantity if value is QuantityT else value) for item, value in annotations.items() }) ) assert_that(document.page_layout, has_properties({ "document_name": "", "draw_cut_markers": True, "draw_page_numbers": False, "draw_sharp_corners": False, "row_spacing": quantity_close_to(2*mm), "column_spacing": quantity_close_to(3*mm), "margin_top": quantity_close_to(4*mm), "margin_bottom": quantity_close_to(5*mm), "margin_left": quantity_close_to(6*mm), "margin_right": quantity_close_to(7*mm), "page_height": quantity_close_to(200*mm), "page_width": quantity_close_to(150*mm) })) @pytest.mark.parametrize("title", ["str", "", "1", "0x1", "1.0.0", "1..0", "01", "1.0"]) def test_load_correctly_sets_document_title( qtbot: QtBot, page_layout: PageLayoutSettings, empty_save_database: sqlite3.Connection, document: Document, title: str): loader = document.loader page_layout.document_name = title create_save_database_with(empty_save_database, [], [], page_layout) with unittest.mock.patch( "mtg_proxy_printer.model.document_loader.open_database", return_value=empty_save_database), \ qtbot.wait_signal(document.action_applied, timeout=1000), \ qtbot.assert_not_emitted(loader.loading_file_failed): loader.load_document(Path("/tmp/invalid.mtgproxies")) assert_that(document.page_layout, has_property("document_name", equal_to(title))) |
Added tests/model/test_image_db.py.
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | # Copyright © 2020-2025 Thomas Hess <thomas.hess@udo.edu> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import io import pytest from PyQt5.QtCore import QBuffer, QIODevice from PyQt5.QtGui import QPixmap from hamcrest import * from pytestqt.qtbot import QtBot from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.imagedb_files import ImageKey from tests.hasgetter import has_getter def qpixmap_to_bytes_io(pixmap: QPixmap) -> io.BytesIO: buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, "PNG", quality=100) image = buffer.data().data() return io.BytesIO(image) DOWNLOADER = "mtg_proxy_printer.model.imagedb.ImageDownloader" def test_delete_disk_cache_entries_removes_empty_parent_directories(qtbot: QtBot, image_db: ImageDatabase): # Setup keys = [ ImageKey("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", True, True), ImageKey("7ef83f4c-abcd-abcd-9876-1234567890ab", True, True), # Same prefix ] blank_image_file = qpixmap_to_bytes_io(image_db.get_blank()) for key in keys: path = image_db.db_path / key.format_relative_path() path.parent.mkdir(exist_ok=True, parents=True) path.write_bytes(blank_image_file.read()) image_db.images_on_disk.update(keys) # Test image_db.delete_disk_cache_entries([keys[0]]) assert_that((image_db.db_path / keys[0].format_relative_path()).is_file(), is_(False)) assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(True)) assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(True)) image_db.delete_disk_cache_entries([keys[1]]) assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(False)) assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(False)) @pytest.mark.parametrize("size", [CardSizes.REGULAR, CardSizes.OVERSIZED]) def test_get_blank(image_db: ImageDatabase, size: CardSize): image = image_db.get_blank(size) assert_that(image, is_(instance_of(QPixmap))) assert_that(image, has_getter("size", equal_to(size.as_qsize_px()))) |
Changes to tests/test_card_info_downloader.py.
︙ | ︙ | |||
20 21 22 23 24 25 26 | import unittest.mock from hamcrest import * import pytest import mtg_proxy_printer.card_info_downloader from mtg_proxy_printer.card_info_downloader import SetWackinessScore | | > | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | import unittest.mock from hamcrest import * import pytest import mtg_proxy_printer.card_info_downloader from mtg_proxy_printer.card_info_downloader import SetWackinessScore from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import MTGSet, Card from mtg_proxy_printer.units_and_sizes import UUID, CardSizes from .helpers import assert_model_is_empty, fill_card_database_with_json_card, load_json, assert_relation_is_empty, \ fill_card_database_with_json_cards, CardDataType class DatabasePrintingData(typing.NamedTuple): |
︙ | ︙ |
Deleted tests/test_card_list.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_carddb.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Changes to tests/test_check_card_rendering.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from PyQt5.QtGui import QPixmap, QColorConstants | | | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from PyQt5.QtGui import QPixmap, QColorConstants from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard from mtg_proxy_printer.units_and_sizes import CardSizes @pytest.fixture def blank_image(qtbot) -> QPixmap: pixmap = QPixmap(CardSizes.REGULAR.as_qsize_px()) pixmap.fill(QColorConstants.White) |
︙ | ︙ |
Deleted tests/test_document.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_document_loader.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_image_db.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Changes to tests/test_page_layout_settings.py.
︙ | ︙ | |||
11 12 13 14 15 16 17 18 19 20 21 22 23 24 | # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import itertools import unittest.mock import pint import mtg_proxy_printer.settings import mtg_proxy_printer.model.document import mtg_proxy_printer.model.document_loader from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UnitT, unit_registry, StrDict | > | 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import itertools import unittest.mock from multiprocessing.context import assert_spawning import pint import mtg_proxy_printer.settings import mtg_proxy_printer.model.document import mtg_proxy_printer.model.document_loader from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UnitT, unit_registry, StrDict |
︙ | ︙ | |||
32 33 34 35 36 37 38 | from tests.hasgetter import has_getters from tests.helpers import quantity_close_to mm: UnitT = unit_registry.mm | < < < < < | 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | from tests.hasgetter import has_getters from tests.helpers import quantity_close_to mm: UnitT = unit_registry.mm @pytest.mark.parametrize("page_type, expected", [ (PageType.OVERSIZED, 4), (PageType.REGULAR, 9), (PageType.MIXED, 9), (PageType.UNDETERMINED, 9), ]) |
︙ | ︙ | |||
82 83 84 85 86 87 88 89 90 91 92 93 94 95 | # because there is no spacing with one row (1000*mm, 1, PageType.REGULAR), (1000*mm, 1, PageType.UNDETERMINED), (1000*mm, 1, PageType.OVERSIZED), ]) def test_page_layout_compute_page_row_count( page_layout: PageLayoutSettings, page_type: PageType, row_spacing: QuantityT, expected: int): page_layout.row_spacing = row_spacing assert_that(page_layout.compute_page_row_count(page_type), is_(equal_to(expected))) def test_page_layout_compute_compute_page_row_count_default_value(page_layout: PageLayoutSettings): assert_that(page_layout.compute_page_row_count(), is_(equal_to(3))) | > > > | 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | # because there is no spacing with one row (1000*mm, 1, PageType.REGULAR), (1000*mm, 1, PageType.UNDETERMINED), (1000*mm, 1, PageType.OVERSIZED), ]) def test_page_layout_compute_page_row_count( page_layout: PageLayoutSettings, page_type: PageType, row_spacing: QuantityT, expected: int): assert_that(page_layout.page_height, quantity_close_to(297*mm), "Setup failed: Environment altered") assert_that(page_layout.margin_top, quantity_close_to(5*mm), "Setup failed: Environment altered") assert_that(page_layout.margin_bottom, quantity_close_to(5*mm), "Setup failed: Environment altered") page_layout.row_spacing = row_spacing assert_that(page_layout.compute_page_row_count(page_type), is_(equal_to(expected))) def test_page_layout_compute_compute_page_row_count_default_value(page_layout: PageLayoutSettings): assert_that(page_layout.compute_page_row_count(), is_(equal_to(3))) |
︙ | ︙ | |||
133 134 135 136 137 138 139 | assert_that(calling(page_layout.__gt__).with_args(1), raises(TypeError)) def test_page_layout_lt_raises_type_error_on_incompatible_types(page_layout: PageLayoutSettings): assert_that(calling(page_layout.__lt__).with_args(1), raises(TypeError)) | | < | | | | < | | | 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | assert_that(calling(page_layout.__gt__).with_args(1), raises(TypeError)) def test_page_layout_lt_raises_type_error_on_incompatible_types(page_layout: PageLayoutSettings): assert_that(calling(page_layout.__lt__).with_args(1), raises(TypeError)) def test_page_layout_gt(page_layout: PageLayoutSettings): page_layout.page_width = 10*mm assert_that(page_layout.compute_page_card_capacity(PageType.REGULAR), is_(0)) assert_that(page_layout, is_not(greater_than(page_layout))) def test_page_layout_lt(page_layout: PageLayoutSettings): assert_that(page_layout.compute_page_card_capacity(PageType.REGULAR), is_(9)) assert_that(page_layout, is_not(less_than(page_layout))) @pytest.mark.parametrize("values", [ { "paper-height": "200 mm", "paper-width": "100 mm", "margin-top": "9 mm", |
︙ | ︙ |
Changes to tests/test_save_file_migrations.py.
︙ | ︙ | |||
19 20 21 22 23 24 25 | import pytest from hamcrest import * import mtg_proxy_printer.model.document_loader from mtg_proxy_printer.model.page_layout import PageLayoutSettings import mtg_proxy_printer.model.document import mtg_proxy_printer.sqlite_helpers | | | < | | < | | | | | | | < | | | | | | | | | | | | | < | | | | | | | | | | | | | | | < | | | | | | | | | | | | | | | | | | | | | 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | import pytest from hamcrest import * import mtg_proxy_printer.model.document_loader from mtg_proxy_printer.model.page_layout import PageLayoutSettings import mtg_proxy_printer.model.document import mtg_proxy_printer.sqlite_helpers from mtg_proxy_printer.model.document_loader import SAVE_FILE_MAGIC_NUMBER, DocumentLoader, CardType import mtg_proxy_printer.save_file_migrations from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT from tests.helpers import quantity_close_to mm: UnitT = unit_registry.mm def validate_save_database_schema(db: sqlite3.Connection, schema_version: int): mtg_proxy_printer.sqlite_helpers.validate_database_schema( db, SAVE_FILE_MAGIC_NUMBER, f"document-v{schema_version}", "Invalid header" ) user_version = db.execute("PRAGMA user_version").fetchone()[0] assert_that(user_version, is_(equal_to(schema_version))) def create_save_db(schema_version: int) -> sqlite3.Connection: return mtg_proxy_printer.sqlite_helpers.open_database(":memory:", f"document-v{schema_version}") @pytest.mark.parametrize("source_version", range(2, 7)) def test_single_migration_step_correctly_transforms_database_schema(page_layout: PageLayoutSettings, source_version: int): target_version = source_version+1 db = create_save_db(source_version) migration: Callable[[sqlite3.Connection, PageLayoutSettings], None] = getattr( mtg_proxy_printer.save_file_migrations, f"_migrate_{source_version}_to_{target_version}") migration(db, page_layout) validate_save_database_schema(db, target_version) def test_migration_2_to_3_transforms_data(): db = create_save_db(2) db.executemany("INSERT INTO CARD VALUES (?, ?, ?)", [(1, 1, 'abc'), (2, 1, 'xyz')]) mtg_proxy_printer.save_file_migrations._migrate_2_to_3(db) assert_that( db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(), contains_exactly((1, 1, 1, 'abc'), (2, 1, 1, 'xyz')) ) def test_migration_3_to_4_transforms_data(page_layout: PageLayoutSettings): db = create_save_db(3) cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')] db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards) mtg_proxy_printer.save_file_migrations._migrate_3_to_4(db, page_layout) assert_that( db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(), contains_exactly(*cards) ) assert_that( db.execute("SELECT * FROM DocumentSettings").fetchall(), contains_exactly( (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude, page_layout.margin_top.to("mm").magnitude, page_layout.margin_bottom.to("mm").magnitude, page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude, page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude, int(page_layout.draw_cut_markers) ) )) def test_migration_4_to_5_transforms_data(page_layout: PageLayoutSettings): db = create_save_db(4) page_layout.draw_sharp_corners = True cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')] db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards) # Insert slightly altered data, then pass the unaltered PageLayoutSettings. # This verifies that the stored data is used and not replaced with the current default settings. db.execute( "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude, page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1, page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude, page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude, int(not page_layout.draw_cut_markers)) ) mtg_proxy_printer.save_file_migrations._migrate_4_to_5(db, page_layout) assert_that( db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(), contains_exactly(*cards) ) assert_that( db.execute("SELECT * FROM DocumentSettings").fetchall(), contains_exactly( (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude, page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1, page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude, page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude, int(not page_layout.draw_cut_markers), int(page_layout.draw_sharp_corners) ) )) def test_migration_5_to_6_transforms_data(page_layout: PageLayoutSettings): db = create_save_db(5) page_layout.draw_sharp_corners = True db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?)", [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')]) # Insert slightly altered data, then pass the unaltered PageLayoutSettings. # This verifies that the stored data is used and not replaced with the current default settings. db.execute( "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude, page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1, page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude, page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude, int(not page_layout.draw_cut_markers), int(not page_layout.draw_sharp_corners)) ) mtg_proxy_printer.save_file_migrations._migrate_5_to_6(db, page_layout) assert_that( db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(), contains_exactly((1, 1, 1, 'abc', 'r'), (2, 1, 1, 'xyz', 'r')) ) assert_that( db.execute("SELECT * FROM DocumentSettings").fetchall(), contains_inanyorder( # Migrated ("page_height", page_layout.page_height.to("mm").magnitude), ("page_width", page_layout.page_width.to("mm").magnitude), ("margin_top", page_layout.margin_top.to("mm").magnitude+1),("margin_bottom", page_layout.margin_bottom.to("mm").magnitude-1), ("margin_left", page_layout.margin_left.to("mm").magnitude),("margin_right", page_layout.margin_right.to("mm").magnitude), ("row_spacing", page_layout.row_spacing.to("mm").magnitude),("column_spacing", page_layout.column_spacing.to("mm").magnitude), ("draw_cut_markers", int(not page_layout.draw_cut_markers)),("draw_sharp_corners", int(not page_layout.draw_sharp_corners)), # New ("document_name", page_layout.document_name), ("card_bleed", page_layout.card_bleed.to("mm").magnitude), ("draw_page_numbers", int(page_layout.draw_page_numbers)), )) def test_migration_6_to_7_transforms_data(page_layout: PageLayoutSettings): db = create_save_db(6) page_layout.draw_sharp_corners = True uuid1 = "aaaabbbb-1111-2222-3333-55556666ffff" uuid2 = "ffffeeee-9999-8888-7777-ddddccccbbbb" db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?, ?)", [(1, 1, 1, uuid1, CardType.REGULAR), (2, 1, 1, uuid2, CardType.REGULAR)]) # Insert slightly altered data, then pass the unaltered PageLayoutSettings. # This verifies that the stored data is used and not replaced with the current default settings. db.executemany( 'INSERT INTO DocumentSettings ("key", value) VALUES (?, ?)',( ("page_height", page_layout.page_height.to("mm").magnitude), ("page_width", page_layout.page_width.to("mm").magnitude), ("margin_top", page_layout.margin_top.to("mm").magnitude+1),("margin_bottom", page_layout.margin_bottom.to("mm").magnitude-1), ("margin_left", page_layout.margin_left.to("mm").magnitude),("margin_right", page_layout.margin_right.to("mm").magnitude), ("row_spacing", page_layout.row_spacing.to("mm").magnitude),("column_spacing", page_layout.column_spacing.to("mm").magnitude), ("card_bleed", page_layout.card_bleed.to("mm").magnitude), ("document_name", page_layout.document_name), ("draw_cut_markers", int(not page_layout.draw_cut_markers)),("draw_sharp_corners", int(not page_layout.draw_sharp_corners)), ("draw_page_numbers", int(page_layout.draw_page_numbers)), ) ) mtg_proxy_printer.save_file_migrations._migrate_6_to_7(db, page_layout) assert_that( data := db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(), contains_exactly((1, 1, True, 'r', uuid1, None), (2, 1, True, 'r', uuid2, None)), f"Bad card data: {data}" ) assert_that( data := db.execute("SELECT * FROM DocumentSettings").fetchall(), contains_inanyorder( contains_exactly("draw_cut_markers", str(not page_layout.draw_cut_markers)), contains_exactly("draw_sharp_corners", str(not page_layout.draw_sharp_corners)), contains_exactly("document_name", page_layout.document_name), contains_exactly("draw_page_numbers", str(page_layout.draw_page_numbers)),), f"Bad settings: {data}" ) assert_that( data := db.execute("SELECT * FROM DocumentDimensions").fetchall(), contains_inanyorder( contains_exactly("page_height", quantity_close_to(page_layout.page_height)), contains_exactly("page_width", quantity_close_to(page_layout.page_width)), contains_exactly("margin_top", quantity_close_to(page_layout.margin_top+1*mm)), contains_exactly("margin_bottom", quantity_close_to(page_layout.margin_bottom-1*mm)), contains_exactly("margin_left", quantity_close_to(page_layout.margin_left)), contains_exactly("margin_right", quantity_close_to(page_layout.margin_right)), contains_exactly("row_spacing", quantity_close_to(page_layout.row_spacing)), contains_exactly("column_spacing", quantity_close_to(page_layout.column_spacing)), contains_exactly("card_bleed", quantity_close_to(page_layout.card_bleed)), ), f"Bad settings: {data}" ) assert_that(db.execute("SELECT * FROM CustomCardData").fetchall(), is_(empty())) |
Changes to tests/test_units_and_sizes.py.
︙ | ︙ | |||
13 14 15 16 17 18 19 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * | > | > > > > > > > > > > > > > > > > > | 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from tests.helpers import quantity_close_to from mtg_proxy_printer.units_and_sizes import UUID, CardSizes, CardSize, PageType, ConfigParser, unit_registry from tests.hasgetter import has_getters @pytest.fixture() def config_parser(): return ConfigParser({"Test": "1 mm"}) def test_ConfigParser_has_get_quantity(config_parser: ConfigParser): assert_that(config_parser, has_property("get_quantity")) assert_that(config_parser.get_quantity("DEFAULT", "Test"), quantity_close_to(1*unit_registry.mm)) def test_SectionProxy_has_get_quantity(config_parser: ConfigParser): proxy = config_parser["DEFAULT"] assert_that(proxy, has_property("get_quantity")) assert_that(proxy.get_quantity("Test"), quantity_close_to(1*unit_registry.mm)) @pytest.mark.parametrize("input_str", [ "2c6e5b25-b721-45ee-894a-697de1310b8c", "1b9ec782-0ba1-41f1-bc39-d3302494ecb3", ]) def test_uuid_with_valid_inputs(input_str: str): assert_that( |
︙ | ︙ | |||
42 43 44 45 46 47 48 | "2c6e5b25-b721-45eee-894a-697de1310b8c", "2c6e5b25-b721-45e-894a-697de1310b8c", "2c6e5b25-b721-45ee-89423-697de1310b8c", "2c6e5b25-b721-45ee-894-697de1310b8c", "2c6e5b25-b721-45ee-894a-4697de1310b8c", "2c6e5b25-b721-45ee-894a-97de1310b8c", ]) | | > > > > > > > > > > > | 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | "2c6e5b25-b721-45eee-894a-697de1310b8c", "2c6e5b25-b721-45e-894a-697de1310b8c", "2c6e5b25-b721-45ee-89423-697de1310b8c", "2c6e5b25-b721-45ee-894-697de1310b8c", "2c6e5b25-b721-45ee-894a-4697de1310b8c", "2c6e5b25-b721-45ee-894a-97de1310b8c", ]) def test_uuid_with_invalid_input_raises_value_error(input_str: str): assert_that( calling(UUID).with_args(input_str), raises(ValueError) ) @pytest.mark.parametrize("input_, expected", [ (PageType.OVERSIZED, CardSizes.OVERSIZED), (PageType.REGULAR, CardSizes.REGULAR), (PageType.UNDETERMINED, CardSizes.REGULAR), (PageType.MIXED, CardSizes.REGULAR), ]) def test_card_sizes_for_page_type(input_: PageType, expected: CardSize): assert_that(CardSizes.for_page_type(input_), is_(expected)) @pytest.mark.parametrize("input_, expected", [(True, CardSizes.OVERSIZED), (False, CardSizes.REGULAR)]) def test_card_sizes_from_bool(input_: bool, expected: CardSize): assert_that(CardSizes.from_bool(input_), is_(expected)) @pytest.mark.parametrize("size, width, height", [ |
︙ | ︙ |
Changes to tests/ui/test_item_delegate.py.
︙ | ︙ | |||
9 10 11 12 13 14 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. | | < < < < | > < < < | < < | | | < < < > | | > > > | | < < > > < < < < | < < < < < < > | | > | < < | | > | | > | < < < | > | | 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from PyQt5.QtCore import QModelIndex from PyQt5.QtWidgets import QSpinBox, QWidget, QStyleOptionViewItem import pytest from hamcrest import * from pytestqt.qtbot import QtBot from mtg_proxy_printer.model.card import MTGSet from mtg_proxy_printer.ui.item_delegates import BoundedCopiesSpinboxDelegate, SetEditorDelegate from tests.hasgetter import has_getters @pytest.fixture() def bounded_copies_spinbox(qtbot: QtBot) -> QSpinBox: parent = QWidget() qtbot.add_widget(parent) delegate = BoundedCopiesSpinboxDelegate() editor = delegate.createEditor(parent, QStyleOptionViewItem(), QModelIndex()) yield editor def test_BoundedCopiesSpinboxDelegate_createEditor_returns_correct_type(bounded_copies_spinbox: QSpinBox): assert_that(bounded_copies_spinbox, is_(instance_of(QSpinBox))) def test_BoundedCopiesSpinboxDelegate_createEditor_has_correct_limits(bounded_copies_spinbox: QSpinBox): assert_that(bounded_copies_spinbox, has_getters(minimum=1, maximum=100)) @pytest.mark.parametrize("mtg_set", [MTGSet("BAR", "bar"), MTGSet("FOO", "foo")]) def test_CustomCardSetEditor_set_data(qtbot: QtBot, mtg_set: MTGSet): editor = SetEditorDelegate.CustomCardSetEditor() qtbot.add_widget(editor) editor.set_data(mtg_set) assert_that(editor.ui.name_editor.text(), is_(equal_to(mtg_set.name))) assert_that(editor.ui.code_edit.text(), is_(equal_to(mtg_set.code))) @pytest.mark.parametrize("mtg_set", [MTGSet("BAR", "bar"), MTGSet("FOO", "foo")]) def test_CustomCardSetEditor_to_mtg_set(qtbot: QtBot, mtg_set: MTGSet): editor = SetEditorDelegate.CustomCardSetEditor() qtbot.add_widget(editor) editor.ui.name_editor.setText(mtg_set.name) editor.ui.code_edit.setText(mtg_set.code) assert_that(editor.to_mtg_set(), is_(equal_to(mtg_set))) |
Changes to tests/ui/test_main_window.py.
︙ | ︙ | |||
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | from pytestqt.qtbot import QtBot from hamcrest import * import pytest import mtg_proxy_printer.http_file import mtg_proxy_printer.downloader_base from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument from mtg_proxy_printer.sqlite_helpers import open_database from mtg_proxy_printer.card_info_downloader import CardInfoDownloader from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.ui.main_window import MainWindow from mtg_proxy_printer.ui.central_widget import Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, \ Ui_TabbedCentralWidget from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.units_and_sizes import CardSizes from mtg_proxy_printer.model.page_layout import PageLayoutSettings | > | | 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | from pytestqt.qtbot import QtBot from hamcrest import * import pytest import mtg_proxy_printer.http_file import mtg_proxy_printer.downloader_base from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument from mtg_proxy_printer.model.document_loader import CardType from mtg_proxy_printer.sqlite_helpers import open_database from mtg_proxy_printer.card_info_downloader import CardInfoDownloader from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.imagedb import ImageDatabase from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.ui.main_window import MainWindow from mtg_proxy_printer.ui.central_widget import Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, \ Ui_TabbedCentralWidget from mtg_proxy_printer.document_controller.page_actions import ActionNewPage from mtg_proxy_printer.units_and_sizes import CardSizes from mtg_proxy_printer.model.page_layout import PageLayoutSettings from tests.helpers import fill_card_database_with_json_cards, create_save_database_with from tests.document_controller.helpers import insert_card_in_page StandardButton = QMessageBox.StandardButton @pytest.fixture(params=[Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, Ui_TabbedCentralWidget]) def main_window(qtbot, card_db: CardDatabase, document: Document, request) -> typing.Generator[MainWindow, None, None]: fill_card_database_with_json_cards(qtbot, card_db, ["regular_english_card", "oversized_card"]) |
︙ | ︙ | |||
65 66 67 68 69 70 71 | with qtbot.wait_exposed(main_window, timeout=1000): main_window.show() yield main_window main_window.hide() del cid main_window.__dict__.clear() | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | with qtbot.wait_exposed(main_window, timeout=1000): main_window.show() yield main_window main_window.hide() del cid main_window.__dict__.clear() def test_declining_card_data_update_offer_results_in_no_action(qtbot: QtBot, main_window: MainWindow): ui = main_window.ui ui.action_download_card_data.setEnabled(False) with unittest.mock.patch.object( mtg_proxy_printer.ui.main_window.QMessageBox, "question", return_value=StandardButton.No), \ unittest.mock.patch( |
︙ | ︙ |
Changes to tests/ui/test_page_card_table_view.py.
︙ | ︙ | |||
18 19 20 21 22 23 24 | from unittest.mock import NonCallableMagicMock, patch import pytest from pytestqt.qtbot import QtBot from hamcrest import * from mtg_proxy_printer.model.document import Document | | > | 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | from unittest.mock import NonCallableMagicMock, patch import pytest from pytestqt.qtbot import QtBot from hamcrest import * from mtg_proxy_printer.model.document import Document from mtg_proxy_printer.model.carddb import CardDatabase from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, AnyCardType from mtg_proxy_printer.units_and_sizes import CardSizes from mtg_proxy_printer.ui.page_card_table_view import PageCardTableView # Import dynamically used by pytest. Without this, the main_window fixture won’t be found by pytest. from .test_main_window import main_window # noqa @pytest.fixture() |
︙ | ︙ |
Changes to tests/ui/test_page_config_container.py.
︙ | ︙ | |||
23 24 25 26 27 28 29 | from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.units_and_sizes import QuantityT, unit_registry from mtg_proxy_printer.ui.page_config_container import PageConfigContainer from tests.helpers import quantity_close_to | | | | | 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | from mtg_proxy_printer.model.page_layout import PageLayoutSettings from mtg_proxy_printer.units_and_sizes import QuantityT, unit_registry from mtg_proxy_printer.ui.page_config_container import PageConfigContainer from tests.helpers import quantity_close_to @pytest.fixture() def container(qtbot: QtBot, page_layout: PageLayoutSettings): container = PageConfigContainer() container.ui.page_config_widget.load_from_page_layout(page_layout) qtbot.add_widget(container) return container @pytest.mark.parametrize( "widget_name", ["draw_cut_markers", "draw_sharp_corners", "draw_page_numbers"]) |
︙ | ︙ |