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,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] 优化图片中"