|
2 | 2 | from __future__ import annotations |
3 | 3 |
|
4 | 4 | import importlib |
5 | | -import os |
6 | 5 | import sys |
7 | | -from pathlib import Path |
8 | 6 | from unittest.mock import MagicMock |
9 | 7 |
|
10 | | -import numpy as np |
11 | 8 | import pytest |
12 | 9 |
|
13 | | -try: |
14 | | - import cv2 as cv |
15 | | -except Exception: |
16 | | - pytest.skip("OpenCV (opencv-python) is required for these tests.", allow_module_level=True) |
17 | | - |
18 | | -import dlclivegui.assets.ascii_art as ascii_mod |
19 | | - |
20 | 10 | MODULE_UNDER_TEST = "dlclivegui.main" |
21 | 11 |
|
22 | 12 |
|
@@ -141,269 +131,3 @@ def test_main_without_splash(monkeypatch, set_use_splash_false): |
141 | 131 | show_splash_mock.assert_not_called() |
142 | 132 | assert calls["count"] == 0 |
143 | 133 | win_instance.show.assert_called_once() |
144 | | - |
145 | | - |
146 | | -# ------------------------- |
147 | | -# Fixtures & small helpers |
148 | | -# ------------------------- |
149 | | - |
150 | | - |
151 | | -@pytest.fixture |
152 | | -def tmp_png_gray(tmp_path: Path): |
153 | | - """Create a simple 16x8 gray gradient PNG without alpha.""" |
154 | | - h, w = 8, 16 |
155 | | - # Horizontal gradient from black to white in BGR |
156 | | - x = np.linspace(0, 255, w, dtype=np.uint8) |
157 | | - img = np.tile(x, (h, 1)) |
158 | | - bgr = cv.cvtColor(img, cv.COLOR_GRAY2BGR) |
159 | | - p = tmp_path / "gray.png" |
160 | | - assert cv.imwrite(str(p), bgr) |
161 | | - return p |
162 | | - |
163 | | - |
164 | | -@pytest.fixture |
165 | | -def tmp_png_bgra_logo(tmp_path: Path): |
166 | | - """Create a small BGRA image with a transparent border and opaque center.""" |
167 | | - h, w = 10, 20 |
168 | | - bgra = np.zeros((h, w, 4), dtype=np.uint8) |
169 | | - # Opaque blue rectangle in center |
170 | | - bgra[2:8, 5:15, 0] = 255 # B |
171 | | - bgra[2:8, 5:15, 3] = 255 # A |
172 | | - p = tmp_path / "logo_bgra.png" |
173 | | - assert cv.imwrite(str(p), bgra) |
174 | | - return p |
175 | | - |
176 | | - |
177 | | -def _force_isatty(monkeypatch, obj, value: bool): |
178 | | - """ |
179 | | - Ensure obj.isatty() returns value. |
180 | | - Try instance patch first; if the object disallows attribute assignment, |
181 | | - patch the method on its class. |
182 | | - """ |
183 | | - try: |
184 | | - # Try patching the instance directly |
185 | | - monkeypatch.setattr(obj, "isatty", lambda: value, raising=False) |
186 | | - except Exception: |
187 | | - # Fallback: patch the class method |
188 | | - cls = type(obj) |
189 | | - monkeypatch.setattr(cls, "isatty", lambda self: value, raising=True) |
190 | | - |
191 | | - |
192 | | -@pytest.fixture |
193 | | -def force_tty(monkeypatch): |
194 | | - """ |
195 | | - Pretend stdout is a TTY and provide a stable terminal size inside the |
196 | | - module-under-test namespace (and the actual sys). |
197 | | - """ |
198 | | - # NO_COLOR must be unset for should_use_color("auto") |
199 | | - monkeypatch.delenv("NO_COLOR", raising=False) |
200 | | - |
201 | | - # Make whatever stdout object exists report TTY=True |
202 | | - _force_isatty(monkeypatch, sys.stdout, True) |
203 | | - _force_isatty(monkeypatch, ascii_mod.sys.stdout, True) |
204 | | - |
205 | | - # Ensure terminal size used by the module is deterministic |
206 | | - monkeypatch.setattr( |
207 | | - ascii_mod.shutil, |
208 | | - "get_terminal_size", |
209 | | - lambda fallback=None: os.terminal_size((80, 24)), |
210 | | - raising=True, |
211 | | - ) |
212 | | - return sys.stdout # not used directly, but handy |
213 | | - |
214 | | - |
215 | | -@pytest.fixture |
216 | | -def force_notty(monkeypatch): |
217 | | - """ |
218 | | - Pretend stdout is not a TTY. |
219 | | - """ |
220 | | - _force_isatty(monkeypatch, sys.stdout, False) |
221 | | - _force_isatty(monkeypatch, ascii_mod.sys.stdout, False) |
222 | | - return sys.stdout |
223 | | - |
224 | | - |
225 | | -# ------------------------- |
226 | | -# Terminal / ANSI behavior |
227 | | -# ------------------------- |
228 | | - |
229 | | - |
230 | | -def test_get_terminal_width_tty(force_tty): |
231 | | - width = ascii_mod.get_terminal_width(default=123) |
232 | | - assert width == 80 |
233 | | - |
234 | | - |
235 | | -def test_get_terminal_width_notty(force_notty): |
236 | | - width = ascii_mod.get_terminal_width(default=123) |
237 | | - assert width == 123 |
238 | | - |
239 | | - |
240 | | -def test_should_use_color_auto_tty(force_tty, monkeypatch): |
241 | | - monkeypatch.delenv("NO_COLOR", raising=False) |
242 | | - assert ascii_mod.should_use_color("auto") is True |
243 | | - |
244 | | - |
245 | | -def test_should_use_color_auto_no_color(force_tty, monkeypatch): |
246 | | - monkeypatch.setenv("NO_COLOR", "1") |
247 | | - assert ascii_mod.should_use_color("auto") is False |
248 | | - |
249 | | - |
250 | | -def test_should_use_color_modes(force_notty): |
251 | | - assert ascii_mod.should_use_color("never") is False |
252 | | - assert ascii_mod.should_use_color("always") is True |
253 | | - |
254 | | - |
255 | | -def test_terminal_is_wide_enough(force_tty): |
256 | | - assert ascii_mod.terminal_is_wide_enough(60) is True |
257 | | - assert ascii_mod.terminal_is_wide_enough(100) is False |
258 | | - |
259 | | - |
260 | | -# ------------------------- |
261 | | -# Image helpers |
262 | | -# ------------------------- |
263 | | - |
264 | | - |
265 | | -def test__to_bgr_converts_gray(): |
266 | | - gray = np.zeros((5, 7), dtype=np.uint8) |
267 | | - bgr = ascii_mod._to_bgr(gray) |
268 | | - assert bgr.shape == (5, 7, 3) |
269 | | - assert bgr.dtype == np.uint8 |
270 | | - |
271 | | - |
272 | | -def test_composite_over_color_bgra(tmp_png_bgra_logo): |
273 | | - img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) |
274 | | - assert img.shape[2] == 4 |
275 | | - bgr = ascii_mod.composite_over_color(img, bg_bgr=(10, 20, 30)) |
276 | | - assert bgr.shape[2] == 3 |
277 | | - # Transparent border should become the bg color |
278 | | - assert tuple(bgr[0, 0]) == (10, 20, 30) |
279 | | - # Opaque center should keep blue channel high |
280 | | - assert bgr[5, 10, 0] == 255 |
281 | | - |
282 | | - |
283 | | -def test_crop_to_content_alpha(tmp_png_bgra_logo): |
284 | | - img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) |
285 | | - cropped = ascii_mod.crop_to_content_alpha(img, alpha_thresh=1, pad=0) |
286 | | - h, w = cropped.shape[:2] |
287 | | - assert h == 6 # 2..7 -> 6 rows |
288 | | - assert w == 10 # 5..14 -> 10 cols |
289 | | - assert cropped[..., 3].min() == 255 |
290 | | - |
291 | | - |
292 | | -def test_crop_to_content_bg_white(tmp_path): |
293 | | - # Create white background with a black rectangle |
294 | | - h, w = 12, 20 |
295 | | - bgr = np.full((h, w, 3), 255, dtype=np.uint8) |
296 | | - bgr[3:10, 4:15] = 0 |
297 | | - p = tmp_path / "white_bg.png" |
298 | | - assert cv.imwrite(str(p), bgr) |
299 | | - cropped = ascii_mod.crop_to_content_bg(bgr, bg="white", tol=10, pad=0) |
300 | | - assert cropped.shape[0] == 7 # 3..9 -> 7 rows |
301 | | - assert cropped.shape[1] == 11 # 4..14 -> 11 cols |
302 | | - |
303 | | - |
304 | | -def test_resize_for_terminal_aspect_env(monkeypatch): |
305 | | - img = np.zeros((100, 200, 3), dtype=np.uint8) |
306 | | - monkeypatch.setenv("DLCLIVE_ASCII_ASPECT", "0.25") |
307 | | - resized = ascii_mod.resize_for_terminal(img, width=80, aspect=None) |
308 | | - # new_h = (h/w) * width * aspect = (100/200)*80*0.25 = 10 |
309 | | - assert resized.shape[:2] == (10, 80) |
310 | | - |
311 | | - |
312 | | -# ------------------------- |
313 | | -# Rendering |
314 | | -# ------------------------- |
315 | | - |
316 | | - |
317 | | -def test_map_luminance_to_chars_simple(): |
318 | | - gray = np.array([[0, 127, 255]], dtype=np.uint8) |
319 | | - lines = list(ascii_mod._map_luminance_to_chars(gray, fine=False)) |
320 | | - assert len(lines) == 1 |
321 | | - # First char should be the densest in the simple ramp '@', last should be space |
322 | | - assert lines[0][0] == ascii_mod.ASCII_RAMP_SIMPLE[0] |
323 | | - assert lines[0][-1] == ascii_mod.ASCII_RAMP_SIMPLE[-1] |
324 | | - |
325 | | - |
326 | | -def test_color_ascii_lines_basic(): |
327 | | - # Small 2x3 color blocks |
328 | | - img = np.zeros((2, 3, 3), dtype=np.uint8) |
329 | | - img[:] = (10, 20, 30) |
330 | | - lines = list(ascii_mod._color_ascii_lines(img, fine=False, invert=False)) |
331 | | - assert len(lines) == 2 |
332 | | - # Expect ANSI 24-bit color sequence present |
333 | | - assert "\x1b[38;2;" in lines[0] |
334 | | - # Reset code present |
335 | | - assert lines[0].endswith("\x1b[0m" * 3) is False # individual chars have resets, but line won't end with triple |
336 | | - |
337 | | - |
338 | | -# ------------------------- |
339 | | -# Public API: generate & print |
340 | | -# ------------------------- |
341 | | - |
342 | | - |
343 | | -@pytest.mark.parametrize("use_color", ["never", "always"]) |
344 | | -def test_generate_ascii_lines_gray(tmp_png_gray, use_color, force_tty): |
345 | | - lines = list( |
346 | | - ascii_mod.generate_ascii_lines( |
347 | | - str(tmp_png_gray), |
348 | | - width=40, |
349 | | - aspect=0.5, |
350 | | - color=use_color, |
351 | | - fine=False, |
352 | | - invert=False, |
353 | | - crop_content=False, |
354 | | - crop_bg="none", |
355 | | - ) |
356 | | - ) |
357 | | - assert len(lines) > 0 |
358 | | - # Width equals number of characters per line |
359 | | - assert all(len(line) == 40 or ("\x1b[38;2;" in line and len(_strip_ansi(line)) == 40) for line in lines) |
360 | | - |
361 | | - |
362 | | -def _strip_ansi(s: str) -> str: |
363 | | - import re |
364 | | - |
365 | | - return re.sub(r"\x1b\[[0-9;]*m", "", s) |
366 | | - |
367 | | - |
368 | | -def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): |
369 | | - lines_no_crop = list( |
370 | | - ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=False) |
371 | | - ) |
372 | | - lines_crop = list( |
373 | | - ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=True) |
374 | | - ) |
375 | | - # Both are non-empty; height may change either way depending on aspect ratio |
376 | | - assert len(lines_no_crop) > 0 and len(lines_crop) > 0 |
377 | | - # Cropping should affect the generated ASCII content |
378 | | - assert lines_crop != lines_no_crop |
379 | | - |
380 | | - |
381 | | -def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): |
382 | | - out_path = tmp_path / "out.txt" |
383 | | - ascii_mod.print_ascii( |
384 | | - str(tmp_png_gray), |
385 | | - width=30, |
386 | | - aspect=0.5, |
387 | | - color="never", |
388 | | - output=str(out_path), |
389 | | - ) |
390 | | - assert out_path.exists() |
391 | | - text = out_path.read_text(encoding="utf-8") |
392 | | - # Expect multiple lines of length 30 |
393 | | - lines = [ln for ln in text.splitlines() if ln] |
394 | | - assert len(lines) > 0 |
395 | | - assert all(len(ln) == 30 for ln in lines) |
396 | | - |
397 | | - |
398 | | -def test_build_help_description_tty(tmp_png_bgra_logo, monkeypatch, force_tty): |
399 | | - monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) |
400 | | - desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) |
401 | | - assert "DeepLabCut-Live GUI" in desc |
402 | | - assert "\x1b[36m" in desc # cyan wrapper now present since TTY is mocked correctly |
403 | | - |
404 | | - |
405 | | -def test_build_help_description_notty(tmp_png_bgra_logo, monkeypatch, force_notty): |
406 | | - monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) |
407 | | - desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) |
408 | | - # Not a TTY -> no banner, just the plain description |
409 | | - assert desc.strip() == "DeepLabCut-Live GUI — launch the graphical interface." |
0 commit comments