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.
@@ -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()