feat(lib): 添加批量图片优化功能与实时进度显示

- 新增 `batchImageOptimization` 函数支持批量处理图片
- 为图片优化添加 spinner 进度指示器和实时计时显示
- 更新版本号至 0.3.2
This commit is contained in:
2026-06-18 16:17:38 +08:00
parent ffcb2801b2
commit 97b30ace84
9 changed files with 553 additions and 479 deletions

View File

@@ -1,150 +1,102 @@
"""Tests for convertPPT class."""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from pptopic.lib import convertPPT
class TestConvertPPTClass:
"""Test convertPPT class initialization and properties."""
class TestConvertPPT:
"""Test convertPPT class."""
def test_ttypes_mapping(self):
"""Test that TTYPES mapping is correct."""
assert convertPPT.TTYPES["JPG"] == 17
assert convertPPT.TTYPES["PNG"] == 18
assert convertPPT.TTYPES["PDF"] == 32
assert convertPPT.TTYPES["XPS"] == 33
TTYPE_VALUES = [("JPG", 17), ("PNG", 18), ("PDF", 32), ("XPS", 33)]
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_valid_file(self, tmp_path):
"""Test initialization with a valid file path."""
@pytest.mark.parametrize("ttype,expected", TTYPE_VALUES)
def test_ttypes_mapping(self, ttype, expected):
assert convertPPT.TTYPES[ttype] == expected
@pytest.mark.parametrize("as_path", [False, True])
@patch("pptopic.lib.sys.platform", "win32")
def test_init_valid(self, as_path, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch:
mock_app = MagicMock()
mock_dispatch.return_value = mock_app
if as_path:
test_file = Path(test_file)
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans="JPG")
assert ppt.savetype == "JPG"
mock_dispatch.assert_called_once_with('PowerPoint.Application')
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_path_object(self, tmp_path):
"""Test initialization with Path object."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(Path(test_file), trans="PNG")
assert ppt.savetype == "PNG"
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_nonexistent_file(self):
"""Test initialization with nonexistent file raises FileNotFoundError."""
@patch("pptopic.lib.sys.platform", "win32")
def test_init_nonexistent_file(self):
with pytest.raises(FileNotFoundError, match="File not found"):
convertPPT("nonexistent.pptx")
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_invalid_trans_type(self, tmp_path):
"""Test initialization with invalid save type raises ValueError."""
@patch("pptopic.lib.sys.platform", "win32")
def test_init_invalid_trans_type(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with pytest.raises(ValueError, match="Save type is not supported"):
convertPPT(test_file, trans="BMP")
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_case_insensitive_trans(self, tmp_path):
"""Test that trans parameter is case insensitive."""
@pytest.mark.parametrize("ttype,expected", [("jpg", "JPG"), ("Pdf", "PDF")])
@patch("pptopic.lib.sys.platform", "win32")
def test_init_case_insensitive_trans(self, ttype, expected, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="jpg")
assert ppt.savetype == "JPG"
ppt = convertPPT(test_file, trans="Pdf")
assert ppt.savetype == "PDF"
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans=ttype)
assert ppt.savetype == expected
class TestConvertPTTNonWindows:
"""Test convertPPT behavior on non-Windows systems."""
@patch('pptopic.lib.sys.platform', 'linux')
def test_init_on_non_windows_raises_error(self):
"""Test that initialization on non-Windows raises SystemError."""
@patch("pptopic.lib.sys.platform", "linux")
def test_init_non_windows_raises_error(self):
with pytest.raises(SystemError, match="Only support Windows system"):
convertPPT("test.pptx")
class TestConvertPPTContextManager:
"""Test convertPPT as context manager."""
@patch('pptopic.lib.sys.platform', 'win32')
def test_context_manager_enter_exit(self, tmp_path):
"""Test __enter__ and __exit__ methods."""
@patch("pptopic.lib.sys.platform", "win32")
def test_context_manager(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch:
with patch("pptopic.lib.wcc.Dispatch") as mock_dispatch:
mock_app = MagicMock()
mock_dispatch.return_value = mock_app
with convertPPT(test_file) as ppt:
assert ppt.savetype == "JPG"
mock_app.Quit.assert_called_once()
class TestConvertPPTSaveAs:
"""Test saveAs method."""
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_default(self, tmp_path):
"""Test saveAs with default parameter."""
@pytest.mark.parametrize(
"save_as,expected",
[(None, "JPG"), ("PDF", "PDF")],
ids=["default_jpg", "explicit_pdf"],
)
@patch("pptopic.lib.sys.platform", "win32")
def test_saveas(self, save_as, expected, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans="PNG")
ppt.saveAs()
assert ppt.savetype == "JPG"
ppt.saveAs(save_as)
assert ppt.savetype == expected
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_with_valid_type(self, tmp_path):
"""Test saveAs with valid save type."""
@patch("pptopic.lib.sys.platform", "win32")
def test_saveas_invalid_type(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="JPG")
ppt.saveAs("PDF")
assert ppt.savetype == "PDF"
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_with_invalid_type(self, tmp_path):
"""Test saveAs with invalid save type raises ValueError."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="JPG")
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file)
with pytest.raises(ValueError, match="Save type is not supported"):
ppt.saveAs("BMP")
class TestConvertPTTOpenClassmethod:
"""Test open class method."""
@patch('pptopic.lib.sys.platform', 'win32')
@patch("pptopic.lib.sys.platform", "win32")
def test_open_classmethod(self, tmp_path):
"""Test open class method creates instance."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT.open(test_file, trans="PNG")
assert ppt.savetype == "PNG"
assert isinstance(ppt, convertPPT)

View File

@@ -1,244 +1,123 @@
"""Tests for PPT_longPic function."""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from PIL import Image
from unittest.mock import MagicMock, patch
import pytest
from pptopic.lib import PPT_longPic
def _make_img_mocks(tmp_path, num_images=1):
"""Build reusable mock objects for PPT_longPic tests.
Returns (img_paths, mock_img, mock_canvas):
- img_paths: real Path objects so ``sorted()`` works
- mock_img: PIL Image mock with .size, .width, .height, .resize
- mock_canvas: PIL Image.new mock
"""
img_paths = [tmp_path / f"slide{i}.jpg" for i in range(1, num_images + 1)]
mock_img = MagicMock()
mock_img.mode = "RGB"
mock_img.size = (100, 100)
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_canvas = MagicMock()
return img_paths, mock_img, mock_canvas
class TestPPTLongPic:
"""Test PPT_longPic function."""
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_save_name(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with saveName parameter."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveName="output.jpg", saveto=tmp_path)
mock_canvas.save.assert_called_once()
# -- width variations --------------------------------------------------
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_without_save_name(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic without saveName uses default JPG."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=tmp_path)
mock_canvas.save.assert_called_once()
@pytest.mark.parametrize(
"width,expected_size",
[
(None, (100, 100)), # no scaling → uses img.size
(50, (50, 50)), # pixel width → (w, h*w//W)
("50%", (50, 50)), # percentage → half
],
ids=["none", "pixel", "percentage"],
)
@patch("pptopic.lib.convertPPT")
def test_width_variations(self, mock_convert_ppt, width, expected_size, tmp_path):
"""PPT_longPic handles None / pixel / percentage width correctly."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
def test_ppt_longpic_invalid_image_type(self, tmp_path):
"""Test PPT_longPic with invalid image type raises ValueError."""
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(tmp_path / "test.pptx", width=width, saveto=tmp_path)
mock_canvas.save.assert_called_once()
# -- saveName / saveto variations -------------------------------------
@pytest.mark.parametrize(
"save_name,saveto",
[
(None, None), # defaults: JPG, ppt parent dir
("output.jpg", None), # named, ppt parent dir
(None, "."), # default name, custom dir
],
ids=["defaults", "named", "custom_dir"],
)
@patch("pptopic.lib.convertPPT")
def test_save_params(self, mock_convert_ppt, save_name, saveto, tmp_path):
"""PPT_longPic handles saveName / saveto combinations."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(
tmp_path / "test.pptx",
saveName=save_name,
saveto=saveto if saveto else tmp_path,
)
mock_canvas.save.assert_called_once()
# -- error paths -------------------------------------------------------
def test_invalid_image_type(self, tmp_path):
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
with pytest.raises(ValueError, match="Unable to save this type"):
PPT_longPic(ppt_file, saveName="output.bmp")
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_no_images_generated(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic when no images are generated raises ValueError."""
@patch("pptopic.lib.convertPPT")
def test_no_images_generated(self, mock_convert_ppt, tmp_path):
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob', return_value=[]):
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch("pptopic.lib.Path.glob", return_value=[]):
with pytest.raises(ValueError, match="No images generated"):
PPT_longPic(ppt_file)
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_percentage_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with percentage width string."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width="50%", saveto=tmp_path)
mock_new.assert_called_once()
# -- multiple images ---------------------------------------------------
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_pixel_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with pixel width."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=50, saveto=tmp_path)
mock_new.assert_called_once()
@patch("pptopic.lib.convertPPT")
def test_multiple_images(self, mock_convert_ppt, tmp_path):
"""PPT_longPic pastes every slide into the canvas."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path, num_images=3)
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_none_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with None width (no scaling)."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=None, saveto=tmp_path)
mock_new.assert_called_once()
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_save_to_current_dir(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic saving to current directory."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=".")
mock_canvas.save.assert_called_once()
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(tmp_path / "test.pptx", saveto=tmp_path)
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_multiple_images(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with multiple images."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path1 = MagicMock()
mock_img_path1.suffix = '.jpg'
mock_img_path2 = MagicMock()
mock_img_path2.suffix = '.jpg'
mock_glob.return_value = [mock_img_path1, mock_img_path2]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=tmp_path)
mock_canvas.paste.assert_called()
assert mock_canvas.paste.call_count == 3
mock_canvas.save.assert_called_once()

View File

@@ -1,227 +1,325 @@
"""Tests for imageOptimization function."""
"""Tests for image optimization functions."""
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from PIL import Image
from unittest.mock import MagicMock, patch
import numpy as np
from pptopic.lib import imageOptimization
import pytest
from PIL import Image
from pptopic.lib import (
_run_engine_with_spinner,
batchImageOptimization,
imageOptimization,
)
def _make_yaspin_mock():
"""Return (mock_yaspin, mock_spinner) wired so __exit__ propagates exceptions."""
mock_spinner = MagicMock()
mock_spinner.__exit__ = MagicMock(return_value=False)
mock_spinner.__enter__ = MagicMock(return_value=mock_spinner)
mock_yaspin = MagicMock(return_value=mock_spinner)
return mock_yaspin, mock_spinner
# ---------------------------------------------------------------------------
# imageOptimization
# ---------------------------------------------------------------------------
class TestImageOptimization:
"""Test imageOptimization function."""
"""Tests for imageOptimization function."""
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_file_path(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with file path input."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_file_path(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Optimize via file path returns a PIL Image."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png")
assert result is not None
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_pil_image(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, tmp_path):
"""Test imageOptimization with PIL Image input."""
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_pil_image(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, tmp_path):
"""Optimize a PIL Image directly."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_img.mode = "RGB"
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(mock_img)
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.shutil.copyfile')
def test_image_optimization_with_save_file(self, mock_copyfile, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with saveFile parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.shutil.copyfile")
def test_with_save_file(self, mock_copyfile, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""When saveFile is provided, result is None and copyfile is called."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.lastFile') as mock_last_file:
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.lastFile") as mock_last_file:
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", saveFile=tmp_path / "output.png")
assert result is None
mock_copyfile.assert_called_once()
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_max_width(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with max_width parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_max_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_width triggers resize."""
mock_img = MagicMock()
mock_img.size = (200, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_width=100)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_max_height(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with max_height parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_max_height(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_height triggers resize."""
mock_img = MagicMock()
mock_img.size = (100, 200)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((200, 100, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((100, 50, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((200, 100, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((100, 50, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_height=100)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_fractional_width(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with fractional max_width (< 1)."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_fractional_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_width < 1 is treated as a scale factor."""
mock_img = MagicMock()
mock_img.size = (200, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_width=0.5)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_without_engine(self, mock_last_file, mock_subprocess,
mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with engine=None."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_without_engine(self, mock_last_file, mock_subprocess, mock_cvtcolor, mock_open, tmp_path):
"""engine=None skips subprocess and saves via PIL."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", engine=None)
mock_subprocess.assert_not_called()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.subprocess.run')
def test_image_optimization_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
"""Test imageOptimization when engine subprocess fails."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.subprocess.run")
def test_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
"""Generic engine error re-raises after printing help text."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_subprocess.side_effect = Exception("Engine not found")
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with pytest.raises(Exception):
imageOptimization("test.png", engine="pngquant", engine_conf="--quality=85")
imageOptimization("test.png", engine="pngquant")
# ---------------------------------------------------------------------------
# _run_engine_with_spinner
# ---------------------------------------------------------------------------
class TestRunEngineWithSpinner:
"""Tests for _run_engine_with_spinner helper."""
def test_runs_subprocess_with_correct_cmd(self):
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "--skip-if-larger", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run") as mock_run:
_run_engine_with_spinner(cmd, desc="优化中")
mock_run.assert_called_once_with(cmd, shell=True, check=True)
def test_called_process_error_calls_spinner_fail(self):
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, cmd)
with pytest.raises(subprocess.CalledProcessError):
_run_engine_with_spinner(cmd)
mock_spinner.fail.assert_called_once_with("图片优化失败")
def test_timer_thread_cleans_up_in_finally(self):
"""Stop event is set and timer thread is joined after execution."""
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run"):
with patch("pptopic.lib.threading.Thread") as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
with patch("pptopic.lib.threading.Event") as mock_event_cls:
mock_event = MagicMock()
mock_event_cls.return_value = mock_event
_run_engine_with_spinner(cmd)
mock_event.set.assert_called_once()
mock_thread.join.assert_called_once_with(timeout=1)
# ---------------------------------------------------------------------------
# batchImageOptimization
# ---------------------------------------------------------------------------
class TestBatchImageOptimization:
"""Tests for batchImageOptimization function."""
def test_processes_each_file(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
results = batchImageOptimization(files)
assert mock_opt.call_count == 3
assert results == []
def test_result_appends_source_when_image_returned(self, tmp_path):
files = [tmp_path / "img1.png"]
files[0].touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = MagicMock(spec=Image.Image)
results = batchImageOptimization(files)
assert results == [files[0]]
def test_with_save_dir(self, tmp_path):
files = [tmp_path / "img1.png"]
files[0].touch()
save_dir = tmp_path / "out"
save_dir.mkdir()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
results = batchImageOptimization(files, save_dir=save_dir)
assert results == [save_dir / "img1.png"]
def test_single_failure_does_not_abort_batch(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.side_effect = [Exception("fail"), None, None]
results = batchImageOptimization(files)
assert mock_opt.call_count == 3
assert results == []
def test_progress_desc_includes_file_index(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
batchImageOptimization(files)
assert mock_opt.call_count == 3
assert mock_opt.call_args_list[0][1]["progress_desc"] == "[1/3] 优化图片中"
assert mock_opt.call_args_list[1][1]["progress_desc"] == "[2/3] 优化图片中"
assert mock_opt.call_args_list[2][1]["progress_desc"] == "[3/3] 优化图片中"