- 新增 `batchImageOptimization` 函数支持批量处理图片 - 为图片优化添加 spinner 进度指示器和实时计时显示 - 更新版本号至 0.3.2
326 lines
13 KiB
Python
326 lines
13 KiB
Python
"""Tests for image optimization functions."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
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:
|
|
"""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_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_open.return_value = mock_img
|
|
|
|
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):
|
|
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_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_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):
|
|
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_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_open.return_value = mock_img
|
|
|
|
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:
|
|
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_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_open.return_value = mock_img
|
|
|
|
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):
|
|
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_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_open.return_value = mock_img
|
|
|
|
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):
|
|
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_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_open.return_value = mock_img
|
|
|
|
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):
|
|
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_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_open.return_value = mock_img
|
|
|
|
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):
|
|
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_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_open.return_value = mock_img
|
|
|
|
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 pytest.raises(Exception):
|
|
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] 优化图片中"
|