diff --git a/adapters/describe.json b/adapters/describe.json index 88312a46..df965d82 100644 --- a/adapters/describe.json +++ b/adapters/describe.json @@ -12,7 +12,7 @@ { "id": 0, "ip": "127.0.0.1", - "persistent": false, + "persistent": true, "port": 502, "timeout": 1000, "type": "tcp" @@ -23,7 +23,7 @@ "connectionId": 0, "consecutiveMax": 125, "id": 1, - "int32LittleEndian": false, + "int32LittleEndian": true, "slaveId": 1 } ], diff --git a/adapters/dummymodbusadapter b/adapters/dummymodbusadapter index b66cd9d6..c261c847 100755 Binary files a/adapters/dummymodbusadapter and b/adapters/dummymodbusadapter differ diff --git a/adapters/dummymodbusadapter.exe b/adapters/dummymodbusadapter.exe index 946c1a41..50b39d6f 100644 Binary files a/adapters/dummymodbusadapter.exe and b/adapters/dummymodbusadapter.exe differ diff --git a/adapters/json-rpc-spec.md b/adapters/json-rpc-spec.md index ee3b8c7b..fd7543a6 100644 --- a/adapters/json-rpc-spec.md +++ b/adapters/json-rpc-spec.md @@ -338,7 +338,7 @@ Notifications are sent by the adapter to the client without a corresponding requ ### `adapter.diagnostic` -_(Reserved for future use.)_ Carries a log or diagnostic message from the adapter. +Carries a log or diagnostic message from the adapter. Emitted for every Qt log message (`qDebug`, `qInfo`, `qWarning`, `qCritical`, `qFatal`) produced during adapter operation. ```json { @@ -356,6 +356,7 @@ _(Reserved for future use.)_ Carries a log or diagnostic message from the adapte | `"debug"` | Verbose internal trace | | `"info"` | Informational | | `"warning"` | Non-fatal issue | +| `"error"` | Critical or fatal error | --- diff --git a/adapters/modbusadapter b/adapters/modbusadapter index 904c792b..3fe38a83 100755 Binary files a/adapters/modbusadapter and b/adapters/modbusadapter differ diff --git a/adapters/modbusadapter.exe b/adapters/modbusadapter.exe index d1c77190..fbd630ce 100644 Binary files a/adapters/modbusadapter.exe and b/adapters/modbusadapter.exe differ diff --git a/src/ProtocolAdapter/adapterclient.cpp b/src/ProtocolAdapter/adapterclient.cpp index 01e1a58d..b4a20ba2 100644 --- a/src/ProtocolAdapter/adapterclient.cpp +++ b/src/ProtocolAdapter/adapterclient.cpp @@ -4,7 +4,8 @@ #include -AdapterClient::AdapterClient(AdapterProcess* pProcess, QObject* parent) : QObject(parent), _pProcess(pProcess) +AdapterClient::AdapterClient(AdapterProcess* pProcess, QObject* parent, int handshakeTimeoutMs) + : QObject(parent), _pProcess(pProcess), _handshakeTimeoutMs(handshakeTimeoutMs) { Q_ASSERT(pProcess); _pProcess->setParent(this); @@ -16,6 +17,7 @@ AdapterClient::AdapterClient(AdapterProcess* pProcess, QObject* parent) : QObjec connect(_pProcess, &AdapterProcess::errorReceived, this, &AdapterClient::onErrorReceived); connect(_pProcess, &AdapterProcess::processError, this, &AdapterClient::onProcessError); connect(_pProcess, &AdapterProcess::processFinished, this, &AdapterClient::onProcessFinished); + connect(_pProcess, &AdapterProcess::notificationReceived, this, &AdapterClient::onNotificationReceived); } AdapterClient::~AdapterClient() = default; @@ -35,7 +37,7 @@ void AdapterClient::prepareAdapter(const QString& adapterPath) qCInfo(scopeComm) << "AdapterClient: process started, sending initialize"; _state = State::INITIALIZING; - _handshakeTimer.start(cHandshakeTimeoutMs); + _handshakeTimer.start(_handshakeTimeoutMs); _pProcess->sendRequest("adapter.initialize", QJsonObject()); } @@ -50,7 +52,7 @@ void AdapterClient::provideConfig(QJsonObject config, QStringList registerExpres _pendingExpressions = registerExpressions; _pendingConfig = config; _state = State::CONFIGURING; - _handshakeTimer.start(cHandshakeTimeoutMs); + _handshakeTimer.start(_handshakeTimeoutMs); QJsonObject params; params["config"] = _pendingConfig; _pProcess->sendRequest("adapter.configure", params); @@ -91,12 +93,13 @@ void AdapterClient::stopSession() { _state = State::STOPPING; _pProcess->sendRequest("adapter.shutdown", QJsonObject()); + _handshakeTimer.start(_handshakeTimeoutMs); } else { + _state = State::STOPPING; _pProcess->stop(); - _state = State::IDLE; - emit sessionStopped(); + /* sessionStopped is emitted from onProcessFinished once the process exits */ } } @@ -111,8 +114,10 @@ void AdapterClient::onResponseReceived(int id, const QString& method, const QJso { qCWarning(scopeComm) << "AdapterClient: unexpected non-object result for" << method; _handshakeTimer.stop(); + /* Set IDLE before stop() so onProcessFinished's IDLE guard suppresses any + duplicate sessionError emission when the process exits asynchronously. */ _state = State::IDLE; - QTimer::singleShot(0, this, [this]() { _pProcess->stop(); }); + _pProcess->stop(); emit sessionError(QString("Unexpected non-object result for %1").arg(method)); } } @@ -125,8 +130,10 @@ void AdapterClient::onErrorReceived(int id, const QString& method, const QJsonOb qCWarning(scopeComm) << "AdapterClient: error for" << method << ":" << errorMsg; State previousState = _state; + /* Set IDLE before stop() so onProcessFinished's IDLE guard suppresses any + duplicate sessionError emission when the process exits asynchronously. */ _state = State::IDLE; - QTimer::singleShot(0, this, [this]() { _pProcess->stop(); }); + _pProcess->stop(); if (previousState != State::STOPPING) { @@ -137,14 +144,22 @@ void AdapterClient::onErrorReceived(int id, const QString& method, const QJsonOb void AdapterClient::onProcessError(const QString& message) { _handshakeTimer.stop(); - _state = State::IDLE; - emit sessionError(message); + if (_state != State::STOPPING) + { + _state = State::IDLE; + emit sessionError(message); + } } void AdapterClient::onProcessFinished() { _handshakeTimer.stop(); - if (_state != State::IDLE) + if (_state == State::STOPPING) + { + _state = State::IDLE; + emit sessionStopped(); + } + else if (_state != State::IDLE) { _state = State::IDLE; emit sessionError("Adapter process exited unexpectedly"); @@ -154,9 +169,35 @@ void AdapterClient::onProcessFinished() void AdapterClient::onHandshakeTimeout() { qCWarning(scopeComm) << "AdapterClient: handshake timed out in state" << static_cast(_state); + bool wasStopping = (_state == State::STOPPING); _state = State::IDLE; - QTimer::singleShot(0, this, [this]() { _pProcess->stop(); }); - emit sessionError("Adapter handshake timed out"); + _pProcess->stop(); + if (wasStopping) + { + emit sessionStopped(); + } + else + { + emit sessionError("Adapter handshake timed out"); + } +} + +void AdapterClient::onNotificationReceived(QString method, QJsonValue params) +{ + if (method != QStringLiteral("adapter.diagnostic")) + { + return; + } + + if (!params.isObject()) + { + qCWarning(scopeComm) << "AdapterClient: adapter.diagnostic params is not an object"; + return; + } + + QJsonObject obj = params.toObject(); + emit diagnosticReceived(obj.value(QStringLiteral("level")).toString(), + obj.value(QStringLiteral("message")).toString()); } void AdapterClient::handleLifecycleResponse(const QString& method, const QJsonObject& result) @@ -215,8 +256,7 @@ void AdapterClient::handleLifecycleResponse(const QString& method, const QJsonOb { qCInfo(scopeComm) << "AdapterClient: shutdown acknowledged"; _pProcess->stop(); - _state = State::IDLE; - emit sessionStopped(); + /* sessionStopped is emitted from onProcessFinished once the process exits */ } else { diff --git a/src/ProtocolAdapter/adapterclient.h b/src/ProtocolAdapter/adapterclient.h index 1466d14e..88a50bb5 100644 --- a/src/ProtocolAdapter/adapterclient.h +++ b/src/ProtocolAdapter/adapterclient.h @@ -26,7 +26,7 @@ class AdapterClient : public QObject Q_OBJECT public: - explicit AdapterClient(AdapterProcess* pProcess, QObject* parent = nullptr); + explicit AdapterClient(AdapterProcess* pProcess, QObject* parent = nullptr, int handshakeTimeoutMs = 10000); ~AdapterClient(); /*! @@ -71,7 +71,10 @@ class AdapterClient : public QObject void requestStatus(); /*! - * \brief Send adapter.shutdown and terminate the adapter process. + * \brief Send adapter.shutdown and signal the adapter process to stop. + * + * The sessionStopped() signal is emitted asynchronously once the process + * has fully exited. */ void stopSession(); @@ -114,6 +117,13 @@ class AdapterClient : public QObject */ void sessionStopped(); + /*! + * \brief Emitted when an adapter.diagnostic notification is received from the adapter. + * \param level Severity level string: "debug", "info", or "warning". + * \param message The diagnostic message from the adapter. + */ + void diagnosticReceived(QString level, QString message); + protected: enum class State { @@ -135,6 +145,7 @@ private slots: void onProcessError(const QString& message); void onProcessFinished(); void onHandshakeTimeout(); + void onNotificationReceived(QString method, QJsonValue params); private: void handleLifecycleResponse(const QString& method, const QJsonObject& result); @@ -143,6 +154,7 @@ private slots: AdapterProcess* _pProcess; QTimer _handshakeTimer; + int _handshakeTimeoutMs; QJsonObject _pendingConfig; QStringList _pendingExpressions; }; diff --git a/src/ProtocolAdapter/adapterprocess.cpp b/src/ProtocolAdapter/adapterprocess.cpp index b55563ae..96fbc7b4 100644 --- a/src/ProtocolAdapter/adapterprocess.cpp +++ b/src/ProtocolAdapter/adapterprocess.cpp @@ -3,14 +3,25 @@ #include "util/scopelogging.h" #include +#include static constexpr int cStartTimeoutMs = 3000; +static constexpr int cStopTimeoutMs = 3000; AdapterProcess::AdapterProcess(QObject* parent) : QObject(parent) { _pProcess = new QProcess(this); _pFramingReader = new FramingReader(this); + _pKillTimer = new QTimer(this); + _pKillTimer->setSingleShot(true); + connect(_pKillTimer, &QTimer::timeout, this, [this]() { + if (_pProcess->state() != QProcess::NotRunning) + { + _pProcess->kill(); + } + }); + connect(_pProcess, &QProcess::readyReadStandardOutput, this, &AdapterProcess::onReadyReadStdout); connect(_pProcess, &QProcess::readyReadStandardError, this, &AdapterProcess::onReadyReadStderr); connect(_pProcess, &QProcess::finished, this, &AdapterProcess::onProcessFinished); @@ -25,6 +36,7 @@ bool AdapterProcess::start(const QString& path) return true; } + _pKillTimer->stop(); _pendingMethods.clear(); _nextRequestId = 1; @@ -49,11 +61,8 @@ void AdapterProcess::stop() if (_pProcess->state() != QProcess::NotRunning) { _pProcess->closeWriteChannel(); - if (!_pProcess->waitForFinished(3000)) - { - _pProcess->kill(); - _pProcess->waitForFinished(1000); - } + _pKillTimer->stop(); + _pKillTimer->start(cStopTimeoutMs); } } @@ -100,11 +109,10 @@ bool AdapterProcess::writeFramed(const QByteArray& json) qint64 written = _pProcess->write(frame); if (written != frame.size()) { - emit processError( - QString("Failed to write to adapter process (wrote %1 of %2 bytes, error: %3)") - .arg(written) - .arg(frame.size()) - .arg(_pProcess->errorString())); + emit processError(QString("Failed to write to adapter process (wrote %1 of %2 bytes, error: %3)") + .arg(written) + .arg(frame.size()) + .arg(_pProcess->errorString())); return false; } return true; @@ -171,5 +179,6 @@ void AdapterProcess::onProcessFinished(int exitCode, QProcess::ExitStatus exitSt { qCInfo(scopeComm) << "AdapterProcess: process finished, exit code:" << exitCode << "status:" << exitStatus; _pendingMethods.clear(); + _pKillTimer->stop(); emit processFinished(); } diff --git a/src/ProtocolAdapter/adapterprocess.h b/src/ProtocolAdapter/adapterprocess.h index 49b04930..68e79024 100644 --- a/src/ProtocolAdapter/adapterprocess.h +++ b/src/ProtocolAdapter/adapterprocess.h @@ -9,6 +9,7 @@ #include #include #include +#include /*! * \brief Transport layer for an external adapter process communicating via JSON-RPC 2.0 over stdio. @@ -37,7 +38,11 @@ class AdapterProcess : public QObject virtual bool start(const QString& path); /*! - * \brief Kill the adapter process and wait for it to finish. + * \brief Signal the adapter process to stop and return immediately. + * + * Closes stdin so the adapter exits cleanly. If it has not exited within + * the stop timeout, it is killed. The \c processFinished signal is emitted + * asynchronously when the process actually exits. */ virtual void stop(); @@ -100,6 +105,7 @@ private slots: bool writeFramed(const QByteArray& json); QProcess* _pProcess; + QTimer* _pKillTimer; FramingReader* _pFramingReader; QMap _pendingMethods; int _nextRequestId{ 1 }; diff --git a/src/communication/modbuspoll.cpp b/src/communication/modbuspoll.cpp index d53b1791..ca7df6c4 100644 --- a/src/communication/modbuspoll.cpp +++ b/src/communication/modbuspoll.cpp @@ -28,8 +28,10 @@ ModbusPoll::ModbusPoll(SettingsModel* pSettingsModel, QObject* parent) : QObject connect(_pAdapterClient, &AdapterClient::sessionError, this, [this](QString message) { qCWarning(scopeComm) << "AdapterClient error:" << message; _bPollActive = false; + disconnect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); }); connect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); + connect(_pAdapterClient, &AdapterClient::diagnosticReceived, this, &ModbusPoll::onAdapterDiagnostic); } ModbusPoll::~ModbusPoll() = default; @@ -52,6 +54,10 @@ void ModbusPoll::startCommunication(QList& registerList) _registerList = registerList; _bPollActive = true; + /* Re-establish auto-restart in case it was disconnected by a prior session error */ + disconnect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); + connect(_pAdapterClient, &AdapterClient::sessionStopped, this, &ModbusPoll::initAdapter); + qCInfo(scopeComm) << QString("Start logging: %1").arg(FormatDateTime::currentDateTime()); resetCommunicationStats(); @@ -119,6 +125,38 @@ void ModbusPoll::onDescribeResult(const QJsonObject& description) _pSettingsModel->updateAdapterFromDescribe("modbus", description); } +/*! \brief Route an adapter.diagnostic notification to the diagnostics log. + * + * Maps the adapter's level string to the appropriate Qt logging severity so + * the message flows through ScopeLogging into DiagnosticModel. + * + * \param level Severity string from the adapter: "debug", "info", "warning", or "error". + * \param message The diagnostic message text. + */ +void ModbusPoll::onAdapterDiagnostic(const QString& level, const QString& message) +{ + if (level == QStringLiteral("debug")) + { + qCDebug(scopeAdapter) << message; + } + else if (level == QStringLiteral("info")) + { + qCInfo(scopeAdapter) << message; + } + else if (level == QStringLiteral("warning")) + { + qCWarning(scopeAdapter) << message; + } + else if (level == QStringLiteral("error")) + { + qCCritical(scopeAdapter) << message; + } + else + { + qCWarning(scopeAdapter) << "AdapterClient: unknown diagnostic level:" << level << "-" << message; + } +} + QStringList ModbusPoll::buildRegisterExpressions(const QList& registerList) { QStringList expressions; diff --git a/src/communication/modbuspoll.h b/src/communication/modbuspoll.h index 4f1103a1..23126e41 100644 --- a/src/communication/modbuspoll.h +++ b/src/communication/modbuspoll.h @@ -27,6 +27,8 @@ class ModbusPoll : public QObject bool isActive(); void resetCommunicationStats(); + void onAdapterDiagnostic(const QString& level, const QString& message); + signals: void registerDataReady(ResultDoubleList registers); diff --git a/src/models/diagnosticmodel.cpp b/src/models/diagnosticmodel.cpp index f2cd86c9..6358fb1d 100644 --- a/src/models/diagnosticmodel.cpp +++ b/src/models/diagnosticmodel.cpp @@ -7,7 +7,7 @@ * \brief Constructor for DiagnosticModel * \param parent parent object */ -DiagnosticModel::DiagnosticModel(QObject *parent) : QAbstractListModel(parent) +DiagnosticModel::DiagnosticModel(QObject* parent) : QAbstractListModel(parent) { _minSeverityLevel = Diagnostic::LOG_INFO; } @@ -16,7 +16,7 @@ DiagnosticModel::DiagnosticModel(QObject *parent) : QAbstractListModel(parent) * \brief Return numbers of rows in model * \return Numbers of rows in model */ -int DiagnosticModel::rowCount(const QModelIndex & /*parent*/) const +int DiagnosticModel::rowCount(const QModelIndex& /*parent*/) const { return size(); } @@ -25,7 +25,7 @@ int DiagnosticModel::rowCount(const QModelIndex & /*parent*/) const * \brief Return numbers of columns in model * \return Numbers of columns in model */ -int DiagnosticModel::columnCount(const QModelIndex & /*parent*/) const +int DiagnosticModel::columnCount(const QModelIndex& /*parent*/) const { return 1; } @@ -36,7 +36,7 @@ int DiagnosticModel::columnCount(const QModelIndex & /*parent*/) const * \param role Requested data role * \return Requested data from model, Empty QVariant() on invalid argument */ -QVariant DiagnosticModel::data(const QModelIndex &index, int role) const +QVariant DiagnosticModel::data(const QModelIndex& index, int role) const { if (index.isValid() && (role == Qt::DisplayRole)) { @@ -72,10 +72,7 @@ QVariant DiagnosticModel::headerData(int section, Qt::Orientation orientation, i { Q_UNUSED(orientation); - if ( - (section == 0) - && (role == Qt::DisplayRole) - ) + if ((section == 0) && (role == Qt::DisplayRole)) { return QString("Messages"); } @@ -88,7 +85,7 @@ QVariant DiagnosticModel::headerData(int section, Qt::Orientation orientation, i * \param index modelindex referring to requested data * \return Flags of index */ -Qt::ItemFlags DiagnosticModel::flags(const QModelIndex & index) const +Qt::ItemFlags DiagnosticModel::flags(const QModelIndex& index) const { Q_UNUSED(index); return Qt::ItemIsSelectable | Qt::ItemIsEnabled; @@ -108,6 +105,11 @@ qint32 DiagnosticModel::size() const */ void DiagnosticModel::clear() { + if (size() == 0) + { + return; + } + beginRemoveRows(QModelIndex(), 0, size() - 1); _logList.clear(); diff --git a/src/util/scopelogging.cpp b/src/util/scopelogging.cpp index f71a9343..7d8ac63b 100644 --- a/src/util/scopelogging.cpp +++ b/src/util/scopelogging.cpp @@ -5,7 +5,7 @@ #include Q_LOGGING_CATEGORY(scopeComm, "scope.comm") -Q_LOGGING_CATEGORY(scopeCommConnection, "scope.comm.connection") +Q_LOGGING_CATEGORY(scopeAdapter, "scope.comm.adapter") Q_LOGGING_CATEGORY(scopeGeneralInfo, "scope.general.info") Q_LOGGING_CATEGORY(scopePreset, "scope.preset") Q_LOGGING_CATEGORY(scopeUi, "scope.ui") diff --git a/src/util/scopelogging.h b/src/util/scopelogging.h index b20db325..715d6c37 100644 --- a/src/util/scopelogging.h +++ b/src/util/scopelogging.h @@ -3,7 +3,7 @@ #include -Q_DECLARE_LOGGING_CATEGORY(scopeCommConnection) +Q_DECLARE_LOGGING_CATEGORY(scopeAdapter) Q_DECLARE_LOGGING_CATEGORY(scopeComm) Q_DECLARE_LOGGING_CATEGORY(scopeGeneralInfo) Q_DECLARE_LOGGING_CATEGORY(scopePreset) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 423c82f9..f050089a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -49,6 +49,7 @@ endfunction() set(CMAKE_AUTOUIC_SEARCH_PATHS ${CMAKE_CURRENT_SOURCE_DIR}/../src/dialogs ${CMAKE_CURRENT_SOURCE_DIR}/../src/customwidgets) +add_subdirectory(communication) add_subdirectory(customwidgets) add_subdirectory(datahandling) add_subdirectory(dialogs) diff --git a/tests/ProtocolAdapter/tst_adapterclient.cpp b/tests/ProtocolAdapter/tst_adapterclient.cpp index 30a389ff..8219ec8c 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.cpp +++ b/tests/ProtocolAdapter/tst_adapterclient.cpp @@ -69,6 +69,12 @@ class MockAdapterProcess : public AdapterProcess emit notificationReceived(method, params); } + //! Simulate the adapter process exiting. + void injectProcessFinished() + { + emit processFinished(); + } + private: int _nextMockId{ 1 }; }; @@ -282,13 +288,58 @@ void TestAdapterClient::notificationIgnored() QSignalSpy spyStarted(&client, &AdapterClient::sessionStarted); QSignalSpy spyError(&client, &AdapterClient::sessionError); QSignalSpy spyData(&client, &AdapterClient::readDataResult); + QSignalSpy spyDiagnostic(&client, &AdapterClient::diagnosticReceived); - /* Simulate a server-initiated notification and verify AdapterClient stays silent */ + /* Simulate a non-diagnostic notification and verify all signals stay silent */ mock->injectNotification("adapter.progress", QJsonObject{ { "percent", 50 } }); QCOMPARE(spyStarted.count(), 0); QCOMPARE(spyError.count(), 0); QCOMPARE(spyData.count(), 0); + QCOMPARE(spyDiagnostic.count(), 0); +} + +void TestAdapterClient::diagnosticNotificationForwarded() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDiagnostic(&client, &AdapterClient::diagnosticReceived); + + mock->injectNotification("adapter.diagnostic", + QJsonObject{ { "level", "warning" }, { "message", "connection lost" } }); + + QCOMPARE(spyDiagnostic.count(), 1); + QCOMPARE(spyDiagnostic.at(0).at(0).toString(), QStringLiteral("warning")); + QCOMPARE(spyDiagnostic.at(0).at(1).toString(), QStringLiteral("connection lost")); +} + +void TestAdapterClient::diagnosticNotificationDebugLevel() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDiagnostic(&client, &AdapterClient::diagnosticReceived); + + mock->injectNotification("adapter.diagnostic", + QJsonObject{ { "level", "debug" }, { "message", "polling started" } }); + + QCOMPARE(spyDiagnostic.count(), 1); + QCOMPARE(spyDiagnostic.at(0).at(0).toString(), QStringLiteral("debug")); + QCOMPARE(spyDiagnostic.at(0).at(1).toString(), QStringLiteral("polling started")); +} + +void TestAdapterClient::diagnosticMalformedParams() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyDiagnostic(&client, &AdapterClient::diagnosticReceived); + + /* params is not an object — diagnosticReceived must not be emitted */ + mock->injectNotification("adapter.diagnostic", QJsonValue(42)); + + QCOMPARE(spyDiagnostic.count(), 0); } void TestAdapterClient::processErrorEmitsSessionError() @@ -454,4 +505,109 @@ void TestAdapterClient::stopSessionDuringAwaitingConfig() QCOMPARE(mock->sentRequests.size(), 2); } +void TestAdapterClient::shutdownNoAckTimesOutToSessionStopped() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock, nullptr, 50 /* ms */); + + QSignalSpy spyStopped(&client, &AdapterClient::sessionStopped); + QSignalSpy spyError(&client, &AdapterClient::sessionError); + + /* Drive to ACTIVE state */ + client.prepareAdapter(QStringLiteral("./dummy")); + mock->injectResponse(1, "adapter.initialize", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(2, "adapter.describe", describeResult()); + client.provideConfig(QJsonObject(), QStringList()); + mock->injectResponse(3, "adapter.configure", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(4, "adapter.start", QJsonObject{ { "status", "ok" } }); + + /* Initiate shutdown — adapter never responds */ + client.stopSession(); + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.shutdown")); + + /* Wait for the shutdown timer to fire */ + QTest::qWait(150); + + /* sessionStopped must be emitted, not sessionError */ + QCOMPARE(spyStopped.count(), 1); + QCOMPARE(spyError.count(), 0); +} + +void TestAdapterClient::shutdownAckEmitsSessionStoppedAfterProcessExit() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyStopped(&client, &AdapterClient::sessionStopped); + QSignalSpy spyError(&client, &AdapterClient::sessionError); + + /* Drive to ACTIVE state */ + client.prepareAdapter(QStringLiteral("./dummy")); + mock->injectResponse(1, "adapter.initialize", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(2, "adapter.describe", describeResult()); + client.provideConfig(QJsonObject(), QStringList()); + mock->injectResponse(3, "adapter.configure", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(4, "adapter.start", QJsonObject{ { "status", "ok" } }); + + /* Initiate shutdown and inject the adapter's acknowledgment */ + client.stopSession(); + QCOMPARE(mock->sentRequests.last().method, QStringLiteral("adapter.shutdown")); + mock->injectResponse(5, "adapter.shutdown", QJsonObject{ { "status", "ok" } }); + + /* sessionStopped must NOT be emitted yet — the process has not exited */ + QCOMPARE(spyStopped.count(), 0); + QCOMPARE(spyError.count(), 0); + + /* Simulate the adapter process exiting — now sessionStopped must fire */ + mock->injectProcessFinished(); + + QCOMPARE(spyStopped.count(), 1); + QCOMPARE(spyError.count(), 0); +} + +void TestAdapterClient::processErrorDuringStoppingNoSessionError() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyError(&client, &AdapterClient::sessionError); + QSignalSpy spyStopped(&client, &AdapterClient::sessionStopped); + + client.prepareAdapter(QStringLiteral("./dummy")); + mock->injectResponse(1, "adapter.initialize", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(2, "adapter.describe", describeResult()); + client.provideConfig(QJsonObject(), QStringList()); + mock->injectResponse(3, "adapter.configure", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(4, "adapter.start", QJsonObject{ { "status", "ok" } }); + + client.stopSession(); + mock->injectProcessError(QStringLiteral("Adapter process crashed")); + + QCOMPARE(spyError.count(), 0); + QCOMPARE(spyStopped.count(), 0); +} + +void TestAdapterClient::processErrorDuringStoppingThenProcessFinished() +{ + auto* mock = new MockAdapterProcess(); + AdapterClient client(mock); + + QSignalSpy spyError(&client, &AdapterClient::sessionError); + QSignalSpy spyStopped(&client, &AdapterClient::sessionStopped); + + client.prepareAdapter(QStringLiteral("./dummy")); + mock->injectResponse(1, "adapter.initialize", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(2, "adapter.describe", describeResult()); + client.provideConfig(QJsonObject(), QStringList()); + mock->injectResponse(3, "adapter.configure", QJsonObject{ { "status", "ok" } }); + mock->injectResponse(4, "adapter.start", QJsonObject{ { "status", "ok" } }); + + client.stopSession(); + mock->injectProcessError(QStringLiteral("Adapter process crashed")); + mock->injectProcessFinished(); + + QCOMPARE(spyError.count(), 0); + QCOMPARE(spyStopped.count(), 1); +} + QTEST_GUILESS_MAIN(TestAdapterClient) diff --git a/tests/ProtocolAdapter/tst_adapterclient.h b/tests/ProtocolAdapter/tst_adapterclient.h index b5d77037..a757a9da 100644 --- a/tests/ProtocolAdapter/tst_adapterclient.h +++ b/tests/ProtocolAdapter/tst_adapterclient.h @@ -18,14 +18,21 @@ private slots: void errorResponseEmitsSessionError(); void unexpectedResponseEmitsNoSignals(); void notificationIgnored(); + void diagnosticNotificationForwarded(); + void diagnosticNotificationDebugLevel(); + void diagnosticMalformedParams(); void processErrorEmitsSessionError(); void stopSessionDuringLifecycle(); void doubleStopSession(); void requestReadDataWhenNotActive(); void nonObjectResultEmitsSessionError(); void errorDuringShutdownSuppressed(); + void shutdownNoAckTimesOutToSessionStopped(); void awaitingConfigPausesBeforeConfigure(); void stopSessionDuringAwaitingConfig(); + void shutdownAckEmitsSessionStoppedAfterProcessExit(); + void processErrorDuringStoppingNoSessionError(); + void processErrorDuringStoppingThenProcessFinished(); }; #endif // TST_ADAPTERCLIENT_H diff --git a/tests/ProtocolAdapter/tst_adapterprocess.cpp b/tests/ProtocolAdapter/tst_adapterprocess.cpp index e2ee74f9..2ff60067 100644 --- a/tests/ProtocolAdapter/tst_adapterprocess.cpp +++ b/tests/ProtocolAdapter/tst_adapterprocess.cpp @@ -46,6 +46,9 @@ void TestAdapterProcess::sendRequestEmitsResponseReceived() /* Close the write channel so the adapter flushes its responses and exits */ process.stop(); + /* stop() is non-blocking; give the event loop time to receive the response */ + QTest::qWait(500); + QCOMPARE(spyProcessError.count(), 0); QCOMPARE(spyResponse.count(), 1); QList args = spyResponse.at(0); @@ -66,6 +69,9 @@ void TestAdapterProcess::processFinishedEmittedOnStop() process.stop(); + /* stop() is non-blocking; give the event loop time to process the exit */ + QTest::qWait(500); + QVERIFY(!process.isRunning()); QCOMPARE(spyFinished.count(), 1); } diff --git a/tests/communication/CMakeLists.txt b/tests/communication/CMakeLists.txt new file mode 100644 index 00000000..c50cdbb4 --- /dev/null +++ b/tests/communication/CMakeLists.txt @@ -0,0 +1 @@ +add_xtest(tst_modbuspoll) diff --git a/tests/communication/tst_modbuspoll.cpp b/tests/communication/tst_modbuspoll.cpp new file mode 100644 index 00000000..87f83f8a --- /dev/null +++ b/tests/communication/tst_modbuspoll.cpp @@ -0,0 +1,103 @@ +#include "tst_modbuspoll.h" + +#include "communication/modbuspoll.h" +#include "models/settingsmodel.h" + +#include +#include + +namespace { +QtMsgType& capturedType() +{ + static QtMsgType t{}; + return t; +} +QString& capturedMessage() +{ + static QString m; + return m; +} + +void captureHandler(QtMsgType type, const QMessageLogContext&, const QString& msg) +{ + capturedType() = type; + capturedMessage() = msg; +} +} // namespace + +void TestModbusPoll::init() +{ + _pSettingsModel = new SettingsModel; + _pModbusPoll = new ModbusPoll(_pSettingsModel); + /* Enable debug output for scope.comm.adapter so qCDebug calls reach the handler */ + QLoggingCategory::setFilterRules(QStringLiteral("scope.comm.adapter.debug=true")); +} + +void TestModbusPoll::cleanup() +{ + QLoggingCategory::setFilterRules(QString()); + delete _pModbusPoll; + delete _pSettingsModel; +} + +void TestModbusPoll::diagnosticDebugLevel() +{ + capturedType() = QtMsgType{}; + capturedMessage() = QString{}; + QtMessageHandler previous = qInstallMessageHandler(captureHandler); + _pModbusPoll->onAdapterDiagnostic(QStringLiteral("debug"), QStringLiteral("polling started")); + qInstallMessageHandler(previous); + + QCOMPARE(capturedType(), QtDebugMsg); + QVERIFY(capturedMessage().contains(QStringLiteral("polling started"))); +} + +void TestModbusPoll::diagnosticInfoLevel() +{ + capturedType() = QtMsgType{}; + capturedMessage() = QString{}; + QtMessageHandler previous = qInstallMessageHandler(captureHandler); + _pModbusPoll->onAdapterDiagnostic(QStringLiteral("info"), QStringLiteral("session active")); + qInstallMessageHandler(previous); + + QCOMPARE(capturedType(), QtInfoMsg); + QVERIFY(capturedMessage().contains(QStringLiteral("session active"))); +} + +void TestModbusPoll::diagnosticWarningLevel() +{ + capturedType() = QtMsgType{}; + capturedMessage() = QString{}; + QtMessageHandler previous = qInstallMessageHandler(captureHandler); + _pModbusPoll->onAdapterDiagnostic(QStringLiteral("warning"), QStringLiteral("register read failed")); + qInstallMessageHandler(previous); + + QCOMPARE(capturedType(), QtWarningMsg); + QVERIFY(capturedMessage().contains(QStringLiteral("register read failed"))); +} + +void TestModbusPoll::diagnosticErrorLevel() +{ + capturedType() = QtMsgType{}; + capturedMessage() = QString{}; + QtMessageHandler previous = qInstallMessageHandler(captureHandler); + _pModbusPoll->onAdapterDiagnostic(QStringLiteral("error"), QStringLiteral("fatal adapter fault")); + qInstallMessageHandler(previous); + + QCOMPARE(capturedType(), QtCriticalMsg); + QVERIFY(capturedMessage().contains(QStringLiteral("fatal adapter fault"))); +} + +void TestModbusPoll::diagnosticUnknownLevel() +{ + capturedType() = QtMsgType{}; + capturedMessage() = QString{}; + QtMessageHandler previous = qInstallMessageHandler(captureHandler); + _pModbusPoll->onAdapterDiagnostic(QStringLiteral("critical"), QStringLiteral("unexpected error")); + qInstallMessageHandler(previous); + + QCOMPARE(capturedType(), QtWarningMsg); + QVERIFY(capturedMessage().contains(QStringLiteral("unknown diagnostic level"))); +} + +QTEST_GUILESS_MAIN(TestModbusPoll) diff --git a/tests/communication/tst_modbuspoll.h b/tests/communication/tst_modbuspoll.h new file mode 100644 index 00000000..9564a73b --- /dev/null +++ b/tests/communication/tst_modbuspoll.h @@ -0,0 +1,27 @@ +#ifndef TST_MODBUSPOLL_H +#define TST_MODBUSPOLL_H + +#include + +class ModbusPoll; +class SettingsModel; + +class TestModbusPoll : public QObject +{ + Q_OBJECT +private slots: + void init(); + void cleanup(); + + void diagnosticDebugLevel(); + void diagnosticInfoLevel(); + void diagnosticWarningLevel(); + void diagnosticErrorLevel(); + void diagnosticUnknownLevel(); + +private: + SettingsModel* _pSettingsModel{ nullptr }; + ModbusPoll* _pModbusPoll{ nullptr }; +}; + +#endif // TST_MODBUSPOLL_H diff --git a/tests/models/tst_diagnosticmodel.cpp b/tests/models/tst_diagnosticmodel.cpp index 5b321bb7..b28df026 100644 --- a/tests/models/tst_diagnosticmodel.cpp +++ b/tests/models/tst_diagnosticmodel.cpp @@ -13,7 +13,6 @@ void TestDiagnosticModel::init() void TestDiagnosticModel::cleanup() { - } void TestDiagnosticModel::addClear() @@ -39,6 +38,19 @@ void TestDiagnosticModel::addClear() QCOMPARE(diagModel.rowCount(), 0); } +void TestDiagnosticModel::clearEmpty() +{ + DiagnosticModel diagModel; + + QCOMPARE(diagModel.size(), 0); + + // Clearing an empty model must not crash or hang + diagModel.clear(); + + QCOMPARE(diagModel.size(), 0); + QCOMPARE(diagModel.rowCount(), 0); +} + void TestDiagnosticModel::headerData() { DiagnosticModel diagModel; @@ -49,7 +61,6 @@ void TestDiagnosticModel::headerData() QCOMPARE(diagModel.headerData(1, Qt::Horizontal, Qt::DisplayRole), QVariant()); QCOMPARE(diagModel.headerData(1, Qt::Horizontal, Qt::EditRole), QVariant()); - } void TestDiagnosticModel::data() @@ -102,14 +113,14 @@ void TestDiagnosticModel::addLog() DiagnosticModel diagModel; diagModel.setMinimumSeverityLevel(Diagnostic::LOG_INFO); - QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector))); + QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex, QModelIndex, QVector))); Diagnostic logErr(_category, Diagnostic::LOG_WARNING, 10, QString("Error")); diagModel.addLog(logErr.category(), logErr.severity(), logErr.timeOffset(), logErr.message()); QCOMPARE(spy.count(), 1); - QModelIndex changedIndex = diagModel.index(diagModel.size()-1); + QModelIndex changedIndex = diagModel.index(diagModel.size() - 1); QList arguments = spy.takeFirst(); @@ -122,7 +133,7 @@ void TestDiagnosticModel::addLogLowerSeverity() DiagnosticModel diagModel; diagModel.setMinimumSeverityLevel(Diagnostic::LOG_INFO); - QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector))); + QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex, QModelIndex, QVector))); Diagnostic logErr(_category, Diagnostic::LOG_DEBUG, 10, QString("Debug")); diagModel.addLog(logErr.category(), logErr.severity(), logErr.timeOffset(), logErr.message()); @@ -136,14 +147,14 @@ void TestDiagnosticModel::addLogSameSeverity() DiagnosticModel diagModel; diagModel.setMinimumSeverityLevel(Diagnostic::LOG_INFO); - QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex,QModelIndex,QVector))); + QSignalSpy spy(&diagModel, SIGNAL(dataChanged(QModelIndex, QModelIndex, QVector))); Diagnostic logErr(_category, Diagnostic::LOG_INFO, 10, QString("Info")); diagModel.addLog(logErr.category(), logErr.severity(), logErr.timeOffset(), logErr.message()); QCOMPARE(spy.count(), 1); - QModelIndex changedIndex = diagModel.index(diagModel.size()-1); + QModelIndex changedIndex = diagModel.index(diagModel.size() - 1); QList arguments = spy.takeFirst(); diff --git a/tests/models/tst_diagnosticmodel.h b/tests/models/tst_diagnosticmodel.h index 0e63b551..e5519773 100644 --- a/tests/models/tst_diagnosticmodel.h +++ b/tests/models/tst_diagnosticmodel.h @@ -1,10 +1,10 @@ -#ifndef TEST_DIAGNOSTICMODEL_H__ -#define TEST_DIAGNOSTICMODEL_H__ +#ifndef TST_DIAGNOSTICMODEL_H +#define TST_DIAGNOSTICMODEL_H #include -class TestDiagnosticModel: public QObject +class TestDiagnosticModel : public QObject { Q_OBJECT private slots: @@ -12,6 +12,7 @@ private slots: void cleanup(); void addClear(); + void clearEmpty(); void headerData(); void data(); void dataSeverity(); @@ -22,9 +23,7 @@ private slots: void addLogSameSeverity(); private: - - QString _category; - + QString _category; }; -#endif /* TEST_DIAGNOSTICMODEL_H__ */ +#endif // TST_DIAGNOSTICMODEL_H