Skip to content

Commit 35df954

Browse files
committed
Rename settings store and add session/timestamp prefs
Rename QtSettingsStore to DLCLiveGUISettingsStore and add recording-related settings: get/set for session_name and get/set for use_timestamp (with robust parsing of stored types). Also add comprehensive unit tests: tests for the settings store (including snapshot save/load and model path store behaviors) and many utility tests (is_model_file, sanitize_name, timestamp_string, split_stem_ext, run indexing, build_run_dir, build_recording_plan, and FPSTracker). Includes an InMemoryQSettings test helper.
1 parent 10381ea commit 35df954

File tree

3 files changed

+618
-1
lines changed

3 files changed

+618
-1
lines changed

dlclivegui/utils/settings_store.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .utils import is_model_file
88

99

10-
class QtSettingsStore:
10+
class DLCLiveGUISettingsStore:
1111
def __init__(self, qsettings: QSettings | None = None):
1212
self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI")
1313

@@ -26,6 +26,26 @@ def get_last_config_path(self) -> str | None:
2626
def set_last_config_path(self, path: str) -> None:
2727
self._s.setValue("app/last_config_path", path or "")
2828

29+
def get_session_name(self) -> str:
30+
v = self._s.value("recording/session_name", "")
31+
return str(v) if v else ""
32+
33+
def set_session_name(self, name: str) -> None:
34+
self._s.setValue("recording/session_name", name or "")
35+
36+
def get_use_timestamp(self, default: bool = True) -> bool:
37+
v = self._s.value("recording/use_timestamp", default)
38+
if isinstance(v, bool):
39+
return v
40+
if isinstance(v, (int, float)):
41+
return bool(v)
42+
if isinstance(v, str):
43+
return v.strip().lower() in ("1", "true", "yes", "on")
44+
return bool(default)
45+
46+
def set_use_timestamp(self, value: bool) -> None:
47+
self._s.setValue("recording/use_timestamp", bool(value))
48+
2949
# --- optional: snapshot full config as JSON in QSettings ---
3050
def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None:
3151
self._s.setValue("app/config_json", cfg.model_dump_json())

tests/utils/test_settings_store.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
import dlclivegui.utils.settings_store as store
9+
10+
pytestmark = pytest.mark.unit
11+
12+
13+
class InMemoryQSettings:
14+
"""Stand-in for QSettings"""
15+
16+
def __init__(self):
17+
self._d = {}
18+
19+
def value(self, key: str, default=None):
20+
return self._d.get(key, default)
21+
22+
def setValue(self, key: str, value):
23+
self._d[key] = value
24+
25+
26+
# -----------------------------
27+
# QtSettingsStore
28+
# -----------------------------
29+
def test_qt_settings_store_last_paths_roundtrip():
30+
s = InMemoryQSettings()
31+
settstore = store.DLCLiveGUISettingsStore(qsettings=s)
32+
33+
assert settstore.get_last_model_path() is None
34+
assert settstore.get_last_config_path() is None
35+
36+
settstore.set_last_model_path("/tmp/model.pt")
37+
settstore.set_last_config_path("/tmp/config.yaml")
38+
39+
assert settstore.get_last_model_path() == "/tmp/model.pt"
40+
assert settstore.get_last_config_path() == "/tmp/config.yaml"
41+
42+
# Empty strings should come back as None
43+
settstore.set_last_model_path("")
44+
settstore.set_last_config_path("")
45+
assert settstore.get_last_model_path() is None
46+
assert settstore.get_last_config_path() is None
47+
48+
49+
def test_qt_settings_store_full_config_snapshot_ok(monkeypatch):
50+
s = InMemoryQSettings()
51+
settstore = store.DLCLiveGUISettingsStore(qsettings=s)
52+
53+
@dataclass
54+
class FakeAppSettings:
55+
x: int = 1
56+
57+
def model_dump_json(self) -> str:
58+
return '{"x": 1}'
59+
60+
@staticmethod
61+
def model_validate_json(raw: str):
62+
# Return a recognizable object
63+
return FakeAppSettings(x=1)
64+
65+
# Patch the imported symbol in the module under test
66+
monkeypatch.setattr(store, "ApplicationSettings", FakeAppSettings)
67+
68+
cfg = FakeAppSettings(x=1)
69+
settstore.save_full_config_snapshot(cfg)
70+
71+
loaded = settstore.load_full_config_snapshot()
72+
assert isinstance(loaded, FakeAppSettings)
73+
assert loaded.x == 1
74+
75+
76+
def test_qt_settings_store_full_config_snapshot_invalid_returns_none(monkeypatch):
77+
s = InMemoryQSettings()
78+
settstore = store.DLCLiveGUISettingsStore(qsettings=s)
79+
80+
@dataclass
81+
class FakeAppSettings:
82+
x: int = 1
83+
84+
def model_dump_json(self) -> str:
85+
return "NOT JSON"
86+
87+
@staticmethod
88+
def model_validate_json(raw: str):
89+
raise ValueError("bad json")
90+
91+
monkeypatch.setattr(store, "ApplicationSettings", FakeAppSettings)
92+
93+
# store invalid json
94+
settstore.save_full_config_snapshot(FakeAppSettings(x=1))
95+
assert settstore.load_full_config_snapshot() is None
96+
97+
98+
# -----------------------------
99+
# ModelPathStore helpers
100+
# -----------------------------
101+
def test_model_path_store_norm_handles_none_and_invalid(monkeypatch):
102+
s = InMemoryQSettings()
103+
mps = store.ModelPathStore(settings=s)
104+
105+
assert mps._norm(None) is None # type: ignore[arg-type]
106+
107+
# Force Path.expanduser() to raise by passing something weird? Hard to do reliably.
108+
# Instead just assert normal path expands/returns str.
109+
assert mps._norm("~/somewhere") is not None
110+
111+
112+
# -----------------------------
113+
# ModelPathStore: load/save
114+
# -----------------------------
115+
def test_model_path_store_load_last_valid_model_file(tmp_path: Path):
116+
settings = InMemoryQSettings()
117+
mps = store.ModelPathStore(settings=settings)
118+
119+
model = tmp_path / "model.pt"
120+
model.write_text("x")
121+
122+
settings.setValue("dlc/last_model_path", str(model))
123+
124+
assert mps.load_last() == str(model)
125+
126+
127+
def test_model_path_store_load_last_invalid_extension_returns_none(tmp_path: Path):
128+
settings = InMemoryQSettings()
129+
mps = store.ModelPathStore(settings=settings)
130+
131+
bad = tmp_path / "model.onnx"
132+
bad.write_text("x")
133+
134+
settings.setValue("dlc/last_model_path", str(bad))
135+
assert mps.load_last() is None
136+
137+
138+
def test_model_path_store_load_last_missing_file_returns_none(tmp_path: Path):
139+
settings = InMemoryQSettings()
140+
mps = store.ModelPathStore(settings=settings)
141+
142+
missing = tmp_path / "missing.pt"
143+
settings.setValue("dlc/last_model_path", str(missing))
144+
assert mps.load_last() is None
145+
146+
147+
def test_model_path_store_load_last_dir_valid(tmp_path: Path):
148+
settings = InMemoryQSettings()
149+
mps = store.ModelPathStore(settings=settings)
150+
151+
d = tmp_path / "models"
152+
d.mkdir()
153+
154+
settings.setValue("dlc/last_model_dir", str(d))
155+
assert mps.load_last_dir() == str(d)
156+
157+
158+
def test_model_path_store_load_last_dir_invalid(tmp_path: Path):
159+
settings = InMemoryQSettings()
160+
mps = store.ModelPathStore(settings=settings)
161+
162+
missing = tmp_path / "nope"
163+
settings.setValue("dlc/last_model_dir", str(missing))
164+
assert mps.load_last_dir() is None
165+
166+
167+
def test_model_path_store_save_if_valid_saves_dir_always_and_file_only_if_valid(tmp_path: Path):
168+
settings = InMemoryQSettings()
169+
mps = store.ModelPathStore(settings=settings)
170+
171+
d = tmp_path / "models"
172+
d.mkdir()
173+
174+
valid = d / "net.pth"
175+
valid.write_text("x")
176+
177+
invalid = d / "net.onnx"
178+
invalid.write_text("x")
179+
180+
# Save invalid first: should save last_model_dir but not last_model_path
181+
mps.save_if_valid(str(invalid))
182+
assert settings.value("dlc/last_model_dir") == str(d)
183+
assert settings.value("dlc/last_model_path", "") in ("", None)
184+
185+
# Save valid: should save both
186+
mps.save_if_valid(str(valid))
187+
assert settings.value("dlc/last_model_dir") == str(d)
188+
assert settings.value("dlc/last_model_path") == str(valid)
189+
190+
191+
def test_model_path_store_save_last_dir_only_saves_when_dir_exists(tmp_path: Path):
192+
settings = InMemoryQSettings()
193+
mps = store.ModelPathStore(settings=settings)
194+
195+
good = tmp_path / "good"
196+
good.mkdir()
197+
bad = tmp_path / "bad"
198+
199+
mps.save_last_dir(str(bad))
200+
assert settings.value("dlc/last_model_dir", "") in ("", None)
201+
202+
mps.save_last_dir(str(good))
203+
assert settings.value("dlc/last_model_dir") == str(good)
204+
205+
206+
# -----------------------------
207+
# ModelPathStore: resolve
208+
# -----------------------------
209+
def test_model_path_store_resolve_prefers_config_path_when_valid(tmp_path: Path):
210+
settings = InMemoryQSettings()
211+
mps = store.ModelPathStore(settings=settings)
212+
213+
model = tmp_path / "cfg_model.pt"
214+
model.write_text("x")
215+
216+
# Persisted points to something else; config_path should win
217+
other = tmp_path / "other.pt"
218+
other.write_text("x")
219+
settings.setValue("dlc/last_model_path", str(other))
220+
221+
assert mps.resolve(str(model)) == str(model)
222+
223+
224+
def test_model_path_store_resolve_falls_back_to_persisted(tmp_path: Path):
225+
settings = InMemoryQSettings()
226+
mps = store.ModelPathStore(settings=settings)
227+
228+
persisted = tmp_path / "persisted.pb"
229+
persisted.write_text("x")
230+
settings.setValue("dlc/last_model_path", str(persisted))
231+
232+
# invalid config path
233+
bad = tmp_path / "notamodel.onnx"
234+
bad.write_text("x")
235+
236+
assert mps.resolve(str(bad)) == str(persisted)
237+
238+
239+
def test_model_path_store_resolve_returns_empty_when_nothing_valid(tmp_path: Path):
240+
settings = InMemoryQSettings()
241+
mps = store.ModelPathStore(settings=settings)
242+
243+
assert mps.resolve(None) == ""
244+
assert mps.resolve("") == ""
245+
246+
247+
# -----------------------------
248+
# ModelPathStore: suggest_start_dir
249+
# -----------------------------
250+
def test_model_path_store_suggest_start_dir_prefers_last_dir(tmp_path: Path):
251+
settings = InMemoryQSettings()
252+
mps = store.ModelPathStore(settings=settings)
253+
254+
d = tmp_path / "lastdir"
255+
d.mkdir()
256+
settings.setValue("dlc/last_model_dir", str(d))
257+
258+
assert mps.suggest_start_dir(fallback_dir=str(tmp_path)) == str(d)
259+
260+
261+
def test_model_path_store_suggest_start_dir_uses_parent_of_last_file(tmp_path: Path):
262+
settings = InMemoryQSettings()
263+
mps = store.ModelPathStore(settings=settings)
264+
265+
d = tmp_path / "models"
266+
d.mkdir()
267+
model = d / "net.pt"
268+
model.write_text("x")
269+
270+
settings.setValue("dlc/last_model_path", str(model))
271+
272+
assert mps.suggest_start_dir(fallback_dir=str(tmp_path / "fallback")) == str(d)
273+
274+
275+
def test_model_path_store_suggest_start_dir_uses_fallback_dir_if_valid(tmp_path: Path):
276+
settings = InMemoryQSettings()
277+
mps = store.ModelPathStore(settings=settings)
278+
279+
fallback = tmp_path / "fallback"
280+
fallback.mkdir()
281+
282+
assert mps.suggest_start_dir(fallback_dir=str(fallback)) == str(fallback)
283+
284+
285+
def test_model_path_store_suggest_start_dir_falls_back_to_home(tmp_path: Path, monkeypatch):
286+
settings = InMemoryQSettings()
287+
mps = store.ModelPathStore(settings=settings)
288+
289+
fake_home = tmp_path / "home"
290+
fake_home.mkdir()
291+
292+
monkeypatch.setattr(store.Path, "home", lambda: fake_home)
293+
294+
assert mps.suggest_start_dir(fallback_dir=None) == str(fake_home)
295+
296+
297+
# -----------------------------
298+
# ModelPathStore: suggest_selected_file
299+
# -----------------------------
300+
def test_model_path_store_suggest_selected_file_returns_existing_file(tmp_path: Path):
301+
settings = InMemoryQSettings()
302+
mps = store.ModelPathStore(settings=settings)
303+
304+
model = tmp_path / "net.pt"
305+
model.write_text("x")
306+
settings.setValue("dlc/last_model_path", str(model))
307+
308+
assert mps.suggest_selected_file() == str(model)
309+
310+
311+
def test_model_path_store_suggest_selected_file_returns_none_when_missing(tmp_path: Path):
312+
settings = InMemoryQSettings()
313+
mps = store.ModelPathStore(settings=settings)
314+
315+
missing = tmp_path / "missing.pt"
316+
settings.setValue("dlc/last_model_path", str(missing))
317+
318+
assert mps.suggest_selected_file() is None

0 commit comments

Comments
 (0)