MTGProxyPrinter

Changes On Branch custom_card_import_dialog
Login

Changes On Branch custom_card_import_dialog

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
5
6







7




8
9
10
11
12


13
14
15
16
17
18
19
# Changelog

# Next version (in development)

## Changed features








- Improved custom card support: It is now possible to save custom cards and empty slots in the apps save file format.




- 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", …



# 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.




|

>
>
>
>
>
>
>
|
>
>
>
>





>
>







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
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  # noqa


# 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",







|







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
22

23
24
25
26
27
28
29


from abc import abstractmethod
import typing

from PyQt5.QtCore import QObject, pyqtSignal as Signal

from mtg_proxy_printer.model.carddb import Card, CardDatabase, CardIdentificationData

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__ = [







|
>







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
45
46
47
48
49
50
51
52
    # noinspection PyUnresolvedReferences,PyUnboundLocalVariable
    profile
except NameError:
    # If not defined, use this identity decorator as a replacement
    def profile(func):
        return func

CardCounter = typing.Counter[Card]
ParsedDeck = typing.Tuple[CardCounter, typing.List[str]]


class ParserBase(QObject):

    @staticmethod
    def supported_file_types() -> typing.Dict[str, typing.List[str]]:







|







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
24

25
26
27
28
29
30
31
import abc
import collections
import csv
import typing

from PyQt5.QtCore import QObject, QCoreApplication

from mtg_proxy_printer.model.carddb import Card, CardDatabase, CardIdentificationData

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








|
>







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
24

25
26
27
28
29
30
31
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 Card, CardDatabase, CardIdentificationData

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]








|
>







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
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.
        """
        pass

    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)







|







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
22

23
24
25

26
27
28
29
30
31
32


import functools
import itertools
import math
import typing

from mtg_proxy_printer.model.carddb 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 ._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__ = [







|
>



>







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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
    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)


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







<
<
<
<
<
<
<
<
<
<
<
<
<
<
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
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.carddb 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







|







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
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 mtg_proxy_printer.model.carddb 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",







|







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

22
23
24
25
26
27
28
29


import functools
import typing

from PyQt5.QtCore import Qt


from mtg_proxy_printer.model.carddb 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








>
|







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
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 mtg_proxy_printer.model.carddb import AnyCardType
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







|







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
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
    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-v6") as db:
            db.execute("BEGIN IMMEDIATE TRANSACTION")
            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" 
                  FROM pragma_page_count
                  INNER JOIN pragma_freelist_count""")).fetchone()[0]:

                db.execute("VACUUM")
        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 (?, ?)\n',
            settings)
        save_file.executemany(
            'INSERT OR REPLACE INTO DocumentDimensions ("key", value) VALUES (?, ?)\n',
            dimensions)
        logger.debug("Written document settings")

    @staticmethod
    def _clear_cards_and_pages(save_file: sqlite3.Connection):
        save_file.execute("DELETE FROM Card")
        save_file.execute("DELETE FROM Page")

    @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 (?, ?)\n",
            pages
        )

    @staticmethod
    def _save_cards(save_file: sqlite3.Connection, document: "Document"):






        for page_number, page in enumerate(document.pages, start=1):
            for slot, container in enumerate(page, start=1):
                card = container.card
                if card.scryfall_id:

                    save_file.execute(
                        "INSERT INTO Card (page, slot, is_front, type, scryfall_id) VALUES (?, ?, ? ,?, ?)",
                        (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id)
                    )
                elif card.image_file is not document.image_db.get_blank(card.size):
                    ActionSaveDocument._save_custom_card(save_file, page_number, slot, card)
                else:  # Empty slot
                    save_file.execute(
                        "INSERT INTO Card (page, slot, is_front, type) VALUES (?, ? ,?, ?)",

                        (page_number, slot, card.is_front, CardType.from_card(card))
                    )
        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_id = ActionSaveDocument._save_custom_card_data(save_file, card)
        save_file.execute(
            "INSERT INTO Card (page, slot, is_front, type, custom_card_id) VALUES (?, ?, ? ,?, ?)",
            (page_number, slot, card.is_front, CardType.from_card(card), custom_card_id)
        )

    @staticmethod
    def _save_custom_card_data(save_file: sqlite3.Connection, card: AnyCardType) -> UUID:
        custom_card_id, image = ActionSaveDocument._serialize_card_image(card)
        if save_file.execute(
                "SELECT EXISTS (SELECT 1 FROM CustomCardData WHERE card_id = ?)",
                (custom_card_id,)).fetchone()[0]:
            return custom_card_id
        parameters = (
            custom_card_id, image, card.name, card.set.name, card.set.code,
            card.collector_number, card.is_front, card.is_oversized)
        save_file.execute(
            cached_dedent("""\

            INSERT INTO CustomCardData (card_id, image, name, set_name, set_code, collector_number, is_front, oversized)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)"""),

            parameters









        )
        return custom_card_id

    @staticmethod
    def _serialize_card_image(card: AnyCardType) -> typing.Tuple[UUID, bytes]:
        """
        Converts the card image into a byte array for storage in the save file.
        Returns the byte data and the UUID-formatted hash of the byte data.

        """
        pixmap = card.image_file
        buffer = QBuffer()
        buffer.open(QIODevice.OpenModeFlag.WriteOnly)
        pixmap.save(buffer, "PNG", quality=100)
        image_bytes = buffer.data().data()
        buffer.close()
        hd = hashlib.md5(image_bytes).hexdigest()  # TODO: Maybe use something else instead of md5?
        uuid = UUID(f"{hd[:8]}-{hd[8:12]}-{hd[12:16]}-{hd[16:20]}-{hd[20:]}")
        return uuid, image_bytes


    @staticmethod
    def _clean_unused_custom_cards(save_file: sqlite3.Connection):
        save_file.execute(cached_dedent("""\
            DELETE FROM CustomCardData
              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)







|
|








|

|
>
|






|


|





|
|









|





>
>
>
>
>
>



|
>

<
|

|

|

<
>
|





<
<
<
<
<
<
<
<
<
<
<
<
|
<
|
|
|
<
>
|
|
>
|
>
>
>
>
>
>
>
>
>
|
|
|
<
|
|
<
<
>
|
|
<
<
<
<
<
<
<
<
>




|



|
|









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
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 mtg_proxy_printer.model.carddb import Card
from mtg_proxy_printer.model.document_page import CardContainer
from mtg_proxy_printer.model.document import Document, PageColumns
from mtg_proxy_printer.units_and_sizes import PageType
__all__ = [
    "ActionShuffleDocument",
]

IndexedCards = typing.List[typing.Tuple[int, Card]]
ModelIndexList = typing.List[QModelIndex]







|
|
|







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
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

from collections import Counter
import dataclasses
import enum
import itertools
import typing

from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, QItemSelection
from PyQt5.QtGui import QIcon


from mtg_proxy_printer.decklist_parser.common import CardCounter
from mtg_proxy_printer.model.carddb import Card, CardIdentificationData, CardDatabase, 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: Card
    copies: int


class CardListColumns(enum.IntEnum):
    Copies = 0
    CardName = enum.auto()
    Set = enum.auto()







|


>

|
>















|







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
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
        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.code
                else:

                    return f"{card.set.name} ({card.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_oversized:

            if role == ItemDataRole.ToolTipRole:
                return self.tr("Beware: Potentially oversized card!\nThis card may not fit in your deck.")
            elif 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:
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: QModelIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        row, column = index.row(), index.column()


        if role == ItemDataRole.EditRole and column in self.EDITABLE_COLUMNS:




            logger.debug(f"Setting card list model data for column {column} to {value}")


            container = self.rows[row]
            card = container.card

            if column == CardListColumns.Copies:
                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 True
            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, 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)
























        return False








    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(







|

>
|








|
>
|
|
|
|



|





>
>
|
>
>
>
>
|
>
>
|
|
>
|
<
<
<
<
|
|
|
|
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
>










|
<







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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
            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)

    @Slot(list)
    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)







<





<







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
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
#  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 enum
import itertools
import functools
import pathlib
import sqlite3
import threading
import typing

from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QPixmap, QColor, QTransform, QPainter, QColorConstants
from PyQt5.QtCore import Qt, QPoint, QRect, QSize, QPointF, QObject, pyqtSignal as Signal, pyqtSlot as Slot


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 PageType, StringList, OptStr, CardSizes, CardSize
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger

QueuedConnection = Qt.ConnectionType.QueuedConnection
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"
ItemDataRole = Qt.ItemDataRole
RenderHint = QPainter.RenderHint
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)




__all__ = [
    "CardIdentificationData",
    "MTGSet",
    "CheckCard",
    "Card",
    "AnyCardType",
    "AnyCardTypeForTypeCheck",
    "CardCorner",
    "CardDatabase",
    "CardList",
    "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: typing.Optional[bool] = None
    oracle_id: OptStr = None


@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.code
        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=False)
    highres_image: bool = dataclasses.field(compare=False)
    size: CardSize = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=False)
    is_dfc: bool = dataclasses.field(compare=False)
    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
        size = self.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return 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=Qt.TransformationMode.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):
        return self.set.code

    @property
    def is_oversized(self) -> bool:
        return self.size is CardSizes.OVERSIZED


@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

    @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 image_uri(self) -> str:
        return ""

    @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 0

    @property
    def is_dfc(self) -> bool:
        return False

    @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()/(card_size.width()*2)
        combined_image = QPixmap(card_size)
        combined_image.fill(QColor.fromRgb(255, 255, 255, 0))  # Fill with fully transparent white
        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:
        if self.front.image_file is None or self.back.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        size = self.front.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return 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.front.image_file is None or self.back.image_file is None:
            return QColorConstants.Transparent
        if corner == CardCorner.TOP_LEFT:
            self.front.corner_color(CardCorner.BOTTOM_LEFT)
        elif corner == CardCorner.TOP_RIGHT:
            self.front.corner_color(CardCorner.TOP_LEFT)
        elif corner == CardCorner.BOTTOM_LEFT:
            self.back.corner_color(CardCorner.BOTTOM_RIGHT)
        elif corner == CardCorner.BOTTOM_RIGHT:
            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}]'


class ImageDatabaseCards(typing.NamedTuple):
    visible: typing.List[typing.Tuple[Card, CacheContent]] = []
    hidden: typing.List[typing.Tuple[Card, CacheContent]] = []
    unknown: typing.List[CacheContent] = []


OptionalCard = typing.Optional[Card]
CardList = typing.List[Card]
AnyCardType = typing.Union[Card, CheckCard]
# Py3.8 compatibility hack, because isinstance(a, AnyCardType) fails on 3.8
AnyCardTypeForTypeCheck = typing.get_args(AnyCardType)
T = typing.TypeVar("T", Card, CheckCard)
write_semaphore = threading.BoundedSemaphore()


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):







<


|


|


<
|

>






|





<


<
<




>
>
>



<
<
<
<
<
<

<














|



<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
|
|
|
<
<
<
<
<
<
<
<
<







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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336

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()


    def __init__(self, db_path: typing.Union[str, pathlib.Path] = DEFAULT_DATABASE_LOCATION, parent: QObject = None,
                 check_same_thread: 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:":
            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







>

|
|












|







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
390
391
392
393
394
395
396
397
        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) -> typing.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:
        """







|







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
441
442
443
444
445
446
447
448
            self.db.execute(
                query, parameters
            )
        ]
        return result

    def get_basic_land_oracle_ids(
            self, include_wastes: bool = False, include_snow_basics: bool = False) -> typing.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:







|







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
621
622
623
624
625
626
627
628
        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)







|







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
663
664
665
666
667
668
669
670
              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) -> typing.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.







|







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
715
716
717
718
719
720
721
722
            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: typing.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),







|







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
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
            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), pathlib.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), pathlib.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) -> typing.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







|






|



















|







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
825
826
827
828
829
830
831
832
        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: typing.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.
        """







|







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
901
902
903
904
905
906
907
908
          )
          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) -> typing.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







|







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
925
926
927
928
929
930
931
932
          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("""\







|







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
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
            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: typing.Sequence[typing.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: typing.List[typing.Tuple[str, bool]], date: datetime.date) -> typing.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: typing.List[typing.Tuple[str, bool]], count: int) -> typing.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 []







|



|















|

















|







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
32
33
34
35
36
37
38
39
  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,
  oversized BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT FALSE,
  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 <> '')
);







<







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


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
import pathlib
import sys
import typing

from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \
    QPersistentModelIndex



from mtg_proxy_printer.model.document_page import CardContainer, Page, PageList
from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize
from mtg_proxy_printer.model.carddb import AnyCardType, CardDatabase, CardIdentificationData, Card, MTGSet

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",
    "PageColumns",
]


class DocumentColumns(enum.IntEnum):
    Page = 0


class PageColumns(enum.IntEnum):
    CardName = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    Language = enum.auto()
    IsFront = enum.auto()
    Image = enum.auto()


INVALID_INDEX = QModelIndex()
ActionStack = typing.Deque[DocumentAction]
AnyIndex = typing.Union[QModelIndex, QPersistentModelIndex]
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation
ItemFlag = Qt.ItemFlag







>
>
|

|
>



















<






<
<
<
<
<
<
<
<
<







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
114
115
116
117
118
119
120
121
        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: PageList = [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")








|







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
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
        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:
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: AnyIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        index = self._to_index(index)
        data = index.internalPointer()


        column = index.column()
        if isinstance(data, CardContainer) \

                and role == ItemDataRole.EditRole \

                and column in self.EDITABLE_COLUMNS:
            logger.debug(f"Setting model data for {column=} to {value}")
            card = data.card
            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, 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







|





|
>
>

|
>
|
>
|
|
<





|







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

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 typing import Counter, Dict, Iterable, List, NamedTuple, Optional, Tuple, TYPE_CHECKING, TypeVar
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


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, CardList, Card, CheckCard, AnyCardType, SCHEMA_NAME, \
    MTGSet
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
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__)







>
|






|
<









|
|



|







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
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):
            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]



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)







|



















|







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
130
131
132
133
134
135
136
137
138
139
140

    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", db: sqlite3.Connection = None):  # db parameter used by test code
        super().__init__(None)
        self.document = document
        self.db = db
        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))







|


<







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
170
171
172
173
174
175
176
177
178
            self.worker.load_document()
        finally:
            self.release_instance()

    def _create_worker(self):
        parent = self.parent
        worker = Worker(parent.document, self.path)
        if parent.db is not None:  # Used by tests to explicitly set the database
            worker._db = parent.db
        # 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)







<
<







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
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
    """
    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 = document.card_db
        self.image_db = image_db = document.image_db
        self._db: sqlite3.Connection = None
        # 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 = ImageDownloader(image_db, self)
        self.image_loader.download_begins.connect(image_db.card_download_starting)
        self.image_loader.download_finished.connect(image_db.card_download_finished)
        self.image_loader.download_progress.connect(image_db.card_download_progress)
        self.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")

    @property
    def db(self) -> sqlite3.Connection:
        # Delay connection creation until first access.
        # Avoids opening connections that aren't actually used and opens the connection
        # in the thread that actually uses it.
        if self._db is None:

            self._db = open_database(self.card_db.db_path, SCHEMA_NAME)
        return self._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"







|

<



|
|
|
|
|









|
|
<
<
<
|
>
|
|







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

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
            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.db.close()
            self._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
        with patch.object(self.card_db, "db", self.db):
            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 []


            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")

        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())




        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_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)








>
|
|












<
|
|
|
|
|
|
|
>
>
|
|

















>
|
|
|
|
|
>
>
>
>


















<


<


















|

















<
















<







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
439
440
441
442
443
444
445

446
447

448
449
450
451
452
453
454
455
456
457
458
459
460
            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

    @staticmethod
    def _load_custom_card_from_save(save_db: sqlite3.Connection, card_row: CardRow) -> Card:
        query = cached_dedent("""\
        SELECT name, set_code, set_name, collector_number, oversized, image
          FROM CustomCardData 
          WHERE card_id = ? AND is_front = ?""")
        result: Tuple[str, str, str, str, bool, bytes] = save_db.execute(query, (card_row.custom_card_id, card_row.is_front)).fetchone()

        name, set_code, set_name, collector_number, oversized, image_bytes = result
        image = QPixmap()

        image.loadFromData(image_bytes)
        # TODO: Improve this
        size = CardSizes.REGULAR if image.width() == 745 else CardSizes.OVERSIZED
        return Card(
            name, MTGSet(set_code, set_name), collector_number, "en", "",
            card_row.is_front, "", "", True, size, 1 + (not card_row.is_front), False, image)

    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.







<
|

|

|
<
>
|
<
>
|
<
<
|
|
<







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

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
            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_number = instance_of(pint.Quantity)


        assert_that(
            settings,
            has_properties(
                card_bleed=is_number,
                page_height=is_number,
                page_width=is_number,
                margin_top=is_number,
                margin_bottom=is_number,
                margin_left=is_number,
                margin_right=is_number,
                row_spacing=is_number,
                column_spacing=is_number,
                draw_cut_markers=is_in(("True", "False")),
                draw_sharp_corners=is_in(("True", "False")),
                draw_page_numbers=is_in(("True", "False")),
                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:
                # TODO: Handle invalid, non-length units
                # 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(







>
|
>
>



|
|
|
|
|
|
|
|
|
|
|
|




<





<







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
21
22
23









24
25
26
27
28
29
30
#  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

from mtg_proxy_printer.model.carddb import AnyCardType, AnyCardTypeForTypeCheck
from mtg_proxy_printer.units_and_sizes import PageType











@dataclasses.dataclass
class CardContainer:
    parent: "Page"
    card: AnyCardType









>



|


>
>
>
>
>
>
>
>
>







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
72
73
74
        super().insert(__index, container)
        return container

    def append(self, __object: AnyCardType) -> CardContainer:
        container = CardContainer(self, __object)
        super().append(container)
        return container


PageList = typing.List[Page]







<
<
<
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
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 mtg_proxy_printer.model.carddb 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"







|







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
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








|












|







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
21
22
23
24
25
26
27
28
29
#  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.carddb import MTGSet


__all__ = [
    "PrettySetListModel",
]
INVALID_INDEX = QModelIndex()
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation







|
<







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
50
51
52
53
54
55
56
57
58
59
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionMode">
      <enum>QAbstractItemView::MultiSelection</enum>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>







<
<
<







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
82
83
84
85
86
87
88
89
90
91
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionMode">
      <enum>QAbstractItemView::MultiSelection</enum>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>







<
<
<







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
75
76
77
78
79
80
81
82
83
84
         </property>
         <property name="lineWidth">
          <number>0</number>
         </property>
         <property name="alternatingRowColors">
          <bool>true</bool>
         </property>
         <property name="selectionMode">
          <enum>QAbstractItemView::MultiSelection</enum>
         </property>
         <property name="selectionBehavior">
          <enum>QAbstractItemView::SelectRows</enum>
         </property>
         <attribute name="verticalHeaderVisible">
          <bool>false</bool>
         </attribute>
        </widget>







<
<
<







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
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="QTableView" name="parsed_cards_table">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
       <horstretch>0</horstretch>
       <verstretch>65</verstretch>
      </sizepolicy>
     </property>
     <property name="sizeAdjustPolicy">







|







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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
  <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>30</height>
    </rect>
   </property>
   <widget class="QMenu" name="menu_file">
    <property name="title">
     <string>Fi&amp;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="separator"/>
    <addaction name="action_quit"/>
   </widget>
   <widget class="QMenu" name="menu_settings">
    <property name="title">
     <string>Settings</string>
    </property>







|















>







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&amp;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
276
277
278
279
280
281
282
283
   </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">







|







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
227
228
229
230
231
232
233
234
          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,
          oversized BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT FALSE,
          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 <> '')







<







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
27
28
29
30
31
32
33
34
import re
import sqlite3
import sys
import textwrap
import typing

from hamcrest import assert_that, contains_exactly
import pint

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__ = [







<







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
149
150
151
152
153
154
155
156
157
158
159
160
161
            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:
            set_abbr = current_model_index.data(ItemDataRole.EditRole)
            collector_numbers = self.card_database.find_collector_numbers_matching(
                self.current_card_name, set_abbr, self.current_language
            )
            logger.debug(
                f'Selected: "{set_abbr}", 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()))







|

|


|







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
240
241
242
243
244
245
246
247
        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.carddb.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([])







|







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
271
272
273
274
275
276
277
278
        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)
        else:
            return None

    @property
    def current_collector_number(self) -> Optional[str]:
        selected = self.ui.collector_number_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
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 functools
import math
import pathlib
import typing

from PyQt5.QtCore import QAbstractTableModel, Qt, QModelIndex, QObject, QBuffer, QIODevice, QItemSelectionModel, QSize
from PyQt5.QtGui import QIcon, QPixmap
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, Card, MTGSet

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
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







<




|
|




|
>


|







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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
    "CacheCleanupWizard",
]
INVALID_INDEX = QModelIndex()
SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation


@functools.lru_cache(maxsize=256)
def get_image_for_tooltip_display(path: pathlib.Path, card_name: OptStr = None) -> str:
    scaling_factor = 3
    source = QPixmap(str(path))
    pixmap = source.scaled(
        source.width() // scaling_factor, source.height() // scaling_factor,
        Qt.AspectRatioMode.KeepAspectRatio, 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 ""
    tooltip_text = f'{card_name}<img src="data:image/png;base64,{image}">'
    return tooltip_text


class KnownCardColumns(enum.IntEnum):
    Name = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    IsHidden = enum.auto()
    IsFront = enum.auto()







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







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
107
108
109
110
111
112
113
114
    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_image_for_tooltip_display(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:







|







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
243
244
245
246
247
248
249
250
            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_image_for_tooltip_display(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:







|







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
493
494
495
    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_image_for_tooltip_display.cache_info()}")
        # Free memory by clearing the cached, base64 encoded PNGs used for tooltip display
        get_image_for_tooltip_display.cache_clear()







|

|
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
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
#  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 pathlib
import platform
import typing

from PyQt5.QtCore import QFile, QUrl, QObject, QSize, QCoreApplication
from PyQt5.QtWidgets import QLabel, QWizard, QWidget, QGraphicsColorizeEffect, QTextEdit
from PyQt5.QtGui import QIcon
# noinspection PyUnresolvedReferences
from PyQt5 import uic


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",
    "set_url_label",
    "load_ui_from_file",
    "format_size",
    "WizardBase",

]

try:
    import mtg_proxy_printer.ui.compiled_resources
except ModuleNotFoundError:
    RESOURCE_PATH_PREFIX = str(pathlib.Path(__file__).resolve().parent.with_name("resources"))
    ICON_PATH_PREFIX = str(pathlib.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)

























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)







|
|

|

|

|



>










<



>





|
|









>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

    def __enter__(self):
        self.qt_object.blockSignals(True)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.qt_object.blockSignals(False)


def set_url_label(label: QLabel, path: pathlib.Path, display_text: str = None):

    url = QUrl.fromLocalFile(str(path.expanduser()))
    if not label.openExternalLinks():
        # The openExternalLinks property is not set in the UI file, so fail fast instead of doing workarounds.
        raise ValueError(
            f"QLabel with disabled openExternalLinks property used to display an external URL. This won’t work, so "
            f"fail now. Label: {label}, Text: {label.text()}")
    if not display_text:
        display_text = str(path)
    label.setText(f"""<a href="{url.path(QUrl.FullyEncoded):s}">{display_text:s}</a>""")


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







<
<
<
<
<
<
<
<
<
<
<
<
<







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
138
139
140
141
142
143
144
145
            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: typing.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}")







|







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
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
#
#  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 math
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, \
    QItemSelection, 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, CardListColumns
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.ui.item_delegates import CardListComboBoxItemDelegate, SpinboxItemDelegate
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:







<







|










|


<







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
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
        # 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)
        self.ui = Ui_SummaryPage()
        self.ui.setupUi(self)
        self.setCommitPage(True)
        self.card_list = CardListModel(card_db, self)
        self.card_list_sort_model = self._create_sort_model(self.card_list)
        self.card_list.oversized_card_count_changed.connect(self._update_accept_button_on_oversized_card_count_changed)
        self.ui.parsed_cards_table.setModel(self.card_list_sort_model)
        self.delegates = self._setup_parsed_cards_table()
        self.selected_cells_count = 0
        self.registerField("should_replace_document", self.ui.should_replace_document)
        self.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)







>
>
>
>
>
>
>

>

|
|


<

|
<
<

|







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
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
        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 _setup_parsed_cards_table(self) -> typing.Tuple[CardListComboBoxItemDelegate, SpinboxItemDelegate]:
        self.ui.parsed_cards_table.selectionModel().selectionChanged.connect(self.parsed_cards_table_selection_changed)
        delegate = CardListComboBoxItemDelegate(self.ui.parsed_cards_table)
        copies_delegate = SpinboxItemDelegate(self.ui.parsed_cards_table)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Copies, copies_delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Set, delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.CollectorNumber, delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Language, delegate)
        for column, scaling_factor in (  # These factors are empirically determined to give reasonable column sizes
                (CardListColumns.Copies, 0.9),
                (CardListColumns.CardName, 2),
                (CardListColumns.Set, 2.75),
                (CardListColumns.CollectorNumber, 0.95),
                (CardListColumns.Language, 0.9)):
            new_size = math.floor(self.ui.parsed_cards_table.columnWidth(column) * scaling_factor)
            self.ui.parsed_cards_table.setColumnWidth(column, new_size)
        return delegate, copies_delegate

    def initializePage(self) -> None:
        super().initializePage()
        self.selected_cells_count = 0
        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:







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


<







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

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
            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)

        have_basic_land_removal_button = not decklist_import_section.getboolean("automatically-remove-basic-lands")
        wizard.setOption(WizardOption.HaveCustomButton1, have_basic_land_removal_button)
        remove_basic_lands_button = wizard.button(WizardButton.CustomButton1)
        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(WizardOption.HaveCustomButton2, True)
        remove_selected_cards_button = wizard.button(WizardButton.CustomButton2)
        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"))




    def cleanupPage(self):
        self.card_list.clear()
        super().cleanupPage()
        wizard = self.wizard()
        wizard.customButtonClicked.disconnect(self.custom_button_clicked)
        wizard.setOption(WizardOption.HaveCustomButton1, False)
        wizard.setOption(WizardOption.HaveCustomButton2, False)



        logger.debug(f"Cleaned up {self.__class__.__name__}")

    @Slot()
    def isComplete(self) -> bool:
        return self.card_list.rowCount() > 0

    @Slot(QItemSelection, QItemSelection)
    def parsed_cards_table_selection_changed(self, selected: QItemSelection, deselected: QItemSelection):
        self.selected_cells_count += selected.count() - deselected.count()
        logger.debug(f"Selection changed: Currently selected cells: {self.selected_cells_count}")
        wizard = self.wizard()
        wizard.button(WizardButton.CustomButton2).setEnabled(self.selected_cells_count > 0)

    @Slot(int)
    def custom_button_clicked(self, button_id: int):
        button = WizardButton(button_id)
        self.wizard().button(button).setEnabled(False)
        if button == WizardButton.CustomButton1:
            logger.info("User requests to remove all basic lands")
            self._remove_basic_lands()
        elif button == WizardButton.CustomButton2:
            self._remove_selected_cards()
            self.selected_cells_count = 0

    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")

        selection_mapped_to_source = self.card_list_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()









>
|
|
|






|
|




>
>
>






|
|
>
>
>






<
<
<
<
<
<
<




|


|

<









>
|







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
635
636
637
638
639
640
641
642
            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(
            self.summary_page.card_list.as_cards(self.summary_page.card_list_sort_model.row_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







>

|







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
27

28
29
30
31
32
33
34

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 Card, CardDatabase

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







|
>







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
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














































































#  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
from PyQt5.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QComboBox, QSpinBox

from mtg_proxy_printer.model.carddb import Card
from mtg_proxy_printer.model.card_list import CardListColumns
from mtg_proxy_printer.model.document import Document, PageColumns
from mtg_proxy_printer.logger import get_logger








logger = get_logger(__name__)
del get_logger
__all__ = [
    "ComboBoxItemDelegate",

    "DocumentComboBoxItemDelegate",
    "CardListComboBoxItemDelegate",
    "SpinboxItemDelegate",
]
ItemDataRole = Qt.ItemDataRole
















class SpinboxItemDelegate(QStyledItemDelegate):

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox:
        editor = QSpinBox(parent)
        editor.setMinimum(1)
        editor.setMaximum(100)
        return editor


class ComboBoxItemDelegate(QStyledItemDelegate):
    """
    Editor widget allowing the user to switch a card printing by offering a choice among valid alternatives.
    """
    COLUMNS: typing.Union[PageColumns, CardListColumns] = None

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QComboBox:
        editor = QComboBox(parent)


        return editor

    def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None:
        model: typing.Union[Document, QSortFilterProxyModel] = index.model()
        column = index.column()
        while hasattr(model, "sourceModel"):  # Resolve the source model to gain access to the card database.
            model = model.sourceModel()
        source_model: Document = model
        card: Card = index.data(ItemDataRole.UserRole)
        if hasattr(self.COLUMNS, "Copies") and column == self.COLUMNS.Copies:
            pass
        elif column == self.COLUMNS.Set:
            matching_sets = source_model.card_db.get_available_sets_for_card(card)
            current_set_code = card.set.code
            current_set_position = 0
            for position, set_data in enumerate(matching_sets):
                editor.addItem(set_data.data(ItemDataRole.DisplayRole), set_data.data(ItemDataRole.EditRole))
                if set_data.code == current_set_code:
                    current_set_position = position
            editor.setCurrentIndex(current_set_position)

        elif column == self.COLUMNS.CollectorNumber:
            matching_collector_numbers = source_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)))

        elif column == self.COLUMNS.Language:
            card = index.data(ItemDataRole.UserRole)
            matching_languages = source_model.card_db.get_available_languages_for_card(card)
            for language in matching_languages:
                editor.addItem(language, language)
            if matching_languages:
                editor.setCurrentIndex(matching_languages.index(index.data(ItemDataRole.EditRole)))

    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 DocumentComboBoxItemDelegate(ComboBoxItemDelegate):



    COLUMNS = PageColumns







class CardListComboBoxItemDelegate(ComboBoxItemDelegate):













    COLUMNS = CardListColumns





















































































>


|

|
<
|

>
>
>
>
>
>
>




|
>
|
|
|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>







|
<
<
<
<
|
|

>
>


<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
|
>
>

>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
27
28
29
30
31
32
33
34

35
36
37
38
39
40
41
42
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, Card, MTGSet
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.units_and_sizes import DEFAULT_SAVE_SUFFIX, CardSizes
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







|







>
|







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
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
    @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
        self.current_dialog = PrintDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_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 = PrintPreviewDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_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 = SavePDFDialog(self, self.document)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_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)








>
>
>
>
>
>
>
>








|
|
|









|
|
|









|
|
|







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
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
            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 = DocumentSettingsDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_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 = SaveDocumentAsDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_dialog.open()

    @Slot()
    def on_action_load_document_triggered(self):
        self.current_dialog = LoadDocumentDialog(self, self.document)
        self.current_dialog.accepted.connect(self.ui.central_widget.select_first_page)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_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 '







|
|
|








|
|
|



|
|
|
|







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
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
            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 images := self._to_pixmaps(event):
            logger.info(f"User drags {len(images)} 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 images := self._to_pixmaps(event):
            logger.info(f"User dropped {len(images)} images onto the main window, adding them as custom cards")
            for image in images:
                card = Card(
                    "Custom card", MTGSet("CUS", "Custom"), "", "", "", True, "", "", True,
                    CardSizes.REGULAR, 1, False, image)
                action = ActionAddCard(card)
                self.document.apply(action)



    @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.
        """







|
|








|
|
<
<
<
<
<
|
>
>







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
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
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 Card, CheckCard, CardDatabase, AnyCardType, CardList, AnyCardTypeForTypeCheck

from mtg_proxy_printer.model.document import PageColumns, Document

from mtg_proxy_printer.ui.item_delegates import DocumentComboBoxItemDelegate

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._combo_box_delegate = self._setup_combo_box_item_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 = DocumentComboBoxItemDelegate(self)
        self.setItemDelegateForColumn(PageColumns.CollectorNumber, combo_box_delegate)




        self.setItemDelegateForColumn(PageColumns.Set, combo_box_delegate)




        self.setItemDelegateForColumn(PageColumns.Language, combo_box_delegate)
        return combo_box_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()}")







|
>
|
>
|
















>
|
>
>
>















<

|

>
>
>
>
|
>
>
>
>
|
|







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
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.carddb import Card, MTGSet
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")







|







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
28
29

30
31
32
33
34
35
36

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.carddb import Card, CardCorner
from mtg_proxy_printer.model.document import Document, 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








|
|
>







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
540






541
542
543
544
545
546
547
        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:






            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)







|
>
>
>
>
>
>







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
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 = [







|







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

47
48
49
50
51
52
53
54
"""

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",
}

TRANSLATIONS_DIR = pathlib.Path("mtg_proxy_printer/resources/translations/")
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")







>


>
>

















>
|







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
126
127
128
129




130
131
132
133
134
135
136
        "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"
    ])






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:







|
|
|
|
>
>
>
>







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
37
38
39
40
41
42
43
44
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
from tests.helpers import fill_card_database_with_json_cards


@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):







>



>






|







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
19

20
21
22
23
24
25
26
#
#  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, 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 *







|
>







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
23

24
25
26
27
28
29
30

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData

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," \







|
>







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
23

24
25
26
27
28
29
30

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData

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"







|
>







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
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.carddb import Card, MTGSet, 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",







|







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
23
24
25
26
27
28
29
30

import copy
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import Card, MTGSet, 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








|







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
22
23
24
25
26
27
28
29


from unittest.mock import MagicMock

from hamcrest import *
import pytest

from mtg_proxy_printer.model.carddb 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







|







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
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
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
from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT, CardSizes
from mtg_proxy_printer.model.carddb import CheckCard
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.model.imagedb_files import ImageKey
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.test_document import document_custom_layout
from tests.helpers import close_to_, 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, 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"""

    card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    capacity = document_custom_layout.page_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(document_custom_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-v6", False) 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-v6") 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):



    """Tests that saving a new document uses the newest database schema version"""
    layout = copy.copy(document_custom_layout.page_layout)
    layout.page_height = 1000*mm
    card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    # Prevent network access when re-loading the document
    blank = document_custom_layout.image_db.get_blank(CardSizes.REGULAR)
    document_custom_layout.image_db.loaded_images[ImageKey(card.scryfall_id, card.is_front, card.highres_image)] = blank

    cards_per_page = document_custom_layout.page_layout.compute_page_card_capacity(card.requested_page_type())



    document_custom_layout.apply(ActionAddCard(card, cards_per_page))

    save_file = Path(tmp_path)/"test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document_custom_layout)
    _validate_database_schema(save_file)
    _validate_saved_document_settings(document_custom_layout, save_file)
    with qtbot.waitSignal(document_custom_layout.page_layout_changed):
        document_custom_layout.apply(ActionEditDocumentSettings(layout))
    action.apply(document_custom_layout)
    with qtbot.waitSignals([document_custom_layout.loading_state_changed]*2,
                           check_params_cbs=[lambda value: value, lambda value: not value]):
        document_custom_layout.loader.load_document(save_file)
    assert_that(
        document_custom_layout.page_layout.page_height,
        is_(quantity_close_to(1000*mm)))


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







>

|
|


<




|
|



















|




>

|





|









|


















|










>
>
>
|
|
|
<
<
|
<
>
|
>
>
>
|

<
<
<
<
<
|
|
|
<
<
<
<
|
<







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
191
192
193
194
195
196
197
198
199
            "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(document: Document, save_file: Path):
    layout = document.page_layout
    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







|
<







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
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.carddb 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








|







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
36
37
38
39
40
41
42
43
44
45
46
47
#  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
from mtg_proxy_printer.units_and_sizes import CardDataType, StrDict, QuantityT
import mtg_proxy_printer.logger
import mtg_proxy_printer.settings
from mtg_proxy_printer.sqlite_helpers import read_resource_text



def _should_skip_network_tests() -> bool:
    result = os.getenv("MTGPROXYPRINTER_RUN_NETWORK_TESTS", "0")
    try:
        result = int(result)
    except ValueError:







>


>
>












>
>
>

|


|
<







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
27

28
29
30
31
32
33
34
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, Card, MTGSet

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):







|
>







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.

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)
    )
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































Deleted tests/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, CardList, \
    Card, MTGSet
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)
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Changes to tests/test_check_card_rendering.py.

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.carddb import Card, CheckCard, MTGSet
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)







|







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.

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.carddb import Card, MTGSet
from mtg_proxy_printer.model.document import Document, 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, document_custom_layout: Document):
    default_layout = PageLayoutSettings.create_from_settings()
    assert_that(
        document_custom_layout,
        has_property("page_layout", not_(equal_to(default_layout)))
    )
    assert_that(
        document_custom_layout.page_layout.compute_page_row_count(),
        is_not(equal_to(default_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(default_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()
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted tests/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
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
#  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 tempfile
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.carddb import CheckCard
import mtg_proxy_printer.sqlite_helpers
from mtg_proxy_printer.model.document import PageColumns, Document
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

CardType = mtg_proxy_printer.model.document_loader.CardType
mm: UnitT = unit_registry.mm


@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):
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)",
        (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.execute(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)',
        (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r")
    )
    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,
    )
    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    ActionSaveDocument.save_settings(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]):
            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_(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)))
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.REGULAR),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.REGULAR)))
    )
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.OVERSIZED),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.OVERSIZED)))
    )

def test_empty_document_loads_correctly(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    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,
    )
    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    ActionSaveDocument.save_settings(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)))
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.REGULAR),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.REGULAR)))
    )
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.OVERSIZED),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.OVERSIZED)))
    )


def test_document_with_mixed_pages_distributes_cards_based_on_size(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)",
        (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r"),
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b", "r"),
         ]
    )
    ActionSaveDocument.save_settings(empty_save_database, PageLayoutSettings.create_from_settings())
    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]):
            loader.load_document(save_path)
        mock.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.value)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR.value)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR.value)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.REGULAR.value)),
    zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.CHECK_CARD.value)),
    # 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.value]),
    zip(repeat(1), repeat(1), repeat(0), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD.value]),
))
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") 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_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") 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 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):
    ActionSaveDocument.save_settings(empty_save_database, document.page_layout)
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)", (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r"),
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b", "r"),
        ]
    )
    loader = document.loader
    loader.begin_loading_loop.connect(loader.cancel)
    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_signals(
                [loader.begin_loading_loop, loader.progress_loading_loop,
                 loader.loading_state_changed, loader.finished], timeout=100):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))


def test_loads_check_card(
        qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection):
    ActionSaveDocument.save_settings(empty_save_database, document.page_layout)
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)", (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", CardType.CHECK_CARD.value),
         ]
    )
    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=product([
    (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)]),

    ], [True, False]))
def legacy_save_file(request):
    (save_version, settings), reverse_unordered = request.param  # type: (int, list), bool
    db = mtg_proxy_printer.sqlite_helpers.open_database(":memory:", f"document-v{save_version}", False)
    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
    if reverse_unordered:
        db.execute("PRAGMA reverse_unordered_selects = TRUE")
    yield db
    db.close()
    del db


def test_load_settings_from_legacy_save_file_is_successful(
        qtbot: QtBot, legacy_save_file: sqlite3.Connection, document_light: Document):
    loader = document_light.loader
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=legacy_save_file), \
            qtbot.wait_signal(document_light.action_applied, timeout=2**30), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))
    annotations = document_light.page_layout.__annotations__
    assert_that(
        document_light.page_layout,
        has_properties({
            item: instance_of(pint.Quantity if value is QuantityT else value)
            for item, value in annotations.items()
        })
    )
    assert_that(document_light.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, empty_save_database: sqlite3.Connection, document_light: Document, title: str):
    loader = document_light.loader
    annotations = document_light.page_layout.__annotations__
    ActionSaveDocument.save_settings(empty_save_database, document_light.page_layout)
    empty_save_database.execute(
        "UPDATE DocumentSettings SET value = ? WHERE key = ?",
        (title, "document_name"))

    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database), \
            qtbot.wait_signal(document_light.action_applied), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))

    assert_that(
        document_light.page_layout,
        has_properties({
            item: instance_of(pint.Quantity if value is QuantityT else value)
            for item, value in annotations.items()
        })
    )
    assert_that(document_light.page_layout, has_property("document_name", equal_to(title)))
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted tests/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_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
39
40
41
42
43
44
45
46
47
48
49
50

from tests.hasgetter import has_getters
from tests.helpers import quantity_close_to

mm: UnitT = unit_registry.mm


@pytest.fixture
def page_layout():
    layout = PageLayoutSettings.create_from_settings()
    return layout


@pytest.mark.parametrize("page_type, expected", [
    (PageType.OVERSIZED, 4),
    (PageType.REGULAR, 9),
    (PageType.MIXED, 9),
    (PageType.UNDETERMINED, 9),
])







<
<
<
<
<







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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
    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():
    layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings()
    layout.page_height = 300*mm
    assert_that(layout.compute_page_card_capacity(PageType.REGULAR), is_(0))
    assert_that(layout, is_not(greater_than(layout)))


def test_page_layout_lt():
    layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings.create_from_settings()
    assert_that(layout.compute_page_card_capacity(PageType.REGULAR), is_(9))
    assert_that(layout, is_not(less_than(layout)))


@pytest.mark.parametrize("values", [
    {
        "paper-height": "200 mm",
        "paper-width": "100 mm",
        "margin-top": "9 mm",







|
<
|
|
|


|
<
|
|







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
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
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
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(source_version: int):
    settings = PageLayoutSettings.create_from_settings()
    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, settings)
    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():
    db = create_save_db(3)
    settings = PageLayoutSettings.create_from_settings()
    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, settings)
    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, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude, settings.margin_bottom.to("mm").magnitude,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(settings.draw_cut_markers)
         )
    ))

def test_migration_4_to_5_transforms_data():
    db = create_save_db(4)
    settings = PageLayoutSettings.create_from_settings()
    settings.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, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers))
    )
    mtg_proxy_printer.save_file_migrations._migrate_4_to_5(db, settings)
    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, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers), int(settings.draw_sharp_corners)
         )
    ))

def test_migration_5_to_6_transforms_data():
    db = create_save_db(5)
    settings = PageLayoutSettings.create_from_settings()
    settings.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, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers), int(not settings.draw_sharp_corners))
    )
    mtg_proxy_printer.save_file_migrations._migrate_5_to_6(db, settings)
    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", settings.page_height.to("mm").magnitude), ("page_width", settings.page_width.to("mm").magnitude),
            ("margin_top", settings.margin_top.to("mm").magnitude+1),("margin_bottom", settings.margin_bottom.to("mm").magnitude-1),
            ("margin_left", settings.margin_left.to("mm").magnitude),("margin_right", settings.margin_right.to("mm").magnitude),
            ("row_spacing", settings.row_spacing.to("mm").magnitude),("column_spacing", settings.column_spacing.to("mm").magnitude),
            ("draw_cut_markers", int(not settings.draw_cut_markers)),("draw_sharp_corners", int(not settings.draw_sharp_corners)),
            # New
            ("document_name", settings.document_name), ("card_bleed", settings.card_bleed.to("mm").magnitude),
            ("draw_page_numbers", int(settings.draw_page_numbers)),
    ))


def test_migration_6_to_7_transforms_data():
    db = create_save_db(6)
    settings = PageLayoutSettings.create_from_settings()
    settings.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, "r"), (2, 1, 1, uuid2, "r")])
    # 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", settings.page_height.to("mm").magnitude), ("page_width", settings.page_width.to("mm").magnitude),
            ("margin_top", settings.margin_top.to("mm").magnitude+1),("margin_bottom", settings.margin_bottom.to("mm").magnitude-1),
            ("margin_left", settings.margin_left.to("mm").magnitude),("margin_right", settings.margin_right.to("mm").magnitude),
            ("row_spacing", settings.row_spacing.to("mm").magnitude),("column_spacing", settings.column_spacing.to("mm").magnitude),
            ("card_bleed", settings.card_bleed.to("mm").magnitude),
            ("document_name", settings.document_name),
            ("draw_cut_markers", int(not settings.draw_cut_markers)),("draw_sharp_corners", int(not settings.draw_sharp_corners)),
            ("draw_page_numbers", int(settings.draw_page_numbers)),
        )
    )
    mtg_proxy_printer.save_file_migrations._migrate_6_to_7(db, settings)
    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 settings.draw_cut_markers)),
            contains_exactly("draw_sharp_corners", str(not settings.draw_sharp_corners)),
            contains_exactly("document_name", settings.document_name),
            contains_exactly("draw_page_numbers", str(settings.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(settings.page_height)), contains_exactly("page_width", quantity_close_to(settings.page_width)),
            contains_exactly("margin_top", quantity_close_to(settings.margin_top+1*mm)), contains_exactly("margin_bottom", quantity_close_to(settings.margin_bottom-1*mm)),
            contains_exactly("margin_left", quantity_close_to(settings.margin_left)), contains_exactly("margin_right", quantity_close_to(settings.margin_right)),
            contains_exactly("row_spacing", quantity_close_to(settings.row_spacing)), contains_exactly("column_spacing", quantity_close_to(settings.column_spacing)),
            contains_exactly("card_bleed", quantity_close_to(settings.card_bleed)),
        ),
        f"Bad settings: {data}"
    )
    assert_that(db.execute("SELECT * FROM CustomCardData").fetchall(), is_(empty()))








|




















|
<




|












|

<


|







|
|
|
|
|



|

<
|






|
|
|
|
|

|







|
|
|
|
|



|

<
|





|
|
|
|
|

|








|
|
|
|
|

|
|



|

<
|


|




|
|
|
|
|
|
|
|


|








|
|
|
|





|
|
|
|
|





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

20
21

















22
23
24
25
26
27
28
#  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 mtg_proxy_printer.units_and_sizes import UUID, CardSizes, CardSize
from tests.hasgetter import has_getters


















@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(







>
|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
49
50
51
52
53











54
55
56
57
58
59
60
    "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_valueerror(input_str: str):
    assert_that(
        calling(UUID).with_args(input_str),
        raises(ValueError)
    )












@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", [







|




>
>
>
>
>
>
>
>
>
>
>







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
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
#  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 itertools
from unittest.mock import NonCallableMagicMock

from PyQt5.QtWidgets import QComboBox
import pytest
from hamcrest import *


from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.model.carddb import CardDatabase, Card, MTGSet
from mtg_proxy_printer.model.card_list import CardListModel, CardListColumns
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.units_and_sizes import CardSizes

from mtg_proxy_printer.ui.item_delegates import CardListComboBoxItemDelegate, DocumentComboBoxItemDelegate


@pytest.fixture(params=itertools.product(range(3), ["", "language"]))
def card_list_empty_carddb(card_db: CardDatabase, request):
    proxy_level, language = request.param  # type: int, str
    card = Card("", MTGSet("", ""), "", language, "", True, "", "", True, CardSizes.REGULAR, 1, False, None)

    source_model = CardListModel(card_db)
    source_model.add_cards(Counter({card: 1}))



    # Having this list keeps the references alive. Without keeping them here, access in test code raises
    #   RuntimeError: wrapped C/C++ object of type NaturallySortedSortFilterProxyModel has been deleted
    models = [source_model]
    for _ in range(proxy_level):


        proxy = NaturallySortedSortFilterProxyModel()
        proxy.setSourceModel(models[-1])
        models.append(proxy)
    yield models[-1]


@pytest.fixture(params=["", "language"])
def document_empty_carddb(card_db: CardDatabase, request):
    card = Card("", MTGSet("", ""), "", request.param, "", True, "", "", True, CardSizes.REGULAR, 1, False, None)
    source_model = Document(card_db, NonCallableMagicMock(spec=ImageDatabase))
    ActionAddCard(card).apply(source_model)
    yield source_model



@pytest.mark.parametrize("column", CardListModel.EDITABLE_COLUMNS-{CardListColumns.Copies})
def test_setEditorData_on_card_list_empty_model(qtbot, card_db: CardDatabase, card_list_empty_carddb, column):

    editor_widget = QComboBox()
    delegate = CardListComboBoxItemDelegate()
    index = card_list_empty_carddb.index(0, column)
    delegate.setEditorData(editor_widget, index)
    assert_that(editor_widget.model().rowCount(), is_(1))  # Data from the source card must round-trip



@pytest.mark.parametrize("column", Document.EDITABLE_COLUMNS)
def test_setEditorData_on_document_empty_model(qtbot, card_db: CardDatabase, document_empty_carddb, column):

    editor_widget = QComboBox()
    delegate = DocumentComboBoxItemDelegate()
    page_index = document_empty_carddb.index(0, 0)
    index = document_empty_carddb.index(0, column, page_index)
    delegate.setEditorData(editor_widget, index)

    assert_that(editor_widget.model().rowCount(), is_(1))  # Data from the source card must round-trip







|
<
<
<
<
|


>

<
<
<
|
<
<
|

|


|
<
<
<
>
|
|
>
>
>
|
|
<
<
>
>
<
<
<
<

|
<
<
<
<
<
<
>


|
|
>
|
<
<
|
|
>


|
|
>
|
<
<
<
|
>
|
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
44
45
46
47
48
49
50
51
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

from tests.helpers import fill_card_database_with_json_cards
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"])







>












|







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
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
        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_main_window_hides_progress_bar_after_downloading_image_during_load(
        qtbot: QtBot, main_window: MainWindow):
    with unittest.mock.patch.object(  # Mock all HTTP-specific I/O calls
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "_read_content_length") as cl_mock, \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "getcode", return_value=200), \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "content_encoding", return_value="identity"), \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "seekable", return_value=True), \
            unittest.mock.patch("mtg_proxy_printer.ui.main_window.QMessageBox.warning") as mb1, \
            unittest.mock.patch("mtg_proxy_printer.ui.main_window.QMessageBox.critical") as mb2:
        temp_path = main_window.image_db.db_path
        mock_image_path = _create_mock_image(main_window.image_db, temp_path)
        cl_mock.return_value = mock_image_path.stat().st_size
        main_window.card_database.db.execute("UPDATE CardFace SET png_image_uri = ?", (mock_image_path.as_uri(),))
        save_file_path = _create_save_file(temp_path)

        assert_that(main_window.progress_bars.ui.inner_progress_bar.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.outer_progress_bar.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.inner_progress_label.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.outer_progress_label.isVisible(), is_(False))

        with qtbot.wait_signals(
                [(main_window.loading_state_changed, "loading_state_changed(True)"),
                 (main_window.loading_state_changed, "loading_state_changed(False)"),
                 (main_window.document.loader.finished, "finished"),], timeout=1000,
                check_params_cbs=[lambda value: value, lambda value: not value, lambda: True]):
            main_window.document.loader.load_document(save_file_path)
            
    assert_that(main_window.progress_bars.ui.inner_progress_bar.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.outer_progress_bar.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.inner_progress_label.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.outer_progress_label.isVisible(), is_(False))
    mb1.assert_not_called()
    mb2.assert_not_called()


def _create_mock_image(image_db: ImageDatabase, temp_path: pathlib.Path) -> pathlib.Path:
    mock_image_path = temp_path / 'temp' / "0000579f-7b35-4ed3-b44c-db2a538066fe.png"
    mock_image_path.parent.mkdir(parents=True, exist_ok=False)
    image_db.get_blank().save(str(mock_image_path), "PNG", 100)
    assert_that(mock_image_path.is_file(), is_(True))
    return mock_image_path


def _create_save_file(temp_path: pathlib.Path):
    save_file_path = temp_path/"test.mtgproxies"
    with open_database(save_file_path, "document-v7") as save_file:
        ActionSaveDocument.save_settings(save_file, PageLayoutSettings.create_from_settings())
        save_file.execute(
            "INSERT INTO Page(page, image_size) VALUES (?, ?)",
            (1, CardSizes.REGULAR.to_save_data())
        )
        save_file.execute(
            "INSERT INTO Card (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)",
            (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r")
        )
    return save_file_path


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(







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







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
25

26
27
28
29
30
31
32
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 Card, MTGSet, CheckCard, CardDatabase, 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()







|
>







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
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):
    container = PageConfigContainer()
    container.ui.page_config_widget.load_from_page_layout(PageLayoutSettings.create_from_settings())
    qtbot.add_widget(container)
    return container


@pytest.mark.parametrize(
    "widget_name",
    ["draw_cut_markers", "draw_sharp_corners", "draw_page_numbers"])







|
|

|







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"])