celldetective 1.5.0b12__py3-none-any.whl → 1.5.0b13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- celldetective/_version.py +1 -1
- celldetective/utils/parsing.py +5 -0
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/METADATA +1 -1
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/RECORD +10 -8
- tests/gui/test_classifier_widget.py +754 -0
- tests/gui/test_measurement_settings.py +1083 -0
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/WHEEL +0 -0
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/entry_points.txt +0 -0
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.5.0b12.dist-info → celldetective-1.5.0b13.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for ClassifierWidget and query classification logic.
|
|
3
|
+
Tests the classify_cells_from_query function with various data types and query patterns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from celldetective.measure import classify_cells_from_query
|
|
12
|
+
from celldetective.exceptions import EmptyQueryError, MissingColumnsError, QueryError
|
|
13
|
+
from celldetective.gui.classifier_widget import ClassifierWidget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockParentChain:
|
|
17
|
+
"""
|
|
18
|
+
Mock parent object chain for ClassifierWidget.
|
|
19
|
+
ClassifierWidget needs: parent_window.parent_window.parent_window.screen_height/width/button_select_all
|
|
20
|
+
and parent_window.mode, parent_window.df
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, df, mode="targets"):
|
|
24
|
+
self.df = df
|
|
25
|
+
self.mode = mode
|
|
26
|
+
# Create nested parent chain
|
|
27
|
+
self.parent_window = self._create_parent()
|
|
28
|
+
|
|
29
|
+
def _create_parent(self):
|
|
30
|
+
class GrandParent:
|
|
31
|
+
screen_height = 1080
|
|
32
|
+
screen_width = 1920
|
|
33
|
+
button_select_all = ""
|
|
34
|
+
|
|
35
|
+
class Parent:
|
|
36
|
+
parent_window = GrandParent()
|
|
37
|
+
|
|
38
|
+
return Parent()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(autouse=True)
|
|
42
|
+
def disable_logging():
|
|
43
|
+
"""Disable all logging to avoid Windows OSError with pytest capture."""
|
|
44
|
+
logger = logging.getLogger()
|
|
45
|
+
try:
|
|
46
|
+
logging.disable(logging.CRITICAL)
|
|
47
|
+
yield
|
|
48
|
+
finally:
|
|
49
|
+
logging.disable(logging.NOTSET)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def sample_data():
|
|
54
|
+
"""Create sample DataFrame with various data types for testing queries."""
|
|
55
|
+
np.random.seed(42)
|
|
56
|
+
n = 20
|
|
57
|
+
return pd.DataFrame(
|
|
58
|
+
{
|
|
59
|
+
"FRAME": list(range(10)) * 2,
|
|
60
|
+
"TRACK_ID": [1.0] * 10 + [2.0] * 10,
|
|
61
|
+
"area": np.random.uniform(100, 500, n),
|
|
62
|
+
"intensity": np.random.uniform(10, 100, n),
|
|
63
|
+
"d/dt.area": np.random.uniform(-1.0, 1.0, n),
|
|
64
|
+
"well": ["W1"] * 10 + ["W2"] * 10,
|
|
65
|
+
"label": ["A", "B"] * 10,
|
|
66
|
+
"category": pd.Categorical(["cat1", "cat2"] * 10),
|
|
67
|
+
"col with space": np.random.uniform(1, 10, n),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def data_with_nans():
|
|
74
|
+
"""Create sample DataFrame with NaN values for edge case testing."""
|
|
75
|
+
return pd.DataFrame(
|
|
76
|
+
{
|
|
77
|
+
"FRAME": [0, 1, 2, 3, 4],
|
|
78
|
+
"TRACK_ID": [1.0, 1.0, 1.0, 2.0, 2.0],
|
|
79
|
+
"area": [100.0, np.nan, 300.0, 400.0, np.nan],
|
|
80
|
+
"intensity": [50.0, 60.0, np.nan, 80.0, 90.0],
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestNumericColumnDetection:
|
|
86
|
+
"""Test numeric column detection with various dtypes."""
|
|
87
|
+
|
|
88
|
+
def test_select_numeric_columns_basic(self, sample_data):
|
|
89
|
+
"""Test that select_dtypes correctly identifies numeric columns."""
|
|
90
|
+
numeric_cols = sample_data.select_dtypes(include=[np.number]).columns.tolist()
|
|
91
|
+
|
|
92
|
+
# These should be numeric
|
|
93
|
+
assert "FRAME" in numeric_cols
|
|
94
|
+
assert "TRACK_ID" in numeric_cols
|
|
95
|
+
assert "area" in numeric_cols
|
|
96
|
+
assert "intensity" in numeric_cols
|
|
97
|
+
assert "d/dt.area" in numeric_cols
|
|
98
|
+
assert "col with space" in numeric_cols
|
|
99
|
+
|
|
100
|
+
# These should NOT be numeric
|
|
101
|
+
assert "well" not in numeric_cols
|
|
102
|
+
assert "label" not in numeric_cols
|
|
103
|
+
assert "category" not in numeric_cols
|
|
104
|
+
|
|
105
|
+
def test_select_numeric_columns_with_extension_dtypes(self):
|
|
106
|
+
"""Test numeric detection with pandas extension dtypes like StringDtype."""
|
|
107
|
+
df = pd.DataFrame(
|
|
108
|
+
{
|
|
109
|
+
"numeric_int": [1, 2, 3],
|
|
110
|
+
"numeric_float": [1.0, 2.0, 3.0],
|
|
111
|
+
"string_dtype": pd.array(["a", "b", "c"], dtype="string"),
|
|
112
|
+
"object_str": ["x", "y", "z"],
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
|
|
116
|
+
|
|
117
|
+
assert "numeric_int" in numeric_cols
|
|
118
|
+
assert "numeric_float" in numeric_cols
|
|
119
|
+
assert "string_dtype" not in numeric_cols
|
|
120
|
+
assert "object_str" not in numeric_cols
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestSimpleNumericQueries:
|
|
124
|
+
"""Test simple numeric comparison queries."""
|
|
125
|
+
|
|
126
|
+
def test_greater_than(self, sample_data):
|
|
127
|
+
"""Test area > 300 query."""
|
|
128
|
+
result = classify_cells_from_query(sample_data, "test_class", "area > 300")
|
|
129
|
+
|
|
130
|
+
assert "status_test_class" in result.columns
|
|
131
|
+
# Verify classification is correct
|
|
132
|
+
expected_matches = sample_data["area"] > 300
|
|
133
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
134
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
135
|
+
|
|
136
|
+
def test_less_than(self, sample_data):
|
|
137
|
+
"""Test intensity < 50 query."""
|
|
138
|
+
result = classify_cells_from_query(sample_data, "test_class", "intensity < 50")
|
|
139
|
+
|
|
140
|
+
expected_matches = sample_data["intensity"] < 50
|
|
141
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
142
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
143
|
+
|
|
144
|
+
def test_numeric_equality_track_id(self, sample_data):
|
|
145
|
+
"""Test TRACK_ID == 1 query."""
|
|
146
|
+
result = classify_cells_from_query(sample_data, "test_class", "TRACK_ID == 1")
|
|
147
|
+
|
|
148
|
+
expected_matches = sample_data["TRACK_ID"] == 1
|
|
149
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
150
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
151
|
+
|
|
152
|
+
def test_numeric_equality_frame(self, sample_data):
|
|
153
|
+
"""Test FRAME == 0 query."""
|
|
154
|
+
result = classify_cells_from_query(sample_data, "test_class", "FRAME == 0")
|
|
155
|
+
|
|
156
|
+
expected_matches = sample_data["FRAME"] == 0
|
|
157
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
158
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestLogicalOperators:
|
|
162
|
+
"""Test queries with logical operators."""
|
|
163
|
+
|
|
164
|
+
def test_and_operator(self, sample_data):
|
|
165
|
+
"""Test area > 200 and intensity < 80 query."""
|
|
166
|
+
result = classify_cells_from_query(
|
|
167
|
+
sample_data, "test_class", "area > 200 and intensity < 80"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expected_matches = (sample_data["area"] > 200) & (sample_data["intensity"] < 80)
|
|
171
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
172
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
173
|
+
|
|
174
|
+
def test_or_operator(self, sample_data):
|
|
175
|
+
"""Test TRACK_ID == 1 or FRAME == 0 query."""
|
|
176
|
+
result = classify_cells_from_query(
|
|
177
|
+
sample_data, "test_class", "TRACK_ID == 1 or FRAME == 0"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
expected_matches = (sample_data["TRACK_ID"] == 1) | (sample_data["FRAME"] == 0)
|
|
181
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
182
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
183
|
+
|
|
184
|
+
def test_multiple_and_conditions(self, sample_data):
|
|
185
|
+
"""Test area > 200 and intensity < 80 and FRAME > 2 query."""
|
|
186
|
+
result = classify_cells_from_query(
|
|
187
|
+
sample_data, "test_class", "area > 200 and intensity < 80 and FRAME > 2"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
expected_matches = (
|
|
191
|
+
(sample_data["area"] > 200)
|
|
192
|
+
& (sample_data["intensity"] < 80)
|
|
193
|
+
& (sample_data["FRAME"] > 2)
|
|
194
|
+
)
|
|
195
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
196
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
197
|
+
|
|
198
|
+
def test_multiple_or_conditions(self, sample_data):
|
|
199
|
+
"""Test TRACK_ID == 1 or FRAME == 0 or area > 400 query."""
|
|
200
|
+
result = classify_cells_from_query(
|
|
201
|
+
sample_data, "test_class", "TRACK_ID == 1 or FRAME == 0 or area > 400"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
expected_matches = (
|
|
205
|
+
(sample_data["TRACK_ID"] == 1)
|
|
206
|
+
| (sample_data["FRAME"] == 0)
|
|
207
|
+
| (sample_data["area"] > 400)
|
|
208
|
+
)
|
|
209
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
210
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
211
|
+
|
|
212
|
+
def test_mixed_and_or_conditions(self, sample_data):
|
|
213
|
+
"""Test (area > 200 and intensity < 80) or FRAME == 0 query."""
|
|
214
|
+
result = classify_cells_from_query(
|
|
215
|
+
sample_data, "test_class", "(area > 200 and intensity < 80) or FRAME == 0"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
expected_matches = (
|
|
219
|
+
(sample_data["area"] > 200) & (sample_data["intensity"] < 80)
|
|
220
|
+
) | (sample_data["FRAME"] == 0)
|
|
221
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
222
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestSpecialColumnNames:
|
|
226
|
+
"""Test queries with special column names requiring backticks."""
|
|
227
|
+
|
|
228
|
+
def test_derivative_column(self, sample_data):
|
|
229
|
+
"""Test `d/dt.area` > 0 query with special characters."""
|
|
230
|
+
result = classify_cells_from_query(sample_data, "test_class", "`d/dt.area` > 0")
|
|
231
|
+
|
|
232
|
+
expected_matches = sample_data["d/dt.area"] > 0
|
|
233
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
234
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
235
|
+
|
|
236
|
+
def test_column_with_space(self, sample_data):
|
|
237
|
+
"""Test `col with space` > 5 query."""
|
|
238
|
+
result = classify_cells_from_query(
|
|
239
|
+
sample_data, "test_class", "`col with space` > 5"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expected_matches = sample_data["col with space"] > 5
|
|
243
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
244
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TestStringQueries:
|
|
248
|
+
"""Test string equality queries."""
|
|
249
|
+
|
|
250
|
+
def test_string_equality(self, sample_data):
|
|
251
|
+
"""Test well == 'W1' query."""
|
|
252
|
+
result = classify_cells_from_query(sample_data, "test_class", 'well == "W1"')
|
|
253
|
+
|
|
254
|
+
expected_matches = sample_data["well"] == "W1"
|
|
255
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
256
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
257
|
+
|
|
258
|
+
def test_string_inequality(self, sample_data):
|
|
259
|
+
"""Test label != 'A' query."""
|
|
260
|
+
result = classify_cells_from_query(sample_data, "test_class", 'label != "A"')
|
|
261
|
+
|
|
262
|
+
expected_matches = sample_data["label"] != "A"
|
|
263
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
264
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
265
|
+
|
|
266
|
+
def test_string_and_numeric_combined(self, sample_data):
|
|
267
|
+
"""Test well == 'W1' and area > 300 query."""
|
|
268
|
+
result = classify_cells_from_query(
|
|
269
|
+
sample_data, "test_class", 'well == "W1" and area > 300'
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
expected_matches = (sample_data["well"] == "W1") & (sample_data["area"] > 300)
|
|
273
|
+
assert (result.loc[expected_matches, "status_test_class"] == 1).all()
|
|
274
|
+
assert (result.loc[~expected_matches, "status_test_class"] == 0).all()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TestNaNHandling:
|
|
278
|
+
"""Test NaN handling in queries."""
|
|
279
|
+
|
|
280
|
+
def test_nan_values_set_to_nan(self, data_with_nans):
|
|
281
|
+
"""Test that rows with NaN in query columns get NaN classification."""
|
|
282
|
+
result = classify_cells_from_query(data_with_nans, "test_class", "area > 200")
|
|
283
|
+
|
|
284
|
+
# Rows with NaN in 'area' should have NaN classification
|
|
285
|
+
nan_rows = data_with_nans["area"].isna()
|
|
286
|
+
assert result.loc[nan_rows, "status_test_class"].isna().all()
|
|
287
|
+
|
|
288
|
+
# Non-NaN rows should be classified normally
|
|
289
|
+
non_nan_rows = ~nan_rows
|
|
290
|
+
expected_matches = data_with_nans.loc[non_nan_rows, "area"] > 200
|
|
291
|
+
assert (
|
|
292
|
+
result.loc[non_nan_rows & expected_matches, "status_test_class"] == 1
|
|
293
|
+
).all()
|
|
294
|
+
|
|
295
|
+
def test_multiple_columns_with_nans(self, data_with_nans):
|
|
296
|
+
"""Test query involving multiple columns where either has NaN."""
|
|
297
|
+
result = classify_cells_from_query(
|
|
298
|
+
data_with_nans, "test_class", "area > 200 and intensity > 50"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Rows with NaN in either column should have NaN classification
|
|
302
|
+
nan_rows = data_with_nans["area"].isna() | data_with_nans["intensity"].isna()
|
|
303
|
+
assert result.loc[nan_rows, "status_test_class"].isna().all()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TestErrorHandling:
|
|
307
|
+
"""Test error handling for invalid queries."""
|
|
308
|
+
|
|
309
|
+
def test_empty_query_raises_error(self, sample_data):
|
|
310
|
+
"""Test that empty query raises EmptyQueryError."""
|
|
311
|
+
with pytest.raises(EmptyQueryError):
|
|
312
|
+
classify_cells_from_query(sample_data, "test_class", "")
|
|
313
|
+
|
|
314
|
+
def test_whitespace_query_raises_error(self, sample_data):
|
|
315
|
+
"""Test that whitespace-only query raises EmptyQueryError."""
|
|
316
|
+
with pytest.raises(EmptyQueryError):
|
|
317
|
+
classify_cells_from_query(sample_data, "test_class", " ")
|
|
318
|
+
|
|
319
|
+
def test_missing_column_raises_error(self, sample_data):
|
|
320
|
+
"""Test that query with non-existent column raises MissingColumnsError."""
|
|
321
|
+
with pytest.raises(MissingColumnsError):
|
|
322
|
+
classify_cells_from_query(
|
|
323
|
+
sample_data, "test_class", "nonexistent_column > 5"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def test_invalid_syntax_raises_error(self, sample_data):
|
|
327
|
+
"""Test that invalid query syntax raises QueryError."""
|
|
328
|
+
with pytest.raises(QueryError):
|
|
329
|
+
classify_cells_from_query(sample_data, "test_class", "area >> 5")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestStatusColumnNaming:
|
|
333
|
+
"""Test that status column naming works correctly."""
|
|
334
|
+
|
|
335
|
+
def test_status_prefix_added(self, sample_data):
|
|
336
|
+
"""Test that 'status_' prefix is added to class name."""
|
|
337
|
+
result = classify_cells_from_query(sample_data, "my_class", "area > 300")
|
|
338
|
+
assert "status_my_class" in result.columns
|
|
339
|
+
|
|
340
|
+
def test_status_prefix_not_duplicated(self, sample_data):
|
|
341
|
+
"""Test that 'status_' prefix is not duplicated if already present."""
|
|
342
|
+
result = classify_cells_from_query(sample_data, "status_my_class", "area > 300")
|
|
343
|
+
assert "status_my_class" in result.columns
|
|
344
|
+
assert "status_status_my_class" not in result.columns
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TestClassifierWidgetUI:
|
|
348
|
+
"""
|
|
349
|
+
UI-level tests for ClassifierWidget.
|
|
350
|
+
These tests instantiate the real widget and interact with it via qtbot.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
@pytest.fixture
|
|
354
|
+
def widget_data(self):
|
|
355
|
+
"""Create sample DataFrame for widget testing."""
|
|
356
|
+
np.random.seed(42)
|
|
357
|
+
n = 20
|
|
358
|
+
return pd.DataFrame(
|
|
359
|
+
{
|
|
360
|
+
"FRAME": list(range(10)) * 2,
|
|
361
|
+
"TRACK_ID": [1.0] * 10 + [2.0] * 10,
|
|
362
|
+
"POSITION_X": np.random.uniform(0, 100, n),
|
|
363
|
+
"POSITION_Y": np.random.uniform(0, 100, n),
|
|
364
|
+
"area": np.random.uniform(100, 500, n),
|
|
365
|
+
"intensity": np.random.uniform(10, 100, n),
|
|
366
|
+
"d/dt.area": np.random.uniform(-1.0, 1.0, n),
|
|
367
|
+
"well": ["W1"] * 10 + ["W2"] * 10,
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def test_widget_instantiation(self, qtbot, widget_data):
|
|
372
|
+
"""Test that ClassifierWidget can be instantiated with mock parent."""
|
|
373
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
374
|
+
widget = ClassifierWidget(parent)
|
|
375
|
+
qtbot.addWidget(widget)
|
|
376
|
+
|
|
377
|
+
assert widget is not None
|
|
378
|
+
assert widget.df is widget_data
|
|
379
|
+
assert widget.mode == "targets"
|
|
380
|
+
|
|
381
|
+
widget.close()
|
|
382
|
+
|
|
383
|
+
def test_query_line_edit_exists(self, qtbot, widget_data):
|
|
384
|
+
"""Test that the query line edit exists and is accessible."""
|
|
385
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
386
|
+
widget = ClassifierWidget(parent)
|
|
387
|
+
qtbot.addWidget(widget)
|
|
388
|
+
widget.show()
|
|
389
|
+
qtbot.wait(100)
|
|
390
|
+
|
|
391
|
+
assert hasattr(widget, "property_query_le")
|
|
392
|
+
assert widget.property_query_le is not None
|
|
393
|
+
|
|
394
|
+
widget.close()
|
|
395
|
+
|
|
396
|
+
def test_submit_button_disabled_initially(self, qtbot, widget_data):
|
|
397
|
+
"""Test that submit button is disabled when query is empty."""
|
|
398
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
399
|
+
widget = ClassifierWidget(parent)
|
|
400
|
+
qtbot.addWidget(widget)
|
|
401
|
+
widget.show()
|
|
402
|
+
qtbot.wait(100)
|
|
403
|
+
|
|
404
|
+
assert hasattr(widget, "submit_query_btn")
|
|
405
|
+
assert not widget.submit_query_btn.isEnabled()
|
|
406
|
+
|
|
407
|
+
widget.close()
|
|
408
|
+
|
|
409
|
+
def test_submit_button_enabled_after_typing_query(self, qtbot, widget_data):
|
|
410
|
+
"""Test that submit button is enabled after typing a query."""
|
|
411
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
412
|
+
widget = ClassifierWidget(parent)
|
|
413
|
+
qtbot.addWidget(widget)
|
|
414
|
+
widget.show()
|
|
415
|
+
qtbot.wait(100)
|
|
416
|
+
|
|
417
|
+
# Type a query
|
|
418
|
+
widget.property_query_le.setText("area > 200")
|
|
419
|
+
qtbot.wait(50)
|
|
420
|
+
|
|
421
|
+
assert widget.submit_query_btn.isEnabled()
|
|
422
|
+
|
|
423
|
+
widget.close()
|
|
424
|
+
|
|
425
|
+
def test_numeric_columns_detected(self, qtbot, widget_data):
|
|
426
|
+
"""Test that numeric columns are correctly detected."""
|
|
427
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
428
|
+
widget = ClassifierWidget(parent)
|
|
429
|
+
qtbot.addWidget(widget)
|
|
430
|
+
|
|
431
|
+
# Check numeric columns
|
|
432
|
+
assert "area" in widget.cols
|
|
433
|
+
assert "intensity" in widget.cols
|
|
434
|
+
assert "FRAME" in widget.cols
|
|
435
|
+
assert "TRACK_ID" in widget.cols
|
|
436
|
+
assert "d/dt.area" in widget.cols
|
|
437
|
+
|
|
438
|
+
# Non-numeric should not be in cols
|
|
439
|
+
assert "well" not in widget.cols
|
|
440
|
+
|
|
441
|
+
widget.close()
|
|
442
|
+
|
|
443
|
+
def test_apply_query_simple_numeric(self, qtbot, widget_data):
|
|
444
|
+
"""Test applying a simple numeric query via UI."""
|
|
445
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
446
|
+
widget = ClassifierWidget(parent)
|
|
447
|
+
qtbot.addWidget(widget)
|
|
448
|
+
widget.show()
|
|
449
|
+
qtbot.wait(100)
|
|
450
|
+
|
|
451
|
+
# Set query and apply
|
|
452
|
+
widget.property_query_le.setText("area > 300")
|
|
453
|
+
widget.name_le.setText("high_area")
|
|
454
|
+
qtbot.wait(50)
|
|
455
|
+
|
|
456
|
+
# Click the submit button
|
|
457
|
+
widget.submit_query_btn.click()
|
|
458
|
+
qtbot.wait(100)
|
|
459
|
+
|
|
460
|
+
# Check that classification was applied
|
|
461
|
+
assert "status_high_area" in widget.df.columns
|
|
462
|
+
|
|
463
|
+
widget.close()
|
|
464
|
+
|
|
465
|
+
def test_apply_query_with_and_condition(self, qtbot, widget_data):
|
|
466
|
+
"""Test applying a query with AND condition via UI."""
|
|
467
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
468
|
+
widget = ClassifierWidget(parent)
|
|
469
|
+
qtbot.addWidget(widget)
|
|
470
|
+
widget.show()
|
|
471
|
+
qtbot.wait(100)
|
|
472
|
+
|
|
473
|
+
widget.property_query_le.setText("area > 200 and intensity < 80")
|
|
474
|
+
widget.name_le.setText("filtered")
|
|
475
|
+
qtbot.wait(50)
|
|
476
|
+
|
|
477
|
+
widget.submit_query_btn.click()
|
|
478
|
+
qtbot.wait(100)
|
|
479
|
+
|
|
480
|
+
assert "status_filtered" in widget.df.columns
|
|
481
|
+
|
|
482
|
+
widget.close()
|
|
483
|
+
|
|
484
|
+
def test_apply_query_with_or_condition(self, qtbot, widget_data):
|
|
485
|
+
"""Test applying a query with OR condition via UI."""
|
|
486
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
487
|
+
widget = ClassifierWidget(parent)
|
|
488
|
+
qtbot.addWidget(widget)
|
|
489
|
+
widget.show()
|
|
490
|
+
qtbot.wait(100)
|
|
491
|
+
|
|
492
|
+
widget.property_query_le.setText("TRACK_ID == 1 or FRAME == 0")
|
|
493
|
+
widget.name_le.setText("selected")
|
|
494
|
+
qtbot.wait(50)
|
|
495
|
+
|
|
496
|
+
widget.submit_query_btn.click()
|
|
497
|
+
qtbot.wait(100)
|
|
498
|
+
|
|
499
|
+
assert "status_selected" in widget.df.columns
|
|
500
|
+
|
|
501
|
+
widget.close()
|
|
502
|
+
|
|
503
|
+
def test_apply_query_frame_equality(self, qtbot, widget_data):
|
|
504
|
+
"""Test applying FRAME == 0 query via UI."""
|
|
505
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
506
|
+
widget = ClassifierWidget(parent)
|
|
507
|
+
qtbot.addWidget(widget)
|
|
508
|
+
widget.show()
|
|
509
|
+
qtbot.wait(100)
|
|
510
|
+
|
|
511
|
+
widget.property_query_le.setText("FRAME == 0")
|
|
512
|
+
widget.name_le.setText("first_frame")
|
|
513
|
+
qtbot.wait(50)
|
|
514
|
+
|
|
515
|
+
widget.submit_query_btn.click()
|
|
516
|
+
qtbot.wait(100)
|
|
517
|
+
|
|
518
|
+
assert "status_first_frame" in widget.df.columns
|
|
519
|
+
|
|
520
|
+
widget.close()
|
|
521
|
+
|
|
522
|
+
def test_apply_query_derivative_column(self, qtbot, widget_data):
|
|
523
|
+
"""Test applying query with derivative-style column name via UI."""
|
|
524
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
525
|
+
widget = ClassifierWidget(parent)
|
|
526
|
+
qtbot.addWidget(widget)
|
|
527
|
+
widget.show()
|
|
528
|
+
qtbot.wait(100)
|
|
529
|
+
|
|
530
|
+
widget.property_query_le.setText("`d/dt.area` > 0")
|
|
531
|
+
widget.name_le.setText("increasing")
|
|
532
|
+
qtbot.wait(50)
|
|
533
|
+
|
|
534
|
+
widget.submit_query_btn.click()
|
|
535
|
+
qtbot.wait(100)
|
|
536
|
+
|
|
537
|
+
assert "status_increasing" in widget.df.columns
|
|
538
|
+
|
|
539
|
+
widget.close()
|
|
540
|
+
|
|
541
|
+
def test_project_times_btn_click(self, qtbot, widget_data):
|
|
542
|
+
"""Test clicking the project times button toggles projection mode."""
|
|
543
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
544
|
+
widget = ClassifierWidget(parent)
|
|
545
|
+
qtbot.addWidget(widget)
|
|
546
|
+
widget.show()
|
|
547
|
+
qtbot.wait(100)
|
|
548
|
+
|
|
549
|
+
# Initially projection should be off
|
|
550
|
+
assert widget.project_times is False
|
|
551
|
+
|
|
552
|
+
# Click the button
|
|
553
|
+
widget.project_times_btn.click()
|
|
554
|
+
qtbot.wait(50)
|
|
555
|
+
|
|
556
|
+
# Projection mode should be toggled
|
|
557
|
+
assert widget.project_times is True
|
|
558
|
+
|
|
559
|
+
# Click again to toggle back
|
|
560
|
+
widget.project_times_btn.click()
|
|
561
|
+
qtbot.wait(50)
|
|
562
|
+
|
|
563
|
+
assert widget.project_times is False
|
|
564
|
+
|
|
565
|
+
widget.close()
|
|
566
|
+
|
|
567
|
+
def test_log_button_click(self, qtbot, widget_data):
|
|
568
|
+
"""Test clicking the log buttons for features."""
|
|
569
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
570
|
+
widget = ClassifierWidget(parent)
|
|
571
|
+
qtbot.addWidget(widget)
|
|
572
|
+
widget.show()
|
|
573
|
+
qtbot.wait(100)
|
|
574
|
+
|
|
575
|
+
# Check log buttons exist
|
|
576
|
+
assert hasattr(widget, "log_btns")
|
|
577
|
+
assert len(widget.log_btns) == 2
|
|
578
|
+
|
|
579
|
+
# Click each log button
|
|
580
|
+
for i, btn in enumerate(widget.log_btns):
|
|
581
|
+
btn.click()
|
|
582
|
+
qtbot.wait(50)
|
|
583
|
+
|
|
584
|
+
widget.close()
|
|
585
|
+
|
|
586
|
+
def test_feature_combo_boxes(self, qtbot, widget_data):
|
|
587
|
+
"""Test feature combo boxes contain correct columns and can be changed."""
|
|
588
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
589
|
+
widget = ClassifierWidget(parent)
|
|
590
|
+
qtbot.addWidget(widget)
|
|
591
|
+
widget.show()
|
|
592
|
+
qtbot.wait(100)
|
|
593
|
+
|
|
594
|
+
assert hasattr(widget, "features_cb")
|
|
595
|
+
assert len(widget.features_cb) == 2
|
|
596
|
+
|
|
597
|
+
# Each combo box should have numeric columns as items
|
|
598
|
+
for cb in widget.features_cb:
|
|
599
|
+
assert cb.count() > 0
|
|
600
|
+
# Try changing the selection
|
|
601
|
+
if cb.count() > 1:
|
|
602
|
+
cb.setCurrentIndex(1)
|
|
603
|
+
qtbot.wait(50)
|
|
604
|
+
|
|
605
|
+
widget.close()
|
|
606
|
+
|
|
607
|
+
def test_frame_slider(self, qtbot, widget_data):
|
|
608
|
+
"""Test frame slider can be moved."""
|
|
609
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
610
|
+
widget = ClassifierWidget(parent)
|
|
611
|
+
qtbot.addWidget(widget)
|
|
612
|
+
widget.show()
|
|
613
|
+
qtbot.wait(100)
|
|
614
|
+
|
|
615
|
+
assert hasattr(widget, "frame_slider")
|
|
616
|
+
|
|
617
|
+
# Set slider value
|
|
618
|
+
widget.frame_slider.setValue(5)
|
|
619
|
+
qtbot.wait(50)
|
|
620
|
+
|
|
621
|
+
assert widget.currentFrame == 5
|
|
622
|
+
|
|
623
|
+
widget.close()
|
|
624
|
+
|
|
625
|
+
def test_alpha_slider(self, qtbot, widget_data):
|
|
626
|
+
"""Test transparency (alpha) slider can be moved."""
|
|
627
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
628
|
+
widget = ClassifierWidget(parent)
|
|
629
|
+
qtbot.addWidget(widget)
|
|
630
|
+
widget.show()
|
|
631
|
+
qtbot.wait(100)
|
|
632
|
+
|
|
633
|
+
assert hasattr(widget, "alpha_slider")
|
|
634
|
+
assert hasattr(widget, "currentAlpha")
|
|
635
|
+
|
|
636
|
+
# Set slider value
|
|
637
|
+
widget.alpha_slider.setValue(0.5)
|
|
638
|
+
qtbot.wait(50)
|
|
639
|
+
|
|
640
|
+
assert widget.currentAlpha == pytest.approx(0.5, abs=0.01)
|
|
641
|
+
|
|
642
|
+
widget.close()
|
|
643
|
+
|
|
644
|
+
def test_time_correlated_checkbox_exists(self, qtbot, widget_data):
|
|
645
|
+
"""Test that time correlated checkbox exists and is enabled when TRACK_ID present."""
|
|
646
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
647
|
+
widget = ClassifierWidget(parent)
|
|
648
|
+
qtbot.addWidget(widget)
|
|
649
|
+
widget.show()
|
|
650
|
+
qtbot.wait(100)
|
|
651
|
+
|
|
652
|
+
assert hasattr(widget, "time_corr")
|
|
653
|
+
# Should be enabled because TRACK_ID is in the data
|
|
654
|
+
assert widget.time_corr.isEnabled()
|
|
655
|
+
|
|
656
|
+
widget.close()
|
|
657
|
+
|
|
658
|
+
def test_time_correlated_checkbox_disabled_without_track_id(self, qtbot):
|
|
659
|
+
"""Test that time correlated checkbox is disabled when TRACK_ID is missing."""
|
|
660
|
+
np.random.seed(42)
|
|
661
|
+
n = 10
|
|
662
|
+
data_no_track = pd.DataFrame(
|
|
663
|
+
{
|
|
664
|
+
"FRAME": list(range(10)),
|
|
665
|
+
"area": np.random.uniform(100, 500, n),
|
|
666
|
+
"intensity": np.random.uniform(10, 100, n),
|
|
667
|
+
}
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
parent = MockParentChain(data_no_track, mode="targets")
|
|
671
|
+
widget = ClassifierWidget(parent)
|
|
672
|
+
qtbot.addWidget(widget)
|
|
673
|
+
widget.show()
|
|
674
|
+
qtbot.wait(100)
|
|
675
|
+
|
|
676
|
+
# Should be disabled because TRACK_ID is not in the data
|
|
677
|
+
assert not widget.time_corr.isEnabled()
|
|
678
|
+
|
|
679
|
+
widget.close()
|
|
680
|
+
|
|
681
|
+
def test_time_corr_enables_radio_buttons(self, qtbot, widget_data):
|
|
682
|
+
"""Test that checking time_corr checkbox enables the radio buttons."""
|
|
683
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
684
|
+
widget = ClassifierWidget(parent)
|
|
685
|
+
qtbot.addWidget(widget)
|
|
686
|
+
widget.show()
|
|
687
|
+
qtbot.wait(100)
|
|
688
|
+
|
|
689
|
+
# Initially radio buttons should be disabled
|
|
690
|
+
assert not widget.irreversible_event_btn.isEnabled()
|
|
691
|
+
assert not widget.unique_state_btn.isEnabled()
|
|
692
|
+
assert not widget.transient_event_btn.isEnabled()
|
|
693
|
+
|
|
694
|
+
# Check the time_corr checkbox
|
|
695
|
+
widget.time_corr.setChecked(True)
|
|
696
|
+
qtbot.wait(50)
|
|
697
|
+
|
|
698
|
+
# Now radio buttons should be enabled
|
|
699
|
+
assert widget.irreversible_event_btn.isEnabled()
|
|
700
|
+
assert widget.unique_state_btn.isEnabled()
|
|
701
|
+
assert widget.transient_event_btn.isEnabled()
|
|
702
|
+
|
|
703
|
+
widget.close()
|
|
704
|
+
|
|
705
|
+
def test_radio_buttons_are_exclusive(self, qtbot, widget_data):
|
|
706
|
+
"""Test that radio buttons are mutually exclusive."""
|
|
707
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
708
|
+
widget = ClassifierWidget(parent)
|
|
709
|
+
qtbot.addWidget(widget)
|
|
710
|
+
widget.show()
|
|
711
|
+
qtbot.wait(100)
|
|
712
|
+
|
|
713
|
+
# Enable time corr first
|
|
714
|
+
widget.time_corr.setChecked(True)
|
|
715
|
+
qtbot.wait(50)
|
|
716
|
+
|
|
717
|
+
# unique_state_btn is initially checked
|
|
718
|
+
assert widget.unique_state_btn.isChecked()
|
|
719
|
+
|
|
720
|
+
# Click irreversible_event_btn
|
|
721
|
+
widget.irreversible_event_btn.click()
|
|
722
|
+
qtbot.wait(50)
|
|
723
|
+
|
|
724
|
+
assert widget.irreversible_event_btn.isChecked()
|
|
725
|
+
assert not widget.unique_state_btn.isChecked()
|
|
726
|
+
assert not widget.transient_event_btn.isChecked()
|
|
727
|
+
|
|
728
|
+
# Click transient_event_btn
|
|
729
|
+
widget.transient_event_btn.click()
|
|
730
|
+
qtbot.wait(50)
|
|
731
|
+
|
|
732
|
+
assert widget.transient_event_btn.isChecked()
|
|
733
|
+
assert not widget.irreversible_event_btn.isChecked()
|
|
734
|
+
assert not widget.unique_state_btn.isChecked()
|
|
735
|
+
|
|
736
|
+
widget.close()
|
|
737
|
+
|
|
738
|
+
def test_name_line_edit(self, qtbot, widget_data):
|
|
739
|
+
"""Test that class name line edit can be changed."""
|
|
740
|
+
parent = MockParentChain(widget_data, mode="targets")
|
|
741
|
+
widget = ClassifierWidget(parent)
|
|
742
|
+
qtbot.addWidget(widget)
|
|
743
|
+
widget.show()
|
|
744
|
+
qtbot.wait(100)
|
|
745
|
+
|
|
746
|
+
assert hasattr(widget, "name_le")
|
|
747
|
+
assert widget.name_le.text() == "custom"
|
|
748
|
+
|
|
749
|
+
widget.name_le.setText("my_new_class")
|
|
750
|
+
qtbot.wait(50)
|
|
751
|
+
|
|
752
|
+
assert widget.name_le.text() == "my_new_class"
|
|
753
|
+
|
|
754
|
+
widget.close()
|