diff options
author | Brett Stottlemyer <bstottle@ford.com> | 2024-12-18 10:33:56 -0500 |
---|---|---|
committer | Friedemann Kleint <Friedemann.Kleint@qt.io> | 2025-03-13 16:28:42 +0100 |
commit | 19abd816e73bebdd489408d0a3b7676822bff39c (patch) | |
tree | 8459ae9401f5e190995b3e24b6ae6968cf457baf /sources/pyside6/tests | |
parent | 3c66c456aeab597b7cb046f81c7f015433bb57a4 (diff) |
Make Remote Objects usable beyond Models
While present, the Qt Remote Objects bindings to Python have not been
very useful. The only usable components were those based on
QAbstractItemModel, due to the lack of a way to interpret .rep files
from Python. This addresses that limitation.
Fixes: PYSIDE-862
Change-Id: Ice57c0c64f11c3c7e74d50ce3c48617bd9b422a3
Reviewed-by: Friedemann Kleint <Friedemann.Kleint@qt.io>
Reviewed-by: Brett Stottlemyer <brett.stottlemyer@gmail.com>
Diffstat (limited to 'sources/pyside6/tests')
9 files changed, 1016 insertions, 1 deletions
diff --git a/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt b/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt index 2f7cb08b9..ace1a00fa 100644 --- a/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt +++ b/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt @@ -1 +1,11 @@ -# Please add some tests, here +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: BSD-3-Clause + +# FIXME: TypeError: Failed to generate default value. Error: name 'int' is not defined. Problematic code: int(2) +if(NOT APPLE) +PYSIDE_TEST(repfile_test.py) +PYSIDE_TEST(dynamic_types_test.py) +PYSIDE_TEST(integration_test.py) + +add_subdirectory(cpp_interop) +endif() diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt new file mode 100644 index 000000000..407a8f874 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt @@ -0,0 +1,25 @@ +# Copyright (C) 2024 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +find_package(Qt6 REQUIRED COMPONENTS Core RemoteObjects) + +add_executable(cpp_interop ${MOC_SOURCES} cpp_interop.cpp) +set_target_properties(cpp_interop PROPERTIES AUTOMOC ON) + +target_link_libraries(cpp_interop PUBLIC + Qt6::Core + Qt6::RemoteObjects +) + +# Add a custom target to build the C++ program +add_custom_target(build_cpp_interop + COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR} --target cpp_interop + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) + +# Exclude windows (see cpp_interop.cpp) +if(NOT WIN32) + PYSIDE_TEST(cpp_interop_test.py) +endif()
\ No newline at end of file diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp new file mode 100644 index 000000000..6aeef91dd --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp @@ -0,0 +1,127 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include <QtCore/qcoreapplication.h> +#include <QtCore/qsocketnotifier.h> +#include <QtCore/qtimer.h> + +#include <QtRemoteObjects/qremoteobjectreplica.h> +#include <QtRemoteObjects/qremoteobjectnode.h> + +#ifdef Q_OS_WIN +# include <QtCore/qt_windows.h> +# include <QtCore/qwineventnotifier.h> +#endif // Q_OS_WIN + +#include <iostream> + +using namespace Qt::StringLiterals; + +class CommandReader : public QObject +{ + Q_OBJECT +public: + explicit CommandReader(QObject *parent = nullptr) : QObject(parent) + { +#ifndef Q_OS_WIN + auto *notifier = new QSocketNotifier(fileno(stdin), QSocketNotifier::Read, this); + connect(notifier, &QSocketNotifier::activated, this, &CommandReader::handleInput); +#else + // FIXME: Does not work, signals triggers too often, the app is stuck in getline() + auto notifier = new QWinEventNotifier(GetStdHandle(STD_INPUT_HANDLE), this); + connect(notifier, &QWinEventNotifier::activated, this, &CommandReader::handleInput); +#endif + } + +signals: + void started(); + +private slots: + void handleInput() + { + std::string line; + if (!std::getline(std::cin, line)) + return; + + if (line == "quit") { + std::cerr << "harness: Received quit. Stopping harness event loop.\n"; + QCoreApplication::quit(); + } else if (line == "start") { + std::cerr << "harness: Received start. Initializing harness nodes.\n"; + emit started(); + } else { + std::cerr << "harness: Unknown command \"" << line << "\"\n"; + } + } +}; + +class Runner : public QObject +{ + Q_OBJECT +public: + Runner(const QUrl &url, const QString &repName, QObject *parent = nullptr) + : QObject(parent) + , m_url(url) + , m_repName(repName) + { + m_host.setObjectName("cpp_host"); + if (!m_host.setHostUrl(QUrl("tcp://127.0.0.1:0"_L1))) { + qWarning() << "harness: setHostUrl failed: " << m_host.lastError() << m_host.hostUrl(); + std::cerr << "harness: Fatal harness error.\n"; + QCoreApplication::exit(-2); + } + + m_node.setObjectName("cpp_node"); + std::cerr << "harness: Host url:" << m_host.hostUrl().toEncoded().constData() << '\n'; + } + +public slots: + void onStart() + { + m_node.connectToNode(m_url); + m_replica.reset(m_node.acquireDynamic(m_repName)); + if (!m_replica->waitForSource(1000)) { + std::cerr << "harness: Failed to acquire replica.\n"; + QCoreApplication::exit(-1); + } + + m_host.enableRemoting(m_replica.get()); + } + +private: + QUrl m_url; + QString m_repName; + QRemoteObjectHost m_host; + QRemoteObjectNode m_node; + std::unique_ptr<QRemoteObjectDynamicReplica> m_replica; +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication a(argc, argv); + if (argc < 3) { + std::cerr << "Usage: " << argv[0] << " <url> <name of type>\n"; + return -1; + } + QUrl url = QUrl::fromUserInput(QString::fromUtf8(argv[1])); + QString repName = QString::fromUtf8(argv[2]); + + if (!url.isValid()) { + std::cerr << "Invalid URL: " << argv[1] << '\n'; + return -1; + } + + CommandReader reader; + Runner runner(url, repName); + + + QRemoteObjectNode node; + node.setObjectName("cpp_node"); + std::unique_ptr<QRemoteObjectDynamicReplica> replica; + + QObject::connect(&reader, &CommandReader::started, &runner, &Runner::onStart); + + return QCoreApplication::exec(); +} + +#include "cpp_interop.moc" diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py new file mode 100644 index 000000000..d9ab60c23 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py @@ -0,0 +1,189 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Verify Python <--> C++ interop''' + +import os +import sys +import textwrap + +import unittest + +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[2])) # For init_paths +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtCore import QUrl, QProcess, QObject, Signal +from PySide6.QtRemoteObjects import (QRemoteObjectHost, QRemoteObjectNode, QRemoteObjectReplica, + RepFile) +from PySide6.QtTest import QSignalSpy, QTest + +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) # For wrap_tests_for_cleanup +from test_shared import wrap_tests_for_cleanup +from helper.usesqapplication import UsesQApplication + + +""" +The previous tests all verify Remote Objects integration, but only +using Python for both Source and Replica. We need to make sure there +aren't any surprises in the interplay between Python and C++. + +This implements an initial test harness with a C++ app that is +started by the Python unittest. We leverage the fact that Remote +Objects can +1) Allow remoting any QObject as a Source with enableRemoting +2) Acquire Dynamic Replicas, where the definition needed for the + Replica is sent from the source. + +With these, we can create a working C++ app that doesn't need to be +compiled with any information about the types being used. We have +a host node in Python that shares a class derived from a RepFile +Source type. The address of this node is passed to the C++ app via +QProcess, and there a C++ node connects to that address to acquire +(dynamically) a replica of the desired object. + +The C++ code also creates a host node and sends the address/port +back to Python via the QProcess interface. Once the Python code +receives the C++ side address and port, it connects a node to that +URL and acquires the RepFile based type from Python. + +Python C++ +Host -----> Node (Dynamic acquire) + | + | Once initialized, the dynamic replica is + | shared (enable_remoting) from the C++ Host + | +Node <----- Host +""" + + +def msg_cannot_start(process, executable): + return ('Cannot start "' + executable + '" in "' + + os.fspath(Path.cwd()) + '": ' + process.errorString()) + + +def stop_process(process): + result = process.waitForFinished(2000) + if not result: + process.kill() + result = process.waitForFinished(2000) + return result + + +class Controller(QObject): + ready = Signal() + + def __init__(self, utest: unittest.TestCase): + super().__init__() + # Store utest so we can make assertions + self.utest = utest + + # Set up nodes + self.host = QRemoteObjectHost() + self.host.setObjectName("py_host") + self.host.setHostUrl(QUrl("tcp://127.0.0.1:0")) + self.cpp_url = None + self.node = QRemoteObjectNode() + self.node.setObjectName("py_node") + self._executable = "cpp_interop.exe" if os.name == "nt" else "./cpp_interop" + + def start(self): + # Start the C++ application + self.process = QProcess() + self.process.readyReadStandardOutput.connect(self.process_harness_output) + self.process.readyReadStandardError.connect(self.process_harness_output) + urls = self.host.hostUrl().toDisplayString() + print(f'Starting C++ application "{self._executable}" "{urls}"', file=sys.stderr) + self.process.start(self._executable, [self.host.hostUrl().toDisplayString(), "Simple"]) + self.utest.assertTrue(self.process.waitForStarted(2000), + msg_cannot_start(self.process, self._executable)) + + # Wait for the C++ application to output the host url + spy = QSignalSpy(self.ready) + self.utest.assertTrue(spy.wait(1000)) + self.utest.assertTrue(self.cpp_url.isValid()) + + self.utest.assertTrue(self.node.connectToNode(self.cpp_url)) + return True + + def stop(self): + if self.process.state() == QProcess.ProcessState.Running: + print(f'Stopping C++ application "{self._executable}" {self.process.processId()}', + file=sys.stderr) + self.process.write("quit\n".encode()) + self.process.closeWriteChannel() + self.utest.assertTrue(stop_process(self.process)) + self.utest.assertEqual(self.process.exitStatus(), QProcess.ExitStatus.NormalExit) + + def add_source(self, Source, Replica): + """ + Source and Replica are types. + + Replica is from the rep file + Source is a class derived from the rep file's Source type + """ + self.process.write("start\n".encode()) + source = Source() + self.host.enableRemoting(source) + replica = self.node.acquire(Replica) + self.utest.assertTrue(replica.waitForSource(5000)) + self.utest.assertEqual(replica.state(), QRemoteObjectReplica.State.Valid) + return source, replica + + def process_harness_output(self): + '''Process stderr from the C++ application''' + output = self.process.readAllStandardError().trimmed() + lines = output.data().decode().split("\n") + HOST_LINE = "harness: Host url:" + for line in lines: + print(line, file=sys.stderr) + if line.startswith(HOST_LINE): + urls = line[len(HOST_LINE):].strip() + print(f'url="{urls}"', file=sys.stderr) + self.cpp_url = QUrl(urls) + self.ready.emit() + + +class HarnessTest(UsesQApplication): + def setUp(self): + super().setUp() + self.rep = RepFile(self.__class__.contents) + self.controller = Controller(self) + self.assertTrue(self.controller.start()) + + def tearDown(self): + self.controller.stop() + self.app.processEvents() + super().tearDown() + QTest.qWait(100) # Wait for 100 msec + + +@wrap_tests_for_cleanup(extra=['rep']) +class TestBasics(HarnessTest): + contents = textwrap.dedent("""\ + class Simple + { + PROP(int i = 2); + PROP(float f = -1. READWRITE); + } + """) + + def compare_properties(self, instance, values): + '''Compare properties of instance with values''' + self.assertEqual(instance.i, values[0]) + self.assertAlmostEqual(instance.f, values[1], places=5) + + def testInitialization(self): + '''Test constructing RepFile from a path string''' + class Source(self.rep.source["Simple"]): + pass + source, replica = self.controller.add_source(Source, self.rep.replica["Simple"]) + self.compare_properties(source, [2, -1]) + self.compare_properties(replica, [2, -1]) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py b/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py new file mode 100644 index 000000000..5fb828cd2 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for dynamic source/replica types''' + +import os +import sys +import unittest +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtRemoteObjects import RepFile + +from test_shared import wrap_tests_for_cleanup + + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; +""" + + +@wrap_tests_for_cleanup(extra=['rep_file']) +class QDynamicReplicas(unittest.TestCase): + '''Test case for dynamic Replicas''' + + def setUp(self): + '''Set up test environment''' + self.rep_file = RepFile(contents) + + def testDynamicReplica(self): + '''Verify that a valid Replica is created''' + Replica = self.rep_file.replica["Simple"] + self.assertIsNotNone(Replica) + replica = Replica() + self.assertIsNotNone(replica) + self.assertIsNotNone(replica.metaObject()) + meta = replica.metaObject() + self.assertEqual(meta.className(), "Simple") + self.assertEqual(meta.superClass().className(), "QRemoteObjectReplica") + i = meta.indexOfProperty("i") + self.assertNotEqual(i, -1) + self.assertEqual(replica.propAsVariant(0), int(2)) + self.assertEqual(replica.propAsVariant(1), float(-1.0)) + self.assertEqual(replica.i, int(2)) + self.assertEqual(replica.f, float(-1.0)) + + +@wrap_tests_for_cleanup(extra=['rep_file']) +class QDynamicSources(unittest.TestCase): + '''Test case for dynamic Sources''' + + def setUp(self): + '''Set up test environment''' + self.rep_file = RepFile(contents) + self.test_val = 0 + + def on_changed(self, val): + self.test_val = val + + def testDynamicSource(self): + '''Verify that a valid Source is created''' + Source = self.rep_file.source["Simple"] + self.assertIsNotNone(Source) + source = Source() + self.assertIsNotNone(source) + self.assertIsNotNone(source.metaObject()) + meta = source.metaObject() + self.assertEqual(meta.className(), "SimpleSource") + self.assertEqual(meta.superClass().className(), "QObject") + i = meta.indexOfProperty("i") + self.assertNotEqual(i, -1) + self.assertIsNotNone(source.__dict__.get('__PROPERTIES__')) + self.assertEqual(source.i, int(2)) + self.assertEqual(source.f, float(-1.0)) + source.iChanged.connect(self.on_changed) + source.fChanged.connect(self.on_changed) + source.i = 7 + self.assertEqual(source.i, int(7)) + self.assertEqual(self.test_val, int(7)) + source.i = 3 + self.assertEqual(self.test_val, int(3)) + source.f = 3.14 + self.assertAlmostEqual(self.test_val, float(3.14), places=5) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/integration_test.py b/sources/pyside6/tests/QtRemoteObjects/integration_test.py new file mode 100644 index 000000000..69b4930da --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/integration_test.py @@ -0,0 +1,369 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for basic Source/Replica communication''' + +import os +import sys +import textwrap +import enum +import gc + +import unittest + +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtCore import QUrl, qWarning +from PySide6.QtRemoteObjects import (QRemoteObjectHost, QRemoteObjectNode, QRemoteObjectReplica, + QRemoteObjectPendingCall, RepFile, getCapsuleCount) +from PySide6.QtTest import QSignalSpy, QTest + +from test_shared import wrap_tests_for_cleanup +from helper.usesqapplication import UsesQApplication + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); + SLOT(int add(int i)); +}; +""" + + +class QBasicTest(UsesQApplication): + '''Test case for basic source/replica communication''' + def setUp(self): + # Separate output to make debugging easier + qWarning(f"\nSet up {self.__class__.__qualname__}") + super().setUp() + '''Set up test environment''' + if hasattr(self.__class__, "contents"): + qWarning(f"Using class contents >{self.__class__.contents}<") + self.rep = RepFile(self.__class__.contents) + else: + self.rep = RepFile(contents) + self.host = QRemoteObjectHost(QUrl("tcp://127.0.0.1:0")) + self.host.setObjectName("host") + self.node = QRemoteObjectNode() + self.node.setObjectName("node") + self.node.connectToNode(self.host.hostUrl()) # pick up the url with the assigned port + + def compare_properties(self, instance, values): + '''Compare properties of instance with values''' + self.assertEqual(instance.i, values[0]) + self.assertAlmostEqual(instance.f, values[1], places=5) + + def default_setup(self): + '''Set up default test environment''' + replica = self.node.acquire(self.rep.replica["Simple"]) + # Make sure the replica is initialized with default values + self.compare_properties(replica, [2, -1]) + self.assertEqual(replica.isInitialized(), False) + source = self.rep.source["Simple"]() + # Make sure the source is initialized with default values + self.compare_properties(source, [2, -1]) + return replica, source + + def tearDown(self): + self.assertEqual(getCapsuleCount(), 0) + self.app.processEvents() + super().tearDown() + # Separate output to make debugging easier + qWarning(f"Tore down {self.__class__.__qualname__}\n") + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaInitialization(QBasicTest): + def test_ReplicaInitialization(self): + replica, source = self.default_setup() + source.i = -1 + source.f = 3.14 + self.compare_properties(source, [-1, 3.14]) + init_spy = QSignalSpy(replica.initialized) + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.assertEqual(replica.state(), QRemoteObjectReplica.State.Valid) + # Make sure the replica values are updated to the source values + self.compare_properties(replica, [-1, 3.14]) + self.assertEqual(init_spy.count(), 1) + self.assertEqual(replica.isInitialized(), True) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class SourcePropertyChange(QBasicTest): + def test_SourcePropertyChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Make sure the replica values are unchanged since the source had the same values + self.compare_properties(replica, [2, -1]) + source_spy = QSignalSpy(source.iChanged) + replica_spy = QSignalSpy(replica.iChanged) + source.i = 42 + self.assertEqual(source_spy.count(), 1) + # Make sure the source value is updated + self.compare_properties(source, [42, source.f]) + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + # Make sure the replica value is updated + self.compare_properties(replica, [42, replica.f]) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaPropertyChange(QBasicTest): + def test_ReplicaPropertyChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Make sure push methods are working + source_spy = QSignalSpy(source.iChanged) + replica_spy = QSignalSpy(replica.iChanged) + replica.pushI(11) + # # Let eventloop run to update the source and verify the values + self.assertTrue(source_spy.wait(1000)) + self.assertEqual(source_spy.count(), 1) + self.compare_properties(source, [11, source.f]) + # Let eventloop run to update the replica and verify the values + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + self.compare_properties(replica, [11, replica.f]) + + # Test setter on replica + source_spy = QSignalSpy(source.fChanged) + replica_spy = QSignalSpy(replica.fChanged) + replica.f = 4.2 + # Make sure the replica values are ** NOT CHANGED ** since the eventloop hasn't run + self.compare_properties(replica, [11, -1]) + # Let eventloop run to update the source and verify the values + self.assertTrue(source_spy.wait(1000)) + self.assertEqual(source_spy.count(), 1) + self.compare_properties(source, [source.i, 4.2]) + # Let eventloop run to update the replica and verify the values + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + self.compare_properties(replica, [replica.i, 4.2]) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class DerivedReplicaPropertyChange(QBasicTest): + def test_DerivedReplicaPropertyChange(self): + # Don't use default_setup(), instead create a derived replica + Replica = self.rep.replica["Simple"] + + class DerivedReplica(Replica): + pass + + replica = self.node.acquire(DerivedReplica) + # Make sure the replica is initialized with default values + self.compare_properties(replica, [2, -1]) + self.assertEqual(replica.isInitialized(), False) + source = self.rep.source["Simple"]() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaSlotNotImplementedChange(QBasicTest): + def test_ReplicaSlotNotImplementedChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Ideally this would fail as the slot is not implemented on the source + res = replica.reset() + self.assertEqual(type(res), type(None)) + QTest.qWait(100) # Wait for 100 ms for async i/o. There isn't a signal to wait on + res = replica.add(5) + self.assertEqual(type(res), QRemoteObjectPendingCall) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaSlotImplementedChange(QBasicTest): + def test_ReplicaSlotImplementedChange(self): + replica = self.node.acquire(self.rep.replica["Simple"]) + replica.setObjectName("replica") + + class Source(self.rep.source["Simple"]): + def __init__(self): + super().__init__() + self.i = 6 + self.f = 3.14 + + def reset(self): + self.i = 0 + self.f = 0 + + def add(self, i): + return self.i + i + + source = Source() + source.setObjectName("source") + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.compare_properties(source, [6, 3.14]) + self.compare_properties(replica, [6, 3.14]) + replica_spy = QSignalSpy(replica.iChanged) + res = replica.reset() + self.assertEqual(type(res), type(None)) + self.assertEqual(replica_spy.wait(1000), True) + self.compare_properties(source, [0, 0]) + self.compare_properties(replica, [0, 0]) + res = replica.add(5) + self.assertEqual(type(res), QRemoteObjectPendingCall) + self.assertEqual(res.waitForFinished(1000), True) + self.assertEqual(res.returnValue(), 5) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class RefCountTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPOD{ + ENUM class Position : unsigned short {position1=1, position2=2, position3=4} + Position pos, + QString name + } + class Simple + { + ENUM Position {Left, Right, Top, Bottom} + PROP(MyPOD myPod); + PROP(Position pos); + } + """) + + def test_RefCount(self): + # Once the rep file is loaded, we should be tracking 4 converter capsules + # - 1 for the POD itself + # - 1 for the enum in the POD + # - 1 for the enum in the Source + # - 1 for the enum in the Replica + # We should be tracking 3 qobject capsules (POD, Replica, Source) + # Note: Source and Replica are distinct types, so Source::EPosition and + # Replica::EPosition are distinct as well. + # Note 2: The name of the enum ("Position") can be reused for different + # types in different classes as shown above. + self.assertEqual(getCapsuleCount(), 7) + MyPod = self.rep.pod["MyPOD"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + MyEnum = MyPod.get_enum("Position") + self.assertTrue(isinstance(MyEnum, type)) + self.assertTrue(issubclass(MyEnum, enum.Enum)) + e = MyEnum(4) # noqa: F841 + Source = self.rep.source["Simple"] + source = Source() # noqa: F841 + source = None # noqa: F841 + Source = None + Replica = self.rep.replica["Simple"] + replica = self.node.acquire(Replica) # noqa: F841 + replica = None # noqa: F841 + Replica = None + MyEnum = None + MyPod = None + self.rep = None + e = None # noqa: F841 + gc.collect() + # The enum and POD capsules will only be deleted (garbage collected) if + # the types storing them (RepFile, Replica and Source) are garbage + # collected first. + self.assertEqual(getCapsuleCount(), 0) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class EnumTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPOD{ + ENUM class Position : unsigned short {position1=1, position2=2, position3=4} + Position pos, + QString name + } + class Simple + { + ENUM Position {Left, Right, Top, Bottom} + PROP(MyPOD myPod); + PROP(Position pos); + } + """) + + def test_Enum(self): + MyPod = self.rep.pod["MyPOD"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + PodEnum = MyPod.get_enum("Position") + self.assertTrue(isinstance(PodEnum, type)) + self.assertTrue(issubclass(PodEnum, enum.Enum)) + t = (PodEnum(4), "test") + myPod = MyPod(*t) + with self.assertRaises(ValueError): + myPod = MyPod(PodEnum(0), "thing") # 0 isn't a valid enum value + myPod = MyPod(PodEnum(2), "thing") + self.assertEqual(myPod.pos, PodEnum.position2) + replica = self.node.acquire(self.rep.replica["Simple"]) + replica.setObjectName("replica") + source = self.rep.source["Simple"]() + source.setObjectName("source") + source.myPod = (PodEnum.position2, "Hello") + SourceEnum = source.get_enum("Position") + self.assertTrue(isinstance(SourceEnum, type)) + self.assertTrue(issubclass(SourceEnum, enum.Enum)) + source.pos = SourceEnum.Top + self.assertEqual(source.myPod, (PodEnum.position2, "Hello")) + self.assertNotEqual(source.pos, 2) + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.assertEqual(replica.myPod, (PodEnum.position2, "Hello")) + ReplicaEnum = replica.get_enum("Position") + # Test invalid comparisons + self.assertNotEqual(replica.pos, 2) + self.assertNotEqual(replica.pos, SourceEnum.Top) + self.assertNotEqual(replica.myPod, (SourceEnum(2), "Hello")) + self.assertNotEqual(replica.myPod, (ReplicaEnum(2), "Hello")) + self.assertNotEqual(replica.myPod, (2, "Hello")) + # Test valid comparisons to Replica enum + self.assertEqual(replica.pos, ReplicaEnum.Top) + self.assertEqual(replica.myPod, (PodEnum(2), "Hello")) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class PodTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPod(int i, QString s) + + class Simple + { + PROP(MyPod pod); + } + """) + + def test_Pod(self): + MyPod = self.rep.pod["MyPod"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + source = self.rep.source["Simple"]() + t = (42, "Hello") + pod = MyPod(*t) + source.pod = t + self.assertEqual(source.pod, t) + self.assertEqual(source.pod, pod) + source.pod = pod + self.assertEqual(source.pod, t) + self.assertEqual(source.pod, pod) + with self.assertRaises(ValueError): + source.pod = (11, "World", "!") + with self.assertRaises(TypeError): + source.pod = MyPod("Hello", "World") + self.assertEqual(source.pod, pod) + self.assertTrue(isinstance(pod, MyPod)) + self.assertEqual(pod.i, 42) + self.assertEqual(pod.s, "Hello") + self.assertTrue(isinstance(source.pod, MyPod)) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/repfile_test.py b/sources/pyside6/tests/QtRemoteObjects/repfile_test.py new file mode 100644 index 000000000..b73c84f3a --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/repfile_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for RepFile''' + +import os +import sys +import unittest +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) +from PySide6.QtRemoteObjects import RepFile + +from test_shared import wrap_tests_for_cleanup + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; +""" + + +@wrap_tests_for_cleanup() +class QRepFileConstructor(unittest.TestCase): + '''Test case for RepFile constructors''' + expected = "RepFile(Classes: [Simple], PODs: [])" + + def setUp(self): + '''Set up test environment''' + self.cwd = Path(__file__).parent + self.path = self.cwd / "simple.rep" + + def testRepFileFromPath(self): + '''Test constructing RepFile from a path''' + with open(self.path, 'r') as f: + rep_file = RepFile(f.read()) + self.assertEqual(str(rep_file), self.expected) + + def testRepFileFromString(self): + '''Test constructing RepFile from a string''' + rep_file = RepFile(contents) + self.assertEqual(str(rep_file), self.expected) + + def testRepFileInvalidString(self): + '''Test constructing RepFile from a string''' + with self.assertRaises(RuntimeError) as result: + RepFile("\n\n}\n\n") + self.assertEqual(str(result.exception), + "Error parsing input, line 3: error: Unknown token encountered") + + def testRepFileNoArguments(self): + '''Test constructing RepFile with no arguments''' + with self.assertRaises(TypeError): + RepFile() + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/simple.rep b/sources/pyside6/tests/QtRemoteObjects/simple.rep new file mode 100644 index 000000000..7e801a8c6 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/simple.rep @@ -0,0 +1,7 @@ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; diff --git a/sources/pyside6/tests/QtRemoteObjects/test_shared.py b/sources/pyside6/tests/QtRemoteObjects/test_shared.py new file mode 100644 index 000000000..5b176ce9d --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/test_shared.py @@ -0,0 +1,126 @@ +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +import gc +import sys +from functools import wraps + + +def _cleanup_local_variables(self, extra, debug): + """ + Function to clean up local variables after a unit test. + + This method will set any local variables defined in the test run to None. It also + sets variables of self to None, if they are provided in the extra list. + + The self argument is passed by the decorator, so we can access the instance variables. + """ + local_vars = self._locals + if debug: + print(f" Cleaning up locals: {local_vars.keys()} and member of self: {extra}", + file=sys.stderr) + exclude_vars = {'__builtins__', 'self', 'args', 'kwargs'} + for var in list(local_vars.keys()): + if var not in exclude_vars: + local_vars[var] = None + if debug: + print(f" Set {var} to None", file=sys.stderr) + # Remove variables added to 'self' during our test + for var in list(vars(self).keys()): + if var in extra: + setattr(self, var, None) + if debug: + print(f" Set self.{var} to None", file=sys.stderr) + gc.collect() + + +# This leverages the tip from # https://stackoverflow.com/a/9187022/169296 +# for capturing local variables using sys.setprofile and a tracer function +def wrap_tests_for_cleanup(extra: str | list[str] = None, debug: bool = False): + """ + Method that returns a decorator for setting variables used in a test to + None, thus allowing the garbage collection to clean up properly and ensure + destruction behavior is correct. Using a method to return the decorator + allows us to pass extra arguments to the decorator, in this case for extra + data members on `self` to set to None or whether to output additional debug + logging. + + It simply returns the class decorator to be used. + """ + def decorator(cls): + """ + This is a class decorator that finds and wraps all test methods in a + class. + + The provided extra is used to define a set() of variables that are set + to None on `self` after the test method has run. This is useful for + making sure the local and self variables can be garbage collected. + """ + _extra = set() + if extra: + if isinstance(extra, str): + _extra.add(extra) + else: + _extra.update(extra) + for name, attr in cls.__dict__.items(): + if name.startswith("test") and callable(attr): + """ + Only wrap methods that start with 'test' and are callable. + """ + def make_wrapper(method): + """ + This is the actual wrapper that will be used to wrap the + test methods. It will set a tracer function to capture the + local variables and then calls our cleanup function to set + the variables to None. + """ + @wraps(method) + def wrapper(self, *args, **kwargs): + if debug: + print(f"wrap_tests_for_cleanup - calling {method.__name__}", + file=sys.stderr) + + def tracer(frame, event, arg): + if event == 'return': + self._locals = frame.f_locals.copy() + + # tracer is activated on next call, return or exception + sys.setprofile(tracer) + try: + # trace the function call + return method(self, *args, **kwargs) + finally: + # disable tracer and replace with old one + sys.setprofile(None) + # call our cleanup function + _cleanup_local_variables(self, _extra, debug) + if debug: + print(f"wrap_tests_for_cleanup - done calling {method.__name__}", + file=sys.stderr) + return wrapper + setattr(cls, name, make_wrapper(attr)) + return cls + return decorator + + +if __name__ == "__main__": + # Set up example test class + @wrap_tests_for_cleanup(extra="name", debug=True) + class test: + def __init__(self): + self.name = "test" + + def testStuff(self): + value = 42 + raise ValueError("Test") + temp = 11 # noqa: F841 + return value + + t = test() + try: + t.testStuff() + except ValueError: + pass + # Should print that `value` and `self.name` are set to None, even with the + # exception being raised. |