Skip to content

Commit f5e6f6c

Browse files
committed
Use MagicMocks for headless display tests
Replace handcrafted FakeTk/Label/PhotoImage classes with unittest.mock MagicMocks in the headless_display_env fixture. The fixture now returns a SimpleNamespace with mock constructors/instances (tk, label, photo) and sets display module attributes with monkeypatch (raising=False). Update tests to use the env mocks, assert on mock calls (title, pack, configure, update, destroy), mock ImageDraw.Draw with a MagicMock, and make colormap sampling deterministic for assertions. This simplifies test setup and enables precise call-based assertions instead of inspecting custom fake object state.
1 parent 287cf2f commit f5e6f6c

2 files changed

Lines changed: 84 additions & 65 deletions

File tree

tests/conftest.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import copy
44
from collections.abc import Callable
5+
from types import SimpleNamespace
56
from typing import Any
7+
from unittest.mock import MagicMock
68

79
import numpy as np
810
import pytest
@@ -15,20 +17,50 @@
1517
# --------------------------------------------------------------------------------------
1618
@pytest.fixture
1719
def headless_display_env(monkeypatch):
18-
"""Patch dlclive.display so tkinter is replaced with fake, non-GUI-safe objects."""
19-
from test_display import FakeLabel, FakePhotoImage, FakeTk
20-
20+
"""
21+
Patch dlclive.display so tkinter + ImageTk are replaced by MagicMocks.
22+
23+
Returns an object with:
24+
- mod: the imported dlclive.display module
25+
- tk_ctor: MagicMock constructor for Tk
26+
- tk: MagicMock instance for the window
27+
- label_ctor: MagicMock constructor for Label
28+
- label: MagicMock instance for the label widget
29+
- photo_ctor: MagicMock function for ImageTk.PhotoImage
30+
- photo: MagicMock instance representing created image
31+
"""
2132
import dlclive.display as display_mod
2233

23-
monkeypatch.setattr(display_mod, "_TKINTER_AVAILABLE", True)
24-
monkeypatch.setattr(display_mod, "Tk", FakeTk)
25-
monkeypatch.setattr(display_mod, "Label", FakeLabel)
34+
# Ensure display path is enabled
35+
monkeypatch.setattr(display_mod, "_TKINTER_AVAILABLE", True, raising=False)
2636

27-
class FakeImageTkModule:
28-
PhotoImage = FakePhotoImage
37+
# Tk / Label mocks
38+
tk = MagicMock(name="TkInstance")
39+
tk_ctor = MagicMock(name="Tk", return_value=tk)
2940

30-
monkeypatch.setattr(display_mod, "ImageTk", FakeImageTkModule)
31-
return display_mod
41+
label = MagicMock(name="LabelInstance")
42+
label_ctor = MagicMock(name="Label", return_value=label)
43+
44+
# ImageTk.PhotoImage mock
45+
photo = MagicMock(name="PhotoImageInstance")
46+
photo_ctor = MagicMock(name="PhotoImage", return_value=photo)
47+
48+
class FakeImageTkModule:
49+
PhotoImage = photo_ctor
50+
51+
monkeypatch.setattr(display_mod, "Tk", tk_ctor, raising=False)
52+
monkeypatch.setattr(display_mod, "Label", label_ctor, raising=False)
53+
monkeypatch.setattr(display_mod, "ImageTk", FakeImageTkModule, raising=False)
54+
55+
return SimpleNamespace(
56+
mod=display_mod,
57+
tk_ctor=tk_ctor,
58+
tk=tk,
59+
label_ctor=label_ctor,
60+
label=label,
61+
photo_ctor=photo_ctor,
62+
photo=photo,
63+
)
3264

3365

3466
# --------------------------------------------------------------------------------------

tests/test_display.py

Lines changed: 42 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,9 @@
1+
from unittest.mock import ANY, MagicMock
2+
13
import numpy as np
24
import pytest
35

46

5-
class FakeTk:
6-
def __init__(self):
7-
self.titles = []
8-
self.updated = 0
9-
self.destroyed = False
10-
11-
def title(self, text):
12-
self.titles.append(text)
13-
14-
def update(self):
15-
self.updated += 1
16-
17-
def destroy(self):
18-
self.destroyed = True
19-
20-
21-
class FakeLabel:
22-
def __init__(self, window):
23-
self.window = window
24-
self.packed = False
25-
self.configured = {}
26-
27-
def pack(self):
28-
self.packed = True
29-
30-
def configure(self, **kwargs):
31-
self.configured.update(kwargs)
32-
33-
34-
class FakePhotoImage:
35-
def __init__(self, image=None, master=None):
36-
self.image = image
37-
self.master = master
38-
39-
407
def test_display_init_raises_when_tk_unavailable(monkeypatch):
418
import dlclive.display as display_mod
429

@@ -47,52 +14,66 @@ def test_display_init_raises_when_tk_unavailable(monkeypatch):
4714

4815

4916
def test_display_frame_creates_window_and_updates(headless_display_env):
50-
display_mod = headless_display_env
17+
env = headless_display_env
18+
display_mod = env.mod
5119
disp = display_mod.Display(radius=3, pcutoff=0.5)
5220

5321
frame = np.zeros((100, 120, 3), dtype=np.uint8)
5422
pose = np.array([[[10, 10, 0.9], [50, 50, 0.2]]]) # 1 animal, 2 bodyparts
5523

5624
disp.display_frame(frame, pose)
5725

58-
assert disp.window is not None
59-
assert disp.lab is not None
60-
assert disp.lab.packed is True
61-
assert disp.window.updated == 1
62-
assert "image" in disp.lab.configured # configured with PhotoImage
26+
# Window created and initialized
27+
env.tk_ctor.assert_called_once_with()
28+
env.tk.title.assert_called_once_with("DLC Live")
29+
30+
# Label created and packed
31+
env.label_ctor.assert_called_once_with(env.tk)
32+
env.label.pack.assert_called_once()
33+
34+
# PhotoImage created with correct master + image passed
35+
env.photo_ctor.assert_called_once_with(image=ANY, master=env.tk)
36+
37+
# Image configured on label and window updated
38+
env.label.configure.assert_called_once_with(image=env.photo)
39+
env.tk.update.assert_called_once_with()
6340

6441

6542
def test_display_draws_only_points_above_cutoff(headless_display_env, monkeypatch):
66-
display_mod = headless_display_env
43+
env = headless_display_env
44+
display_mod = env.mod
6745
disp = display_mod.Display(radius=3, pcutoff=0.5)
6846

47+
# Patch colormap so color sampling is deterministic and always long enough
48+
class FakeCC:
49+
bmy = [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0)]
50+
51+
monkeypatch.setattr(display_mod, "cc", FakeCC)
52+
6953
frame = np.zeros((100, 100, 3), dtype=np.uint8)
7054
pose = np.array(
7155
[
7256
[
7357
[10, 10, 0.9], # draw
7458
[20, 20, 0.49], # don't draw
75-
[30, 30, 0.5001], # draw (>=)
59+
[30, 30, 0.5001], # draw (> pcutoff)
7660
]
7761
],
7862
dtype=float,
7963
)
8064

81-
ellipses = []
82-
83-
class DrawRecorder:
84-
def ellipse(self, coords, fill=None, outline=None):
85-
ellipses.append((coords, fill, outline))
86-
87-
monkeypatch.setattr(display_mod.ImageDraw, "Draw", lambda img: DrawRecorder())
65+
draw = MagicMock(name="DrawInstance")
66+
monkeypatch.setattr(display_mod.ImageDraw, "Draw", MagicMock(return_value=draw))
8867

8968
disp.display_frame(frame, pose)
9069

91-
assert len(ellipses) == 2
70+
# Two points above cutoff => two ellipse calls
71+
assert draw.ellipse.call_count == 2
9272

9373

9474
def test_destroy_calls_window_destroy(headless_display_env):
95-
display_mod = headless_display_env
75+
env = headless_display_env
76+
display_mod = env.mod
9677
disp = display_mod.Display()
9778

9879
frame = np.zeros((10, 10, 3), dtype=np.uint8)
@@ -101,13 +82,13 @@ def test_destroy_calls_window_destroy(headless_display_env):
10182
disp.display_frame(frame, pose)
10283
disp.destroy()
10384

104-
assert disp.window.destroyed is True
85+
env.tk.destroy.assert_called_once_with()
10586

10687

10788
def test_set_display_color_sampling_safe(headless_display_env, monkeypatch):
108-
display_mod = headless_display_env
89+
env = headless_display_env
90+
display_mod = env.mod
10991

110-
# Provide a fixed colormap list
11192
class FakeCC:
11293
bmy = [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0), (0, 1, 1), (1, 0, 1)]
11394

@@ -118,3 +99,9 @@ class FakeCC:
11899

119100
assert disp.colors is not None
120101
assert len(disp.colors) >= 3
102+
103+
# Also verify window setup calls happened
104+
env.tk_ctor.assert_called_once_with()
105+
env.tk.title.assert_called_once_with("DLC Live")
106+
env.label_ctor.assert_called_once_with(env.tk)
107+
env.label.pack.assert_called_once()

0 commit comments

Comments
 (0)