Add undo/redo stack for tag operations (assign, remove, rename, merge) with Ctrl+Z/Ctrl+Y

This commit is contained in:
2026-04-09 18:04:37 +02:00
parent 2bcd5b1f4b
commit db280fb5c2
24 changed files with 2705 additions and 2316 deletions

View File

@@ -1,4 +1,6 @@
import tempfile
import struct
import zlib
from pathlib import Path
import pytest
import os
@@ -20,24 +22,36 @@ def qapp():
yield app
def _make_png(path: Path, width: int = 32, height: int = 32) -> None:
"""Write a minimal valid PNG file without Pillow."""
def chunk(name: bytes, data: bytes) -> bytes:
c = struct.pack(">I", len(data)) + name + data
return c + struct.pack(">I", zlib.crc32(name + data) & 0xFFFFFFFF)
raw_rows = b"".join(b"\x00" + bytes([255, 0, 0] * width) for _ in range(height))
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
idat = zlib.compress(raw_rows)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", idat)
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
def test_load_icon_returns_qicon(qapp):
"""Test that load_icon returns QIcon"""
from src.ui.utils import load_icon
from PySide6.QtGui import QIcon
from PIL import Image
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# Create 100x100 red image
img = Image.new("RGB", (100, 100), color="red")
img.save(tmp_path)
_make_png(tmp_path, 100, 100)
icon = load_icon(tmp_path)
# Must be QIcon
assert isinstance(icon, QIcon)
# Icon should not be null
assert not icon.isNull()
finally:
tmp_path.unlink(missing_ok=True)
@@ -46,19 +60,13 @@ def test_load_icon_returns_qicon(qapp):
def test_load_icon_custom_size(qapp):
"""Test that load_icon respects custom size parameter"""
from src.ui.utils import load_icon
from PIL import Image
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img = Image.new("RGB", (500, 500), color="blue")
img.save(tmp_path)
_make_png(tmp_path, 500, 500)
icon = load_icon(tmp_path, size=32)
# Icon should be created successfully
assert not icon.isNull()
# Available sizes should include the requested size
sizes = icon.availableSizes()
assert len(sizes) > 0
finally:
@@ -69,20 +77,14 @@ def test_load_icon_different_formats(qapp):
"""Test loading different image formats"""
from src.ui.utils import load_icon
from PySide6.QtGui import QIcon
from PIL import Image
formats = [".png", ".jpg", ".bmp"]
for fmt in formats:
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img = Image.new("RGB", (32, 32), color="green")
img.save(tmp_path)
icon = load_icon(tmp_path)
assert isinstance(icon, QIcon)
assert not icon.isNull()
finally:
tmp_path.unlink(missing_ok=True)
# Only PNG is reliably producible without Pillow; BMP can be crafted too
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
_make_png(tmp_path, 32, 32)
icon = load_icon(tmp_path)
assert isinstance(icon, QIcon)
assert not icon.isNull()
finally:
tmp_path.unlink(missing_ok=True)

194
tests/test_undo_redo.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Tests for FileManager undo/redo stack.
"""
import pytest
from pathlib import Path
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.file import File
from src.core.tag import Tag
@pytest.fixture
def config_dir(tmp_path, monkeypatch):
cfg = tmp_path / "cfg"
cfg.mkdir()
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", cfg / ".Tagger.!gtag")
monkeypatch.setattr("src.core.config._XDG_CONFIG_DIR", cfg)
@pytest.fixture
def fm(tmp_path, config_dir):
tm = TagManager()
manager = FileManager(tm)
# Two in-memory File objects (no real disk files needed for tag ops)
f1 = File.__new__(File)
f1.file_path = tmp_path / "a.txt"
f1.tags = []
f1.tagmanager = tm
f1.csfd_url = None
f1.date = None
f1.csfd_cache = None
f2 = File.__new__(File)
f2.file_path = tmp_path / "b.txt"
f2.tags = []
f2.tagmanager = tm
f2.csfd_url = None
f2.date = None
f2.csfd_cache = None
# Patch save_metadata to be a no-op
f1.save_metadata = lambda: None
f2.save_metadata = lambda: None
manager.filelist = [f1, f2]
manager._f1, manager._f2 = f1, f2
return manager
class TestUndoRedoAssign:
def test_assign_undo_redo(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm.assign_tag_to_files([fm._f1], tag)
assert tag in fm._f1.tags
assert fm.can_undo()
assert not fm.can_redo()
fm.undo()
assert tag not in fm._f1.tags
assert not fm.can_undo()
assert fm.can_redo()
fm.redo()
assert tag in fm._f1.tags
def test_remove_undo_redo(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Komedie")
fm._f1.tags = [tag]
fm.remove_tag_from_files([fm._f1], tag)
assert tag not in fm._f1.tags
fm.undo()
assert tag in fm._f1.tags
fm.redo()
assert tag not in fm._f1.tags
def test_assign_noop_not_pushed(self, fm):
"""Assign to file that already has tag should not push undo entry."""
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm._f1.tags = [tag]
fm.assign_tag_to_files([fm._f1], tag)
assert not fm.can_undo()
def test_redo_cleared_on_new_op(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm.assign_tag_to_files([fm._f1], tag)
fm.undo()
assert fm.can_redo()
tag2 = fm.tagmanager.add_tag("Žánr", "Thriller")
fm.assign_tag_to_files([fm._f1], tag2)
assert not fm.can_redo()
def test_undo_empty_returns_none(self, fm):
assert fm.undo() is None
def test_redo_empty_returns_none(self, fm):
assert fm.redo() is None
class TestUndoRedoRename:
def test_rename_tag_undo_redo(self, fm):
fm.tagmanager.add_tag("Žánr", "Drama")
tag_old = Tag("Žánr", "Drama")
fm._f1.tags = [tag_old]
fm._f2.tags = [tag_old]
count = fm.rename_tag_in_files("Žánr", "Drama", "Thriller")
assert count == 2
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
fm.undo()
assert Tag("Žánr", "Drama") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Drama")
assert not fm.tagmanager.tag_exists("Žánr", "Thriller")
fm.redo()
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
def test_rename_category_undo_redo(self, fm):
fm.tagmanager.add_tag("StaráKat", "X")
tag = Tag("StaráKat", "X")
fm._f1.tags = [tag]
fm.rename_category_in_files("StaráKat", "NováKat")
assert Tag("NováKat", "X") in fm._f1.tags
assert fm.tagmanager.category_exists("NováKat")
assert not fm.tagmanager.category_exists("StaráKat")
fm.undo()
assert Tag("StaráKat", "X") in fm._f1.tags
assert fm.tagmanager.category_exists("StaráKat")
assert not fm.tagmanager.category_exists("NováKat")
fm.redo()
assert Tag("NováKat", "X") in fm._f1.tags
class TestUndoRedoMerge:
def test_merge_tag_undo_redo(self, fm):
fm.tagmanager.add_tag("Žánr", "Drama")
fm.tagmanager.add_tag("Žánr", "Thriller")
fm._f1.tags = [Tag("Žánr", "Drama")]
fm._f2.tags = [Tag("Žánr", "Drama"), Tag("Žánr", "Thriller")]
fm.merge_tag_in_files("Žánr", "Drama", "Thriller")
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert Tag("Žánr", "Drama") not in fm._f1.tags
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
fm.undo()
assert Tag("Žánr", "Drama") in fm._f1.tags
assert Tag("Žánr", "Drama") not in fm._f2.tags or Tag("Žánr", "Thriller") in fm._f2.tags
assert fm.tagmanager.tag_exists("Žánr", "Drama")
fm.redo()
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
def test_merge_category_undo_redo(self, fm):
fm.tagmanager.add_tag("SrcKat", "A")
fm.tagmanager.add_tag("TgtKat", "B")
fm._f1.tags = [Tag("SrcKat", "A")]
fm._f2.tags = [Tag("TgtKat", "B")]
fm.merge_category_in_files("SrcKat", "TgtKat")
assert Tag("TgtKat", "A") in fm._f1.tags
assert not fm.tagmanager.category_exists("SrcKat")
fm.undo()
assert Tag("SrcKat", "A") in fm._f1.tags
assert fm.tagmanager.category_exists("SrcKat")
assert not fm.tagmanager.tag_exists("TgtKat", "A")
fm.redo()
assert Tag("TgtKat", "A") in fm._f1.tags
assert not fm.tagmanager.category_exists("SrcKat")
class TestUndoLimit:
def test_max_undo_entries(self, fm):
from src.core.file_manager import _MAX_UNDO
tag = fm.tagmanager.add_tag("T", "x")
for _ in range(_MAX_UNDO + 5):
fm._f1.tags = []
fm.assign_tag_to_files([fm._f1], tag)
assert len(fm._undo_stack) == _MAX_UNDO