File: TestPythonHotPlug.cpp

package info (click to toggle)
camitk 6.0.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 389,496 kB
  • sloc: cpp: 103,476; sh: 2,448; python: 1,618; xml: 984; makefile: 128; perl: 84; sed: 20
file content (572 lines) | stat: -rw-r--r-- 27,334 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
/*****************************************************************************
 * $CAMITK_LICENCE_BEGIN$
 *
 * CamiTK - Computer Assisted Medical Intervention ToolKit
 * (c) 2001-2025 Univ. Grenoble Alpes, CNRS, Grenoble INP - UGA, TIMC, 38000 Grenoble, France
 *
 * Visit http://camitk.imag.fr for more information
 *
 * This file is part of CamiTK.
 *
 * CamiTK is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License version 3
 * only, as published by the Free Software Foundation.
 *
 * CamiTK is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License version 3 for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * version 3 along with CamiTK.  If not, see <http://www.gnu.org/licenses/>.
 *
 * $CAMITK_LICENCE_END$
 ****************************************************************************/

#include <QtTest>

#include <HotPlugActionExtension.h>
#include <HotPlugAction.h>
#include <HotPlugExtensionManager.h>

#include <Application.h>
#include <ExtensionManager.h>
#include <MainWindow.h>
#include <Application.h>
#include <Component.h>
#include <Action.h>
#include <Log.h>

#include <TransformEngine.h>
#include <CamiTKExtensionModel.h>

class TestPythonHotPlug : public QObject {
    Q_OBJECT
public:

    void setArg(int argc, char* argv[]) {
        this->argc = argc;
        this->argv = argv;
    }

private:
    // needs to be static otherwise memory is not kept between tests (needs inline to declare it here as well)
    static inline camitk::Application* camitkApp; // single application used for all the tests
    static inline int argc;
    static inline char** argv;

    QDir tempDir;
    bool allTestPassed;
    QDir currentTestTempDir;
    QString resourceSubdirectory; // resources subdirectory (where the test files for this qtest app are stored)

    /// write a file in currentTestTempDir using the given string
    bool writeScript(QFile& scriptFile, QString sourceCode) {
        // WriteOnly → overwrite automatically
        if (!scriptFile.open(QIODevice::WriteOnly | QIODevice::Text)) {
            qWarning() << "Could not open file for writing:" << scriptFile.errorString();
            return false;
        }
        QTextStream out(&scriptFile);
        out << sourceCode;
        scriptFile.close();
        return true;
    }

    void testUserScript(QString extensionFileBasename, QString sourceCode, bool initShouldBeSuccessful, bool processShouldBeSuccessful = false) {
        QString extensionFilename = extensionFileBasename + ".camitk";
        QString scriptFilename = "test_action.py";

        QString camitkFileFullPath = currentTestTempDir.filePath(extensionFilename);
        QString scriptfilePath = currentTestTempDir.filePath(scriptFilename);
        QFile scriptFile(scriptfilePath);

        // create script if
        if (!sourceCode.isNull()) {
            replacePySideVersionInString(sourceCode);
            QVERIFY(writeScript(scriptFile, sourceCode));
        }

        camitk::HotPlugActionExtension* actionExtension = nullptr;

        actionExtension = camitk::HotPlugExtensionManager::load(camitkFileFullPath);

        QCOMPARE(actionExtension != nullptr, initShouldBeSuccessful);
        if (actionExtension != nullptr) {
            QCOMPARE(actionExtension->getActions().size(), 1);
            camitk::HotPlugAction* hotPlugAction = dynamic_cast<camitk::HotPlugAction*>(actionExtension->getActions().at(0));
            QVERIFY(hotPlugAction != nullptr);
            QVERIFY(hotPlugAction->update());
            QCOMPARE(hotPlugAction->apply() == camitk::Action::SUCCESS, processShouldBeSuccessful);

            //-- unload (this will delete actionExtension)
            QVERIFY(camitk::HotPlugExtensionManager::unload(camitkFileFullPath));
        }

        // check the final state
        QCOMPARE(camitk::Application::getActions().size(), 0);
        QCOMPARE(camitk::HotPlugExtensionManager::getLoadedExtensions().size(), 0);

        // should return false as ExtensionManager does not manage any .camitk extension
        QCOMPARE(camitk::ExtensionManager::unloadActionExtension(camitkFileFullPath), false);
        QCOMPARE(camitk::ExtensionManager::getActionExtensionsList().size(), 0);

        if (!sourceCode.isNull()) {
            QVERIFY(scriptFile.remove());
        }
    }

    /// replace PySideX in String depending on the current Qt Version
    // FIXME Remove this when all supported OS are using Qt6
    void replacePySideVersionInString(QString& sourceString) {
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
        const QString pysideVersion = "PySide6";
#else
        const QString pysideVersion = "PySide2";
#endif
        sourceString.replace("PySideX", pysideVersion);
    }

    /// replace PySideX in String depending on the current Qt Version
    // FIXME Remove this when all supported OS are using Qt6
    void replacePySideVersionInFile(QString sourceFilePath) {
        QFile file(sourceFilePath);
        QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text), "Failed to open file for reading");
        QString content = QTextStream(&file).readAll();
        file.close();

        int numberOfPySideRef = content.count("PySideX");
        if (numberOfPySideRef > 0) {
            replacePySideVersionInString(content);

            // ensure writability
            QVERIFY(file.setPermissions(file.permissions() | QFile::WriteUser | QFile::WriteOwner));
            QVERIFY(QFileInfo(file).isWritable());

            if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
                QFAIL(qPrintable(QString("open WriteOnly file %1 (to replace %2 PySideX occurence) failed: %3; error=%4; perms=%5")
                                 .arg(sourceFilePath)
                                 .arg(numberOfPySideRef)
                                 .arg(file.errorString())
                                 .arg(int(file.error()))
                                 .arg(int(QFileInfo(sourceFilePath).permissions()))));
            }

            QTextStream(&file) << content;
            file.close();

            // Verify the replacement worked
            QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text), "Failed to open file for verification");
            content = QTextStream(&file).readAll();
            file.close();

#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
            const QString pysideVersion = "PySide6";
#else
            const QString pysideVersion = "PySide2";
#endif
            QCOMPARE(content.count(pysideVersion), numberOfPySideRef);
        }
        QVERIFY(!content.contains("PySideX"));
    }

    /// copy the .camitk extension file to the current test temp directory
    void copyExtensionFile(QString resourcePrefix) {
        QString extensionFilePath = ":/" + resourcePrefix + "/extensionfile";
        QFile extensionFile(extensionFilePath);
        QVERIFY(extensionFile.exists());
        // copy the extension file to the current test directory
        QString camitkFileFullPath = currentTestTempDir.filePath(resourcePrefix + ".camitk");
        CAMITK_INFO_ALT("Copying '" + extensionFilePath + "' to '" + camitkFileFullPath + "'")
        QVERIFY(extensionFile.copy(camitkFileFullPath));
        replacePySideVersionInFile(camitkFileFullPath);
        QCOMPARE(currentTestTempDir.count() - 2, 1); // n-2 files counting for . and .. compare to 1 extension file
    }

    /// setup a directory with all the files declared under the given resource prefix.
    /// @param resourcePrefix is a prefix defined in the .qrc file
    /// @param durationBetweenActionApplyCalls if not 0, wait for the given duration in ms between two action->apply() call
    ///        (this is useful if the script action has a timer, so that it leaves the timer enough time to starts and ticks
    ///         e.g. for the timer state test. It uses QTest::qWait(), to wait for durationBetweenActionApplyCalls msecs.
    ///         While waiting, events will be processed and your test will stay responsive to user interface events or
    ///         network communication.)
    void testPythonHotPlug(QString resourcePrefix, int durationBetweenActionApplyCalls = 0, int initialActionCount = 0, int initialActionExtensionCount = 0) {
        copyExtensionFile(resourcePrefix);
        QString camitkFileFullPath = currentTestTempDir.filePath(resourcePrefix + ".camitk");

        // copy all python action files to the current test directory
        QString fullPrefix = ":/" + resourcePrefix + resourceSubdirectory + "/";
        QStringList pythonFiles = QDir(fullPrefix).entryList();
        QVERIFY(pythonFiles.size() >= 1); // at least one .py action script
        CAMITK_INFO_ALT("Found " + QString::number(pythonFiles.size()) + " action files:\n- " + pythonFiles.join("\n- "))

        for (QString pythonFile : pythonFiles) {
            QString pythonFilePath = currentTestTempDir.filePath(pythonFile);
            CAMITK_INFO_ALT("Copying '" + fullPrefix + pythonFile + "' to '" + pythonFilePath + "'")
            QVERIFY(QFile::copy(fullPrefix + pythonFile, pythonFilePath));
            replacePySideVersionInFile(pythonFilePath);
        }

        currentTestTempDir.refresh(); // WARNING: this is REALLY required otherwise the count is not updated
        QCOMPARE(currentTestTempDir.count() - 2, pythonFiles.size() + 1); // n-2 files counting for . and .. compare to all python files + extension file

        // check the initial state
        QCOMPARE(camitk::HotPlugExtensionManager::getLoadedExtensions().size(), 0);
        QCOMPARE(camitk::Application::getActions().size(), initialActionCount);

        //-- load (create the instances and check requirements)
        // instantiate all actions and call init
        camitk::HotPlugActionExtension* actionExtension = nullptr;
        actionExtension = camitk::HotPlugExtensionManager::load(camitkFileFullPath);
        QVERIFY(actionExtension != nullptr);

        QVERIFY(actionExtension->getName() != "");
        QVERIFY(actionExtension->getDescription() != "");
        QCOMPARE(actionExtension->getLocation(), camitkFileFullPath);

        // test without the application
        QVERIFY(actionExtension->getActions().size() > 0);
        for (auto a : actionExtension->getActions()) {
            camitk::HotPlugAction* hotPlugAction = dynamic_cast<camitk::HotPlugAction*>(a);
            QVERIFY(hotPlugAction != nullptr);
            QVERIFY(hotPlugAction->update());
        }

        // compare instantiated actions in the action extension with the expected values from the data model
        TransformEngine transformEngine;
        CamiTKExtensionModel camitkExtensionModel(camitkFileFullPath);
        VariantDataModel& dataModel = camitkExtensionModel.getModel();
        QString extensionName = transformEngine.transformToString("$title(name)$", QJsonObject::fromVariantMap(dataModel.getValue().toMap()));
        QCOMPARE(actionExtension->getName(), extensionName);
        QCOMPARE(actionExtension->getDescription(), dataModel["description"].toString());
        QCOMPARE(actionExtension->getActions().size(), dataModel["actions"].size());
        for (int i = 0; i < dataModel["actions"].size(); i++) {
            VariantDataModel& actionDataModel = dataModel["actions"][i];
            QString actionName = transformEngine.transformToString("$title(name)$", QJsonObject::fromVariantMap(actionDataModel.getValue().toMap()));
            camitk::Action* a = actionExtension->getActions().at(i);
            QVERIFY(a != nullptr);
            QCOMPARE(a->getDescription(), actionDataModel["description"].toString());
            QCOMPARE(a->getComponentClassName(), actionDataModel["componentClass"].toString());
            QCOMPARE(a->getFamily(), actionDataModel["classification"]["family"].toString());
            QVERIFY(a->getWidget() != nullptr);
            // the python action should have at least opened one component of the proper type for a
            // use it to set the current action targets (if the action requires one)
            // there should be at least one of the required type
            if (!a->getComponentClassName().isEmpty()) {
                camitk::ComponentList allTopLevels = camitk::Application::getTopLevelComponents();
                QVERIFY(allTopLevels.size() > 0);
                camitk::ComponentList potentialTargets;
                for (camitk::Component* c : allTopLevels) {
                    if (c->getHierarchy().contains(a->getComponentClassName())) {
                        potentialTargets.append(c);
                    }
                }
                a->setInputComponents(potentialTargets);
                QVERIFY(a->getTargets().size() > 0);
            }
            // run the action
            QCOMPARE(a->apply(), camitk::Action::SUCCESS);

            if (durationBetweenActionApplyCalls > 0) {
                // Simulate time: wait the given amount of ms and process events (if the action has timers)
                QTest::qWait(durationBetweenActionApplyCalls); // waits and runs event loop
            }
        }

        // compare instantiated actions registered in the Application with the expected values from the data model
        QCOMPARE(camitk::Application::getActions().size(), initialActionCount + dataModel["actions"].size());
        for (int i = 0; i < dataModel["actions"].size(); i++) {
            VariantDataModel& actionDataModel = dataModel["actions"][i];
            QString actionName = transformEngine.transformToString("$title(name)$", QJsonObject::fromVariantMap(actionDataModel.getValue().toMap()));
            camitk::Action* a = camitk::Application::getAction(actionName);
            QVERIFY(a != nullptr);
            QCOMPARE(a->getDescription(), actionDataModel["description"].toString());
            QCOMPARE(a->getComponentClassName(), actionDataModel["componentClass"].toString());
            QCOMPARE(a->getFamily(), actionDataModel["classification"]["family"].toString());
            // run the action again
            QCOMPARE(a->apply(), camitk::Action::SUCCESS);
        }

        //-- unload (this will delete actionExtension)
        QVERIFY(camitk::HotPlugExtensionManager::unload(camitkFileFullPath));

        // check the final state
        QCOMPARE(camitk::Application::getActions().size(), initialActionCount);

        QCOMPARE(camitk::HotPlugExtensionManager::getLoadedExtensions().size(), 0);

        // should return false as ExtensionManager does not manage any .camitk extension
        QCOMPARE(camitk::ExtensionManager::unloadActionExtension(camitkFileFullPath), false);
        QCOMPARE(camitk::ExtensionManager::getActionExtensionsList().size(), initialActionExtensionCount);
    }

private slots:

    // called once before any tests are run.
    void initTestCase() {
        // the resource subdirectory corresponding to this this qtest app (see also corresponding .qrc)
        resourceSubdirectory = "/pythonhotplug";
        allTestPassed = false;

        // Ensure all log messages are visible in the standard output
        camitk::Log::getLogger()->setLogLevel(camitk::InterfaceLogger::TRACE);
        camitk::Log::getLogger()->setMessageBoxLevel(camitk::InterfaceLogger::NONE);
        // no time stamp for reproducible log diff
        camitk::Log::getLogger()->setTimeStampInformation(false);

        // Create a new default application+main window, without loading any extension, no console redirection
        camitkApp = new camitk::Application("TestPythonHotPlug", argc, argv, false, false);
        camitk::MainWindow* defaultMainWindow = camitkApp->getMainWindow();
        defaultMainWindow->redirectToConsole(false);

        // now load only the component and viewer extensions (no action)
        camitk::ExtensionManager::autoload(camitk::ExtensionManager::COMPONENT);
        camitk::ExtensionManager::autoload(camitk::ExtensionManager::VIEWER);

        defaultMainWindow->show();
        QVERIFY(QTest::qWaitForWindowExposed(defaultMainWindow));

        // minimize window to avoid disturbance when the test is done by a dev, not on CI
        defaultMainWindow->setWindowState(Qt::WindowMinimized);

        // Simulate time: wait 500ms and process events (if the action has timers)
        QTest::qWait(500); // waits and runs event loop

        // create temporary location for all the tests
        QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/CamiTKExtensionCheck_" + QDateTime::currentDateTime().toString("yyyyMMddHHmmss");
        QVERIFY(QDir().mkpath(tempPath));
        tempDir.setPath(tempPath);

        // init global test status
        allTestPassed = true;
    }

    // called once after all tests have been run.
    void cleanupTestCase() {
        if (allTestPassed) {
            qDebug().noquote().nospace() << "Removing test application temp directory recursively: " << tempDir.absolutePath();
            tempDir.removeRecursively();
        }
        else {
            qDebug().noquote().nospace() << "Temporary test application directory not removed: " << tempDir.path();
        }
    }

    // called before each test function
    void init() {
        // Create temporary dir just for the current test using the current test name
        QString tempPath = tempDir.filePath(QTest::currentTestFunction());
        QDir dir;
        QVERIFY(dir.mkpath(tempPath));

        QFileInfo dirInfo(tempPath);
        QVERIFY(dirInfo.isDir());
        QVERIFY(dirInfo.isWritable());

        // QFile tempDir = QFile(tempPath);
        // QFile::Permissions originalPermissions = tempDir.permissions();
        // QFile::Permissions writablePermissions = originalPermissions | QFile::WriteOwner;
        // QVERIFY2(QFile::setPermissions(tempPath, writablePermissions),
        //          qPrintable(QString("Could not grant write permission to temp directory: %1").arg(tempPath)));
        
        // QVERIFY2(tempDir.exists() && tempDir.isWritable(), "Failed to make directory writable");

        currentTestTempDir.setPath(tempPath);
    }

    // called after each test function.
    void cleanup() {
        if (!QTest::currentTestFailed()) {
            qDebug().noquote().nospace() << "Removing current test temp directory recursively: " << currentTestTempDir.absolutePath();
            currentTestTempDir.removeRecursively();
        }
        else {
            allTestPassed = false;
            qDebug().noquote().nospace() << "Temporary directory not removed: " << currentTestTempDir.path();
        }
    }

    void image() {
        testPythonHotPlug("image");
    }

    void mesh() {
        testPythonHotPlug("mesh");
    }

    void state() {
        testPythonHotPlug("state", 2000);
        // Note: 500ms (default in the actions that uses timers)
        // are not enough to make sure a QTimer inside python has time to tick at least once
        // Benchmark using:
        // for i in {1..50}; do echo "======== $i in $(pwd) ========"; ctest -VVV -R pythonhotplug-state >> /tmp/ctest-log-500ms; done
        // shows that when durationBetweenActionApplyCalls is set to 500ms, under CPU high loads, about 20%
        // of the time the timer has not started ticking inside the 500ms
        // → use 2000ms even if this means slower test time to ensure at least one tick is performed
    }

    void qt() {
        // test qt application is visible from python
        copyExtensionFile("scriptQtTest");

        CAMITK_INFO(QString("Qt version (C++): %1").arg(qVersion()));
        CAMITK_INFO("Prefix (C++): " + QLibraryInfo::location(QLibraryInfo::PrefixPath));
        CAMITK_INFO("Plugins (C++): " + QLibraryInfo::location(QLibraryInfo::PluginsPath));

        // Check that Python is seeing the QApplication created by C++"
        testUserScript("scriptQtTest", R"python(import camitk
def init(self:camitk.Action):
    from PySideX.QtCore import QLibraryInfo, qVersion
    print("Qt version (Python):", qVersion())
    print("Prefix (Python):", QLibraryInfo.location(QLibraryInfo.PrefixPath))
    print("Plugins (Python):", QLibraryInfo.location(QLibraryInfo.PluginsPath))

    from PySideX.QtWidgets import QApplication
    print(QApplication.instance())
    return QApplication.instance() is not None
)python", true);
    }

    void userScriptValidity() {
        // A script is valid if
        // - it python file exists
        // - it does have no syntax error
        // - it does not have any other python error (NameError etc...)
        // - the process() method exists and is callable
        // - if init() or process() returns a value that can be cast to a boolean, 
        //   this boolean must be true
        copyExtensionFile("scriptTest");        

        CAMITK_INFO_ALT("=== Test 1 === Should generate a CamiTK warning: `Python script '.../userScript/test_action.py' not found")
        testUserScript("scriptTest", QString(), false);

        CAMITK_INFO_ALT("=== Test 2 === Should generate a python interpreter exception `SyntaxError: invalid syntax (test_action.py, line 3)`")
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    syntax_error:
)python", false);

        CAMITK_INFO_ALT("=== Test 3 === Should generate a python interpreter exception `NameError: name 'false' is not defined`")
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    return false
)python", false);

        CAMITK_INFO_ALT("=== Test 4 === Should generate a python interpreter exception `NameError: name 'x' is not defined`")
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    print(f"{x}")
)python", false);

        CAMITK_INFO_ALT("=== Test 5 === Should generate no interpreter exception, but the method returns bad status and generates a CamiTK warning `Error during 'init()'`")
        // When the python script runs into an error, the python code can report it by returning False
        // A False returned value means there was an error, and the application should report it as a warning.
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    return False
        )python", false);

        CAMITK_INFO_ALT("=== Test 6 === Initialization ok: No init() method → no error; but generates a `missing function 'process(self)' error` when apply() is called")
        testUserScript("scriptTest", R"python(import camitk
)python", true, false);

        CAMITK_INFO_ALT("=== Test 7 === Initialization ok: no value return, no check of the boolean status; but generates a `missing function 'process(self)' error` when apply() is called")
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    pass
        )python", true, false);

        CAMITK_INFO_ALT("=== Test 8 === Initialization ok: there is a proper bool return value and it is True; but generates a `missing function 'process(self)' error` when apply() is called")
        testUserScript("scriptTest", R"python(import camitk
def init(self:camitk.Action):
    return True
        )python", true, false);

        CAMITK_INFO_ALT("=== Test 9 === Initialization ok: no method; process ok: no value return, no check of the boolean status")
        testUserScript("scriptTest", R"python(import camitk
def process(self:camitk.Action):
    pass
        )python", true, true);

        CAMITK_INFO_ALT("=== Test 11 === Initialization ok: no method; but generates a CamiTK error `Error during call to method process() of action 'Test Action'` when apply() is called as the method returns False")
        testUserScript("scriptTest", R"python(import camitk
def process(self:camitk.Action):
    return False
        )python", true, false);

        CAMITK_INFO_ALT("=== Test 12 === Initialization ok: no method; process ok: there is a proper bool return value and it is True")
        testUserScript("scriptTest", R"python(import camitk
def process(self:camitk.Action):
    return True
        )python", true, true);

        CAMITK_INFO_ALT("=== Test 13 === Initialization ok: no method; but generates a CamiTK warning `Python script '.../userScriptValidity/test_action.py' of action 'Test Action': 'process' is not a function` as process is a symbol but not callable.")
        testUserScript("scriptTest", R"python(import camitk
process = True
        )python", true, false);
    }

    void pipelineAndTransformation() {
        testPythonHotPlug("pipelineAndTransformation");
    }

    void meshPoints() {
        // use 2s of waiting before moving to the next action, to make sure the timer
        // as at least one tick
        testPythonHotPlug("meshPoints", 2000);
    }

    void transformationManager() {
        testPythonHotPlug("transformationManager");
    }

    void actionPipeline() {
        // the pipeline action requires three actions:
        // - "Threshold (VTK)"
        // - "Reconstruction"
        // - "Mesh Projection"
        // → use autoload instead of looking for the proper extension dll/so/dynlib to load
        camitk::ExtensionManager::autoload(camitk::ExtensionManager::ACTION);
        // the initialActionCount and initialActionExtensionCount must be provided as they are not the default 0
        testPythonHotPlug("actionPipeline", 0, camitk::Application::getActions().size(), camitk::ExtensionManager::getActionExtensionsList().size());
    }
    // NOTE: DO NOT FORGET TO ADD ANY NEW TEST IN THE MAIN (see below)
};

int main(int argc, char* argv[]) {
    // Hook to satisfy the CI QtTestDiscoveryTest cmake macro
    //
    // Analysis:
    // As TestPythonHotplug needs to instantiate a CamiTK Application (which inherits QApplication)
    // it cannot use the QTEST_MAIN(TestPythonHotPlug) macro (as it creates a QApplication and there
    // only can be one, qapp is a singleton).
    //
    // Workaround:
    // This main() simulates the QTEST_MAIN during CMake configure test discovery.
    // This main() returns the lists of tests as expected from a classic QTEST_MAIN if the argument is
    // -datatags  (as it is done by QtTestDiscoveryTest during CMake Configuration).
    //
    // Consequence:
    // -> Each new test has to be added manually to be listed by CMake

    if (QString(argv[1]) == "-datatags") {
        std::cout << "TestPythonHotPlug image" << std::endl;
        std::cout << "TestPythonHotPlug mesh" << std::endl;
        std::cout << "TestPythonHotPlug state" << std::endl;
        std::cout << "TestPythonHotPlug qt" << std::endl;
        std::cout << "TestPythonHotPlug userScriptValidity" << std::endl;
        std::cout << "TestPythonHotPlug meshPoints" << std::endl;
        std::cout << "TestPythonHotPlug transformationManager" << std::endl;
        std::cout << "TestPythonHotPlug actionPipeline" << std::endl;
        return 0;
    }
    else {
        // Real tests when launched
        TestPythonHotPlug testPythonApp;
        testPythonApp.setArg(argc, argv);

        return QTest::qExec(&testPythonApp, argc, argv);
    }
}

#include "TestPythonHotPlug.moc"