|
From: <fli...@li...> - 2026-07-31 22:26:58
|
unknown user pushed a commit to branch next
in repository flightgear.
The following commit(s) were added to refs/heads/next by this push:
new 5e15864c1 Tests for Phi/WebSocket backends
5e15864c1 is described below
SF URL: http://sourceforge.net/p/flightgear/flightgear/ci/5e15864c15cde751bbb2eeaf07c61a7b56f452fb/
Commit: 5e15864c15cde751bbb2eeaf07c61a7b56f452fb
Author: James Turner
Committer: James Turner
AuthorDate: Wed Jul 29 14:08:20 2026 +0100
Tests for Phi/WebSocket backends
---
src/Network/http/MirrorPropertyTreeWebsocket.cxx | 19 +--
test_suite/unit_tests/Network/CMakeLists.txt | 12 +-
test_suite/unit_tests/Network/TestSuite.cxx | 10 +-
test_suite/unit_tests/Network/httpTestHelpers.hxx | 66 ++++++++
.../unit_tests/Network/test_JsonUriHandler.cxx | 71 +++++++++
.../unit_tests/Network/test_JsonUriHandler.hxx | 31 ++++
.../Network/test_MirrorPropertyTreeWebsocket.cxx | 167 +++++++++++++++++++++
.../Network/test_MirrorPropertyTreeWebsocket.hxx | 47 ++++++
.../Network/test_PropertyChangeWebsocket.cxx | 115 ++++++++++++++
.../Network/test_PropertyChangeWebsocket.hxx | 42 ++++++
10 files changed, 569 insertions(+), 11 deletions(-)
diff --git a/src/Network/http/MirrorPropertyTreeWebsocket.cxx b/src/Network/http/MirrorPropertyTreeWebsocket.cxx
index 144a93dca..e34a197fb 100644
--- a/src/Network/http/MirrorPropertyTreeWebsocket.cxx
+++ b/src/Network/http/MirrorPropertyTreeWebsocket.cxx
@@ -201,12 +201,13 @@ using std::string;
auto it = idHash.find(child);
if (it != idHash.end()) {
- removedNodes.insert(it->second);
+ const auto id = it->second; // save before erase invalidates the iterator
+ removedNodes.insert(id);
idHash.erase(it);
// record so we can map removed+add of the same property into
// a simple value change (this happens commonly with the canvas
// due to lazy Nasal scripting)
- recentlyRemoved.emplace_back(child, it->second);
+ recentlyRemoved.emplace_back(child, id);
#if defined (MIRROR_DEBUG)
SG_LOG(SG_NETWORK, SG_INFO, "adding RR:" << recentlyRemoved.back().path);
#endif
@@ -374,16 +375,16 @@ static void handleExecCommand(cJSON* json)
}
#endif
-MirrorPropertyTreeWebsocket::MirrorPropertyTreeWebsocket(const std::string& path) :
- _rootPath(path),
- _listener(new MirrorTreeListener),
- _minSendInterval(100)
-{
- checkNodeExists();
-}
+ MirrorPropertyTreeWebsocket::MirrorPropertyTreeWebsocket(const std::string& path) : _rootPath(path),
+ _listener(new MirrorTreeListener),
+ _minSendInterval(fgGetInt("/sim/http/mirror-websocket/update-interval-ms", 100))
+ {
+ checkNodeExists();
+ }
MirrorPropertyTreeWebsocket::~MirrorPropertyTreeWebsocket()
{
+ close();
}
void MirrorPropertyTreeWebsocket::close()
diff --git a/test_suite/unit_tests/Network/CMakeLists.txt b/test_suite/unit_tests/Network/CMakeLists.txt
index d0bc758ff..95f39fda7 100644
--- a/test_suite/unit_tests/Network/CMakeLists.txt
+++ b/test_suite/unit_tests/Network/CMakeLists.txt
@@ -1,7 +1,17 @@
# SPDX-FileCopyrightText: 2016 Edward d'Auvergne
# SPDX-License-Identifier: GPL-2.0-or-later
-target_sources(fgfs_test_suite PRIVATE TestSuite.cxx)
+target_sources(fgfs_test_suite PRIVATE
+ TestSuite.cxx
+ httpTestHelpers.hxx
+ test_JsonUriHandler.cxx test_JsonUriHandler.hxx
+ test_PropertyChangeWebsocket.cxx test_PropertyChangeWebsocket.hxx
+ test_MirrorPropertyTreeWebsocket.cxx test_MirrorPropertyTreeWebsocket.hxx
+)
+
+fg_add_test_suite(JsonUriHandlerTests u)
+fg_add_test_suite(PropertyChangeWebsocketTests u)
+fg_add_test_suite(MirrorPropertyTreeWebsocketTests u)
if(ENABLE_SWIFT)
target_sources(fgfs_test_suite PRIVATE
diff --git a/test_suite/unit_tests/Network/TestSuite.cxx b/test_suite/unit_tests/Network/TestSuite.cxx
index 217dcda57..984de7d5c 100644
--- a/test_suite/unit_tests/Network/TestSuite.cxx
+++ b/test_suite/unit_tests/Network/TestSuite.cxx
@@ -5,6 +5,14 @@
#include "config.h"
+#include "test_JsonUriHandler.hxx"
+#include "test_MirrorPropertyTreeWebsocket.hxx"
+#include "test_PropertyChangeWebsocket.hxx"
+
+// HTTP handler and websocket unit tests
+CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(JsonUriHandlerTests, "Unit tests");
+CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(PropertyChangeWebsocketTests, "Unit tests");
+CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(MirrorPropertyTreeWebsocketTests, "Unit tests");
#if defined(ENABLE_SWIFT)
#include "test_swiftAircraftManager.hxx"
@@ -14,4 +22,4 @@
CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(SwiftAircraftManagerTest, "Unit tests");
CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(SwiftServiceTest, "Unit tests");
-#endif
\ No newline at end of file
+#endif
diff --git a/test_suite/unit_tests/Network/httpTestHelpers.hxx b/test_suite/unit_tests/Network/httpTestHelpers.hxx
new file mode 100644
index 000000000..e640e72d3
--- /dev/null
+++ b/test_suite/unit_tests/Network/httpTestHelpers.hxx
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+#pragma once
+
+#include <Network/http/HTTPRequest.hxx>
+#include <Network/http/Websocket.hxx>
+#include <Network/http/urihandler.hxx>
+
+#include <string>
+#include <vector>
+
+namespace FGTestApi {
+namespace http {
+
+/**
+ * Captures every frame written to a websocket during a test.
+ * Only text (opcode 1) and binary (opcode 2) frames are stored;
+ * control frames (ping/pong/close) are silently acknowledged.
+ */
+class MockWebsocketWriter : public flightgear::http::WebsocketWriter
+{
+public:
+ int writeToWebsocket(int opcode, const char* data, size_t len) override
+ {
+ if (opcode == 1 || opcode == 2) {
+ frames.emplace_back(data, len);
+ }
+ return static_cast<int>(len);
+ }
+
+ std::vector<std::string> frames;
+};
+
+/**
+ * Minimal Connection stub for URI handler tests.
+ * Stores key/value connection data (via the base class map) and
+ * captures any raw bytes written via write().
+ */
+class MockConnection : public flightgear::http::Connection
+{
+public:
+ void write(const char* data, size_t len) override
+ {
+ written.append(data, len);
+ }
+
+ std::string written;
+};
+
+/**
+ * Convenience factory for building HTTPRequest objects in tests.
+ */
+inline flightgear::http::HTTPRequest buildRequest(
+ const std::string& method,
+ const std::string& uri,
+ const std::string& content = {})
+{
+ flightgear::http::HTTPRequest req;
+ req.Method = method;
+ req.Uri = uri;
+ req.Content = content;
+ return req;
+}
+
+} // namespace http
+} // namespace FGTestApi
diff --git a/test_suite/unit_tests/Network/test_JsonUriHandler.cxx b/test_suite/unit_tests/Network/test_JsonUriHandler.cxx
new file mode 100644
index 000000000..00435c04c
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_JsonUriHandler.cxx
@@ -0,0 +1,71 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "test_JsonUriHandler.hxx"
+#include "httpTestHelpers.hxx"
+
+#include "test_suite/FGTestApi/testGlobals.hxx"
+
+#include <Main/fg_props.hxx>
+#include <Network/http/HTTPResponse.hxx>
+#include <Network/http/JsonUriHandler.hxx>
+
+#include <nlohmann/json.hpp>
+
+using namespace flightgear::http;
+using namespace FGTestApi::http;
+
+void JsonUriHandlerTests::setUp()
+{
+ FGTestApi::setUp::initTestGlobals("JsonUriHandler");
+}
+
+void JsonUriHandlerTests::tearDown()
+{
+ FGTestApi::tearDown::shutdownTestGlobals();
+}
+
+// ---------------------------------------------------------------------------
+
+void JsonUriHandlerTests::testGetExistingProperty()
+{
+ fgSetString("/test/aircraft", "c172p");
+
+ JsonUriHandler handler("/json/");
+ auto req = buildRequest("GET", "/json/test/aircraft");
+ HTTPResponse resp;
+
+ bool handled = handler.handleRequest(req, resp, nullptr);
+ CPPUNIT_ASSERT(handled);
+ CPPUNIT_ASSERT_EQUAL(200, resp.StatusCode);
+
+ auto json = nlohmann::json::parse(resp.Content);
+ CPPUNIT_ASSERT_EQUAL(std::string("c172p"), json.at("value").get<std::string>());
+ CPPUNIT_ASSERT_EQUAL(std::string("/test/aircraft"), json.at("path").get<std::string>());
+}
+
+void JsonUriHandlerTests::testGetMissingProperty()
+{
+ JsonUriHandler handler("/json/");
+ auto req = buildRequest("GET", "/json/this/does/not/exist");
+ HTTPResponse resp;
+
+ bool handled = handler.handleRequest(req, resp, nullptr);
+ CPPUNIT_ASSERT(handled);
+ CPPUNIT_ASSERT_EQUAL(404, resp.StatusCode);
+}
+
+void JsonUriHandlerTests::testPostProperty()
+{
+ fgSetString("/test/writable", "original");
+
+ JsonUriHandler handler("/json/");
+ // The JSON body uses {"value":...} to update the node at the URI directly
+ auto req = buildRequest("POST", "/json/test/writable", R"({"value":"updated"})");
+ HTTPResponse resp;
+
+ bool handled = handler.handleRequest(req, resp, nullptr);
+ CPPUNIT_ASSERT(handled);
+ CPPUNIT_ASSERT_EQUAL(200, resp.StatusCode);
+ CPPUNIT_ASSERT_EQUAL(std::string("updated"), std::string(fgGetString("/test/writable")));
+}
diff --git a/test_suite/unit_tests/Network/test_JsonUriHandler.hxx b/test_suite/unit_tests/Network/test_JsonUriHandler.hxx
new file mode 100644
index 000000000..95f090791
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_JsonUriHandler.hxx
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+#pragma once
+
+#include <cppunit/TestFixture.h>
+#include <cppunit/extensions/HelperMacros.h>
+
+/**
+ * Tests for JsonUriHandler — the plain HTTP GET/POST interface to the
+ * property tree. These tests exercise the synchronous request/response
+ * path without any real HTTP server.
+ */
+class JsonUriHandlerTests : public CppUnit::TestFixture
+{
+ CPPUNIT_TEST_SUITE(JsonUriHandlerTests);
+ CPPUNIT_TEST(testGetExistingProperty);
+ CPPUNIT_TEST(testGetMissingProperty);
+ CPPUNIT_TEST(testPostProperty);
+ CPPUNIT_TEST_SUITE_END();
+
+public:
+ void setUp();
+ void tearDown();
+
+ // GET an existing property node → 200 with JSON value
+ void testGetExistingProperty();
+ // GET a path that has no property node → 404
+ void testGetMissingProperty();
+ // POST a new JSON value → property updated in the tree
+ void testPostProperty();
+};
diff --git a/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.cxx b/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.cxx
new file mode 100644
index 000000000..0e4d873d2
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.cxx
@@ -0,0 +1,167 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "test_MirrorPropertyTreeWebsocket.hxx"
+#include "httpTestHelpers.hxx"
+
+#include "test_suite/FGTestApi/testGlobals.hxx"
+
+#include <Main/fg_props.hxx>
+#include <Main/globals.hxx>
+#include <Network/http/MirrorPropertyTreeWebsocket.hxx>
+
+#include <simgear/props/props.hxx>
+
+#include <nlohmann/json.hpp>
+
+using namespace flightgear::http;
+using namespace FGTestApi::http;
+
+// Helper: drain the initial "created" frame emitted when a websocket first
+// registers a pre-existing sub-tree, then clear the frame list.
+static void drainInitialFrame(MirrorPropertyTreeWebsocket& ws, MockWebsocketWriter& writer)
+{
+ ws.poll(writer);
+ writer.frames.clear();
+}
+
+void MirrorPropertyTreeWebsocketTests::setUp()
+{
+ FGTestApi::setUp::initTestGlobals("MirrorPropertyTreeWebsocket");
+ // Disable the send-rate throttle so every poll() fires immediately.
+ // This value is read by the MirrorPropertyTreeWebsocket constructor.
+ fgSetInt("/sim/http/mirror-websocket/update-interval-ms", 0);
+
+ // Production code sets VALUE_CHANGED_DOWN on /sim so that value changes
+ // on any descendant propagate up to ancestor listeners. Replicate that
+ // for the /test namespace used by these tests.
+ fgGetNode("/test", true)->setAttribute(SGPropertyNode::VALUE_CHANGED_DOWN, true);
+}
+
+void MirrorPropertyTreeWebsocketTests::tearDown()
+{
+ FGTestApi::tearDown::shutdownTestGlobals();
+}
+
+// ---------------------------------------------------------------------------
+
+void MirrorPropertyTreeWebsocketTests::testInitialSubtreeSent()
+{
+ // Build the sub-tree before constructing the websocket so the listener
+ // picks it up via registerSubtree() in checkNodeExists().
+ fgSetString("/test/mirror/name", "FlightGear");
+ fgSetDouble("/test/mirror/altitude", 10000.0);
+
+ MirrorPropertyTreeWebsocket ws("/test/mirror");
+ MockWebsocketWriter writer;
+
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+
+ // The initial frame must contain a "created" array with our nodes
+ CPPUNIT_ASSERT(json.contains("created"));
+ CPPUNIT_ASSERT(!json.at("created").empty());
+}
+
+void MirrorPropertyTreeWebsocketTests::testNewChildSent()
+{
+ fgSetString("/test/mirror2/existing", "here");
+
+ MirrorPropertyTreeWebsocket ws("/test/mirror2");
+ MockWebsocketWriter writer;
+ drainInitialFrame(ws, writer);
+
+ // Add a new child after the websocket is already connected
+ fgSetDouble("/test/mirror2/newchild", 42.0);
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+
+ CPPUNIT_ASSERT(json.contains("created"));
+ const auto& created = json.at("created");
+ bool found = false;
+ for (const auto& entry : created) {
+ if (entry.value("path", std::string{}) == "/test/mirror2/newchild") {
+ found = true;
+ break;
+ }
+ }
+ CPPUNIT_ASSERT_MESSAGE("new child not found in 'created'", found);
+}
+
+void MirrorPropertyTreeWebsocketTests::testValueChangeSent()
+{
+ fgSetDouble("/test/mirror3/speed", 100.0);
+
+ MirrorPropertyTreeWebsocket ws("/test/mirror3");
+ MockWebsocketWriter writer;
+ drainInitialFrame(ws, writer);
+
+ // Change the value; the listener will pick this up synchronously
+ fgSetDouble("/test/mirror3/speed", 250.0);
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+
+ // A "changed" array should be present — it contains [id, newValue] pairs
+ CPPUNIT_ASSERT(json.contains("changed"));
+ CPPUNIT_ASSERT(!json.at("changed").empty());
+}
+
+void MirrorPropertyTreeWebsocketTests::testRemovedChildSent()
+{
+ fgSetString("/test/mirror4/child", "toremove");
+
+ MirrorPropertyTreeWebsocket ws("/test/mirror4");
+ MockWebsocketWriter writer;
+ drainInitialFrame(ws, writer);
+
+ // Remove the child node
+ SGPropertyNode_ptr root = globals->get_props()->getNode("/test/mirror4");
+ CPPUNIT_ASSERT(root != nullptr);
+ root->removeChild("child", 0);
+
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+
+ // A "removed" array of integer IDs should be present
+ CPPUNIT_ASSERT(json.contains("removed"));
+ CPPUNIT_ASSERT(!json.at("removed").empty());
+}
+
+void MirrorPropertyTreeWebsocketTests::testDeleteAndReAddAppearsAsChange()
+{
+ // This simulates the Nasal pattern where a property is deleted and
+ // immediately re-created (e.g. by a Nasal timer) between two polls.
+ // The listener keeps a recentlyRemoved list so it can recognise the
+ // re-add as a recycled node and emit only a "changed" entry rather
+ // than a "removed" + "created" pair.
+ fgSetString("/test/mirror5/item", "original");
+
+ MirrorPropertyTreeWebsocket ws("/test/mirror5");
+ MockWebsocketWriter writer;
+ drainInitialFrame(ws, writer);
+
+ // Remove then immediately re-create the child, all before the next poll.
+ SGPropertyNode_ptr root = globals->get_props()->getNode("/test/mirror5");
+ CPPUNIT_ASSERT(root != nullptr);
+ root->removeChild("item", 0);
+ fgSetString("/test/mirror5/item", "updated"); // same path, new value
+
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+
+ // The recycled node must appear as a value change, not a remove/create pair.
+ CPPUNIT_ASSERT_MESSAGE("unexpected 'removed' entry", !json.contains("removed"));
+ CPPUNIT_ASSERT_MESSAGE("unexpected 'created' entry", !json.contains("created"));
+ CPPUNIT_ASSERT(json.contains("changed"));
+ CPPUNIT_ASSERT(!json.at("changed").empty());
+}
diff --git a/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.hxx b/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.hxx
new file mode 100644
index 000000000..622a65a2b
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_MirrorPropertyTreeWebsocket.hxx
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+#pragma once
+
+#include <cppunit/TestFixture.h>
+#include <cppunit/extensions/HelperMacros.h>
+
+/**
+ * Tests for MirrorPropertyTreeWebsocket — the websocket that mirrors an
+ * entire property sub-tree to a connected client.
+ *
+ * The test pattern mirrors the server's update loop:
+ * 1. Build a property sub-tree.
+ * 2. Construct MirrorPropertyTreeWebsocket (registers a recursive listener).
+ * 3. Call ws.poll() — the listener accumulates changes and the websocket
+ * serialises them as a JSON frame via MockWebsocketWriter.
+ * 4. Mutate the sub-tree and call ws.poll() again to check delta frames.
+ *
+ * The send-rate throttle is disabled by setting
+ * /sim/http/mirror-websocket/update-interval-ms = 0 before constructing.
+ */
+class MirrorPropertyTreeWebsocketTests : public CppUnit::TestFixture
+{
+ CPPUNIT_TEST_SUITE(MirrorPropertyTreeWebsocketTests);
+ CPPUNIT_TEST(testInitialSubtreeSent);
+ CPPUNIT_TEST(testNewChildSent);
+ CPPUNIT_TEST(testValueChangeSent);
+ CPPUNIT_TEST(testRemovedChildSent);
+ CPPUNIT_TEST(testDeleteAndReAddAppearsAsChange);
+ CPPUNIT_TEST_SUITE_END();
+
+public:
+ void setUp();
+ void tearDown();
+
+ // On first poll after construction the whole existing sub-tree is sent
+ void testInitialSubtreeSent();
+ // A child added after construction appears in the next poll frame
+ void testNewChildSent();
+ // A value change in the sub-tree appears in the next poll frame
+ void testValueChangeSent();
+ // A child removed from the sub-tree appears in the next poll frame
+ void testRemovedChildSent();
+ // A child removed and immediately re-added appears as a value change,
+ // not as a separate remove + create pair
+ void testDeleteAndReAddAppearsAsChange();
+};
diff --git a/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.cxx b/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.cxx
new file mode 100644
index 000000000..95b0340b6
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.cxx
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "test_PropertyChangeWebsocket.hxx"
+#include "httpTestHelpers.hxx"
+
+#include "test_suite/FGTestApi/testGlobals.hxx"
+
+#include <Main/fg_props.hxx>
+#include <Network/http/PropertyChangeObserver.hxx>
+#include <Network/http/PropertyChangeWebsocket.hxx>
+
+#include <nlohmann/json.hpp>
+
+using namespace flightgear::http;
+using namespace FGTestApi::http;
+
+void PropertyChangeWebsocketTests::setUp()
+{
+ FGTestApi::setUp::initTestGlobals("PropertyChangeWebsocket");
+ // Disable the per-poll rate throttle so every poll() fires unconditionally.
+ // This value is read by the PropertyChangeWebsocket constructor.
+ fgSetDouble("/sim/http/property-websocket/update-interval-secs", 0.0);
+}
+
+void PropertyChangeWebsocketTests::tearDown()
+{
+ FGTestApi::tearDown::shutdownTestGlobals();
+}
+
+// ---------------------------------------------------------------------------
+
+void PropertyChangeWebsocketTests::testAddListenerAndPoll()
+{
+ fgSetString("/test/pcws/value", "hello");
+
+ PropertyChangeObserver observer;
+ PropertyChangeWebsocket ws(&observer);
+ MockWebsocketWriter writer;
+
+ // addListener registers the observation; initial _changed flag is true so
+ // the very first poll() will emit the current value without needing check()
+ auto req = buildRequest("GET", "/PropertyListener",
+ R"({"command":"addListener","node":"/test/pcws/value"})");
+ ws.handleRequest(req, writer);
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT(!writer.frames.empty());
+ auto json = nlohmann::json::parse(writer.frames.back());
+ CPPUNIT_ASSERT_EQUAL(std::string("/test/pcws/value"), json.at("path").get<std::string>());
+ CPPUNIT_ASSERT_EQUAL(std::string("hello"), json.at("value").get<std::string>());
+}
+
+void PropertyChangeWebsocketTests::testGetCommand()
+{
+ fgSetDouble("/test/pcws/altitude", 5000.0);
+
+ PropertyChangeObserver observer;
+ PropertyChangeWebsocket ws(&observer);
+ MockWebsocketWriter writer;
+
+ // get returns the value immediately, without registering a listener
+ auto req = buildRequest("GET", "/PropertyListener",
+ R"({"command":"get","node":"/test/pcws/altitude"})");
+ ws.handleRequest(req, writer);
+
+ CPPUNIT_ASSERT_EQUAL(size_t(1), writer.frames.size());
+ auto json = nlohmann::json::parse(writer.frames.front());
+ CPPUNIT_ASSERT_DOUBLES_EQUAL(5000.0, json.at("value").get<double>(), 1.0);
+}
+
+void PropertyChangeWebsocketTests::testSetCommand()
+{
+ fgSetDouble("/test/pcws/heading", 90.0);
+
+ PropertyChangeObserver observer;
+ PropertyChangeWebsocket ws(&observer);
+ MockWebsocketWriter writer;
+
+ auto req = buildRequest("GET", "/PropertyListener",
+ R"({"command":"set","node":"/test/pcws/heading","value":180.0})");
+ ws.handleRequest(req, writer);
+
+ // set writes to the property tree; no frame is emitted
+ CPPUNIT_ASSERT(writer.frames.empty());
+ CPPUNIT_ASSERT_DOUBLES_EQUAL(180.0, fgGetDouble("/test/pcws/heading"), 0.01);
+}
+
+void PropertyChangeWebsocketTests::testNoChangePollEmitsNothing()
+{
+ fgSetString("/test/pcws/stable", "constant");
+
+ PropertyChangeObserver observer;
+ PropertyChangeWebsocket ws(&observer);
+ MockWebsocketWriter writer;
+
+ auto req = buildRequest("GET", "/PropertyListener",
+ R"({"command":"addListener","node":"/test/pcws/stable"})");
+ ws.handleRequest(req, writer);
+
+ // The observer's _prevValue starts empty, so two full check/poll/uncheck
+ // cycles are needed before it stabilises on the current value.
+ for (int i = 0; i < 2; ++i) {
+ observer.check();
+ ws.poll(writer);
+ observer.uncheck();
+ }
+
+ // Third cycle: value is unchanged → poll must be silent
+ observer.check();
+ writer.frames.clear();
+ ws.poll(writer);
+
+ CPPUNIT_ASSERT(writer.frames.empty());
+}
diff --git a/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.hxx b/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.hxx
new file mode 100644
index 000000000..2129622bb
--- /dev/null
+++ b/test_suite/unit_tests/Network/test_PropertyChangeWebsocket.hxx
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2026 James Turner
+// SPDX-License-Identifier: GPL-2.0-or-later
+#pragma once
+
+#include <cppunit/TestFixture.h>
+#include <cppunit/extensions/HelperMacros.h>
+
+/**
+ * Tests for PropertyChangeWebsocket — the websocket interface for watching
+ * individual property nodes for value changes.
+ *
+ * The test pattern for websocket handlers:
+ * 1. Create a MockWebsocketWriter to capture emitted JSON frames.
+ * 2. Call ws.handleRequest() to deliver a JSON command (addListener / get / set).
+ * 3. Drive the check/poll/uncheck cycle that the HTTP server normally runs.
+ * 4. Assert the frames captured by the mock writer.
+ *
+ * Rate throttling is disabled by setting
+ * /sim/http/property-websocket/update-interval-secs = 0 in setUp().
+ */
+class PropertyChangeWebsocketTests : public CppUnit::TestFixture
+{
+ CPPUNIT_TEST_SUITE(PropertyChangeWebsocketTests);
+ CPPUNIT_TEST(testAddListenerAndPoll);
+ CPPUNIT_TEST(testGetCommand);
+ CPPUNIT_TEST(testSetCommand);
+ CPPUNIT_TEST(testNoChangePollEmitsNothing);
+ CPPUNIT_TEST_SUITE_END();
+
+public:
+ void setUp();
+ void tearDown();
+
+ // addListener then poll → initial value frame emitted
+ void testAddListenerAndPoll();
+ // get command → value frame returned immediately
+ void testGetCommand();
+ // set command → property tree updated, no frame emitted
+ void testSetCommand();
+ // addListener, stabilise, no change → poll is silent
+ void testNoChangePollEmitsNothing();
+};
|