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