MTGProxyPrinter

Changes On Branch bogus
Login

Changes On Branch bogus

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch bogus Excluding Merge-Ins

This is equivalent to a diff from 7fd2678252 to 1d17856aa5

2025-10-05
16:01
Fixed crash when trying to switch the printing o a card. This was caused by a double signal connection, applying the same DocumentAction twice. Fixes [4ddeb969368396ad] check-in: b0fb2950a9 user: thomas tags: trunk
11:51
Patch Qt.ConnectionType: Add an __or__(other: ConnectionType) implementation that allows combining Qt ConnectionType items as stated by the Qt documentation. check-in: c5969183c1 user: thomas tags: prevent_duplicate_connections
2025-10-02
16:35
Implemented moving cards around via drag&drop. Implements [851b0af285008db4], [fb4ed046b020652d]. Closed-Leaf check-in: 1d17856aa5 user: thomas tags: bogus
16:35
Updated changelog entry. check-in: f65e828585 user: thomas tags: drag_drop_move_cards
2025-09-25
20:43
Merge with trunk, importing crash fixes check-in: 7e2f3a4228 user: thomas tags: drag_drop_move_cards
20:40
Restore connection between DocumentLoader.unknown_scryfall_ids_found signal and main_window.on_document_loading_found_unknown_scryfall_ids slot. check-in: 7fd2678252 user: thomas tags: trunk
20:33
Fixed crash when loading a document containing a hidden printing without an available replacement. Fixes [9f34dd5d1d2f1ce3] check-in: ba1171e668 user: thomas tags: trunk

Changes to doc/changelog.md.
1
2
3
4







5
6
7
8
9
10
11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18




+
+
+
+
+
+
+







# Changelog

# Next version (in development)

# New features

- Move cards around via drag&drop
  - Drop onto other pages to move cards to those pages
  - Drop cards between pages to move those cards onto a new page at that location
  - Re-order cards within a page

# Fixed issues

- Fixed crash when trying to load a document that contains a hidden printing
  that does not have an available replacement printing.
- Restored reporting if hidden printings were migrated to available ones during document loading 

# Version 0.34.0 (2025-09-11)  <a name="v0_34_0"></a>
Changes to mtg_proxy_printer/document_controller/compact_document.py.
17
18
19
20
21
22
23
24

25
26
27
28
29
30
31
17
18
19
20
21
22
23

24
25
26
27
28
29
30
31







-
+








from PySide6.QtCore import QObject

from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import PageType
from ._interface import DocumentAction, IllegalStateError, ActionList, Self
from .page_actions import ActionRemovePage
from .move_cards import ActionMoveCards
from .move_cards import ActionMoveCardsBetweenPages

from mtg_proxy_printer.logger import get_logger


logger = get_logger(__name__)
del get_logger
__all__ = [
79
80
81
82
83
84
85
86

87
88
89
90
91
92
93
79
80
81
82
83
84
85

86
87
88
89
90
91
92
93







-
+







                logger.debug(f"Found {cards_to_add} empty slots on page {current_index}")
                while cards_to_add and current_index < last_index:
                    page_to_draw_from = document.pages[last_index]
                    if page_to_draw_from.page_type() is not page_type:
                        last_index -= 1
                        continue
                    cards_to_take = min(len(page_to_draw_from), cards_to_add)
                    action = ActionMoveCards(last_index, range(cards_to_take), current_index)
                    action = ActionMoveCardsBetweenPages(last_index, range(cards_to_take), current_index)
                    self.actions.append(action.apply(document))
                    cards_to_add -= cards_to_take
                    logger.debug(f"Moved {cards_to_take} from page {last_index} to page {current_index}. "
                                 f"Free slots in target: {maximum_cards_per_page-len(current_page)}")
                    if not page_to_draw_from:
                        logger.debug(f"Page {last_index} now empty.")
                        last_index -= 1
Changes to mtg_proxy_printer/document_controller/edit_document_settings.py.
18
19
20
21
22
23
24
25

26
27
28
29
30
31
32
18
19
20
21
22
23
24

25
26
27
28
29
30
31
32







-
+







import itertools
import typing

from PySide6.QtCore import QObject

from ._interface import DocumentAction, ActionList, Self, split_iterable
from .card_actions import ActionRemoveCards
from .move_cards import ActionMoveCards
from .move_cards import ActionMoveCardsBetweenPages
from .page_actions import ActionNewPage
from mtg_proxy_printer.logger import get_logger

from mtg_proxy_printer.units_and_sizes import PageType
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

if typing.TYPE_CHECKING:
108
109
110
111
112
113
114
115

116
117
118
119
120
121
122
108
109
110
111
112
113
114

115
116
117
118
119
120
121
122







-
+







        start_index = document.find_page_list_index(partition.pages[0])
        end_index = start_index + partition.size()
        page_capacity = document.page_layout.compute_page_card_capacity(partition.page_type)
        # TODO: The algorithm currently isn't very optimized. This loop should insert new pages,
        #  if the excess exceeds some threshold.
        for page_index, page in enumerate(partition.pages[:-1], start=start_index):
            if (page_length := len(page)) > page_capacity:
                action = ActionMoveCards(page_index, range(page_capacity, page_length), page_index+1, 0)
                action = ActionMoveCardsBetweenPages(page_index, range(page_capacity, page_length), page_index + 1, 0)
                self.reflow_actions.append(action.apply(document))
        last_page = partition.pages[-1]
        if (page_length := len(last_page)) > page_capacity:
            excess = (c.card for c in last_page[page_capacity:])
            excess = split_iterable(excess, page_capacity)
            self.reflow_actions.append(ActionRemoveCards(range(page_capacity, page_length), end_index-1).apply(document))
            self.reflow_actions.append(ActionNewPage(end_index, count=len(excess), content=excess).apply(document))
Changes to mtg_proxy_printer/document_controller/move_cards.py.
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
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







-
+









+








-
+
+



-
+





-
+










+


+
+
+
+
+
+
-
+





+
+











+

-



-
+







#  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 enum
from collections.abc import Sequence
import functools
import typing

from PySide6.QtCore import QModelIndex, QObject

from mtg_proxy_printer.natsort import to_list_of_ranges
from ._interface import DocumentAction, IllegalStateError, Self
from mtg_proxy_printer.logger import get_logger
from .page_actions import ActionNewPage

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document_page import Page
    from mtg_proxy_printer.model.document import Document

logger = get_logger(__name__)
del get_logger
__all__ = [
    "ActionMoveCards",
    "ActionMoveCardsBetweenPages",
    "ActionMoveCardsWithinPage",
]


class ActionMoveCards(DocumentAction):
class ActionMoveCardsBetweenPages(DocumentAction):
    """
    Moves a sequence of cards from a source page to a target page. By default, cards are appended.
    Values of consecutive card ranges are inclusive.
    """

    COMPARISON_ATTRIBUTES = ["source_page", "target_page", "card_ranges_to_move", "target_row"]
    COMPARISON_ATTRIBUTES = ["source_page", "target_page", "card_ranges_to_move", "target_row", "insert_page_action"]

    def __init__(
            self, source: int, cards_to_move: Sequence[int],
            target_page: int, target_row: int = None, parent: QObject = None):
        """
        :param source: The source page, as integer page number (0-indexed)
        :param cards_to_move: The cards to move, as indices into the source Page. May be in any order. (0-indexed)
        :param target_page: The target page, as integer page number. (0-indexed)
        :param target_row: If given, the cards_to_move are inserted at that array index (0-indexed).
                           Existing cards in the target page at that index are pushed back.
                           None means "append". -1 means "0, but insert new page at target_page"
        """
        super().__init__(parent)
        if target_row == -1:
            target_row = None
            self.insert_page_action = ActionNewPage(target_page, parent=self)
        else:
            self.insert_page_action = None
        # When inserting a new page before the source page, add one to compensate
        self.source_page = source
        self.source_page = source + (target_page < source and self.insert_page_action is not None)
        self.target_page = target_page
        self.target_row = target_row
        self.card_ranges_to_move = to_list_of_ranges(cards_to_move)

    def apply(self, document: "Document") -> Self:
        if self.insert_page_action is not None:
            self.insert_page_action.apply(document)
        source_page = document.pages[self.source_page]
        target_page = document.pages[self.target_page]
        source_page_type = source_page.page_type()
        target_page_type = target_page.page_type()
        if not target_page.accepts_card(source_page_type):
            raise IllegalStateError(
                f"Can not move card requesting page type {source_page_type} "
                f"onto a page with type {target_page_type}"
            )
        source_index = document.index(self.source_page, 0)
        target_index = document.index(self.target_page, 0)
        destination_row = len(target_page) if self.target_row is None else self.target_row

        target_row = len(target_page) if self.target_row is None else self.target_row
        for source_row_first, source_row_last in reversed(self.card_ranges_to_move):
            self._move_cards_to_target_page(
                document, source_index, source_page, source_row_first, source_row_last, target_index,
                target_page, target_row
                target_page, destination_row
            )
        if source_page.page_type() != source_page_type:
            document.page_type_changed.emit(source_index)
        if target_page.page_type() != target_page_type:
            document.page_type_changed.emit(target_index)
        return super().apply(document)

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

















































































































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







-
+
+


















+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
        source_row_first = len(source_page) - self._total_moved_cards() if self.target_row is None else self.target_row
        for target_row_first, target_row_last in self.card_ranges_to_move:
            source_row_last = source_row_first + target_row_last - target_row_first
            self._move_cards_to_target_page(
                document, source_index, source_page, source_row_first, source_row_last, target_index,
                target_page, target_row_first
            )

        if self.insert_page_action is not None:
            self.insert_page_action.undo(document)
        if source_page.page_type() != source_page_type:
            document.page_type_changed.emit(source_index)
        if target_page.page_type() != target_page_type:
            document.page_type_changed.emit(target_index)
        return super().undo(document)

    def _total_moved_cards(self) -> int:
        return sum(last-first+1 for first, last in self.card_ranges_to_move)

    @functools.cached_property
    def as_str(self):
        source_page = self.source_page+1
        target_page = self.target_page+1
        count = self._total_moved_cards()
        return self.tr(
            "Move %n card(s) from page {source_page} to {target_page}",
            "Undo/redo tooltip text", count
        ).format(source_page=source_page, target_page=target_page)


class CardMove(typing.NamedTuple):
    first: int
    last: int
    target_row: int
    moved_cards_count: int


class ActionMoveCardsWithinPage(DocumentAction):
    """Move a subset of cards on a page to another position within the same page."""

    def __init__(
            self, page: int, cards_to_move: Sequence[int],
            target_row: int | None, parent: QObject = None):
        """
        :param page: The page with cards, as integer page number (0-indexed)
        :param cards_to_move: The cards to move, as indices into the source Page. May be in any order. (0-indexed)
        :param target_row: The cards_to_move are inserted before that array index (0-indexed).
        """
        super().__init__(parent)
        self.page = page
        self.card_ranges_to_move = to_list_of_ranges(cards_to_move)
        self.target_row = target_row
        self.card_moves: list[CardMove] = []

    def _total_moved_cards(self) -> int:
        return sum(last-first+1 for first, last in self.card_ranges_to_move)

    def _get_card_move_ranges_without_zero_moves_at_ends(self, target_row: int, cards_on_page: int):
        card_ranges = self.card_ranges_to_move.copy()
        # Shortcut two special cases:
        # If the first row is selected and the target is before the first row, skip that move and move the target back
        # so that further moves put cards after the first block
        if target_row == (card_range := card_ranges[0])[0] == 0:
            target_row += card_range[1] - card_range[0] + 1
            del card_ranges[0]
        if not card_ranges:
            return [], target_row
        # If the last row is selected and the target is after the last row, skip that move and move the target forward
        # so that further moves put cards before the last block
        if target_row == (card_range := card_ranges[-1])[1] + 1 == cards_on_page:
            target_row -= card_range[1] - card_range[0] + 1
            del card_ranges[-1]
        return card_ranges, target_row

    def _compute_card_moves(self, document: "Document", page_index: QModelIndex):
        result: list[CardMove] = []
        card_ranges, target_row = self._get_card_move_ranges_without_zero_moves_at_ends(
            self._get_target_row(document, page_index), document.rowCount(page_index))
        if not card_ranges:
            return result

        source_offset = 0
        for first, last in card_ranges:
            moved_cards = last-first+1
            if first <= target_row <= last+1:
                # This batch of cards is currently at the correct location already, so no need to do anything further
                continue
            if last < target_row:
                # While processing batches before the target_row, moving cards to the back will move the next ranges
                # by that many cards to the front
                first -= source_offset
                last -= source_offset
                source_offset += moved_cards
            result.append(CardMove(first, last, target_row, moved_cards))
            if first > target_row:
                # When moving cards to the front, the target row moves back that many cards to keep the order stable
                target_row += moved_cards
        return result

    def apply(self, document: "Document") -> Self:
        super().apply(document)
        page_index = document.index(self.page, 0)
        page: Page = page_index.internalPointer()
        self.card_moves = self._compute_card_moves(document, page_index)
        for first, last, target_row, moved_cards_count in self.card_moves:  # type: int, int, int, int
            document.beginMoveRows(page_index, first, last, page_index, target_row)
            moving_cards = page[first:last+1]
            del page[first:last+1]
            # If cards were removed before the target row, the target shifts moved_cards_count slots to the front.
            target_row -= (last < target_row) * moved_cards_count
            page[target_row:target_row] = moving_cards
            document.endMoveRows()
        return self

    def _get_target_row(self, document: "Document", page_index: QModelIndex):
        return self.target_row if isinstance(self.target_row, int) else document.rowCount(page_index)

    def undo(self, document: "Document") -> Self:
        super().undo(document)
        page_index = document.index(self.page, 0)
        page: Page = page_index.internalPointer()
        card_moves = list(reversed(self.card_moves))
        for target_row, _, first, moved_cards_count in card_moves:  # type: int, int, int, int
            first -= (first > target_row) * moved_cards_count
            last = first + moved_cards_count
            document.beginMoveRows(page_index, first, last-1, page_index, target_row + (first < target_row) * moved_cards_count)
            moving_cards = page[first:last]
            del page[first:last]
            # If cards were removed before the target row, the target shifts moved_cards_count slots to the front.
            page[target_row:target_row] = moving_cards
            document.endMoveRows()
        return self

    @functools.cached_property
    def as_str(self):
        page = self.page+1
        count = self._total_moved_cards()
        return self.tr(
            "Reorder %n card(s) on page {page}",
            "Undo/redo tooltip text", count
        ).format(page=page)
Changes to mtg_proxy_printer/model/document.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
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







-
+

+
+




-
-
+






+
+
+
+












-
-
-
-
-




-
-
-
-
-










-
+





+
+
+
+
+
+
+







#  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 collections
from collections import deque, Counter
from collections.abc import Generator, Iterable
import json
import typing
import enum
import itertools
import math
from pathlib import Path
import sys
from typing import Any, Counter
from typing import Any, Literal

from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Slot, Signal, \
    QPersistentModelIndex, QMimeData

from mtg_proxy_printer.async_tasks.base import AsyncTask
from mtg_proxy_printer.async_tasks.image_downloader import SingleDownloadTask, SingleActions
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
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCardsBetweenPages, ActionMoveCardsWithinPage
from mtg_proxy_printer.document_controller.move_page import ActionMovePage
from mtg_proxy_printer.model.imagedb_files import ImageKey
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, CustomCard
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
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

PAGE_MOVE_MIME_TYPE = "application/x-MTGProxyPrinter-PageMove"

logger = get_logger(__name__)
del get_logger

if sys.version_info[:2] >= (3, 9):
    Counter = collections.Counter
else:
    Counter = Counter

__all__ = [
    "Document",
]


class DocumentColumns(enum.IntEnum):
    Page = 0


INVALID_INDEX = QModelIndex()
ActionStack = collections.deque[DocumentAction]
ActionStack = deque[DocumentAction]
AnyIndex = QModelIndex | QPersistentModelIndex
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation
ItemFlag = Qt.ItemFlag
BlockingQueuedConnection = Qt.ConnectionType.BlockingQueuedConnection
PAGE_MOVE_MIME_TYPE = "application/x-MTGProxyPrinter-PageMove"
CARD_MOVE_MIME_TYPE = "application/x-MTGProxyPrinter-CardMove"
DRAG_OPERATION_TYPE = Literal["application/x-MTGProxyPrinter-PageMove"] | Literal["application/x-MTGProxyPrinter-CardMove"] | None

class CardMoveMimeData(typing.TypedDict):
    page: int
    cards: list[int]


class Document(QAbstractItemModel):
    """
    This holds a multi-page document that contains any number of same-size pages.
    The pages hold the individual proxy images
    """
98
99
100
101
102
103
104
105
106


107
108
109
110
111
112
113
114





115
116
117
118
119
120
121
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







-
-
+
+








+
+
+
+
+







            PageColumns.Set: self.tr("Set"),
            PageColumns.CollectorNumber: self.tr("Collector #"),
            PageColumns.Language: self.tr("Language"),
            PageColumns.Image: self.tr("Image"),
            PageColumns.IsFront: self.tr("Side"),
        }

        self.undo_stack: ActionStack = collections.deque()
        self.redo_stack: ActionStack = collections.deque()
        self.undo_stack: ActionStack = deque()
        self.redo_stack: ActionStack = deque()
        self.save_file_path: Path | None = None
        self.card_db = card_db
        self.image_db = image_db
        self.pages: list[Page] = [first_page := Page()]
        # Mapping from page id() to list index in the page list
        self.page_index_cache: dict[int, int] = {id(first_page): 0}
        self.currently_edited_page = first_page
        self.page_layout = PageLayoutSettings.create_from_settings()
        # The last started drag operation affects the index flags().
        self.current_drag_operation: DRAG_OPERATION_TYPE = None
        # Also note the size of dragged cards. This allows checking, if pages can accept the drop,
        # to prevent creation of MIXED pages.
        self.current_drag_card_size: PageType = PageType.UNDETERMINED
        logger.debug(f"Loaded document settings from configuration file: {self.page_layout}")
        logger.info(f"Created {self.__class__.__name__} instance")

    @Slot(DocumentAction)
    def apply(self, action: DocumentAction):
        if self.redo_stack:
            # Do not discard the rest redo stack if the top is equal to the given action
244
245
246
247
248
249
250
251
252
253
254









255
256
257
258
259
260
261
251
252
253
254
255
256
257




258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273







-
-
-
-
+
+
+
+
+
+
+
+
+








    def flags(self, index: AnyIndex) -> Qt.ItemFlag:
        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
        if isinstance(data, Page):
            flags |= ItemFlag.ItemIsDragEnabled  # Pages can be moved
        if not index.isValid():
            flags |= ItemFlag.ItemIsDropEnabled  # Only the root can accept drops to not overwrite items
        if isinstance(data, Page|CardContainer):
            flags |= ItemFlag.ItemIsDragEnabled  # Pages and cards can be moved
        if (not index.isValid()  # Top level can accept any drop, both pages and cards, where the latter gets a new page
            or (
                isinstance(data, Page)
                and self.current_drag_operation == CARD_MOVE_MIME_TYPE  # Pages only accept cards …
                and data.accepts_card(self.current_drag_card_size)  # with an acceptable size.
                )):
            flags |= ItemFlag.ItemIsDropEnabled
        return flags

    def setData(self, index: AnyIndex, value: 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
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
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







+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+





+
-
+
+
+
+
+
+

+
+
+
+

-
+



+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+






-
-
+
+








    def _fetch_image_and_apply_action(self, action: SingleActions):
        task = SingleDownloadTask(self.image_db, action)
        task.request_action.connect(self.apply, BlockingQueuedConnection)
        self.request_run_async_task.emit(task)

    def mimeData(self, indexes: list[QModelIndex], /) -> QMimeData:
        """
        Reads model data and converts them into QMimeData used for Drag&Drop.
        Dragging a page encodes its initial position
        Dragging cards encodes their shared page index, and a list of card indices.
        """
        """Supports encoding the row of a singular QModelIndex as QMimeData. Used for moving Pages via drag&drop."""
        if len(indexes) != 1:
            return QMimeData()
        row = indexes[0].row()
        logger.debug(f"Initiating drag for page {row}")
        mime_data = QMimeData()
        if not indexes:
            return mime_data

        if not (first := indexes[0]).parent().isValid():
            row = first.row()
            logger.debug(f"Initiating drag for page {row}")
        mime_data = QMimeData()
        mime_data.setData(PAGE_MOVE_MIME_TYPE, row.to_bytes(8))
            mime_data.setData(PAGE_MOVE_MIME_TYPE, row.to_bytes(8))
            self.current_drag_operation = PAGE_MOVE_MIME_TYPE
            return mime_data
        page = first.parent().row()
        cards = sorted(set(index.row() for index in indexes))
        logger.debug(f"Initiating drag for {len(cards)} cards on page {page}")
        data: CardMoveMimeData = {"page": page, "cards": cards}
        encoded_data = json.dumps(data).encode("utf-8")
        mime_data.setData(CARD_MOVE_MIME_TYPE, encoded_data)
        self.current_drag_operation = CARD_MOVE_MIME_TYPE
        self.current_drag_card_size = self.pages[page].page_type()
        return mime_data

    def dropMimeData(
            self, data: QMimeData, action: Qt.DropAction,
            row: int, column: PageColumns | DocumentColumns, parent: QModelIndex, /):
        """Supports dropping cards or pages moved via drag&drop."""
        """Supports dropping pages moved via drag&drop. Only Page moves supported at the moment."""

        # https://doc.qt.io/qt-6/qabstractitemmodel.html#dropMimeData:
        # "When row and column are -1 it means that the dropped data should be considered as
        # dropped directly on parent. Usually this will mean appending the data as child items of parent.
        # If row and column are greater than or equal zero, it means that the drop occurred just
        # before the specified row and column in the specified parent."
        if data.hasFormat(PAGE_MOVE_MIME_TYPE):
            # Here, parent is always invalid. row == column == -1 means the drop ended on empty space within the view.
            # The only location with empty space is below the last page, so treat it as if the user dropped directly
            # below the last page, and move the page to the end.
            # If row != -1, row states the drop location, so use that.
            logger.debug(f"Received page drop onto {row=}")
            if row == -1:  # Drop onto empty space or after last entry. Append in this case
            if row == -1:
                row = self.rowCount()
            source_row = int.from_bytes(data.data(PAGE_MOVE_MIME_TYPE).data())
            self.apply(ActionMovePage(source_row, row))
        elif data.hasFormat(CARD_MOVE_MIME_TYPE):
            # Here, parent may be valid, and there are two main cases, one of which has 2 subcases:
            card_data: CardMoveMimeData = json.loads(data.data(CARD_MOVE_MIME_TYPE).data())
            logger.debug(f"Received card drop onto {row=}: {card_data}")
            # Case 1:  Cards are dropped onto an existing page, given by parent.row().
            if parent.isValid():
                if row == column == -1:
                    # The drop ended on empty space within the page card table view.
                    # Append the cards at the end of the given page
                    row = self.rowCount(parent)
                if parent.row() == card_data["page"]:
                    action = ActionMoveCardsWithinPage(parent.row(), card_data["cards"], row)
                else:
                    action = ActionMoveCardsBetweenPages(card_data["page"], card_data["cards"], parent.row(), None)
            else:
                # Case 2: Cards are dropped between pages, and a new page must be inserted for the dropped cards
                if row == column == -1:
                    # Subcase 1: The drop ended on empty space within the view. Append a new page.
                    row = self.rowCount()
                # Subcase 2: Cards are moved to row on the page given by parent
                action = ActionMoveCardsBetweenPages(card_data["page"], card_data["cards"], row, -1)
            self.apply(action)

        return False  # Move complete, so signal via False that the caller does not have to remove the source rows

    def supportedDropActions(self, /) -> Qt.DropAction:
        return Qt.DropAction.MoveAction

    def mimeTypes(self, /) -> list[str]:
        """Supported mime types. Currently only supporting Page moves"""
        return [PAGE_MOVE_MIME_TYPE]
        """Supported mime types."""
        return [PAGE_MOVE_MIME_TYPE, CARD_MOVE_MIME_TYPE]

    @staticmethod
    def _to_index(other: QPersistentModelIndex | QModelIndex) -> QModelIndex:
        return QModelIndex(other) if isinstance(other, QPersistentModelIndex) else other

    def _request_replacement_card(self, index: QModelIndex, card_data: CardIdentificationData):
        if result := self.card_db.get_cards_from_data(card_data):
374
375
376
377
378
379
380
381

382
383
384
385
386
387
388
435
436
437
438
439
440
441

442
443
444
445
446
447
448
449







-
+







                return card.image_file
            elif column == PageColumns.IsFront:
                return card.is_front if role == ItemDataRole.EditRole else (
                    self.tr("Front") if card.is_front else self.tr("Back"))
        return None

    def _get_page_preview(self, page: Page):
        names = collections.Counter(container.card.name for container in page)
        names = Counter(container.card.name for container in page)
        return "\n".join(self.tr(
            "%n× {name}",
            "Used to display a card name and amount of copies in the page overview. "
            "Only needs translation for RTL language support", count).format(name=name) for name, count in names.items()
        )

    @Slot(QModelIndex)
401
402
403
404
405
406
407
408

409
410
411
412
413
414
415
462
463
464
465
466
467
468

469
470
471
472
473
474
475
476







-
+







            raise RuntimeError("Cannot save without a file path!")
        ActionSaveDocument(self.save_file_path).apply(self)  # Note: Not using the action stack. Saving cannot be undone

    def compute_pages_saved_by_compacting(self) -> int:
        """
        Computes the number of pages that can be saved by compacting the document.
        """
        cards: Counter[PageType] = collections.Counter()
        cards: Counter[PageType] = Counter()
        for page in self.pages:
            cards[page.page_type()] += len(page)
        required_pages = (
            math.ceil(cards[PageType.OVERSIZED] / self.page_layout.compute_page_card_capacity(PageType.OVERSIZED))
            + math.ceil(cards[PageType.REGULAR] / self.page_layout.compute_page_card_capacity(PageType.REGULAR))
        ) or 1
        result = self.rowCount() - required_pages
Changes to mtg_proxy_printer/model/document_page.py.
33
34
35
36
37
38
39



40
41
42
43
44
45
46
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49







+
+
+









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

    def __repr__(self):
        return f"{self.__class__.__name__}(card={self.card})"


class Page(list[CardContainer]):

    def __init__(self, __iterable: Iterable[AnyCardType] = None):
        __iterable = __iterable or []
        __iterable = map(partial(CardContainer, self), __iterable)
        super().__init__(__iterable)
Changes to mtg_proxy_printer/page_scene/page_scene.py.
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
359
360
361
362
363
364
365








366
367
368


369
370
371
372
373
374
375
376
377







-
-
-
-
-
-
-
-



-
-
+
+








    def draw_card(self, index: QModelIndex, page_type: PageType, next_item: CardItem = None):
        position = self._compute_position_for_image(index.row(), page_type)
        if index.data(ItemDataRole.DisplayRole) is not None:  # Card has a QPixmap set
            card_item = CardItem(index, self.document)
            self.addItem(card_item)
            card_item.setPos(position)
            if next_item is not None:
                # See https://doc.qt.io/qt-6/qgraphicsitem.html#sorting
                # "You can call stackBefore() to reorder the list of children.
                # This will directly modify the insertion order."
                # This is required to keep the card order consistent with the model when inserting cards in the
                # middle of the page. This can happen when undoing a card removal. The caller has to supply the
                # item which’s position the new item takes.
                card_item.stackBefore(next_item)

    def update_card_positions(self):
        page_type: PageType = self.selected_page.data(ItemDataRole.UserRole)
        for index, card in enumerate(self.card_items):
            card.setPos(self._compute_position_for_image(index, page_type))
        for card in self.card_items:
            card.setPos(self._compute_position_for_image(card.index.row(), page_type))

    def _is_valid_page_index(self, index: QModelIndex | QPersistentModelIndex):
        return index.isValid() and not index.parent().isValid() and index.row() < self.document.rowCount()

    @Slot(QModelIndex)
    def on_page_type_changed(self, page: QModelIndex):
        if page.row() == self.selected_page.row():
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
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







+
-
+
+
+
+
+
+
+
+
+
+
+
+



-
+
-
-
+
-
-
-
-
+
+
+







            self.update_card_positions()
            self.update_card_bleeds()
        elif not parent.isValid():
            # Page removed. Update the page number text, as it contains the total number of pages
            self._update_page_number_text()

    def on_rows_moved(self, parent: QModelIndex, start: int, end: int, destination: QModelIndex, row: int):
        source_page_row = parent.row()
        if parent.isValid() and parent.row() == self.selected_page.row():
        current_page_row = self.selected_page.row()
        destination_page_row = destination.row()
        if not parent.isValid():
            # Moved pages around. Needs to update the current page text
            self._update_page_number_text()
            return
        # Parent is valid, thus start:end+1 point to cards on the page source_page_row.
        if source_page_row != current_page_row == destination_page_row:
            # Cards moved onto the current page are treated as if they were added
            logger.debug("Cards moved onto the currently shown page, calling card insertion handler.")
            self.on_rows_inserted(destination, row, row + end - start)
        elif source_page_row == current_page_row != destination_page_row:
            # Cards moved away are treated as if they were deleted
            logger.debug("Cards moved away from the currently shown page, calling card removal handler.")
            self.on_rows_removed(parent, start, end)
        if destination.isValid() and destination.row() == self.selected_page.row():
        elif source_page_row == current_page_row == destination_page_row:
            # Moved in cards are treated as if they were added
            logger.debug("Cards moved onto the currently shown page, calling card insertion handler.")
            logger.debug("Cards moved within the current page, reordering them")
            self.on_rows_inserted(destination, row, row+end-start)
        if not parent.isValid() and not destination.isValid():
            # Moved pages around. Needs to update the current page text
            self._update_page_number_text()
            self.update_card_positions()
        # Remaining cases are card moves happening "off-screen", so nothing has to be done on them.


    @functools.lru_cache(None)
    def _compute_position_for_image(self, index_row: int, page_type: PageType) -> QPointF:
        """Returns the page-absolute position of the top-left pixel of the given image."""
        page_layout: PageLayoutSettings = self.document.page_layout
        page_width = distance_to_rounded_px(page_layout.page_width)
        page_height = distance_to_rounded_px(page_layout.page_height)
Changes to resources/ui/central_widget/columnar.ui.
90
91
92
93
94
95
96






97
98
99
100
101
102
103
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109







+
+
+
+
+
+







     </property>
     <property name="contextMenuPolicy">
      <enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="dragDropMode">
      <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
     </property>
     <property name="defaultDropAction">
      <enum>Qt::DropAction::MoveAction</enum>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectionBehavior::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
126
127
128
129
130
131
132
133

134
135
136
137
138
139
140
132
133
134
135
136
137
138

139
140
141
142
143
144
145
146







-
+







       <verstretch>1</verstretch>
      </sizepolicy>
     </property>
     <property name="dragDropOverwriteMode">
      <bool>false</bool>
     </property>
     <property name="dragDropMode">
      <enum>QAbstractItemView::DragDropMode::InternalMove</enum>
      <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
     </property>
     <property name="defaultDropAction">
      <enum>Qt::DropAction::MoveAction</enum>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
Changes to resources/ui/central_widget/grouped.ui.
36
37
38
39
40
41
42
43

44
45
46
47
48
49
50
36
37
38
39
40
41
42

43
44
45
46
47
48
49
50







-
+







     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
       <horstretch>3</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="dragDropMode">
      <enum>QAbstractItemView::DragDropMode::InternalMove</enum>
      <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
     </property>
     <property name="defaultDropAction">
      <enum>Qt::DropAction::MoveAction</enum>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
128
129
130
131
132
133
134






135
136
137
138
139
140
141
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147







+
+
+
+
+
+







     </property>
     <property name="contextMenuPolicy">
      <enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="dragDropMode">
      <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
     </property>
     <property name="defaultDropAction">
      <enum>Qt::DropAction::MoveAction</enum>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectionBehavior::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
Changes to resources/ui/central_widget/tabbed_vertical.ui.
70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
70
71
72
73
74
75
76

77
78
79
80
81
82
83
84







-
+







          <iconset theme="arrow-down"/>
         </property>
        </widget>
       </item>
       <item row="0" column="0" colspan="2">
        <widget class="QListView" name="document_view">
         <property name="dragDropMode">
          <enum>QAbstractItemView::DragDropMode::InternalMove</enum>
          <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
         </property>
         <property name="defaultDropAction">
          <enum>Qt::DropAction::MoveAction</enum>
         </property>
        </widget>
       </item>
      </layout>
115
116
117
118
119
120
121






122
123
124
125
126
127
128
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134







+
+
+
+
+
+







         </property>
         <property name="contextMenuPolicy">
          <enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
         </property>
         <property name="lineWidth">
          <number>0</number>
         </property>
         <property name="dragDropMode">
          <enum>QAbstractItemView::DragDropMode::DragDrop</enum>
         </property>
         <property name="defaultDropAction">
          <enum>Qt::DropAction::MoveAction</enum>
         </property>
         <property name="alternatingRowColors">
          <bool>true</bool>
         </property>
         <property name="selectionBehavior">
          <enum>QAbstractItemView::SelectionBehavior::SelectRows</enum>
         </property>
         <attribute name="verticalHeaderVisible">
Changes to setup_cx_freeze.py.
20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34







-
+







import pathlib
import re
import sys

from cx_Freeze import setup, Executable

ROOT_DIR = pathlib.Path(__file__).parent
resource_path = ROOT_DIR / "resources.bak"
resource_path = ROOT_DIR / "resources"
main_package = "mtg_proxy_printer"
meta_data = (ROOT_DIR/main_package/"meta_data.py").read_text()
version = re.search(
    r"""^__version__\s*=\s*"(.*)"\s*""",
    meta_data,
    re.M
    ).group(1)
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
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







+












+








if sys.platform == "win32":
    excludes += [
        "platformdirs.android",
        "platformdirs.macos",
        "platformdirs.unix",
    ]


def get_icon() -> str:
    icon_path = resource_path / "icons" / "MTGPP.png"
    if sys.platform == "win32":
        dest = ROOT_DIR/"MTGPP.ico"
        if not dest.exists():
            from PIL import Image
            with Image.open(icon_path) as src:
                src.save(dest, "ICO")
        return str(dest)
    else:
        return str(icon_path)


icon = get_icon()

setup_parameters = {
    "executables": [
        Executable(
            f"{main_package}/__main__.py",
Changes to tests/conftest.py.
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
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







-
+















-
+






-
-
+
+












+






-
+
-








    db = mtg_proxy_printer.sqlite_helpers.open_database(":memory:", "document-v7", check_same_thread=False)
    if request.param:
        db.execute("PRAGMA reverse_unordered_selects = TRUE")
    return db


@pytest.fixture
def image_db(qtbot, tmp_path: Path):
def image_db(qtbot, tmp_path: Path) -> ImageDatabase:
    image_db = ImageDatabase(tmp_path)
    regular = image_db.get_blank(CardSizes.REGULAR)
    large = image_db.get_blank(CardSizes.OVERSIZED)
    for scryfall_id, is_front in itertools.product(
            ["0000579f-7b35-4ed3-b44c-db2a538066fe", "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d"], [True, False]):
        # Regular card images
        key = ImageKey(scryfall_id, is_front, True)
        image_db.loaded_images[key] = regular.copy()
        image_db.images_on_disk.add(key)
    for scryfall_id in ["650722b4-d72b-4745-a1a5-00a34836282b"]:
        # Oversized card images
        key = ImageKey(scryfall_id, True, True)
        image_db.loaded_images[key] = large.copy()
        image_db.images_on_disk.add(key)

    yield image_db
    return image_db


@pytest.fixture
def document(qtbot, card_db: CardDatabase, image_db: ImageDatabase) -> Document:
    fill_card_database_with_json_cards(qtbot, card_db, [
        "regular_english_card", "oversized_card", "english_double_faced_card"])
    document = Document(card_db, image_db)
    yield document
    return Document(card_db, image_db)


@pytest.fixture
def mock_imagedb():
    mock_image_db = unittest.mock.NonCallableMagicMock(spec=ImageDatabase)
    blanks = {
        CardSizes.REGULAR: QPixmap(CardSizes.REGULAR.as_qsize_px()),
        CardSizes.OVERSIZED: QPixmap(CardSizes.OVERSIZED.as_qsize_px()),
    }
    blanks[CardSizes.REGULAR].fill(QColorConstants.Transparent)
    blanks[CardSizes.OVERSIZED].fill(QColorConstants.Transparent)
    mock_image_db.get_blank = blanks.get
    return mock_image_db


@pytest.fixture
def document_light(qtbot, mock_imagedb) -> Document:
    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)
    return Document(mock_card_db, mock_imagedb)
    yield document


@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/document_controller/test_action_compact_document.py.
20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34







-
+








from mtg_proxy_printer.units_and_sizes import PageType, CardSizes
from mtg_proxy_printer.model.document import Document
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, ActionRemovePage
from mtg_proxy_printer.document_controller.compact_document import ActionCompactDocument
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCards
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCardsBetweenPages

from tests.helpers import create_card
from .helpers import append_new_card_in_page, card_container_with

OVERSIZED = CardSizes.OVERSIZED
REGULAR = CardSizes.REGULAR

102
103
104
105
106
107
108
109

110
111
112
113
114
115
116
102
103
104
105
106
107
108

109
110
111
112
113
114
115
116







-
+







    )

    action = ActionCompactDocument()
    action.apply(document_light)
    assert_that(
        action.actions,
        contains_exactly(
            *[instance_of(ActionMoveCards)]*4,
            *[instance_of(ActionMoveCardsBetweenPages)] * 4,
            instance_of(ActionRemovePage)
        )
    )
    assert_that(pages, has_length(4))
    assert_that(
        pages,
        contains_exactly(
149
150
151
152
153
154
155
156

157
158
149
150
151
152
153
154
155

156
157
158







-
+


                card_container_with(oversized1, pages[1]),
                card_container_with(oversized2, pages[1])),
        )
    )
    assert_that(
        action.actions,
        contains_exactly(
            *[instance_of(ActionMoveCards)]*2,
            *[instance_of(ActionMoveCardsBetweenPages)] * 2,
            instance_of(ActionRemovePage))
    )
Changes to tests/document_controller/test_action_edit_document_settings.py.
23
24
25
26
27
28
29
30

31
32
33
34
35
36
37
23
24
25
26
27
28
29

30
31
32
33
34
35
36
37







-
+







from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, CardSizes
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.model.document import Document
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.move_cards import ActionMoveCards
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCardsBetweenPages
from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings

from tests.helpers import create_card
from .helpers import card_container_with, append_new_card_in_page


def test_create_action_raises_value_error_on_zero_page_capacity():
201
202
203
204
205
206
207
208

209
210
211
212
213
214
215
201
202
203
204
205
206
207

208
209
210
211
212
213
214
215







-
+








    action = ActionEditDocumentSettings(document_light.page_layout)
    action._already_applied = True
    old_settings = action.old_settings = copy.copy(document_light.page_layout)
    document_light.page_layout.page_height += 1*unit_registry.mm
    action.reflow_actions += [
        new_page,
        ActionMoveCards(0, range(7, 10), 1)
        ActionMoveCardsBetweenPages(0, range(7, 10), 1)
    ]
    for sub_action in action.reflow_actions:
        sub_action._already_applied = True
    action.new_settings = document_light.page_layout

    with qtbot.wait_signals([document_light.page_layout_changed]):
        action.undo(document_light)
Changes to tests/document_controller/test_action_move_cards.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
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







-
+











-
+



+







#  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 functools import partial
from typing import Optional
from typing import Optional, Sequence

import pytest
from hamcrest import *
from PySide6.QtCore import QModelIndex
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import CardSizes, IntList
from mtg_proxy_printer.model.document_page import PageType
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCards
from mtg_proxy_printer.document_controller.move_cards import ActionMoveCardsBetweenPages, ActionMoveCardsWithinPage

from .helpers import card_container_with, append_new_card_in_page, card_container_with_name
OptInt = Optional[int]


def validate_qt_model_move_signal_parameter(
        expected_source: int, expected_row_start: int, expected_row_end: int,
        expected_target: int, expected_target_row: int,

        source_index: QModelIndex, row_start: int, row_end: int,
        target_index: QModelIndex, target_row: int) -> bool:
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
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







-
+











-
+








def test_apply_raises_exception_when_trying_to_create_a_mixed_size_page(document_light: Document):
    ActionNewPage().apply(document_light)
    append_new_card_in_page(document_light.pages[0], "Normal", CardSizes.REGULAR)
    append_new_card_in_page(document_light.pages[1], "Large", CardSizes.OVERSIZED)
    assert_that(document_light.pages[0].page_type(), is_(PageType.REGULAR))
    assert_that(document_light.pages[1].page_type(), is_(PageType.OVERSIZED))
    assert_that(calling(ActionMoveCards(0, [0], 1).apply).with_args(document_light), raises(IllegalStateError))
    assert_that(calling(ActionMoveCardsBetweenPages(0, [0], 1).apply).with_args(document_light), raises(IllegalStateError))


def test_apply_move_all_cards_onto_empty_page(qtbot: QtBot, document_light: Document):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
    to_move = append_new_card_in_page(pages[0], "Normal")
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 0)
    with qtbot.wait_signals(
            [document_light.page_type_changed] * 2 + [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
            timeout=1000, check_params_cbs=[lambda index: index.row() == 0, lambda index: index.row() == 1] +
                                           [row_move_validator] * 2):
        ActionMoveCards(0, [0], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [0], 1).apply(document_light)

    assert_that(
        pages,
        contains_exactly(
            empty(),
            contains_exactly(
                card_container_with(to_move, pages[1]))
95
96
97
98
99
100
101
102

103
104
105
106
107
108
109
96
97
98
99
100
101
102

103
104
105
106
107
108
109
110







-
+







    to_move = append_new_card_in_page(pages[0], "Move")
    on_page_1 = append_new_card_in_page(pages[1], "Stay on 1")
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 1)
    with qtbot.wait_signals(
            [document_light.page_type_changed, document_light.rowsAboutToBeMoved, document_light.rowsMoved],
            timeout=1000, check_params_cbs=[lambda index: index.row() == 0] +
                                           [row_move_validator] * 2):
        ActionMoveCards(0, [0], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [0], 1).apply(document_light)

    assert_that(
        pages,
        contains_exactly(
            empty(),
            contains_exactly(
                card_container_with(on_page_1, pages[1]),
121
122
123
124
125
126
127
128

129
130
131
132
133
134
135
122
123
124
125
126
127
128

129
130
131
132
133
134
135
136







-
+







    on_page_0 = append_new_card_in_page(pages[0], "Stay on 0")

    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 0)
    with qtbot.wait_signals(
            [document_light.page_type_changed, document_light.rowsAboutToBeMoved, document_light.rowsMoved],
            timeout=1000, check_params_cbs=[lambda index: index.row() == 1] +
                                           [row_move_validator] * 2):
        ActionMoveCards(0, [0], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [0], 1).apply(document_light)

    assert_that(
        pages,
        contains_exactly(
            contains_exactly(
                card_container_with(on_page_0, pages[0])),
            contains_exactly(
146
147
148
149
150
151
152
153

154
155
156
157
158
159
160
147
148
149
150
151
152
153

154
155
156
157
158
159
160
161







-
+







    on_page_0 = append_new_card_in_page(pages[0], "Stay on 0")
    on_page_1 = append_new_card_in_page(pages[1], "Stay on 1")
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 1)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
                timeout=1000, check_params_cbs=[row_move_validator] * 2):
        ActionMoveCards(0, [0], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [0], 1).apply(document_light)

    assert_that(
        pages,
        contains_exactly(
            contains_exactly(
                card_container_with(on_page_0, pages[0])),
            contains_exactly(
190
191
192
193
194
195
196
197

198
199
200
201
202
203
204
191
192
193
194
195
196
197

198
199
200
201
202
203
204
205







-
+







        "Test setup failed"
    )
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 1, 2, 1, 2)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
                timeout=1000, check_params_cbs=[row_move_validator] * 2):
        ActionMoveCards(0, [1, 2], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [1, 2], 1).apply(document_light)

    assert_that(
        pages,
        contains_exactly(
            contains_exactly(
                card_container_with(on_page_0_0, pages[0]),
                card_container_with(on_page_0_1, pages[0])),
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
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







-
+




















-
+







    )
    row_move_validator_1 = partial(validate_qt_model_move_signal_parameter, 0, 2, 2, 1, 1)
    row_move_validator_2 = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 1)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved]*2,
                timeout=1000, check_params_cbs=[row_move_validator_1] * 2 + [row_move_validator_2] * 2):
        ActionMoveCards(0, [0, 2], 1).apply(document_light)
        ActionMoveCardsBetweenPages(0, [0, 2], 1).apply(document_light)
    assert_that(
        pages,
        contains_exactly(
            contains_exactly(
                card_container_with(on_page_0_0, pages[0])),
            contains_exactly(
                card_container_with(on_page_1_0, pages[1]),
                card_container_with(to_move_1, pages[1]),
                card_container_with(to_move_2, pages[1])),
        ),
        "Incorrect card move"
    )


def test_apply_move_card_with_target_inserts_at_front(qtbot: QtBot, document_light: Document):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
    card_1 = append_new_card_in_page(pages[0], "Move1")
    not_moved = append_new_card_in_page(pages[0], "Stay on 0")
    card_2 = append_new_card_in_page(pages[1], "After")
    action = ActionMoveCards(0, [0], 1, 0)
    action = ActionMoveCardsBetweenPages(0, [0], 1, 0)
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 0)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
                timeout=1000, check_params_cbs=[row_move_validator] * 2):
        action.apply(document_light)
    assert_that(
283
284
285
286
287
288
289
290

291
292
293
294
295
296
297
284
285
286
287
288
289
290

291
292
293
294
295
296
297
298







-
+







def test_apply_move_card_with_target_inserts_between_cards(qtbot: QtBot, document_light: Document):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
    card_1 = append_new_card_in_page(pages[0], "Move1")
    not_moved = append_new_card_in_page(pages[0], "Stay on 0")
    card_2 = append_new_card_in_page(pages[1], "Before")
    card_3 = append_new_card_in_page(pages[1], "After")
    action = ActionMoveCards(0, [0], 1, 1)
    action = ActionMoveCardsBetweenPages(0, [0], 1, 1)
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 1)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
                timeout=1000, check_params_cbs=[row_move_validator] * 2):
        action.apply(document_light)
    assert_that(
313
314
315
316
317
318
319
320

321
322
323
324
325
326
327
314
315
316
317
318
319
320

321
322
323
324
325
326
327
328







-
+







@pytest.mark.parametrize("target_row", [None, 1])  # Because the target has 1 card, both should give the same result
def test_apply_move_card_with_target_appends_to_page(qtbot: QtBot, document_light: Document, target_row: OptInt):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
    card_1 = append_new_card_in_page(pages[0], "Move1")
    not_moved = append_new_card_in_page(pages[0], "Stay on 0")
    card_2 = append_new_card_in_page(pages[1], "Before")
    action = ActionMoveCards(0, [0], 1, target_row)
    action = ActionMoveCardsBetweenPages(0, [0], 1, target_row)
    row_move_validator = partial(validate_qt_model_move_signal_parameter, 0, 0, 0, 1, 1)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.wait_signals(
                [document_light.rowsAboutToBeMoved, document_light.rowsMoved],
                timeout=1000, check_params_cbs=[row_move_validator] * 2):
        action.apply(document_light)
    assert_that(
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
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







-
+




















-
+





-
-
+
+








@pytest.mark.parametrize("target_row", [0, 1, None])
def test_apply_without_indices_does_nothing(qtbot: QtBot, document_light: Document, target_row: OptInt):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
    card_1 = append_new_card_in_page(pages[0], "Move1")
    card_2 = append_new_card_in_page(pages[1], "After")
    action = ActionMoveCards(0, [], 1, target_row)
    action = ActionMoveCardsBetweenPages(0, [], 1, target_row)
    with qtbot.assert_not_emitted(document_light.page_type_changed), \
            qtbot.assert_not_emitted(document_light.rowsAboutToBeMoved), \
            qtbot.assert_not_emitted(document_light.rowsMoved):
        action.apply(document_light)
    assert_that(
        pages,
        contains_exactly(
            contains_exactly(
                card_container_with(card_1, pages[0])
            ),
            contains_exactly(
                card_container_with(card_2, pages[1]),
            )
        ),
        "Unexpected card move"
    )


@pytest.mark.parametrize("indices", [[], [0], [1], [0, 1], [0, 2], [0, 1, 2], [0, 1, 3, 4]])
def test___total_moved_cards(indices: IntList):
    action = ActionMoveCards(0, indices, 0)
    action = ActionMoveCardsBetweenPages(0, indices, 0)
    expected_result = len(indices)
    assert_that(action._total_moved_cards(), is_(equal_to(expected_result)))


def _create_applied_action(
        source: int, cards_to_move: list[int], target_page: int, target_row: int = None) -> ActionMoveCards:
    action = ActionMoveCards(source, cards_to_move, target_page, target_row)
        source: int, cards_to_move: list[int], target_page: int, target_row: int = None) -> ActionMoveCardsBetweenPages:
    action = ActionMoveCardsBetweenPages(source, cards_to_move, target_page, target_row)
    action._already_applied = True
    return action


def test_undo_resets_already_applied(document_light: Document):
    pages = document_light.pages
    ActionNewPage().apply(document_light)
600
601
602
603
604
605
606






























































































































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







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
            ),
            contains_exactly(
                card_container_with(c3, pages[1]),
            )
        ),
        "Incorrect card move"
    )

@pytest.fixture()
def document_with_cards(document_light: Document) -> Document:
    ActionNewPage(count=3, content=[[], [], []]).apply(document_light)
    pages = document_light.pages
    content: list[tuple[int, str]] = [
        (0, "A1"),
        (0, "A2"),
        (0, "A3"),
        (1, "B1"),
        (1, "B2"),
        (1, "B3"),
        (3, "D1"),
        (3, "D2"),
        (3, "D3"),
        (3, "D4"),
        (3, "D5"),
        (3, "D6"),
    ]
    for page, name in content:
        append_new_card_in_page(pages[page], name)
    return document_light


def gather_card_names(document: Document) -> Sequence[str]:
    return [
        ",".join(container.card.name for container in page)
        for page in document.pages
    ]

def generate_test_cases_for_card_moves_between_pages():
    # Tuples source, cards_to_move, target_page, target_row, expected
    # Origin card order: ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]

    # Move onto another existing page
    yield 0, [0], 1, None, ["A2,A3", "B1,B2,B3,A1", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [0], 1, 3, ["A2,A3", "B1,B2,B3,A1", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [0], 1, 0, ["A2,A3", "A1,B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [0, 2], 1, None, ["A2", "B1,B2,B3,A1,A3", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [1], 1, None, ["A1,A3", "B1,B2,B3,A2", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [0, 1, 2], 1, None, ["", "B1,B2,B3,A1,A2,A3", "", "D1,D2,D3,D4,D5,D6"]
    yield 0, [0, 1, 2], 1, 1, ["", "B1,A1,A2,A3,B2,B3", "", "D1,D2,D3,D4,D5,D6"]
    yield 1, [0], 2, None, ["A1,A2,A3", "B2,B3", "B1", "D1,D2,D3,D4,D5,D6"]
    yield 1, [0], 3, None, ["A1,A2,A3", "B2,B3", "", "D1,D2,D3,D4,D5,D6,B1"]
    yield 3, [0, 2], 0, None, ["A1,A2,A3,D1,D3", "B1,B2,B3", "", "D2,D4,D5,D6"]
    # Move to new page before target_page
    yield 3, [0, 2, 3, 5], 0, -1, ["D1,D3,D4,D6", "A1,A2,A3", "B1,B2,B3", "", "D2,D5"]
    yield 3, [0, 2, 3, 5], 1, -1, ["A1,A2,A3", "D1,D3,D4,D6", "B1,B2,B3", "", "D2,D5"]
    # Move to new page at document end
    yield 3, [0, 2, 3, 5], 4, -1, ["A1,A2,A3", "B1,B2,B3", "", "D2,D5", "D1,D3,D4,D6"]


@pytest.mark.parametrize(
    "source, cards_to_move, target_page, target_row, expected",
    generate_test_cases_for_card_moves_between_pages())
def test_ActionMoveCardsBetweenPages_apply(
        document_with_cards: Document,
        source: int, cards_to_move: list[int], target_page: int, target_row: int|None,
        expected: list[str]):
    action = ActionMoveCardsBetweenPages(source, cards_to_move, target_page, target_row)
    action.apply(document_with_cards)
    result = gather_card_names(document_with_cards)
    assert_that(result, contains_exactly(*expected), f"Got: {result}")


@pytest.mark.parametrize(
    "source, cards_to_move, target_page, target_row, expected",
    generate_test_cases_for_card_moves_between_pages())
def test_ActionMoveCardsBetweenPages_undo(
        document_with_cards: Document,
        source: int, cards_to_move: list[int], target_page: int, target_row: int|None,
        expected: list[str]):
    action = ActionMoveCardsBetweenPages(source, cards_to_move, target_page, target_row)
    action.apply(document_with_cards)
    try:
        assert_that(gather_card_names(document_with_cards), contains_exactly(*expected))
    except AssertionError:
        pytest.skip("Test setup failed")
    action.undo(document_with_cards)
    result = gather_card_names(document_with_cards)
    assert_that(result, contains_exactly("A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"), f"Got: {result}")

def generate_test_cases_for_card_moves_within_page():
    # Tuples page, cards_to_move, target_row, expected
    # Origin card order: ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]

    yield 0, [0], None, ["A2,A3,A1", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 0
    yield 0, [0], 0, ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 1
    yield 0, [0], 1, ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 2
    yield 0, [0], 2, ["A2,A1,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 3
    yield 0, [0], 3, ["A2,A3,A1", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 4
    yield 3, [0, 1, 5], None, ["A1,A2,A3", "B1,B2,B3", "", "D3,D4,D5,D1,D2,D6"]  # 5
    yield 3, [0, 5], 0, ["A1,A2,A3", "B1,B2,B3", "", "D1,D6,D2,D3,D4,D5"]  # 6
    yield 3, [0, 5], 3, ["A1,A2,A3", "B1,B2,B3", "", "D2,D3,D1,D6,D4,D5"]  # 7
    yield 3, [0, 2], 4, ["A1,A2,A3", "B1,B2,B3", "", "D2,D4,D1,D3,D5,D6"]  # 8
    yield 3, [3, 5], 0, ["A1,A2,A3", "B1,B2,B3", "", "D4,D6,D1,D2,D3,D5"]  # 9
    yield 3, [1, 2, 4, 5], 0, ["A1,A2,A3", "B1,B2,B3", "", "D2,D3,D5,D6,D1,D4"]  # 10
    yield 0, [2], 0, ["A3,A1,A2", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 11
    yield 0, [2], 1, ["A1,A3,A2", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 12
    yield 0, [2], 2, ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 13
    yield 0, [2], 3, ["A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"]  # 14


@pytest.mark.parametrize(
    "page, cards_to_move, target_row, expected",
    generate_test_cases_for_card_moves_within_page())
def test_ActionMoveCardsWithinPage_apply(document_with_cards: Document, page: int, cards_to_move: list[int],
                                         target_row: int|None, expected: list[str]):
    ActionMoveCardsWithinPage(page, cards_to_move, target_row).apply(document_with_cards)
    result = gather_card_names(document_with_cards)
    assert_that(result, contains_exactly(*expected), f"Got: {result}")


@pytest.mark.parametrize(
    "page, cards_to_move, target_row, after_apply",
    generate_test_cases_for_card_moves_within_page())
def test_ActionMoveCardsWithinPage_undo(document_with_cards: Document, page: int, cards_to_move: list[int],
                                        target_row: int|None, after_apply: list[str]):
    (action := ActionMoveCardsWithinPage(page, cards_to_move, target_row)).apply(document_with_cards)
    try:
        assert_that(gather_card_names(document_with_cards), contains_exactly(*after_apply))
    except AssertionError:
        pytest.skip("Test setup failed")
    action.undo(document_with_cards)
    result = gather_card_names(document_with_cards)
    assert_that(result, contains_exactly("A1,A2,A3", "B1,B2,B3", "", "D1,D2,D3,D4,D5,D6"), f"Got: {result}")