Skip to content

Commit 502e16e

Browse files
committed
Add ascii_art tests and update coverage omit
Fix a small header comment in dlclivegui/assets/ascii_art.py and add dlclivegui/assets/* to coverage omit. Expand tests/gui/test_app_entrypoint.py with extensive unit tests for dlclivegui.assets.ascii_art (terminal/ANSI behavior, image helpers, rendering, generate/print API, and help banner), including TTY/NOTTY fixtures, image fixtures (using OpenCV, skipping if missing), and helpers for deterministic terminal sizing. Also adjust imports in the test to reference dlclivegui.assets.ascii_art.
1 parent 3611774 commit 502e16e

3 files changed

Lines changed: 282 additions & 1 deletion

File tree

dlclivegui/assets/ascii_art.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Dependencies: opencv-python, numpy
1212
"""
1313

14-
# dlclivegui/assets/ascii.py
14+
# dlclivegui/assets/ascii_art.py
1515
from __future__ import annotations
1616

1717
import os

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ exclude_lines = [
133133
]
134134
omit = [
135135
"tests/*",
136+
"dlclivegui/assets/*",
136137
]
137138
[tool.coverage.run]
138139
branch = true

tests/gui/test_app_entrypoint.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
from __future__ import annotations
33

44
import importlib
5+
import os
56
import sys
7+
from pathlib import Path
68
from unittest.mock import MagicMock
79

10+
import numpy as np
811
import pytest
912

13+
# Import the module under test
14+
# Adjust the import to your package layout as needed:
15+
# from dlclivegui.assets import ascii as ascii_mod
16+
import dlclivegui.assets.ascii_art as ascii_mod
17+
1018
MODULE_UNDER_TEST = "dlclivegui.main"
1119

1220

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

0 commit comments

Comments
 (0)