• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

paulmthompson / WhiskerToolbox / 18888855713

28 Oct 2025 06:31PM UTC coverage: 73.01% (-0.05%) from 73.058%
18888855713

push

github

paulmthompson
horizontal scale bar export added

1 of 69 new or added lines in 3 files covered. (1.45%)

318 existing lines in 3 files now uncovered.

56336 of 77162 relevant lines covered (73.01%)

44772.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.52
/src/WhiskerToolbox/DataViewer_Widget/DataViewer_Widget.test.cpp
1
#include "DataViewer_Widget.hpp"
2

3
#include "Feature_Tree_Widget/Feature_Tree_Widget.hpp"
4

5
#include "AnalogTimeSeries/Analog_Time_Series.hpp"
6
#include "DataManager.hpp"
7
#include "DataViewer/AnalogTimeSeries/AnalogTimeSeriesDisplayOptions.hpp"
8
#include "DataViewer/DigitalEvent/DigitalEventSeriesDisplayOptions.hpp"
9
#include "DataViewer/DigitalInterval/DigitalIntervalSeriesDisplayOptions.hpp"
10
#include "DigitalTimeSeries/Digital_Event_Series.hpp"
11
#include "DigitalTimeSeries/Digital_Interval_Series.hpp"
12
#include "TimeFrame/StrongTimeTypes.hpp"
13
#include "TimeFrame/TimeFrame.hpp"
14
#include "TimeScrollBar/TimeScrollBar.hpp"
15

16
#include <catch2/catch_test_macros.hpp>
17
#include <catch2/catch_approx.hpp>
18

19
#include <QApplication>
20
#include <QMetaObject>
21
#include <QTimer>
22
#include <QTreeWidget>
23
#include <QWidget>
24
#include <QDoubleSpinBox>
25

26
#include "OpenGLWidget.hpp"
27
#include <algorithm>
28
#include <cmath>
29
#include <memory>
30
#include <vector>
31

32
/**
33
 * @brief Test fixture for DataViewer_Widget data cleanup tests
34
 * 
35
 * Creates a Qt application, DataManager with test data, and DataViewer_Widget
36
 * to test proper cleanup when data is deleted from the DataManager.
37
 */
38
class DataViewerWidgetCleanupTestFixture {
39
protected:
40
    DataViewerWidgetCleanupTestFixture() {
10✔
41
        // Create Qt application if one doesn't exist
42
        if (!QApplication::instance()) {
10✔
43
            static int argc = 1;
44
            static char * argv[] = {const_cast<char *>("test")};
45
            m_app = std::make_unique<QApplication>(argc, argv);
10✔
46
        }
47

48
        // Initialize DataManager
49
        m_data_manager = std::make_shared<DataManager>();
10✔
50

51
        // Create a mock TimeScrollBar and wire DataManager (matches Analysis_Dashboard fixture)
52
        m_time_scrollbar = std::make_unique<TimeScrollBar>();
10✔
53
        m_time_scrollbar->setDataManager(m_data_manager);
10✔
54

55
        // Create test data
56
        populateWithTestData();
10✔
57

58
        // Create the DataViewer_Widget
59
        m_widget = std::make_unique<DataViewer_Widget>(m_data_manager, m_time_scrollbar.get(), nullptr);
10✔
60
    }
10✔
61

62
    ~DataViewerWidgetCleanupTestFixture() = default;
10✔
63

64
    /**
65
     * @brief Get the DataViewer_Widget instance
66
     * @return Reference to the widget
67
     */
68
    DataViewer_Widget & getWidget() { return *m_widget; }
9✔
69

70
    /**
71
     * @brief Get the DataManager instance
72
     * @return Reference to the DataManager
73
     */
74
    DataManager & getDataManager() { return *m_data_manager; }
7✔
75

76
    /**
77
     * @brief Get the test data keys
78
     * @return Vector of test data keys
79
     */
80
    std::vector<std::string> getTestDataKeys() const { return m_test_data_keys; }
4✔
81

82
private:
83
    void populateWithTestData() {
10✔
84
        // Create a default time frame
85
        std::vector<int> t(4000);
30✔
86
        std::iota(std::begin(t), std::end(t), 0);
10✔
87

88
        auto new_timeframe = std::make_shared<TimeFrame>(t);
10✔
89

90
        auto time_key = TimeKey("time");
10✔
91

92
        m_data_manager->removeTime(TimeKey("time"));
10✔
93
        m_data_manager->setTime(TimeKey("time"), new_timeframe);
10✔
94

95

96
        // Add test AnalogTimeSeries
97
        std::vector<float> analog_values = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f};
30✔
98
        auto analog_data = std::make_shared<AnalogTimeSeries>(analog_values, analog_values.size());
10✔
99
        m_data_manager->setData<AnalogTimeSeries>("test_analog", analog_data, time_key);
30✔
100
        m_test_data_keys.push_back("test_analog");
30✔
101

102
        // Add test DigitalEventSeries
103
        auto event_data = std::make_shared<DigitalEventSeries>();
10✔
104
        event_data->addEvent(1000);
10✔
105
        event_data->addEvent(2000);
10✔
106
        event_data->addEvent(3000);
10✔
107
        m_data_manager->setData<DigitalEventSeries>("test_events", event_data, time_key);
30✔
108
        m_test_data_keys.push_back("test_events");
30✔
109

110
        // Add test DigitalIntervalSeries
111
        auto interval_data = std::make_shared<DigitalIntervalSeries>();
10✔
112
        interval_data->addEvent(TimeFrameIndex(500), TimeFrameIndex(1500));
10✔
113
        interval_data->addEvent(TimeFrameIndex(2500), TimeFrameIndex(3500));
10✔
114
        m_data_manager->setData<DigitalIntervalSeries>("test_intervals", interval_data, time_key);
30✔
115
        m_test_data_keys.push_back("test_intervals");
30✔
116
    }
20✔
117

118
    std::unique_ptr<QApplication> m_app;
119
    std::shared_ptr<DataManager> m_data_manager;
120
    std::unique_ptr<TimeScrollBar> m_time_scrollbar;
121
    std::unique_ptr<DataViewer_Widget> m_widget;
122
    std::vector<std::string> m_test_data_keys;
123
};
124

125
// -----------------------------------------------------------------------------
126
// Simple lifecycle tests to validate creation/open/close/destruction stability
127
// -----------------------------------------------------------------------------
128
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Lifecycle (Construct/Destruct)", "[DataViewer_Widget][Lifecycle]") {
1✔
129
    auto * app = QApplication::instance();
1✔
130
    REQUIRE(app != nullptr);
1✔
131

132
    // Widget constructed in fixture
133
    auto & widget = getWidget();
1✔
134
    REQUIRE(&widget != nullptr);
1✔
135

136
    // Allow any queued init to run
137
    app->processEvents();
1✔
138
}
1✔
139

140
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Lifecycle (Open/Close)", "[DataViewer_Widget][Lifecycle]") {
1✔
141
    auto * app = QApplication::instance();
1✔
142
    REQUIRE(app != nullptr);
1✔
143

144
    auto & widget = getWidget();
1✔
145

146
    // Open and process events
147
    widget.openWidget();
1✔
148
    app->processEvents();
1✔
149

150
    // Close event by hiding the widget (avoid deep teardown here)
151
    widget.hide();
1✔
152
    app->processEvents();
1✔
153
}
1✔
154

155
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Lifecycle (Show/Hide/Destroy)", "[DataViewer_Widget][Lifecycle]") {
1✔
156
    auto * app = QApplication::instance();
1✔
157
    REQUIRE(app != nullptr);
1✔
158

159
    // Create a fresh widget locally to validate destruction path
160
    auto data_manager = std::make_shared<DataManager>();
1✔
161
    // Provide a minimal timeframe so XAxis setup has bounds
162
    data_manager->setTime(TimeKey("time"), std::make_shared<TimeFrame>());
1✔
163

164
    TimeScrollBar tsb;
1✔
165
    tsb.setDataManager(data_manager);
1✔
166

167
    // Create, show, process, then destroy
168
    {
169
        DataViewer_Widget dvw(data_manager, &tsb, nullptr);
1✔
170
        dvw.openWidget();
1✔
171
        app->processEvents();
1✔
172
        dvw.hide();
1✔
173
        app->processEvents();
1✔
174
    }
1✔
175

176
    // If we reached here without a crash, basic lifecycle is OK
177
    REQUIRE(true);
1✔
178
}
2✔
179

180
// -----------------------------------------------------------------------------
181
// Existing comprehensive tests follow
182
// -----------------------------------------------------------------------------
183
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Data Cleanup on Deletion", "[DataViewer_Widget]") {
4✔
184
    auto & widget = getWidget();
4✔
185
    auto & dm = getDataManager();
4✔
186
    auto test_keys = getTestDataKeys();
4✔
187

188
    SECTION("Widget initialization with data") {
4✔
189
        REQUIRE(&widget != nullptr);
1✔
190
        REQUIRE(&dm != nullptr);
1✔
191
        REQUIRE(test_keys.size() == 3);
1✔
192

193
        // Open the widget to initialize it
194
        widget.openWidget();
1✔
195

196
        // Process Qt events to ensure initialization completes
197
        QApplication::processEvents();
1✔
198
    }
4✔
199

200
    SECTION("Data cleanup after deletion from DataManager") {
4✔
201
        // Open the widget to initialize it
202
        widget.openWidget();
1✔
203
        QApplication::processEvents();
1✔
204

205
        // Store weak pointers to verify cleanup
206
        std::vector<std::weak_ptr<void>> weak_refs;
1✔
207

208
        // Get weak references to the data
209
        for (auto const & key: test_keys) {
4✔
210
            if (key == "test_analog") {
3✔
211
                auto analog_data = dm.getData<AnalogTimeSeries>(key);
1✔
212
                weak_refs.push_back(analog_data);
1✔
213
            } else if (key == "test_events") {
3✔
214
                auto event_data = dm.getData<DigitalEventSeries>(key);
1✔
215
                weak_refs.push_back(event_data);
1✔
216
            } else if (key == "test_intervals") {
2✔
217
                auto interval_data = dm.getData<DigitalIntervalSeries>(key);
1✔
218
                weak_refs.push_back(interval_data);
1✔
219
            }
1✔
220
        }
221

222
        // Verify weak references are valid initially
223
        for (auto const & weak_ref: weak_refs) {
4✔
224
            REQUIRE(!weak_ref.expired());
3✔
225
        }
226

227
        // Delete data from DataManager
228
        for (auto const & key: test_keys) {
4✔
229
            bool deleted = dm.deleteData(key);
3✔
230
            REQUIRE(deleted);
3✔
231
        }
232

233
        // Process Qt events to ensure the observer callback executes
234
        QApplication::processEvents();
1✔
235

236
        // Verify that weak references are now expired (data cleaned up)
237
        for (auto const & weak_ref: weak_refs) {
4✔
238
            REQUIRE(weak_ref.expired());
3✔
239
        }
240
    }
5✔
241

242
    SECTION("Partial data cleanup - delete only some data") {
4✔
243
        // Open the widget to initialize it
244
        widget.openWidget();
1✔
245
        QApplication::processEvents();
1✔
246

247
        // Delete only the analog time series
248
        bool deleted = dm.deleteData("test_analog");
3✔
249
        REQUIRE(deleted);
1✔
250

251
        // Process Qt events
252
        QApplication::processEvents();
1✔
253

254
        // Verify analog data is cleaned up from DataManager
255
        auto analog_data = dm.getData<AnalogTimeSeries>("test_analog");
3✔
256
        REQUIRE(analog_data == nullptr);
1✔
257

258
        // Verify other data remains
259
        auto event_data = dm.getData<DigitalEventSeries>("test_events");
3✔
260
        auto interval_data = dm.getData<DigitalIntervalSeries>("test_intervals");
3✔
261
        REQUIRE(event_data != nullptr);
1✔
262
        REQUIRE(interval_data != nullptr);
1✔
263
    }
5✔
264

265
    SECTION("Data cleanup with observer pattern") {
4✔
266
        // Test that the observer pattern properly notifies the widget
267
        // when data is deleted from the DataManager
268

269
        // Open the widget to initialize it
270
        widget.openWidget();
1✔
271
        QApplication::processEvents();
1✔
272

273
        // Get initial data counts
274
        int initial_analog_count = 0;
1✔
275
        int initial_event_count = 0;
1✔
276
        int initial_interval_count = 0;
1✔
277

278
        for (auto const & key: test_keys) {
4✔
279
            if (key == "test_analog") {
3✔
280
                auto data = dm.getData<AnalogTimeSeries>(key);
1✔
281
                if (data) initial_analog_count++;
1✔
282
            } else if (key == "test_events") {
3✔
283
                auto data = dm.getData<DigitalEventSeries>(key);
1✔
284
                if (data) initial_event_count++;
1✔
285
            } else if (key == "test_intervals") {
2✔
286
                auto data = dm.getData<DigitalIntervalSeries>(key);
1✔
287
                if (data) initial_interval_count++;
1✔
288
            }
1✔
289
        }
290

291
        REQUIRE(initial_analog_count == 1);
1✔
292
        REQUIRE(initial_event_count == 1);
1✔
293
        REQUIRE(initial_interval_count == 1);
1✔
294

295
        // Delete all data
296
        for (auto const & key: test_keys) {
4✔
297
            dm.deleteData(key);
3✔
298
        }
299

300
        // Process Qt events to ensure observer callbacks execute
301
        QApplication::processEvents();
1✔
302

303
        // Verify all data has been cleaned up
304
        int final_analog_count = 0;
1✔
305
        int final_event_count = 0;
1✔
306
        int final_interval_count = 0;
1✔
307

308
        for (auto const & key: test_keys) {
4✔
309
            if (key == "test_analog") {
3✔
310
                auto data = dm.getData<AnalogTimeSeries>(key);
1✔
311
                if (data) final_analog_count++;
1✔
312
            } else if (key == "test_events") {
3✔
313
                auto data = dm.getData<DigitalEventSeries>(key);
1✔
314
                if (data) final_event_count++;
1✔
315
            } else if (key == "test_intervals") {
2✔
316
                auto data = dm.getData<DigitalIntervalSeries>(key);
1✔
317
                if (data) final_interval_count++;
1✔
318
            }
1✔
319
        }
320

321
        REQUIRE(final_analog_count == 0);
1✔
322
        REQUIRE(final_event_count == 0);
1✔
323
        REQUIRE(final_interval_count == 0);
1✔
324
    }
4✔
325
}
8✔
326

327
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Memory Management", "[DataViewer_Widget]") {
1✔
328
    auto & widget = getWidget();
1✔
329
    auto & dm = getDataManager();
1✔
330

331
    SECTION("Shared pointer reference counting") {
1✔
332
        // Open the widget to initialize it
333
        widget.openWidget();
1✔
334
        QApplication::processEvents();
1✔
335

336
        // Get shared pointers to the data
337
        auto analog_data = dm.getData<AnalogTimeSeries>("test_analog");
3✔
338
        auto event_data = dm.getData<DigitalEventSeries>("test_events");
3✔
339
        auto interval_data = dm.getData<DigitalIntervalSeries>("test_intervals");
3✔
340

341
        REQUIRE(analog_data.use_count() > 1);// DataManager + any internal references
1✔
342
        REQUIRE(event_data.use_count() > 1);
1✔
343
        REQUIRE(interval_data.use_count() > 1);
1✔
344

345
        // Create weak references for correctness of lifetime checks
346
        std::weak_ptr<AnalogTimeSeries> weak_analog = analog_data;
1✔
347
        std::weak_ptr<DigitalEventSeries> weak_event = event_data;
1✔
348
        std::weak_ptr<DigitalIntervalSeries> weak_interval = interval_data;
1✔
349

350
        // Delete data from DataManager
351
        dm.deleteData("test_analog");
3✔
352
        dm.deleteData("test_events");
3✔
353
        dm.deleteData("test_intervals");
3✔
354

355
        // Process Qt events
356
        QApplication::processEvents();
1✔
357

358
        // Release our local strong references
359
        analog_data.reset();
1✔
360
        event_data.reset();
1✔
361
        interval_data.reset();
1✔
362

363
        // Verify that the objects are now expired
364
        REQUIRE(weak_analog.expired());
1✔
365
        REQUIRE(weak_event.expired());
1✔
366
        REQUIRE(weak_interval.expired());
1✔
367
    }
2✔
368
}
1✔
369

370
TEST_CASE_METHOD(DataViewerWidgetCleanupTestFixture, "DataViewer_Widget - Observer Pattern Integration", "[DataViewer_Widget]") {
2✔
371
    auto & widget = getWidget();
2✔
372
    auto & dm = getDataManager();
2✔
373

374
    SECTION("Observer callback execution") {
2✔
375
        // Open the widget to initialize it
376
        widget.openWidget();
1✔
377
        QApplication::processEvents();
1✔
378

379
        // Verify that the observer is properly set up
380
        // This is implicit in the cleanup tests, but we can verify the behavior
381

382
        // Delete data and verify cleanup happens automatically
383
        bool deleted = dm.deleteData("test_analog");
3✔
384
        REQUIRE(deleted);
1✔
385

386
        // Process Qt events to trigger observer callback
387
        QApplication::processEvents();
1✔
388

389
        // Verify the data is no longer accessible
390
        auto analog_data = dm.getData<AnalogTimeSeries>("test_analog");
3✔
391
        REQUIRE(analog_data == nullptr);
1✔
392
    }
3✔
393

394
    SECTION("Multiple data deletion handling") {
2✔
395
        // Open the widget to initialize it
396
        widget.openWidget();
1✔
397
        QApplication::processEvents();
1✔
398

399
        // Delete multiple data items in sequence
400
        std::vector<std::string> keys_to_delete = {"test_analog", "test_events", "test_intervals"};
3✔
401

402
        for (auto const & key: keys_to_delete) {
4✔
403
            bool deleted = dm.deleteData(key);
3✔
404
            REQUIRE(deleted);
3✔
405

406
            // Process events after each deletion
407
            QApplication::processEvents();
3✔
408

409
            // Verify the deleted data is no longer accessible
410
            if (key == "test_analog") {
3✔
411
                auto data = dm.getData<AnalogTimeSeries>(key);
1✔
412
                REQUIRE(data == nullptr);
1✔
413
            } else if (key == "test_events") {
3✔
414
                auto data = dm.getData<DigitalEventSeries>(key);
1✔
415
                REQUIRE(data == nullptr);
1✔
416
            } else if (key == "test_intervals") {
2✔
417
                auto data = dm.getData<DigitalIntervalSeries>(key);
1✔
418
                REQUIRE(data == nullptr);
1✔
419
            }
1✔
420
        }
421
    }
3✔
422
}
2✔
423

424
// -----------------------------------------------------------------------------
425
// New tests: enabling multiple analog series one by one via the widget API
426
// -----------------------------------------------------------------------------
427
class DataViewerWidgetMultiAnalogTestFixture {
428
protected:
429
    DataViewerWidgetMultiAnalogTestFixture() {
8✔
430
        // Ensure a Qt application exists
431
        if (!QApplication::instance()) {
8✔
432
            static int argc = 1;
433
            static char * argv[] = {const_cast<char *>("test")};
434
            m_app = std::make_unique<QApplication>(argc, argv);
8✔
435
        }
436

437
        // DataManager and TimeScrollBar
438
        m_data_manager = std::make_shared<DataManager>();
8✔
439
        m_time_scrollbar = std::make_unique<TimeScrollBar>();
8✔
440
        m_time_scrollbar->setDataManager(m_data_manager);
8✔
441

442
        // Create a default time frame and register under key "time"
443
        std::vector<int> t(4000);
24✔
444
        std::iota(std::begin(t), std::end(t), 0);
8✔
445

446
        auto new_timeframe = std::make_shared<TimeFrame>(t);
8✔
447

448
        m_time_key = TimeKey("time");
8✔
449

450
        m_data_manager->removeTime(TimeKey("time"));
8✔
451
        m_data_manager->setTime(TimeKey("time"), new_timeframe);
8✔
452

453
        // Populate with 5 analog time series
454
        populateAnalogSeries(5);
8✔
455

456
        // Create the widget
457
        m_widget = std::make_unique<DataViewer_Widget>(m_data_manager, m_time_scrollbar.get(), nullptr);
8✔
458
    }
16✔
459

460
    ~DataViewerWidgetMultiAnalogTestFixture() = default;
8✔
461

462
    DataViewer_Widget & getWidget() { return *m_widget; }
8✔
463
    DataManager & getDataManager() { return *m_data_manager; }
5✔
464
    std::vector<std::string> const & getAnalogKeys() const { return m_analog_keys; }
8✔
465

466
private:
467
    void populateAnalogSeries(int count) {
8✔
468
        // Generate simple waveforms of equal length
469
        constexpr int kNumSamples = 1000;
8✔
470
        std::vector<float> base(kNumSamples);
24✔
471
        for (int i = 0; i < kNumSamples; ++i) {
8,008✔
472
            base[static_cast<size_t>(i)] = std::sin(static_cast<float>(i) * 0.01f);
8,000✔
473
        }
474

475
        m_analog_keys.clear();
8✔
476
        m_analog_keys.reserve(static_cast<size_t>(count));
8✔
477

478
        for (int i = 0; i < count; ++i) {
48✔
479
            // Vary amplitude slightly by index for realism
480
            std::vector<float> values = base;
40✔
481
            float const scale = 1.0f + static_cast<float>(i) * 0.1f;
40✔
482
            for (auto & v: values) v *= scale;
40,040✔
483

484
            auto series = std::make_shared<AnalogTimeSeries>(values, values.size());
40✔
485
            std::string key = std::string("analog_") + std::to_string(i + 1);
120✔
486
            m_data_manager->setData<AnalogTimeSeries>(key, series, m_time_key);
40✔
487
            m_analog_keys.push_back(std::move(key));
40✔
488
        }
40✔
489
    }
16✔
490

491
    std::unique_ptr<QApplication> m_app;
492
    std::shared_ptr<DataManager> m_data_manager;
493
    std::unique_ptr<TimeScrollBar> m_time_scrollbar;
494
    std::unique_ptr<DataViewer_Widget> m_widget;
495
    TimeKey m_time_key{"time"};
496
    std::vector<std::string> m_analog_keys;
497
};
498

499
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Enable Analog Series One By One", "[DataViewer_Widget][Analog]") {
1✔
500
    auto & widget = getWidget();
1✔
501
    auto & dm = getDataManager();
1✔
502
    auto const keys = getAnalogKeys();
1✔
503

504
    REQUIRE(keys.size() == 5);
1✔
505

506
    // Open the widget and let it initialize
507
    widget.openWidget();
1✔
508
    QApplication::processEvents();
1✔
509

510
    // One-by-one enable each analog series using the widget's private slot via meta-object
511
    for (size_t i = 0; i < keys.size(); ++i) {
6✔
512
        auto const & key = keys[i];
5✔
513

514
        bool invoked = QMetaObject::invokeMethod(
5✔
515
                &widget,
516
                "_addFeatureToModel",
517
                Qt::DirectConnection,
518
                Q_ARG(QString, QString::fromStdString(key)),
10✔
519
                Q_ARG(bool, true));
10✔
520
        REQUIRE(invoked);
5✔
521

522
        // Process events to allow the UI/OpenGLWidget to add and layout the series
523
        QApplication::processEvents();
5✔
524

525
        // Validate that the series is now visible via the display options accessor
526
        auto cfg = widget.getAnalogConfig(key);
5✔
527
        REQUIRE(cfg.has_value());
5✔
528
        REQUIRE(cfg.value() != nullptr);
5✔
529
        REQUIRE(cfg.value()->is_visible);
5✔
530

531
        // Gather centers and heights for all enabled series so far
532
        std::vector<float> centers;
5✔
533
        std::vector<float> heights;
5✔
534
        centers.reserve(i + 1);
5✔
535
        heights.reserve(i + 1);
5✔
536

537
        for (size_t j = 0; j <= i; ++j) {
20✔
538
            auto c = widget.getAnalogConfig(keys[j]);
15✔
539
            REQUIRE(c.has_value());
15✔
540
            REQUIRE(c.value() != nullptr);
15✔
541
            centers.push_back(static_cast<float>(c.value()->allocated_y_center));
15✔
542
            heights.push_back(static_cast<float>(c.value()->allocated_height));
15✔
543
        }
544

545
        std::sort(centers.begin(), centers.end());
5✔
546

547
        // Expected evenly spaced centers across [-1, 1] at fractions k/(N+1)
548
        size_t const enabled_count = i + 1;
5✔
549
        float const tol_center = 0.22f;// generous tolerance for layout differences
5✔
550
        for (size_t k = 0; k < enabled_count; ++k) {
20✔
551
            float const expected = -1.0f + 2.0f * (static_cast<float>(k + 1) / static_cast<float>(enabled_count + 1));
15✔
552
            REQUIRE(std::abs(centers[k] - expected) <= tol_center);
15✔
553
        }
554

555
        // Heights should be roughly proportional to the available spacing between centers
556
        // Expected spacing between adjacent centers
557
        float const expected_spacing = 2.0f / static_cast<float>(enabled_count + 1);
5✔
558
        float const min_h = *std::min_element(heights.begin(), heights.end());
5✔
559
        float const max_h = *std::max_element(heights.begin(), heights.end());
5✔
560

561
        // Bounds: each height within [0.4, 1.2] * expected spacing
562
        for (auto const h: heights) {
20✔
563
            //           REQUIRE(h >= expected_spacing * 0.4f);
564
            //         REQUIRE(h <= expected_spacing * 1.2f);
565
        }
566

567
        // And heights should be fairly consistent across series (within 40%)
568
        if (min_h > 0.0f) {
5✔
569
            REQUIRE((max_h / min_h) <= 1.4f);
5✔
570
        }
571
    }
5✔
572
}
2✔
573

574
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Enable Five Analog Series via Group Toggle", "[DataViewer_Widget][Analog][Group]") {
1✔
575
    auto & widget = getWidget();
1✔
576
    auto const & keys = getAnalogKeys();
1✔
577
    REQUIRE(keys.size() == 5);
1✔
578

579
    std::cout << "CTEST_FULL_OUTPUT" << std::endl;
1✔
580

581
    widget.openWidget();
1✔
582
    QApplication::processEvents();
1✔
583

584
    // Locate the Feature_Tree_Widget inside the DataViewer widget
585
    auto ftw = widget.findChild<Feature_Tree_Widget *>("feature_tree_widget");
2✔
586
    REQUIRE(ftw != nullptr);
1✔
587

588
    // Ensure the tree is populated
589
    ftw->refreshTree();
1✔
590
    QApplication::processEvents();
1✔
591

592
    QTreeWidget * tree = ftw->treeWidget();
1✔
593
    REQUIRE(tree != nullptr);
1✔
594

595
    // Find the top-level "Analog" node
596
    QTreeWidgetItem * analogRoot = nullptr;
1✔
597
    for (int i = 0; i < tree->topLevelItemCount(); ++i) {
1✔
598
        QTreeWidgetItem * item = tree->topLevelItem(i);
1✔
599
        if (item && item->text(0) == QString("analog")) {
1✔
600
            analogRoot = item;
1✔
601
            break;
1✔
602
        }
603
    }
604
    REQUIRE(analogRoot != nullptr);
1✔
605

606
    // Find the group node for prefix "analog" (derived from keys like analog_1..analog_5)
607
    QTreeWidgetItem * analogGroup = nullptr;
1✔
608
    for (int i = 0; i < analogRoot->childCount(); ++i) {
1✔
609
        QTreeWidgetItem * child = analogRoot->child(i);
1✔
610
        if (child && child->text(0) == QString("analog")) {
1✔
611
            analogGroup = child;
1✔
612
            break;
1✔
613
        }
614
    }
615
    REQUIRE(analogGroup != nullptr);
1✔
616

617
    // Toggle the group checkbox (column 1 is the checkbox column)
618
    analogGroup->setCheckState(1, Qt::Checked);
1✔
619
    QApplication::processEvents();
1✔
620

621
    // Verify that all five analog series became visible
622
    for (auto const & key: keys) {
6✔
623
        auto cfg = widget.getAnalogConfig(key);
5✔
624
        REQUIRE(cfg.has_value());
5✔
625
        REQUIRE(cfg.value() != nullptr);
5✔
626
        REQUIRE(cfg.value()->is_visible);
5✔
627
    }
628
}
1✔
629

630
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Group toggle then single enable resets stacking", "[DataViewer_Widget][Analog][Group][Regression]") {
1✔
631
    auto & widget = getWidget();
1✔
632
    auto const & keys = getAnalogKeys();
1✔
633
    REQUIRE(keys.size() == 5);
1✔
634

635
    widget.openWidget();
1✔
636
    QApplication::processEvents();
1✔
637

638
    // Locate the Feature_Tree_Widget inside the DataViewer widget
639
    auto ftw = widget.findChild<Feature_Tree_Widget *>("feature_tree_widget");
2✔
640
    REQUIRE(ftw != nullptr);
1✔
641

642
    // Ensure the tree is populated
643
    ftw->refreshTree();
1✔
644
    QApplication::processEvents();
1✔
645

646
    QTreeWidget * tree = ftw->treeWidget();
1✔
647
    REQUIRE(tree != nullptr);
1✔
648

649
    // Find the top-level "Analog" node
650
    QTreeWidgetItem * analogRoot = nullptr;
1✔
651
    for (int i = 0; i < tree->topLevelItemCount(); ++i) {
1✔
652
        QTreeWidgetItem * item = tree->topLevelItem(i);
1✔
653
        if (item && item->text(0) == QString("analog")) {
1✔
654
            analogRoot = item;
1✔
655
            break;
1✔
656
        }
657
    }
658
    REQUIRE(analogRoot != nullptr);
1✔
659

660
    // Find the group node for prefix "analog" (derived from keys like analog_1..analog_5)
661
    QTreeWidgetItem * analogGroup = nullptr;
1✔
662
    for (int i = 0; i < analogRoot->childCount(); ++i) {
1✔
663
        QTreeWidgetItem * child = analogRoot->child(i);
1✔
664
        if (child && child->text(0) == QString("analog")) {
1✔
665
            analogGroup = child;
1✔
666
            break;
1✔
667
        }
668
    }
669
    REQUIRE(analogGroup != nullptr);
1✔
670

671
    // 1) Enable the whole group
672
    analogGroup->setCheckState(1, Qt::Checked);
1✔
673
    QApplication::processEvents();
1✔
674

675
    // Verify that all five analog series became visible
676
    for (auto const & key: keys) {
6✔
677
        auto cfg = widget.getAnalogConfig(key);
5✔
678
        REQUIRE(cfg.has_value());
5✔
679
        REQUIRE(cfg.value() != nullptr);
5✔
680
        REQUIRE(cfg.value()->is_visible);
5✔
681
    }
682

683
    // 2) Disable the whole group
684
    analogGroup->setCheckState(1, Qt::Unchecked);
1✔
685
    QApplication::processEvents();
1✔
686

687
    // Verify that all five analog series are no longer visible
688
    for (auto const & key: keys) {
6✔
689
        auto cfg = widget.getAnalogConfig(key);
5✔
690
        if (cfg.has_value() && cfg.value() != nullptr) {
5✔
UNCOV
691
            REQUIRE_FALSE(cfg.value()->is_visible);
×
692
        }
693
    }
694

695
    // 3) Re-enable a single key (simulate selecting one channel from the group)
696
    std::string const single_key = keys.front();
1✔
697
    bool invoked = QMetaObject::invokeMethod(
1✔
698
            &widget,
699
            "_addFeatureToModel",
700
            Qt::DirectConnection,
701
            Q_ARG(QString, QString::fromStdString(single_key)),
2✔
702
            Q_ARG(bool, true));
2✔
703
    REQUIRE(invoked);
1✔
704
    QApplication::processEvents();
1✔
705

706
    // 4) Assert this single key is treated as a single-lane stack:
707
    //    center ~ 0 and height ~ full canvas (about 2.0), not a 1/5 lane.
708
    auto cfg_single = widget.getAnalogConfig(single_key);
1✔
709
    REQUIRE(cfg_single.has_value());
1✔
710
    REQUIRE(cfg_single.value() != nullptr);
1✔
711
    REQUIRE(cfg_single.value()->is_visible);
1✔
712

713
    float const center = static_cast<float>(cfg_single.value()->allocated_y_center);
1✔
714
    float const height = static_cast<float>(cfg_single.value()->allocated_height);
1✔
715

716
    // Center should be near 0.0
717
    REQUIRE(std::abs(center - 0.0f) <= 0.25f);
1✔
718
    // Height should be near full canvas height ([-1,1] -> ~2.0)
719
    REQUIRE(height >= 1.6f);
1✔
720
    REQUIRE(height <= 2.2f);
1✔
721

722
    // And all other keys should remain not visible
723
    for (size_t i = 1; i < keys.size(); ++i) {
5✔
724
        auto cfg = widget.getAnalogConfig(keys[i]);
4✔
725
        if (cfg.has_value() && cfg.value() != nullptr) {
4✔
UNCOV
726
            REQUIRE_FALSE(cfg.value()->is_visible);
×
727
        }
728
    }
729
}
2✔
730

731
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Apply spikesorter configuration ordering", "[DataViewer_Widget][Analog][Config]") {
1✔
732
    auto & widget = getWidget();
1✔
733
    auto & dm = getDataManager();
1✔
734
    auto const keys = getAnalogKeys();
1✔
735
    REQUIRE(keys.size() >= 4);
1✔
736

737
    widget.openWidget();
1✔
738
    QApplication::processEvents();
1✔
739

740
    // Enable four channels from the same group (analog prefix)
741
    for (size_t i = 0; i < 4; ++i) {
5✔
742
        bool invoked = QMetaObject::invokeMethod(
4✔
743
                &widget,
744
                "_addFeatureToModel",
745
                Qt::DirectConnection,
746
                Q_ARG(QString, QString::fromStdString(keys[i])),
8✔
747
                Q_ARG(bool, true));
8✔
748
        REQUIRE(invoked);
4✔
749
        QApplication::processEvents();
4✔
750
    }
751

752
    // Capture centers before loading configuration
753
    std::vector<std::pair<std::string, float>> centers_before;
1✔
754
    for (size_t i = 0; i < 4; ++i) {
5✔
755
        auto c = widget.getAnalogConfig(keys[i]);
4✔
756
        REQUIRE(c.has_value());
4✔
757
        centers_before.emplace_back(keys[i], static_cast<float>(c.value()->allocated_y_center));
4✔
758
    }
759

760
    // Build a small spikesorter configuration text with distinct y values
761
    // Header + rows: row ch x y
762
    char const * cfg =
1✔
763
            "poly2\n"
764
            "1 1 0 300\n"
765
            "2 2 0 100\n"
766
            "3 3 0 200\n"
767
            "4 4 0 400\n";
768

769
    // Load configuration directly via helper to avoid file dialogs
770
    bool invoked = QMetaObject::invokeMethod(
1✔
771
            &widget,
772
            "_loadSpikeSorterConfigurationFromText",
773
            Qt::DirectConnection,
774
            Q_ARG(QString, QString("analog")),
2✔
775
            Q_ARG(QString, QString(cfg)));
2✔
776
    REQUIRE(invoked);
1✔
777
    QApplication::processEvents();
1✔
778

779
    // After config, the highest y (400) should be at the top (largest allocated_y_center)
780
    std::vector<std::pair<std::string, float>> key_center;
1✔
781
    for (size_t i = 0; i < 4; ++i) {
5✔
782
        auto c = widget.getAnalogConfig(keys[i]);
4✔
783
        REQUIRE(c.has_value());
4✔
784
        key_center.emplace_back(keys[i], static_cast<float>(c.value()->allocated_y_center));
4✔
785
    }
786
    // Capture centers after
787
    std::vector<std::pair<std::string, float>> centers_after = key_center;
1✔
788

789
    INFO("Centers before:");
1✔
790
    for (auto const & kv: centers_before) {
5✔
791
        INFO(kv.first << " -> " << kv.second);
4✔
792
    }
4✔
793
    INFO("Centers after:");
1✔
794
    for (auto const & kv: centers_after) {
5✔
795
        INFO(kv.first << " -> " << kv.second);
4✔
796
    }
4✔
797

798
    // Ensure at least one center changed due to configuration ordering
799
    bool any_changed = false;
1✔
800
    for (size_t i = 0; i < centers_before.size(); ++i) {
5✔
801
        for (size_t j = 0; j < centers_after.size(); ++j) {
20✔
802
            if (centers_before[i].first == centers_after[j].first) {
16✔
803
                if (std::abs(centers_before[i].second - centers_after[j].second) > 1e-6f) {
4✔
804
                    any_changed = true;
3✔
805
                }
806
            }
807
        }
808
    }
809
    REQUIRE(any_changed);
1✔
810
    // Sort by center descending to get top-to-bottom order
811
    std::sort(key_center.begin(), key_center.end(), [](auto const & a, auto const & b) { return a.second > b.second; });
7✔
812

813
    // Expected order by y: 400 (ch 3)-> key 4, then 300 (ch 0)-> key 1, then 200 (ch 2)-> key 3, then 100 (ch 1)-> key 2
814
    REQUIRE(key_center.size() == 4);
1✔
815
    REQUIRE(key_center[0].first == keys[3]);
1✔
816
    REQUIRE(key_center[1].first == keys[0]);
1✔
817
    REQUIRE(key_center[2].first == keys[2]);
1✔
818
    REQUIRE(key_center[3].first == keys[1]);
1✔
819
}
2✔
820

821
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - X axis unchanged on global gain change", "[DataViewer_Widget][Analog][XAxis]") {
1✔
822
    auto & widget = getWidget();
1✔
823
    auto const keys = getAnalogKeys();
1✔
824
    REQUIRE(keys.size() >= 1);
1✔
825

826
    widget.openWidget();
1✔
827
    QApplication::processEvents();
1✔
828

829
    // Enable a single analog series
830
    bool invoked = QMetaObject::invokeMethod(
1✔
831
            &widget,
832
            "_addFeatureToModel",
833
            Qt::DirectConnection,
834
            Q_ARG(QString, QString::fromStdString(keys[0])),
2✔
835
            Q_ARG(bool, true));
2✔
836
    REQUIRE(invoked);
1✔
837
    QApplication::processEvents();
1✔
838

839
    // Locate the OpenGLWidget to query XAxis
840
    auto glw = widget.findChild<OpenGLWidget *>("openGLWidget");
2✔
841
    REQUIRE(glw != nullptr);
1✔
842

843
    // Set an initial center (time) and range width via widget slots
844
    int const initial_time_index = 1000;
1✔
845
    invoked = QMetaObject::invokeMethod(
2✔
846
            &widget,
847
            "_updatePlot",
848
            Qt::DirectConnection,
849
            Q_ARG(int, initial_time_index));
2✔
850
    REQUIRE(invoked);
1✔
851

852
    int const initial_range_width = 2000;
1✔
853
    invoked = QMetaObject::invokeMethod(
2✔
854
            &widget,
855
            "_handleXAxisSamplesChanged",
856
            Qt::DirectConnection,
857
            Q_ARG(int, initial_range_width));
2✔
858
    REQUIRE(invoked);
1✔
859
    QApplication::processEvents();
1✔
860

861
    auto x_before = glw->getXAxis();
1✔
862
    auto const start_before = x_before.getStart();
1✔
863
    auto const end_before = x_before.getEnd();
1✔
864

865
    // Change global gain via the private slot and re-draw
866
    double const new_gain = 2.0;
1✔
867
    invoked = QMetaObject::invokeMethod(
2✔
868
            &widget,
869
            "_updateGlobalScale",
870
            Qt::DirectConnection,
871
            Q_ARG(double, new_gain));
2✔
872
    REQUIRE(invoked);
1✔
873
    QApplication::processEvents();
1✔
874

875
    auto x_after = glw->getXAxis();
1✔
876
    auto const start_after = x_after.getStart();
1✔
877
    auto const end_after = x_after.getEnd();
1✔
878

879
    // Verify X window did not change
880
    REQUIRE(start_before == start_after);
1✔
881
    REQUIRE(end_before == end_after);
1✔
882
}
2✔
883

884
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Preserve analog selections when adding digital interval", "[DataViewer_Widget][Analog][DigitalInterval]") {
1✔
885
    auto & widget = getWidget();
1✔
886
    auto & dm = getDataManager();
1✔
887
    auto const keys = getAnalogKeys();
1✔
888
    REQUIRE(keys.size() == 5);
1✔
889

890
    widget.openWidget();
1✔
891
    QApplication::processEvents();
1✔
892

893
    // Enable 3 out of 5 analog series (sparse selection)
894
    std::vector<std::string> enabled = {keys[0], keys[2], keys[4]};
6✔
895
    for (auto const & k: enabled) {
4✔
896
        bool invoked = QMetaObject::invokeMethod(
3✔
897
                &widget,
898
                "_addFeatureToModel",
899
                Qt::DirectConnection,
900
                Q_ARG(QString, QString::fromStdString(k)),
6✔
901
                Q_ARG(bool, true));
6✔
902
        REQUIRE(invoked);
3✔
903
        QApplication::processEvents();
3✔
904
    }
905

906
    // Verify the enabled set is visible and others are not present/visible
907
    auto isVisible = [&](std::string const & k) {
1✔
908
        auto cfg = widget.getAnalogConfig(k);
6✔
909
        return cfg.has_value() && cfg.value() != nullptr && cfg.value()->is_visible;
12✔
910
    };
1✔
911

912
    REQUIRE(isVisible(keys[0]));
1✔
913
    REQUIRE(isVisible(keys[2]));
1✔
914
    REQUIRE(isVisible(keys[4]));
1✔
915
    // Not enabled yet: may be nullopt or not visible
916
    auto c1 = widget.getAnalogConfig(keys[1]);
1✔
917
    if (c1.has_value()) REQUIRE_FALSE(c1.value()->is_visible);
1✔
918
    auto c3 = widget.getAnalogConfig(keys[3]);
1✔
919
    if (c3.has_value()) REQUIRE_FALSE(c3.value()->is_visible);
1✔
920

921
    // Add a new DigitalIntervalSeries to the DataManager (should NOT clear visible analog series)
922
    auto interval_series = std::make_shared<DigitalIntervalSeries>();
1✔
923
    interval_series->addEvent(TimeFrameIndex(100), TimeFrameIndex(300));
1✔
924
    dm.setData<DigitalIntervalSeries>("interval_added_late", interval_series, TimeKey("time"));
3✔
925

926
    // Process events so the feature tree and any observers react
927
    QApplication::processEvents();
1✔
928

929
    // Verify the originally enabled analog series remain visible
930
    REQUIRE(isVisible(keys[0]));
1✔
931
    REQUIRE(isVisible(keys[2]));
1✔
932
    REQUIRE(isVisible(keys[4]));
1✔
933

934
    // Still not enabled ones should remain not visible
935
    c1 = widget.getAnalogConfig(keys[1]);
1✔
936
    if (c1.has_value()) REQUIRE_FALSE(c1.value()->is_visible);
1✔
937
    c3 = widget.getAnalogConfig(keys[3]);
1✔
938
    if (c3.has_value()) REQUIRE_FALSE(c3.value()->is_visible);
1✔
939
}
3✔
940

941
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Adding digital interval does not change analog gain", "[DataViewer_Widget][Analog][DigitalInterval][Regression]") {
1✔
942
    auto & widget = getWidget();
1✔
943
    auto & dm = getDataManager();
1✔
944
    auto const keys = getAnalogKeys();
1✔
945
    REQUIRE(keys.size() >= 2);
1✔
946

947
    widget.openWidget();
1✔
948
    QApplication::processEvents();
1✔
949

950
    // Enable two analog series
951
    for (size_t i = 0; i < 2; ++i) {
3✔
952
        bool const invoked = QMetaObject::invokeMethod(
2✔
953
                &widget,
954
                "_addFeatureToModel",
955
                Qt::DirectConnection,
956
                Q_ARG(QString, QString::fromStdString(keys[i])),
4✔
957
                Q_ARG(bool, true));
4✔
958
        REQUIRE(invoked);
2✔
959
        QApplication::processEvents();
2✔
960
    }
961

962
    // Capture their allocated heights (proxy for gain)
963
    auto cfg_a0_before = widget.getAnalogConfig(keys[0]);
1✔
964
    auto cfg_a1_before = widget.getAnalogConfig(keys[1]);
1✔
965
    REQUIRE(cfg_a0_before.has_value());
1✔
966
    REQUIRE(cfg_a1_before.has_value());
1✔
967
    float const h0_before = static_cast<float>(cfg_a0_before.value()->allocated_height);
1✔
968
    float const h1_before = static_cast<float>(cfg_a1_before.value()->allocated_height);
1✔
969
    // Sanity: they should be similar (two-lane stacking)
970
    if (std::min(h0_before, h1_before) > 0.0f) {
1✔
971
        REQUIRE((std::max(h0_before, h1_before) / std::min(h0_before, h1_before)) <= 1.4f);
1✔
972
    }
973

974
    // Add a digital interval series that by default renders full-canvas
975
    auto interval_series = std::make_shared<DigitalIntervalSeries>();
1✔
976
    interval_series->addEvent(TimeFrameIndex(150), TimeFrameIndex(350));
1✔
977
    std::string const interval_key = "interval_fullcanvas";
3✔
978
    dm.setData<DigitalIntervalSeries>(interval_key, interval_series, TimeKey("time"));
1✔
979
    QApplication::processEvents();
1✔
980

981
    // Enable the digital interval in the view
982
    bool const invoked_interval = QMetaObject::invokeMethod(
1✔
983
            &widget,
984
            "_addFeatureToModel",
985
            Qt::DirectConnection,
986
            Q_ARG(QString, QString::fromStdString(interval_key)),
2✔
987
            Q_ARG(bool, true));
2✔
988
    REQUIRE(invoked_interval);
1✔
989
    QApplication::processEvents();
1✔
990

991
    // Verify the interval is visible and near full canvas height
992
    auto cfg_interval = widget.getDigitalIntervalConfig(interval_key);
1✔
993
    REQUIRE(cfg_interval.has_value());
1✔
994
    REQUIRE(cfg_interval.value() != nullptr);
1✔
995
    REQUIRE(cfg_interval.value()->is_visible);
1✔
996
    REQUIRE(cfg_interval.value()->allocated_height >= 1.6f);
1✔
997
    REQUIRE(cfg_interval.value()->allocated_height <= 2.2f);
1✔
998

999
    // Re-capture analog heights after enabling the interval
1000
    auto cfg_a0_after = widget.getAnalogConfig(keys[0]);
1✔
1001
    auto cfg_a1_after = widget.getAnalogConfig(keys[1]);
1✔
1002
    REQUIRE(cfg_a0_after.has_value());
1✔
1003
    REQUIRE(cfg_a1_after.has_value());
1✔
1004
    float const h0_after = static_cast<float>(cfg_a0_after.value()->allocated_height);
1✔
1005
    float const h1_after = static_cast<float>(cfg_a1_after.value()->allocated_height);
1✔
1006

1007
    INFO("h0_before=" << h0_before << ", h0_after=" << h0_after);
1✔
1008
    INFO("h1_before=" << h1_before << ", h1_after=" << h1_after);
1✔
1009

1010
    // Regression check: enabling a full-canvas digital interval must NOT attenuate analog gain.
1011
    // Allow a small tolerance for layout jitter.
1012
    auto approx_equal = [](float a, float b) {
1✔
1013
        float const denom = std::max(1.0f, std::max(std::abs(a), std::abs(b)));
2✔
1014
        return std::abs(a - b) / denom <= 0.1f; // <=10% relative change
2✔
1015
    };
1016
    REQUIRE(approx_equal(h0_after, h0_before));
1✔
1017
    REQUIRE(approx_equal(h1_after, h1_before));
1✔
1018
}
2✔
1019

1020
TEST_CASE_METHOD(DataViewerWidgetMultiAnalogTestFixture, "DataViewer_Widget - Adding digital interval does not change global zoom or analog mapping", "[DataViewer_Widget][Analog][DigitalInterval][Regression]") {
1✔
1021
    auto & widget = getWidget();
1✔
1022
    auto & dm = getDataManager();
1✔
1023
    auto const keys = getAnalogKeys();
1✔
1024
    REQUIRE(keys.size() >= 2);
1✔
1025

1026
    widget.openWidget();
1✔
1027
    QApplication::processEvents();
1✔
1028

1029
    // Enable two analog series
1030
    for (size_t i = 0; i < 2; ++i) {
3✔
1031
        bool const ok = QMetaObject::invokeMethod(
2✔
1032
                &widget,
1033
                "_addFeatureToModel",
1034
                Qt::DirectConnection,
1035
                Q_ARG(QString, QString::fromStdString(keys[i])),
4✔
1036
                Q_ARG(bool, true));
4✔
1037
        REQUIRE(ok);
2✔
1038
        QApplication::processEvents();
2✔
1039
    }
1040

1041
    // Access the global zoom spinbox and record its value
1042
    auto zoomSpin = widget.findChild<QDoubleSpinBox *>("global_zoom");
2✔
1043
    REQUIRE(zoomSpin != nullptr);
1✔
1044
    double const zoom_before = zoomSpin->value();
1✔
1045

1046
    // Also capture an analog canvasY->value slope proxy for one series
1047
    auto glw = widget.findChild<OpenGLWidget *>("openGLWidget");
2✔
1048
    REQUIRE(glw != nullptr);
1✔
1049
    QApplication::processEvents();
1✔
1050
    auto size = glw->getCanvasSize();
1✔
1051
    float const h = static_cast<float>(size.second);
1✔
1052
    float const y1 = h * 0.25f;
1✔
1053
    float const y2 = h * 0.75f;
1✔
1054
    auto map_delta = [&](std::string const & key) {
1✔
1055
        float const v1 = glw->canvasYToAnalogValue(y1, key);
2✔
1056
        float const v2 = glw->canvasYToAnalogValue(y2, key);
2✔
1057
        return std::abs(v2 - v1) / std::max(1e-6f, (y2 - y1));
2✔
1058
    };
1✔
1059
    float const slope_before = map_delta(keys[0]);
1✔
1060

1061
    // Add and enable a full-canvas digital interval
1062
    auto interval_series = std::make_shared<DigitalIntervalSeries>();
1✔
1063
    interval_series->addEvent(TimeFrameIndex(100), TimeFrameIndex(300));
1✔
1064
    std::string const int_key = "interval_fc";
3✔
1065
    dm.setData<DigitalIntervalSeries>(int_key, interval_series, TimeKey("time"));
1✔
1066
    QApplication::processEvents();
1✔
1067
    bool const ok2 = QMetaObject::invokeMethod(
1✔
1068
            &widget,
1069
            "_addFeatureToModel",
1070
            Qt::DirectConnection,
1071
            Q_ARG(QString, QString::fromStdString(int_key)),
2✔
1072
            Q_ARG(bool, true));
2✔
1073
    REQUIRE(ok2);
1✔
1074
    QApplication::processEvents();
1✔
1075

1076
    // Global zoom should not change when adding a full-canvas interval
1077
    double const zoom_after = zoomSpin->value();
1✔
1078
    INFO("zoom_before=" << zoom_before << ", zoom_after=" << zoom_after);
1✔
1079
    REQUIRE(Catch::Approx(zoom_after).margin(zoom_before * 0.05 + 1e-6) == zoom_before);
1✔
1080

1081
    // Analog mapping slope should remain approximately the same
1082
    float const slope_after = map_delta(keys[0]);
1✔
1083
    INFO("slope_before=" << slope_before << ", slope_after=" << slope_after);
1✔
1084
    if (std::max(slope_before, slope_after) > 0.0f) {
1✔
UNCOV
1085
        float const rel_change = std::abs(slope_after - slope_before) / std::max(1e-6f, std::max(slope_before, slope_after));
×
UNCOV
1086
        REQUIRE(rel_change <= 0.1f);
×
1087
    }
1088
}
2✔
1089

1090
// -----------------------------------------------------------------------------
1091
// New tests: enabling multiple digital event series one by one via the widget API
1092
// -----------------------------------------------------------------------------
1093
class DataViewerWidgetMultiEventTestFixture {
1094
protected:
1095
    DataViewerWidgetMultiEventTestFixture() {
2✔
1096
        if (!QApplication::instance()) {
2✔
1097
            static int argc = 1;
1098
            static char * argv[] = {const_cast<char *>("test")};
1099
            m_app = std::make_unique<QApplication>(argc, argv);
2✔
1100
        }
1101

1102
        m_data_manager = std::make_shared<DataManager>();
2✔
1103
        m_time_scrollbar = std::make_unique<TimeScrollBar>();
2✔
1104
        m_time_scrollbar->setDataManager(m_data_manager);
2✔
1105

1106
        // Create a default time frame and register under key "time"
1107
        std::vector<int> t(4000);
6✔
1108
        std::iota(std::begin(t), std::end(t), 0);
2✔
1109
        auto new_timeframe = std::make_shared<TimeFrame>(t);
2✔
1110
        m_time_key = TimeKey("time");
2✔
1111
        m_data_manager->removeTime(TimeKey("time"));
2✔
1112
        m_data_manager->setTime(TimeKey("time"), new_timeframe);
2✔
1113

1114
        // Populate with 5 digital event series
1115
        populateEventSeries(5);
2✔
1116

1117
        m_widget = std::make_unique<DataViewer_Widget>(m_data_manager, m_time_scrollbar.get(), nullptr);
2✔
1118
    }
4✔
1119

1120
    ~DataViewerWidgetMultiEventTestFixture() = default;
2✔
1121

1122
    DataViewer_Widget & getWidget() { return *m_widget; }
2✔
1123
    DataManager & getDataManager() { return *m_data_manager; }
1124
    std::vector<std::string> const & getEventKeys() const { return m_event_keys; }
2✔
1125

1126
private:
1127
    void populateEventSeries(int count) {
2✔
1128
        m_event_keys.clear();
2✔
1129
        m_event_keys.reserve(static_cast<size_t>(count));
2✔
1130

1131
        for (int i = 0; i < count; ++i) {
12✔
1132
            auto series = std::make_shared<DigitalEventSeries>();
10✔
1133
            // Add a few events within the visible range
1134
            series->addEvent(1000);
10✔
1135
            series->addEvent(2000);
10✔
1136
            series->addEvent(3000);
10✔
1137

1138
            std::string key = std::string("event_") + std::to_string(i + 1);
30✔
1139
            m_data_manager->setData<DigitalEventSeries>(key, series, m_time_key);
10✔
1140
            m_event_keys.push_back(std::move(key));
10✔
1141
        }
10✔
1142
    }
2✔
1143

1144
    std::unique_ptr<QApplication> m_app;
1145
    std::shared_ptr<DataManager> m_data_manager;
1146
    std::unique_ptr<TimeScrollBar> m_time_scrollbar;
1147
    std::unique_ptr<DataViewer_Widget> m_widget;
1148
    TimeKey m_time_key{"time"};
1149
    std::vector<std::string> m_event_keys;
1150
};
1151

1152
TEST_CASE_METHOD(DataViewerWidgetMultiEventTestFixture, "DataViewer_Widget - Enable Digital Event Series One By One", "[DataViewer_Widget][DigitalEvent]") {
1✔
1153
    auto & widget = getWidget();
1✔
1154
    auto const keys = getEventKeys();
1✔
1155
    REQUIRE(keys.size() == 5);
1✔
1156

1157
    widget.openWidget();
1✔
1158
    QApplication::processEvents();
1✔
1159

1160
    for (size_t i = 0; i < keys.size(); ++i) {
6✔
1161
        auto const & key = keys[i];
5✔
1162

1163
        bool invoked = QMetaObject::invokeMethod(
5✔
1164
                &widget,
1165
                "_addFeatureToModel",
1166
                Qt::DirectConnection,
1167
                Q_ARG(QString, QString::fromStdString(key)),
10✔
1168
                Q_ARG(bool, true));
10✔
1169
        REQUIRE(invoked);
5✔
1170

1171
        QApplication::processEvents();
5✔
1172

1173
        auto cfg = widget.getDigitalEventConfig(key);
5✔
1174
        REQUIRE(cfg.has_value());
5✔
1175
        REQUIRE(cfg.value() != nullptr);
5✔
1176
        REQUIRE(cfg.value()->is_visible);
5✔
1177

1178
        std::vector<float> centers;
5✔
1179
        std::vector<float> heights;
5✔
1180
        centers.reserve(i + 1);
5✔
1181
        heights.reserve(i + 1);
5✔
1182

1183
        for (size_t j = 0; j <= i; ++j) {
20✔
1184
            auto c = widget.getDigitalEventConfig(keys[j]);
15✔
1185
            REQUIRE(c.has_value());
15✔
1186
            REQUIRE(c.value() != nullptr);
15✔
1187
            centers.push_back(static_cast<float>(c.value()->allocated_y_center));
15✔
1188
            heights.push_back(static_cast<float>(c.value()->allocated_height));
15✔
1189
        }
1190

1191
        std::sort(centers.begin(), centers.end());
5✔
1192

1193
        size_t const enabled_count = i + 1;
5✔
1194
        float const tol_center = 0.22f;
5✔
1195
        for (size_t k = 0; k < enabled_count; ++k) {
20✔
1196
            // Expected evenly spaced centers across [-1, 1] at fractions k/(N+1)
1197
            float const expected = -1.0f + 2.0f * (static_cast<float>(k + 1) / static_cast<float>(enabled_count + 1));
15✔
1198
            REQUIRE(std::abs(centers[k] - expected) <= tol_center);
15✔
1199
        }
1200

1201
        float const expected_height = 2.0f / static_cast<float>(enabled_count);
5✔
1202
        float const min_h = *std::min_element(heights.begin(), heights.end());
5✔
1203
        float const max_h = *std::max_element(heights.begin(), heights.end());
5✔
1204

1205
        for (auto const h: heights) {
20✔
1206
            // Heights should be within their allocated lane (allow tolerance)
1207
            REQUIRE(h > 0.0f);
15✔
1208
            REQUIRE(h >= expected_height * 0.4f);
15✔
1209
            REQUIRE(h <= expected_height * 1.2f);
15✔
1210
        }
1211

1212
        if (min_h > 0.0f) {
5✔
1213
            REQUIRE((max_h / min_h) <= 1.4f);
5✔
1214
        }
1215
    }
5✔
1216
}
2✔
1217

1218
// -----------------------------------------------------------------------------
1219
// Mixed stacking test: analog + digital events share vertical space uniformly
1220
// -----------------------------------------------------------------------------
1221
class DataViewerWidgetMixedStackingTestFixture {
1222
protected:
1223
    DataViewerWidgetMixedStackingTestFixture() {
2✔
1224
        if (!QApplication::instance()) {
2✔
1225
            static int argc = 1;
1226
            static char * argv[] = {const_cast<char *>("test")};
1227
            m_app = std::make_unique<QApplication>(argc, argv);
2✔
1228
        }
1229

1230
        m_data_manager = std::make_shared<DataManager>();
2✔
1231
        m_time_scrollbar = std::make_unique<TimeScrollBar>();
2✔
1232
        m_time_scrollbar->setDataManager(m_data_manager);
2✔
1233

1234
        // Master time frame
1235
        std::vector<int> t(4000);
6✔
1236
        std::iota(std::begin(t), std::end(t), 0);
2✔
1237
        auto tf = std::make_shared<TimeFrame>(t);
2✔
1238
        m_time_key = TimeKey("time");
2✔
1239
        m_data_manager->removeTime(TimeKey("time"));
2✔
1240
        m_data_manager->setTime(TimeKey("time"), tf);
2✔
1241

1242
        // 3 analog series
1243
        for (int i = 0; i < 3; ++i) {
8✔
1244
            constexpr int kNumSamples = 1000;
6✔
1245
            std::vector<float> values(kNumSamples, 0.0f);
18✔
1246
            for (int j = 0; j < kNumSamples; ++j) {
6,006✔
1247
                values[static_cast<size_t>(j)] = std::sin(static_cast<float>(j) * 0.01f) * (1.0f + 0.1f * static_cast<float>(i));
6,000✔
1248
            }
1249
            auto series = std::make_shared<AnalogTimeSeries>(values, values.size());
6✔
1250
            std::string key = std::string("analog_") + std::to_string(i + 1);
18✔
1251
            m_analog_keys.push_back(key);
6✔
1252
            m_data_manager->setData<AnalogTimeSeries>(key, series, m_time_key);
6✔
1253
        }
6✔
1254

1255
        // 2 event series
1256
        for (int i = 0; i < 2; ++i) {
6✔
1257
            auto series = std::make_shared<DigitalEventSeries>();
4✔
1258
            series->addEvent(1000);
4✔
1259
            series->addEvent(2000);
4✔
1260
            series->addEvent(3000);
4✔
1261
            std::string key = std::string("event_") + std::to_string(i + 1);
12✔
1262
            m_event_keys.push_back(key);
4✔
1263
            m_data_manager->setData<DigitalEventSeries>(key, series, m_time_key);
4✔
1264
        }
4✔
1265

1266
        m_widget = std::make_unique<DataViewer_Widget>(m_data_manager, m_time_scrollbar.get(), nullptr);
2✔
1267
    }
4✔
1268

1269
    ~DataViewerWidgetMixedStackingTestFixture() = default;
2✔
1270

1271
    DataViewer_Widget & getWidget() { return *m_widget; }
2✔
1272
    std::vector<std::string> const & getAnalogKeys() const { return m_analog_keys; }
2✔
1273
    std::vector<std::string> const & getEventKeys() const { return m_event_keys; }
2✔
1274

1275
private:
1276
    std::unique_ptr<QApplication> m_app;
1277
    std::shared_ptr<DataManager> m_data_manager;
1278
    std::unique_ptr<TimeScrollBar> m_time_scrollbar;
1279
    std::unique_ptr<DataViewer_Widget> m_widget;
1280
    TimeKey m_time_key{"time"};
1281
    std::vector<std::string> m_analog_keys;
1282
    std::vector<std::string> m_event_keys;
1283
};
1284

1285
TEST_CASE_METHOD(DataViewerWidgetMixedStackingTestFixture, "DataViewer_Widget - Mixed stacking for analog + digital events", "[DataViewer_Widget][Mixed][Stacking]") {
1✔
1286
    auto & widget = getWidget();
1✔
1287
    auto const analog = getAnalogKeys();
1✔
1288
    auto const ev = getEventKeys();
1✔
1289
    REQUIRE(analog.size() == 3);
1✔
1290
    REQUIRE(ev.size() == 2);
1✔
1291

1292
    widget.openWidget();
1✔
1293
    QApplication::processEvents();
1✔
1294

1295
    // Enable analog first
1296
    for (auto const & k: analog) {
4✔
1297
        bool const invoked = QMetaObject::invokeMethod(
3✔
1298
                &widget,
1299
                "_addFeatureToModel",
1300
                Qt::DirectConnection,
1301
                Q_ARG(QString, QString::fromStdString(k)),
6✔
1302
                Q_ARG(bool, true));
6✔
1303
        REQUIRE(invoked);
3✔
1304
        QApplication::processEvents();
3✔
1305
    }
1306

1307
    // Then enable events
1308
    for (auto const & k: ev) {
3✔
1309
        bool const invoked = QMetaObject::invokeMethod(
2✔
1310
                &widget,
1311
                "_addFeatureToModel",
1312
                Qt::DirectConnection,
1313
                Q_ARG(QString, QString::fromStdString(k)),
4✔
1314
                Q_ARG(bool, true));
4✔
1315
        REQUIRE(invoked);
2✔
1316
        QApplication::processEvents();
2✔
1317
    }
1318

1319
    // Collect centers and heights across all 5 stackable series
1320
    struct Item {
1321
        float center;
1322
        float height;
1323
        bool is_event;
1324
        std::string key;
1325
    };
1326
    std::vector<Item> items;
1✔
1327
    items.reserve(5);
1✔
1328

1329
    for (auto const & k: analog) {
4✔
1330
        auto c = widget.getAnalogConfig(k);
3✔
1331
        REQUIRE(c.has_value());
3✔
1332
        REQUIRE(c.value()->is_visible);
3✔
1333
        items.push_back(Item{static_cast<float>(c.value()->allocated_y_center), static_cast<float>(c.value()->allocated_height), false, k});
3✔
1334
    }
1335
    for (auto const & k: ev) {
3✔
1336
        auto c = widget.getDigitalEventConfig(k);
2✔
1337
        REQUIRE(c.has_value());
2✔
1338
        REQUIRE(c.value()->is_visible);
2✔
1339
        items.push_back(Item{static_cast<float>(c.value()->allocated_y_center), static_cast<float>(c.value()->allocated_height), true, k});
2✔
1340
    }
1341

1342
    REQUIRE(items.size() == 5);
1✔
1343
    std::sort(items.begin(), items.end(), [](Item const & a, Item const & b) { return a.center < b.center; });
10✔
1344

1345
    // Expect 5 evenly spaced centers across [-1,1]
1346
    size_t const N = items.size();
1✔
1347
    float const tol_center = 0.22f;
1✔
1348
    for (size_t i = 0; i < N; ++i) {
6✔
1349
        float const expected = -1.0f + 2.0f * (static_cast<float>(i + 1) / static_cast<float>(N + 1));
5✔
1350
        REQUIRE(std::abs(items[i].center - expected) <= tol_center);
5✔
1351
    }
1352

1353
    // Heights should be consistent across analog and events when stacked together
1354
    float const expected_height = 2.0f / static_cast<float>(N);
1✔
1355
    float min_h = std::numeric_limits<float>::max();
1✔
1356
    float max_h = 0.0f;
1✔
1357
    for (auto const & it: items) {
6✔
1358
        min_h = std::min(min_h, it.height);
5✔
1359
        max_h = std::max(max_h, it.height);
5✔
1360
        REQUIRE(it.height >= expected_height * 0.4f);
5✔
1361
        REQUIRE(it.height <= expected_height * 1.2f);
5✔
1362
    }
1363
    if (min_h > 0.0f) {
1✔
1364
        REQUIRE((max_h / min_h) <= 1.4f);
1✔
1365
    }
1366

1367
    // Additional safety: effective model height for events must be within lane
1368
    for (auto const & k: ev) {
3✔
1369
        auto cfg = widget.getDigitalEventConfig(k);
2✔
1370
        REQUIRE(cfg.has_value());
2✔
1371
        float const lane = expected_height;
2✔
1372
        float const eff_model_height = std::min(cfg.value()->event_height, cfg.value()->allocated_height) * cfg.value()->margin_factor * cfg.value()->global_vertical_scale;
2✔
1373
        REQUIRE(eff_model_height <= lane * 1.1f);
2✔
1374
        REQUIRE(eff_model_height < 1.8f);// definitely not full canvas
2✔
1375
    }
1376
}
2✔
1377

1378
// -----------------------------------------------------------------------------
1379
// Mode regression test: ensure FullCanvas event uses full height and Stacked stays in lane
1380
// -----------------------------------------------------------------------------
1381
TEST_CASE_METHOD(DataViewerWidgetMixedStackingTestFixture, "DataViewer_Widget - Digital event plotting modes", "[DataViewer_Widget][DigitalEvent][Modes]") {
1✔
1382
    auto & widget = getWidget();
1✔
1383
    auto const analog = getAnalogKeys();
1✔
1384
    auto const ev = getEventKeys();
1✔
1385
    REQUIRE(analog.size() == 3);
1✔
1386
    REQUIRE(ev.size() == 2);
1✔
1387

1388
    widget.openWidget();
1✔
1389
    QApplication::processEvents();
1✔
1390

1391
    // Enable one analog to ensure mixed context
1392
    {
1393
        bool const invoked = QMetaObject::invokeMethod(
1✔
1394
                &widget,
1395
                "_addFeatureToModel",
1396
                Qt::DirectConnection,
1397
                Q_ARG(QString, QString::fromStdString(analog[0])),
2✔
1398
                Q_ARG(bool, true));
2✔
1399
        REQUIRE(invoked);
1✔
1400
        QApplication::processEvents();
1✔
1401
    }
1402

1403
    // Enable two events
1404
    for (auto const & k: ev) {
3✔
1405
        bool const invoked = QMetaObject::invokeMethod(
2✔
1406
                &widget,
1407
                "_addFeatureToModel",
1408
                Qt::DirectConnection,
1409
                Q_ARG(QString, QString::fromStdString(k)),
4✔
1410
                Q_ARG(bool, true));
4✔
1411
        REQUIRE(invoked);
2✔
1412
        QApplication::processEvents();
2✔
1413
    }
1414

1415
    // Force first event to FullCanvas via its display options
1416
    {
1417
        auto cfg0 = widget.getDigitalEventConfig(ev[0]);
1✔
1418
        REQUIRE(cfg0.has_value());
1✔
1419
        cfg0.value()->display_mode = EventDisplayMode::FullCanvas;
1✔
1420
    }
1421
    // Force a redraw so allocation reflects new mode
1422
    {
1423
        auto glw = widget.findChild<OpenGLWidget *>("openGLWidget");
2✔
1424
        REQUIRE(glw != nullptr);
1✔
1425
        glw->updateCanvas();
1✔
1426
        QApplication::processEvents();
1✔
1427
    }
1428

1429
    // Read configs
1430
    auto cfg_full = widget.getDigitalEventConfig(ev[0]);
1✔
1431
    auto cfg_stacked = widget.getDigitalEventConfig(ev[1]);
1✔
1432
    REQUIRE(cfg_full.has_value());
1✔
1433
    REQUIRE(cfg_stacked.has_value());
1✔
1434

1435
    // FullCanvas should be centered and nearly full height
1436
    REQUIRE(std::abs(cfg_full.value()->allocated_y_center - 0.0f) <= 0.25f);
1✔
1437
    REQUIRE(cfg_full.value()->allocated_height >= 1.6f);
1✔
1438
    REQUIRE(cfg_full.value()->allocated_height <= 2.2f);
1✔
1439

1440
    // Stacked should be a lane with significantly smaller height
1441
    REQUIRE(cfg_stacked.value()->allocated_height < cfg_full.value()->allocated_height);
1✔
1442
}
2✔
1443

1444
// -----------------------------------------------------------------------------
1445
// Regression: two stacked digital events should not render near full canvas height
1446
// -----------------------------------------------------------------------------
1447
TEST_CASE_METHOD(DataViewerWidgetMultiEventTestFixture, "DataViewer_Widget - Two stacked digital events height bounded", "[DataViewer_Widget][DigitalEvent][Height]") {
1✔
1448
    auto & widget = getWidget();
1✔
1449
    auto const keys = getEventKeys();
1✔
1450
    REQUIRE(keys.size() >= 2);
1✔
1451

1452
    widget.openWidget();
1✔
1453
    QApplication::processEvents();
1✔
1454

1455
    // Enable two events in stacked mode (default)
1456
    for (size_t i = 0; i < 2; ++i) {
3✔
1457
        bool const invoked = QMetaObject::invokeMethod(
2✔
1458
                &widget,
1459
                "_addFeatureToModel",
1460
                Qt::DirectConnection,
1461
                Q_ARG(QString, QString::fromStdString(keys[i])),
4✔
1462
                Q_ARG(bool, true));
4✔
1463
        REQUIRE(invoked);
2✔
1464
        QApplication::processEvents();
2✔
1465
    }
1466

1467
    size_t const N = 2;
1✔
1468
    float const lane = 2.0f / static_cast<float>(N);
1✔
1469

1470
    for (size_t i = 0; i < 2; ++i) {
3✔
1471
        auto cfg = widget.getDigitalEventConfig(keys[i]);
2✔
1472
        REQUIRE(cfg.has_value());
2✔
1473
        // Effective model height derived from configuration (assumes unit global vertical scale)
1474
        float const eff_model_height = std::min(cfg.value()->event_height, cfg.value()->allocated_height) * cfg.value()->margin_factor * cfg.value()->global_vertical_scale;
2✔
1475
        // Should be substantially smaller than lane height (default event height 0.05 << lane=1.0)
1476
        REQUIRE(eff_model_height <= lane * 0.5f);
2✔
1477
        REQUIRE(eff_model_height > 0.0f);
2✔
1478
    }
1479
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc