Skip to content

Commit 35330ca

Browse files
committed
Add unit tests for GenTL discovery utils
Introduce a new test module covering `gentl_discovery` behavior end-to-end: CTI input normalization, explicit/env/extra-dir discovery, glob validation and allowed-root checks, candidate deduplication, and selection policies (`FIRST`, `NEWEST`, `RAISE_IF_MULTIPLE`). It also adds lifecycle tests for `SharedHarvesterPool`/`SharedHarvesterEntry`, including reuse/refcount semantics and failure reporting when CTI loading fails.
1 parent a206fff commit 35330ca

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from dlclivegui.cameras.backends.utils import gentl_discovery as gd
9+
10+
pytestmark = pytest.mark.unit
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def clear_shared_harvester_pool():
15+
gd.SharedHarvesterPool._entries.clear()
16+
yield
17+
gd.SharedHarvesterPool._entries.clear()
18+
19+
20+
def test_cti_files_as_list_handles_none_strings_and_sequences():
21+
assert gd.cti_files_as_list(None) == []
22+
assert gd.cti_files_as_list("") == []
23+
assert gd.cti_files_as_list(" ") == []
24+
assert gd.cti_files_as_list("camera.cti") == ["camera.cti"]
25+
assert gd.cti_files_as_list(["a.cti", None, "", " ", Path("b.cti")]) == ["a.cti", "b.cti"]
26+
27+
28+
def test_discover_explicit_cti_file_without_harvester(tmp_path: Path):
29+
cti = tmp_path / "producer.cti"
30+
cti.write_text("", encoding="utf-8")
31+
32+
candidates, diag = gd.discover_cti_files(
33+
cti_file=str(cti),
34+
include_env=False,
35+
)
36+
37+
assert candidates == [str(cti.resolve())]
38+
assert diag.explicit_files == [str(cti)]
39+
assert diag.candidates == [str(cti.resolve())]
40+
assert diag.rejected == []
41+
42+
43+
def test_discover_rejects_missing_explicit_file(tmp_path: Path):
44+
missing = tmp_path / "missing.cti"
45+
46+
candidates, diag = gd.discover_cti_files(
47+
cti_file=str(missing),
48+
include_env=False,
49+
)
50+
51+
assert candidates == []
52+
assert diag.rejected == [(str(missing.resolve()), "not a file (explicit)")]
53+
54+
55+
def test_discover_rejects_non_cti_file(tmp_path: Path):
56+
not_cti = tmp_path / "producer.txt"
57+
not_cti.write_text("", encoding="utf-8")
58+
59+
candidates, diag = gd.discover_cti_files(
60+
cti_file=str(not_cti),
61+
include_env=False,
62+
)
63+
64+
assert candidates == []
65+
assert diag.rejected == [(str(not_cti.resolve()), "not a .cti (explicit)")]
66+
67+
68+
def test_discover_accepts_missing_cti_when_must_exist_false(tmp_path: Path):
69+
missing = tmp_path / "missing.cti"
70+
71+
candidates, diag = gd.discover_cti_files(
72+
cti_file=str(missing),
73+
include_env=False,
74+
must_exist=False,
75+
)
76+
77+
assert candidates == [str(missing.resolve())]
78+
assert diag.rejected == []
79+
80+
81+
def test_discover_extra_dir_collects_cti_files_non_recursive(tmp_path: Path):
82+
root = tmp_path / "ctis"
83+
root.mkdir()
84+
85+
a = root / "a.cti"
86+
b = root / "b.cti"
87+
ignored = root / "ignored.txt"
88+
nested = root / "nested"
89+
nested.mkdir()
90+
nested_cti = nested / "nested.cti"
91+
92+
a.write_text("", encoding="utf-8")
93+
b.write_text("", encoding="utf-8")
94+
ignored.write_text("", encoding="utf-8")
95+
nested_cti.write_text("", encoding="utf-8")
96+
97+
candidates, diag = gd.discover_cti_files(
98+
include_env=False,
99+
extra_dirs=[str(root)],
100+
recursive_extra_search=False,
101+
)
102+
103+
assert candidates == [str(a.resolve()), str(b.resolve())]
104+
assert diag.extra_dirs == [str(root)]
105+
106+
107+
def test_discover_extra_dir_collects_cti_files_recursive(tmp_path: Path):
108+
root = tmp_path / "ctis"
109+
nested = root / "nested"
110+
nested.mkdir(parents=True)
111+
112+
top = root / "top.cti"
113+
child = nested / "child.cti"
114+
115+
top.write_text("", encoding="utf-8")
116+
child.write_text("", encoding="utf-8")
117+
118+
candidates, _diag = gd.discover_cti_files(
119+
include_env=False,
120+
extra_dirs=[str(root)],
121+
recursive_extra_search=True,
122+
)
123+
124+
assert candidates == [str(top.resolve()), str(child.resolve())]
125+
126+
127+
def test_discover_deduplicates_candidates_preserving_order(tmp_path: Path):
128+
cti = tmp_path / "producer.cti"
129+
cti.write_text("", encoding="utf-8")
130+
131+
candidates, diag = gd.discover_cti_files(
132+
cti_file=str(cti),
133+
cti_files=[str(cti)],
134+
extra_dirs=[str(tmp_path)],
135+
include_env=False,
136+
)
137+
138+
assert candidates == [str(cti.resolve())]
139+
assert diag.candidates == [str(cti.resolve())]
140+
141+
142+
def test_discover_env_var_direct_file(monkeypatch, tmp_path: Path):
143+
cti = tmp_path / "producer.cti"
144+
cti.write_text("", encoding="utf-8")
145+
146+
monkeypatch.setenv("MY_GENTL_PATH", str(cti))
147+
148+
candidates, diag = gd.discover_cti_files(
149+
include_env=True,
150+
env_vars=("MY_GENTL_PATH",),
151+
)
152+
153+
assert candidates == [str(cti.resolve())]
154+
assert diag.env_vars_used == {"MY_GENTL_PATH": str(cti)}
155+
assert diag.env_paths_expanded == [str(cti)]
156+
157+
158+
def test_discover_env_var_directory(monkeypatch, tmp_path: Path):
159+
cti = tmp_path / "producer.cti"
160+
cti.write_text("", encoding="utf-8")
161+
162+
monkeypatch.setenv("MY_GENTL_PATH", str(tmp_path))
163+
164+
candidates, diag = gd.discover_cti_files(
165+
include_env=True,
166+
env_vars=("MY_GENTL_PATH",),
167+
)
168+
169+
assert candidates == [str(cti.resolve())]
170+
assert diag.env_vars_used == {"MY_GENTL_PATH": str(tmp_path)}
171+
assert diag.env_paths_expanded == [str(tmp_path)]
172+
173+
174+
def test_discover_env_var_multiple_entries(monkeypatch, tmp_path: Path):
175+
d1 = tmp_path / "one"
176+
d2 = tmp_path / "two"
177+
d1.mkdir()
178+
d2.mkdir()
179+
180+
cti1 = d1 / "one.cti"
181+
cti2 = d2 / "two.cti"
182+
cti1.write_text("", encoding="utf-8")
183+
cti2.write_text("", encoding="utf-8")
184+
185+
monkeypatch.setenv("MY_GENTL_PATH", os.pathsep.join([str(d1), str(d2)]))
186+
187+
candidates, _diag = gd.discover_cti_files(
188+
include_env=True,
189+
env_vars=("MY_GENTL_PATH",),
190+
)
191+
192+
assert candidates == [str(cti1.resolve()), str(cti2.resolve())]
193+
194+
195+
def test_validate_glob_pattern_rejects_empty_pattern():
196+
ok, reason = gd._validate_glob_pattern("")
197+
198+
assert ok is False
199+
assert reason == "empty glob pattern"
200+
201+
202+
def test_validate_glob_pattern_rejects_traversal(tmp_path: Path):
203+
pattern = str(tmp_path / ".." / "*.cti")
204+
205+
ok, reason = gd._validate_glob_pattern(pattern)
206+
207+
assert ok is False
208+
assert reason == "glob pattern contains '..' traversal"
209+
210+
211+
def test_validate_glob_pattern_rejects_non_cti_pattern(tmp_path: Path):
212+
pattern = str(tmp_path / "*.txt")
213+
214+
ok, reason = gd._validate_glob_pattern(pattern)
215+
216+
assert ok is False
217+
assert reason == "glob pattern does not target .cti files"
218+
219+
220+
def test_validate_glob_pattern_rejects_outside_allowed_roots(tmp_path: Path):
221+
allowed = tmp_path / "allowed"
222+
outside = tmp_path / "outside"
223+
allowed.mkdir()
224+
outside.mkdir()
225+
226+
pattern = str(outside / "*.cti")
227+
228+
ok, reason = gd._validate_glob_pattern(
229+
pattern,
230+
allowed_roots=[str(allowed)],
231+
)
232+
233+
assert ok is False
234+
assert reason == "glob pattern base is outside allowed roots"
235+
236+
237+
def test_discover_glob_pattern_with_allowed_root(tmp_path: Path):
238+
cti_dir = tmp_path / "ctis"
239+
cti_dir.mkdir()
240+
241+
cti = cti_dir / "producer.cti"
242+
cti.write_text("", encoding="utf-8")
243+
244+
candidates, diag = gd.discover_cti_files(
245+
cti_search_paths=[str(cti_dir / "*.cti")],
246+
include_env=False,
247+
root_globs_allowed=[str(tmp_path)],
248+
)
249+
250+
assert candidates == [str(cti.resolve())]
251+
assert diag.rejected == []
252+
253+
254+
def test_choose_cti_files_first_policy():
255+
assert gd.choose_cti_files(
256+
["a.cti", "b.cti", "c.cti"],
257+
policy=gd.GenTLDiscoveryPolicy.FIRST,
258+
max_files=2,
259+
) == ["a.cti", "b.cti"]
260+
261+
262+
def test_choose_cti_files_raise_if_multiple_policy_raises():
263+
with pytest.raises(RuntimeError, match="Multiple GenTL producers"):
264+
gd.choose_cti_files(
265+
["a.cti", "b.cti"],
266+
policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE,
267+
max_files=1,
268+
)
269+
270+
271+
def test_choose_cti_files_raise_if_multiple_policy_allows_within_limit():
272+
assert gd.choose_cti_files(
273+
["a.cti"],
274+
policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE,
275+
max_files=1,
276+
) == ["a.cti"]
277+
278+
279+
def test_choose_cti_files_newest_policy(tmp_path: Path):
280+
old = tmp_path / "old.cti"
281+
new = tmp_path / "new.cti"
282+
283+
old.write_text("", encoding="utf-8")
284+
new.write_text("", encoding="utf-8")
285+
286+
os.utime(old, (1000, 1000))
287+
os.utime(new, (2000, 2000))
288+
289+
assert gd.choose_cti_files(
290+
[str(old), str(new)],
291+
policy=gd.GenTLDiscoveryPolicy.NEWEST,
292+
max_files=1,
293+
) == [str(new)]
294+
295+
296+
def test_choose_cti_files_empty_candidates():
297+
assert gd.choose_cti_files([]) == []
298+
299+
300+
def test_choose_cti_files_unknown_policy_raises():
301+
with pytest.raises(ValueError, match="Unknown policy"):
302+
gd.choose_cti_files(
303+
["a.cti"],
304+
policy=object(), # type: ignore[arg-type]
305+
)
306+
307+
308+
def test_shared_harvester_pool_reuses_entry_and_refcounts(monkeypatch, tmp_path: Path):
309+
calls: list[tuple[str, str | None]] = []
310+
311+
class FakeHarvester:
312+
def __init__(self):
313+
calls.append(("init", None))
314+
315+
def add_file(self, path: str) -> None:
316+
calls.append(("add_file", path))
317+
318+
def update(self) -> None:
319+
calls.append(("update", None))
320+
321+
def reset(self) -> None:
322+
calls.append(("reset", None))
323+
324+
cti = tmp_path / "producer.cti"
325+
cti.write_text("", encoding="utf-8")
326+
327+
monkeypatch.setattr(gd, "Harvester", FakeHarvester)
328+
gd.SharedHarvesterPool._entries.clear()
329+
330+
entry1 = gd.SharedHarvesterPool.acquire([str(cti)])
331+
entry2 = gd.SharedHarvesterPool.acquire([str(cti)])
332+
333+
assert entry1 is entry2
334+
assert gd.SharedHarvesterPool.get_refcount(entry1) == 2
335+
336+
gd.SharedHarvesterPool.release(entry1)
337+
assert gd.SharedHarvesterPool.get_refcount(entry2) == 1
338+
339+
gd.SharedHarvesterPool.release(entry2)
340+
assert gd.SharedHarvesterPool.get_refcount(entry2) == 0
341+
assert ("reset", None) in calls
342+
343+
344+
def test_shared_harvester_entry_reports_failed_files(monkeypatch, tmp_path: Path):
345+
class FakeHarvester:
346+
def add_file(self, path: str) -> None:
347+
raise RuntimeError("load failed")
348+
349+
def update(self) -> None:
350+
raise AssertionError("update should not be called")
351+
352+
def reset(self) -> None:
353+
pass
354+
355+
cti = tmp_path / "bad.cti"
356+
cti.write_text("", encoding="utf-8")
357+
358+
monkeypatch.setattr(gd, "Harvester", FakeHarvester)
359+
360+
with pytest.raises(RuntimeError, match="No GenTL producer"):
361+
gd.SharedHarvesterEntry([str(cti)])

0 commit comments

Comments
 (0)