This is an automated email from the ASF dual-hosted git repository.
chrisdutz pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new c4466a8d8c test: Increase the tests of the spi-drivers module.
c4466a8d8c is described below
commit c4466a8d8c94cee42817cbddd61020bbde4e69ed
Author: Christofer Dutz <[email protected]>
AuthorDate: Wed Jun 24 22:50:06 2026 +0200
test: Increase the tests of the spi-drivers module.
---
plc4j/spi/drivers/pom.xml | 24 ++-
.../drivers/DriverBaseTransportValidationTest.java | 176 +++++++++++++++++
.../java/spi/drivers/MessageCodecBaseTest.java | 212 ++++++++++++++++++++
.../spi/drivers/messages/BuildersAndPojosTest.java | 139 +++++++++++++
.../java/spi/drivers/messages/MessagesTest.java | 193 ++++++++++++++++++
.../spi/drivers/throttle/RequestThrottleTest.java | 220 +++++++++++++++++++++
6 files changed, 959 insertions(+), 5 deletions(-)
diff --git a/plc4j/spi/drivers/pom.xml b/plc4j/spi/drivers/pom.xml
index 94a0fe39ad..66a694839e 100644
--- a/plc4j/spi/drivers/pom.xml
+++ b/plc4j/spi/drivers/pom.xml
@@ -83,15 +83,29 @@
<executions>
<!--
This is the SPI3 driver-base core (ConnectionBase, DriverBase,
MessageCodecBase, …).
- It is still largely covered by integration tests in the individual
driver modules
- rather than unit tests here, so it does not yet meet the
project-wide coverage rule.
- Keep measuring/reporting coverage, but do not fail the build until
the SPI3 core has
- its own unit tests. TODO: remove this override once coverage
reaches the 0.80 minimum.
+ The connection-lifecycle / receive-loop orchestration
(ConnectionBase) and the exhaustive
+ PlcValue converters on the response classes are exercised by the
per-driver integration
+ tests rather than by unit tests here, so the module does not reach
the project-wide 0.80
+ instruction minimum. We still enforce a realistic floor (catching
coverage regressions)
+ and drop the "every class covered" rule. TODO: raise the minimum
towards 0.80 as more
+ SPI-core unit tests are added.
-->
<execution>
<id>check-coverage</id>
<configuration>
- <haltOnFailure>false</haltOnFailure>
+ <haltOnFailure>true</haltOnFailure>
+ <rules combine.self="override">
+ <rule implementation="org.jacoco.maven.RuleConfiguration">
+ <element>BUNDLE</element>
+ <limits>
+ <limit implementation="org.jacoco.report.check.Limit">
+ <counter>INSTRUCTION</counter>
+ <value>COVEREDRATIO</value>
+ <minimum>0.25</minimum>
+ </limit>
+ </limits>
+ </rule>
+ </rules>
</configuration>
</execution>
</executions>
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTransportValidationTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTransportValidationTest.java
new file mode 100644
index 0000000000..7bc538a98a
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTransportValidationTest.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers;
+
+import org.apache.plc4x.java.spi.config.Configuration;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies the SPI-core check that a driver may only be opened over one of
the transports it
+ * declares it supports, plus the {@code allow-unsupported-transport} opt-out.
+ *
+ * <p>The tests exercise the guard purely at the {@code
DriverBase.getConnection(...)} boundary,
+ * with no network and no registered transport, by distinguishing two failure
messages:</p>
+ * <ul>
+ * <li><b>Guard rejection</b> — message contains {@value #GUARD_MARKER} (the
driver does not
+ * support the requested transport).</li>
+ * <li><b>Pre-existing registered-transport failure</b> — message contains
+ * {@value #REGISTERED_MARKER} (the transport passed the guard but is
not a registered
+ * transport in this test JVM).</li>
+ * </ul>
+ */
+class DriverBaseTransportValidationTest {
+
+ /** Substring unique to the driver-supported-transport rejection message.
*/
+ private static final String GUARD_MARKER = "is not supported by driver";
+ /** Substring of the pre-existing "transport not registered at all"
failure message. */
+ private static final String REGISTERED_MARKER = "Unsupported transport";
+
+ /**
+ * Configurable {@link DriverBase} stub: protocol code, optional default
transport, and the
+ * declared supported-transport list are all set per test. It never
actually connects.
+ */
+ static final class StubDriver extends DriverBase {
+ private final String protocolCode;
+ private final String defaultTransport; // nullable -> no
default
+ private final List<String> supportedTransports; // may be empty
+
+ StubDriver(String protocolCode, String defaultTransport, List<String>
supportedTransports) {
+ this.protocolCode = protocolCode;
+ this.defaultTransport = defaultTransport;
+ this.supportedTransports = supportedTransports;
+ }
+
+ @Override public String getProtocolCode() { return protocolCode; }
+ @Override public String getProtocolName() { return protocolCode; }
+ @Override public Optional<String> getDefaultTransportCode() { return
Optional.ofNullable(defaultTransport); }
+ @Override public List<String> getSupportedTransportCodes() { return
supportedTransports; }
+ @Override protected Class<? extends Configuration>
getConfigurationClass() { return Configuration.class; }
+ @Override protected ConnectionBase<?> getConnection(Configuration c,
TransportInstance<?> t, AuditLog a) {
+ throw new UnsupportedOperationException("test stub does not
connect");
+ }
+ }
+
+ /** A driver that supports exactly one transport, "supported", with no
default. */
+ private static StubDriver singleSupportDriver() {
+ return new StubDriver("stub", null, List.of("supported"));
+ }
+
+ /** Open the connection, assert it throws, and return the (non-null)
exception message. */
+ private static String messageFromFailedConnect(DriverBase driver, String
connectionString) {
+ Throwable t = assertThrows(Throwable.class, () ->
driver.getConnection(connectionString));
+ String msg = t.getMessage();
+ assertTrue(msg != null && !msg.isBlank(), "expected a non-empty
failure message");
+ return msg;
+ }
+
+ @Nested
+ class StrictCheck {
+
+ @Test
+ void unsupportedTransportIsRejectedWithRequestedAndSupportedNamed() {
+ String msg = messageFromFailedConnect(singleSupportDriver(),
"stub:other://host");
+ assertTrue(msg.contains(GUARD_MARKER), "should be the guard
rejection: " + msg);
+ assertTrue(msg.contains("other"), "message must name the requested
transport: " + msg);
+ assertTrue(msg.contains("supported"), "message must list the
supported transport(s): " + msg);
+ }
+
+ @Test
+ void supportedTransportPassesTheGuard() {
+ String msg = messageFromFailedConnect(singleSupportDriver(),
"stub:supported://host");
+ assertFalse(msg.contains(GUARD_MARKER), "guard must NOT reject a
supported transport: " + msg);
+ assertTrue(msg.contains(REGISTERED_MARKER), "should reach the
registered-transport lookup: " + msg);
+ }
+
+ @Test
+ void supportedButUnregisteredTransportStillFailsPreExisting() {
+ StubDriver driver = new StubDriver("stub", null, List.of("ghost"));
+ String msg = messageFromFailedConnect(driver, "stub:ghost://host");
+ assertFalse(msg.contains(GUARD_MARKER), "guard passed (ghost is
declared supported): " + msg);
+ assertTrue(msg.contains(REGISTERED_MARKER), "pre-existing
unregistered-transport failure: " + msg);
+ }
+ }
+
+ @Nested
+ class OptOut {
+
+ @Test
+ void optOutAllowsUnsupportedTransportThroughGuard() {
+ String msg = messageFromFailedConnect(singleSupportDriver(),
+ "stub:other://host?allow-unsupported-transport=true");
+ assertFalse(msg.contains(GUARD_MARKER), "opt-out must skip the
guard: " + msg);
+ }
+
+ @Test
+ void optOutDoesNotBypassRegisteredTransportCheck() {
+ String msg = messageFromFailedConnect(singleSupportDriver(),
+ "stub:other://host?allow-unsupported-transport=true");
+ assertTrue(msg.contains(REGISTERED_MARKER),
+ "registered-transport lookup must still run under opt-out: " +
msg);
+ }
+
+ @Test
+ void invalidOptOutValueIsTreatedAsStrict() {
+ String msg = messageFromFailedConnect(singleSupportDriver(),
+ "stub:other://host?allow-unsupported-transport=notabool");
+ assertTrue(msg.contains(GUARD_MARKER),
+ "invalid opt-out value must fall back to strict and reject: "
+ msg);
+ }
+ }
+
+ @Nested
+ class DefaultOnlyDriver {
+
+ /** No explicit supported list, but a default transport "supported". */
+ private StubDriver defaultOnlyDriver() {
+ return new StubDriver("stub", "supported", List.of());
+ }
+
+ @Test
+ void defaultTransportExplicitPassesTheGuard() {
+ String msg = messageFromFailedConnect(defaultOnlyDriver(),
"stub:supported://host");
+ assertFalse(msg.contains(GUARD_MARKER), "default transport must
pass the guard: " + msg);
+ assertTrue(msg.contains(REGISTERED_MARKER), msg);
+ }
+
+ @Test
+ void defaultTransportOmittedPassesTheGuard() {
+ String msg = messageFromFailedConnect(defaultOnlyDriver(),
"stub://host");
+ assertFalse(msg.contains(GUARD_MARKER), "omitted code -> default
must pass the guard: " + msg);
+ assertTrue(msg.contains(REGISTERED_MARKER), msg);
+ }
+
+ @Test
+ void otherTransportRejectedForDefaultOnlyDriver() {
+ String msg = messageFromFailedConnect(defaultOnlyDriver(),
"stub:other://host");
+ assertTrue(msg.contains(GUARD_MARKER), "non-default transport must
be rejected: " + msg);
+ assertTrue(msg.contains("other"), msg);
+ }
+ }
+}
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/MessageCodecBaseTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/MessageCodecBaseTest.java
new file mode 100644
index 0000000000..7fbf8d280f
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/MessageCodecBaseTest.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers;
+
+import org.apache.plc4x.java.spi.buffers.api.Message;
+import org.apache.plc4x.java.spi.buffers.api.WithOption;
+import org.apache.plc4x.java.spi.buffers.api.WriteBuffer;
+import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException;
+import org.apache.plc4x.java.spi.buffers.bytebased.ReadBufferByteBased;
+import org.apache.plc4x.java.spi.buffers.bytebased.WithByteBasedOption;
+import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
+import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration;
+import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MessageCodecBaseTest {
+
+ /**
+ * Trivial wire message: {@code [ totalLength=2 ][ value ]} (two unsigned
bytes, big-endian).
+ */
+ record TestMessage(int value) implements Message {
+ @Override public int getLengthInBytes() { return 2; }
+ @Override public int getLengthInBits() { return 16; }
+ @Override public void serialize(WriteBuffer writeBuffer) throws
BufferException {
+ writeBuffer.writeUnsignedShort(8, (short) 2);
+ writeBuffer.writeUnsignedShort(8, (short) value);
+ }
+ }
+
+ /** Codec for {@link TestMessage}: 1-byte header carrying the total
message length. */
+ static class TestCodec extends MessageCodecBase<TestMessage> {
+ TestCodec(TransportInstance<?> transport, Consumer<TestMessage>
handler) {
+ super("TEST", transport, handler);
+ }
+ @Override protected int getMinimumHeaderSize() { return 1; }
+ @Override protected int calculateTotalMessageSize(byte[] header, int
availableBytes) {
+ return header[0] & 0xFF;
+ }
+ @Override protected TestMessage parseMessage(ReadBufferByteBased
readBuffer) throws BufferException {
+ readBuffer.readUnsignedShort(8); // length
+ return new TestMessage(readBuffer.readUnsignedShort(8));
+ }
+ // The default buffers carry no integer encoding; supply the
big-endian defaults the
+ // generated (un)marshalling expects so
writeUnsignedShort/readUnsignedShort work.
+ @Override protected WriteBufferByteBased createWriteBuffer(int size) {
+ return new WriteBufferByteBased(new byte[size],
+ WithOption.WithUnsignedIntegerEncoding("unsigned-binary"),
+ WithByteBasedOption.WithByteOrder("BIG_ENDIAN"));
+ }
+ @Override protected ReadBufferByteBased createReadBuffer(byte[] data) {
+ return new ReadBufferByteBased(data,
+ WithOption.WithUnsignedIntegerEncoding("unsigned-binary"),
+ WithByteBasedOption.WithByteOrder("BIG_ENDIAN"));
+ }
+ }
+
+ /** In-memory transport: inbound bytes are staged via {@link #feed};
writes are captured. */
+ static class FakeTransport implements
TransportInstance<TransportConfiguration> {
+ private byte[] inbound = new byte[0];
+ private int pos = 0;
+ private final ByteArrayOutputStream written = new
ByteArrayOutputStream();
+ private boolean open = true;
+ private boolean closed = false;
+
+ void feed(byte... data) {
+ byte[] remaining = Arrays.copyOfRange(inbound, pos,
inbound.length);
+ byte[] combined = new byte[remaining.length + data.length];
+ System.arraycopy(remaining, 0, combined, 0, remaining.length);
+ System.arraycopy(data, 0, combined, remaining.length, data.length);
+ inbound = combined;
+ pos = 0;
+ }
+
+ byte[] written() { return written.toByteArray(); }
+ boolean isClosed() { return closed; }
+ void setOpen(boolean open) { this.open = open; }
+
+ @Override public TransportConfiguration getConfiguration() { return
null; }
+ @Override public boolean isOpen() { return open; }
+ @Override public int getNumBytesAvailable() { return inbound.length -
pos; }
+ @Override public byte[] peekReadableBytes(int numBytes) { return
Arrays.copyOfRange(inbound, pos, pos + numBytes); }
+ @Override public byte[] read(int numBytes) {
+ byte[] out = Arrays.copyOfRange(inbound, pos, pos + numBytes);
+ pos += numBytes;
+ return out;
+ }
+ @Override public void write(byte[] bytes) throws TransportException {
written.writeBytes(bytes); }
+ @Override public void close() { closed = true; open = false; }
+ }
+
+ @Test
+ void sendSerializesMessageToTransport() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ TestCodec codec = new TestCodec(transport, m -> { });
+
+ codec.send(new TestMessage(0x2A));
+
+ assertArrayEquals(new byte[]{0x02, 0x2A}, transport.written());
+ }
+
+ @Test
+ void processIncomingDataDeliversCompleteMessage() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ List<TestMessage> received = new ArrayList<>();
+ TestCodec codec = new TestCodec(transport, received::add);
+
+ transport.feed((byte) 0x02, (byte) 0x7F);
+ codec.processIncomingData();
+
+ assertEquals(1, received.size());
+ assertEquals(0x7F, received.get(0).value());
+ assertEquals(0, transport.getNumBytesAvailable(), "frame should be
fully consumed");
+ }
+
+ @Test
+ void processIncomingDataWaitsForIncompleteMessage() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ List<TestMessage> received = new ArrayList<>();
+ TestCodec codec = new TestCodec(transport, received::add);
+
+ // header says total length 2, but only 1 byte is available
+ transport.feed((byte) 0x02);
+ codec.processIncomingData();
+
+ assertTrue(received.isEmpty(), "must not deliver until the full frame
arrived");
+ assertEquals(1, transport.getNumBytesAvailable(), "partial frame must
stay buffered");
+
+ // now the rest arrives
+ transport.feed((byte) 0x55);
+ codec.processIncomingData();
+ assertEquals(1, received.size());
+ assertEquals(0x55, received.get(0).value());
+ }
+
+ @Test
+ void processIncomingDataDeliversMultipleFrames() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ List<TestMessage> received = new ArrayList<>();
+ TestCodec codec = new TestCodec(transport, received::add);
+
+ transport.feed((byte) 0x02, (byte) 0x01, (byte) 0x02, (byte) 0x02);
+ codec.processIncomingData();
+
+ assertEquals(2, received.size());
+ assertEquals(0x01, received.get(0).value());
+ assertEquals(0x02, received.get(1).value());
+ }
+
+ @Test
+ void processIncomingDataReturnsWhenNoHeaderYet() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ List<TestMessage> received = new ArrayList<>();
+ TestCodec codec = new TestCodec(transport, received::add);
+
+ codec.processIncomingData(); // nothing fed
+ assertTrue(received.isEmpty());
+ }
+
+ @Test
+ void isOpenAndCloseDelegateToTransport() throws Exception {
+ FakeTransport transport = new FakeTransport();
+ TestCodec codec = new TestCodec(transport, m -> { });
+
+ assertTrue(codec.isOpen());
+ transport.setOpen(false);
+ assertFalse(codec.isOpen());
+
+ codec.close();
+ assertTrue(transport.isClosed());
+ }
+
+ @Test
+ void sendWrapsTransportFailureAsCodecException() {
+ TransportInstance<TransportConfiguration> failing = new
FakeTransport() {
+ @Override public void write(byte[] bytes) throws
TransportException {
+ throw new TransportException("boom");
+ }
+ };
+ TestCodec codec = new TestCodec(failing, m -> { });
+ assertThrows(MessageCodecException.class, () -> codec.send(new
TestMessage(1)));
+ }
+}
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/BuildersAndPojosTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/BuildersAndPojosTest.java
new file mode 100644
index 0000000000..6bede16626
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/BuildersAndPojosTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers.messages;
+
+import org.apache.plc4x.java.api.messages.PlcReadRequest;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
+import org.apache.plc4x.java.api.messages.PlcWriteRequest;
+import org.apache.plc4x.java.api.model.PlcQuery;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.types.OptionType;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.api.types.PlcValueType;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcTagErrorItem;
+import org.apache.plc4x.java.spi.drivers.messages.metadata.DefaultMetadata;
+import org.apache.plc4x.java.spi.drivers.messages.metadata.DefaultOption;
+import
org.apache.plc4x.java.spi.drivers.messages.metadata.DefaultOptionMetadata;
+import org.apache.plc4x.java.spi.drivers.functions.PlcReader;
+import org.apache.plc4x.java.spi.drivers.functions.PlcSubscriber;
+import org.apache.plc4x.java.spi.drivers.functions.PlcWriter;
+import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
+import org.apache.plc4x.java.spi.drivers.tags.TagConfigParser;
+import org.apache.plc4x.java.spi.values.DefaultPlcValueHandler;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildersAndPojosTest {
+
+ record TestTag(String address) implements PlcTag {
+ @Override public String getAddressString() { return address; }
+ @Override public PlcValueType getPlcValueType() { return
PlcValueType.INT; }
+ }
+
+ /** A tag handler that maps any address to a {@link TestTag}; queries are
not used here. */
+ static class TestTagHandler implements PlcTagHandler {
+ @Override public PlcTag parseTag(String tagAddress) { return new
TestTag(tagAddress); }
+ @Override public PlcQuery parseQuery(String query) { return null; }
+ }
+
+ private final PlcTagHandler handler = new TestTagHandler();
+
+ // Non-null stubs - the builders require a non-null owner but build()
never invokes it.
+ private final PlcReader reader = req -> null;
+ private final PlcWriter writer = req -> null;
+ private final PlcSubscriber subscriber = new PlcSubscriber() {
+ @Override public
java.util.concurrent.CompletableFuture<org.apache.plc4x.java.api.messages.PlcSubscriptionResponse>
subscribe(PlcSubscriptionRequest r) { return null; }
+ @Override public
java.util.concurrent.CompletableFuture<org.apache.plc4x.java.api.messages.PlcUnsubscriptionResponse>
unsubscribe(org.apache.plc4x.java.api.messages.PlcUnsubscriptionRequest r) {
return null; }
+ @Override public
org.apache.plc4x.java.api.model.PlcConsumerRegistration
registerConsumer(java.util.function.Consumer<org.apache.plc4x.java.api.messages.PlcSubscriptionEvent>
c, java.util.Collection<org.apache.plc4x.java.api.model.PlcSubscriptionHandle>
h) { return null; }
+ @Override public void
unregisterConsumer(org.apache.plc4x.java.api.model.PlcConsumerRegistration
registration) { }
+ };
+
+ @Test
+ void readRequestBuilderProducesRequest() {
+ PlcReadRequest request = new DefaultPlcReadRequest.Builder(reader,
handler)
+ .addTagAddress("a", "addr-a")
+ .addTagAddress("b", "addr-b")
+ .build();
+ assertEquals(2, request.getNumberOfTags());
+ assertTrue(request.getTagNames().contains("a"));
+ assertEquals("addr-b", request.getTag("b").getAddressString());
+ }
+
+ @Test
+ void writeRequestBuilderProducesRequest() {
+ PlcWriteRequest request = new DefaultPlcWriteRequest.Builder(writer,
handler, new DefaultPlcValueHandler())
+ .addTagAddress("a", "addr-a", 42)
+ .build();
+ assertEquals(1, request.getNumberOfTags());
+ assertEquals(42, request.getPlcValue("a").getInt());
+ }
+
+ @Test
+ void subscriptionRequestBuilderProducesRequest() {
+ PlcSubscriptionRequest request = new
DefaultPlcSubscriptionRequest.Builder(subscriber, handler)
+ .addCyclicTagAddress("cyc", "addr-c", Duration.ofSeconds(1))
+ .addChangeOfStateTagAddress("cos", "addr-d")
+ .build();
+ assertEquals(2, request.getNumberOfTags());
+ assertTrue(request.getTagNames().contains("cyc"));
+ assertTrue(request.getTagNames().contains("cos"));
+ }
+
+ @Test
+ void tagErrorItemCarriesResponseCode() {
+ DefaultPlcTagErrorItem item = new
DefaultPlcTagErrorItem(PlcResponseCode.INVALID_ADDRESS);
+ assertEquals(PlcResponseCode.INVALID_ADDRESS, item.getResponseCode());
+ }
+
+ @Test
+ void optionMetadataExposesOptions() {
+ DefaultOption option = new DefaultOption(
+ "timeout", OptionType.LONG, "request timeout", false, 5000L,
"1.0");
+ assertEquals("timeout", option.getKey());
+ assertEquals(OptionType.LONG, option.getType());
+ assertEquals("request timeout", option.getDescription());
+
+ DefaultOptionMetadata metadata = new
DefaultOptionMetadata(List.of(option));
+ assertEquals(1, metadata.getOptions().size());
+ }
+
+ @Test
+ void metadataStoresAndReturnsValues() {
+ DefaultMetadata metadata = new DefaultMetadata(Map.of());
+ assertNotNull(metadata.keys());
+ assertTrue(metadata.entries().isEmpty());
+ }
+
+ @Test
+ void tagConfigParserExtractsTrailingConfig() {
+ Map<String, String> config =
TagConfigParser.parse("%DB1.DBW0:INT{poll-rate: 100, name: \"x\"}");
+ assertEquals("100", config.get("poll-rate"));
+ assertEquals("x", config.get("name"));
+
+ // an address with no trailing config block yields an empty map
+ assertTrue(TagConfigParser.parse("%DB1.DBW0:INT").isEmpty());
+ }
+}
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/MessagesTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/MessagesTest.java
new file mode 100644
index 0000000000..d08386b9b6
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/messages/MessagesTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers.messages;
+
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.api.types.PlcSubscriptionType;
+import org.apache.plc4x.java.api.types.PlcValueType;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcTagItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcTagValueItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcTagItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcTagValueItem;
+import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo;
+import org.apache.plc4x.java.spi.values.PlcBOOL;
+import org.apache.plc4x.java.spi.values.PlcINT;
+import org.apache.plc4x.java.spi.values.PlcREAL;
+import org.apache.plc4x.java.spi.values.PlcSTRING;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MessagesTest {
+
+ /** Minimal {@link PlcTag} - the interface only requires an address
string. */
+ record TestTag(String address, PlcValueType valueType) implements PlcTag {
+ @Override public String getAddressString() { return address; }
+ @Override public PlcValueType getPlcValueType() { return valueType; }
+ }
+
+ private static LinkedHashMap<String, PlcTagItem<PlcTag>> tagItems(String
name, PlcValueType type) {
+ LinkedHashMap<String, PlcTagItem<PlcTag>> tags = new LinkedHashMap<>();
+ tags.put(name, new DefaultPlcTagItem<>(new TestTag(name + "-addr",
type)));
+ return tags;
+ }
+
+ private static Map<String, PlcResponseItem<PlcValue>> ok(String name,
PlcValue value) {
+ Map<String, PlcResponseItem<PlcValue>> values = new LinkedHashMap<>();
+ values.put(name, new DefaultPlcResponseItem<>(PlcResponseCode.OK,
value));
+ return values;
+ }
+
+ @Test
+ void readRequestExposesTags() {
+ DefaultPlcReadRequest request = new DefaultPlcReadRequest(null,
tagItems("a", PlcValueType.INT));
+ assertEquals(1, request.getNumberOfTags());
+ assertTrue(request.getTagNames().contains("a"));
+ assertEquals(1, request.getTags().size());
+ assertEquals("a-addr", request.getTag("a").getAddressString());
+ }
+
+ @Test
+ void readResponseNumericGettersDelegateToValue() {
+ DefaultPlcReadRequest request = new DefaultPlcReadRequest(null,
tagItems("a", PlcValueType.INT));
+ DefaultPlcReadResponse response = new DefaultPlcReadResponse(request,
ok("a", new PlcINT(42)));
+
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("a"));
+ assertEquals(42, response.getInteger("a"));
+ assertEquals(42L, response.getLong("a"));
+ assertEquals((short) 42, response.getShort("a"));
+ assertEquals((byte) 42, response.getByte("a"));
+ assertEquals(42.0f, response.getFloat("a"));
+ assertEquals(42.0, response.getDouble("a"));
+ assertEquals(java.math.BigInteger.valueOf(42),
response.getBigInteger("a"));
+ assertEquals(42, ((Number) response.getObject("a")).intValue());
+ assertEquals("a-addr", response.getTag("a").getAddressString());
+ assertNotNull(response.getPlcValue("a"));
+ assertEquals(1, response.getNumberOfValues("a"));
+ assertEquals(1, response.getTagNames().size());
+ assertNotNull(response.getRequest());
+
+ // isValid* must never throw and reflect the value type
+ assertTrue(response.isValidInteger("a"));
+ assertFalse(response.isValidDate("a"));
+ assertFalse(response.isValidTime("a"));
+ assertFalse(response.isValidDateTime("a"));
+
+ // bulk accessors
+ assertEquals(1, response.getAllIntegers("a").size());
+ assertEquals(1, response.getAllObjects("a").size());
+ }
+
+ @Test
+ void readResponseStringAndBooleanAndReal() {
+ DefaultPlcReadRequest request = new DefaultPlcReadRequest(null,
tagItems("s", PlcValueType.STRING));
+ DefaultPlcReadResponse strResp = new DefaultPlcReadResponse(request,
ok("s", new PlcSTRING("hi")));
+ assertEquals("hi", strResp.getString("s"));
+ assertTrue(strResp.isValidString("s"));
+ assertEquals("hi", strResp.getObject("s"));
+
+ DefaultPlcReadResponse boolResp = new DefaultPlcReadResponse(
+ new DefaultPlcReadRequest(null, tagItems("b", PlcValueType.BOOL)),
ok("b", new PlcBOOL(true)));
+ assertTrue(boolResp.getBoolean("b"));
+ assertTrue(boolResp.isValidBoolean("b"));
+
+ DefaultPlcReadResponse realResp = new DefaultPlcReadResponse(
+ new DefaultPlcReadRequest(null, tagItems("r", PlcValueType.INT)),
ok("r", new PlcREAL(1.5f)));
+ assertEquals(1.5f, realResp.getFloat("r"));
+ assertEquals(1.5, realResp.getDouble("r"), 0.0001);
+ }
+
+ @Test
+ void readResponseReportsErrorCodePerTag() {
+ DefaultPlcReadRequest request = new DefaultPlcReadRequest(null,
tagItems("a", PlcValueType.INT));
+ Map<String, PlcResponseItem<PlcValue>> values = new LinkedHashMap<>();
+ values.put("a", new
DefaultPlcResponseItem<>(PlcResponseCode.NOT_FOUND, null));
+ DefaultPlcReadResponse response = new DefaultPlcReadResponse(request,
values);
+ assertEquals(PlcResponseCode.NOT_FOUND, response.getResponseCode("a"));
+ }
+
+ @Test
+ void subscriptionEventExposesTimestampAndValues() {
+ Instant now = Instant.ofEpochMilli(1_700_000_000_000L);
+ DefaultPlcSubscriptionEvent event = new
DefaultPlcSubscriptionEvent(now, ok("a", new PlcINT(7)));
+ assertEquals(now, event.getTimestamp());
+ assertEquals(PlcResponseCode.OK, event.getResponseCode("a"));
+ assertEquals(7, event.getInteger("a"));
+ assertEquals("7", event.getString("a"));
+ assertTrue(event.isValidInteger("a"));
+ assertTrue(event.getTagNames().contains("a"));
+ assertNotNull(event.getPlcValue("a"));
+ }
+
+ @Test
+ void writeRequestAndResponse() {
+ LinkedHashMap<String, PlcTagValueItem<PlcTag>> tags = new
LinkedHashMap<>();
+ tags.put("a", new DefaultPlcTagValueItem<>(new TestTag("a-addr",
PlcValueType.INT), new PlcINT(5)));
+ DefaultPlcWriteRequest request = new DefaultPlcWriteRequest(null,
tags);
+ assertEquals(1, request.getNumberOfTags());
+ assertEquals(5, request.getPlcValue("a").getInt());
+ assertEquals("a-addr", request.getTag("a").getAddressString());
+
+ DefaultPlcWriteResponse response = new
DefaultPlcWriteResponse(request, Map.of("a", PlcResponseCode.OK));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("a"));
+ assertTrue(response.getTagNames().contains("a"));
+ assertNotNull(response.getRequest());
+ }
+
+ @Test
+ void responseAndTagValueItems() {
+ DefaultPlcResponseItem<PlcValue> item = new
DefaultPlcResponseItem<>(PlcResponseCode.OK, new PlcINT(1));
+ assertEquals(PlcResponseCode.OK, item.getResponseCode());
+ assertEquals(1, item.getValue().getInt());
+
+ DefaultPlcTagItem<PlcTag> tagItem = new DefaultPlcTagItem<>(new
TestTag("x", PlcValueType.INT));
+ assertEquals("x", tagItem.getTag().getAddressString());
+
+ DefaultPlcTagValueItem<PlcTag> valueItem =
+ new DefaultPlcTagValueItem<>(new TestTag("y", PlcValueType.INT),
new PlcINT(9));
+ assertEquals("y", valueItem.getTag().getAddressString());
+ assertEquals(9, valueItem.getValue().getInt());
+ }
+
+ @Test
+ void subscriptionTagAndArrayInfo() {
+ DefaultPlcSubscriptionTag tag = new DefaultPlcSubscriptionTag(
+ PlcSubscriptionType.CYCLIC, new TestTag("a", PlcValueType.INT),
Duration.ofSeconds(1));
+ assertEquals(PlcSubscriptionType.CYCLIC, tag.getPlcSubscriptionType());
+ assertTrue(tag.getDuration().isPresent());
+ assertEquals("a", tag.getTag().getAddressString());
+
+ DefaultArrayInfo arrayInfo = new DefaultArrayInfo(0, 9);
+ assertEquals(0, arrayInfo.getLowerBound());
+ assertEquals(9, arrayInfo.getUpperBound());
+ assertEquals(10, arrayInfo.getSize());
+ }
+}
diff --git
a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/throttle/RequestThrottleTest.java
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/throttle/RequestThrottleTest.java
new file mode 100644
index 0000000000..fac58417d6
--- /dev/null
+++
b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/throttle/RequestThrottleTest.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.spi.drivers.throttle;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RequestThrottleTest {
+
+ @Test
+ @DisplayName("Should initialize with correct max concurrent requests")
+ void testInitialization() {
+ RequestThrottle throttle = new RequestThrottle(3);
+
+ assertEquals(3, throttle.getMaxConcurrentRequests());
+ assertEquals(3, throttle.getAvailablePermits());
+ assertEquals(0, throttle.getInFlightRequests());
+ }
+
+ @Test
+ @DisplayName("Should reject invalid max concurrent requests")
+ void testInvalidInitialization() {
+ assertThrows(IllegalArgumentException.class, () -> new
RequestThrottle(0));
+ assertThrows(IllegalArgumentException.class, () -> new
RequestThrottle(-1));
+ }
+
+ @Test
+ @DisplayName("Should throttle concurrent requests to max limit")
+ void testThrottling() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(2);
+ AtomicInteger concurrentCount = new AtomicInteger(0);
+ AtomicInteger maxConcurrent = new AtomicInteger(0);
+
+ // Create 5 slow requests
+ CompletableFuture<?>[] futures = new CompletableFuture[5];
+ for (int i = 0; i < 5; i++) {
+ futures[i] = throttle.execute(() -> {
+ int current = concurrentCount.incrementAndGet();
+ maxConcurrent.updateAndGet(max -> Math.max(max, current));
+
+ return CompletableFuture.runAsync(() -> {
+ try {
+ Thread.sleep(50); // Simulate work
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }).thenRun(concurrentCount::decrementAndGet);
+ });
+ }
+
+ // Wait for all to complete
+ CompletableFuture.allOf(futures).get();
+
+ // Verify max concurrent never exceeded 2
+ assertEquals(2, maxConcurrent.get(), "Max concurrent requests should
not exceed throttle limit");
+ assertEquals(0, concurrentCount.get(), "All requests should have
completed");
+ }
+
+ @Test
+ @DisplayName("Should release permit on request completion")
+ void testPermitRelease() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(1);
+
+ CompletableFuture<String> future = throttle.execute(() ->
+ CompletableFuture.completedFuture("test"));
+
+ future.get();
+
+ assertEquals(1, throttle.getAvailablePermits());
+ assertEquals(0, throttle.getInFlightRequests());
+ }
+
+ @Test
+ @DisplayName("Should release permit on request failure")
+ void testPermitReleaseOnFailure() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(1);
+
+ CompletableFuture<String> future = throttle.execute(() ->
+ CompletableFuture.failedFuture(new RuntimeException("test
error")));
+
+ try {
+ future.get();
+ fail("Should have thrown exception");
+ } catch (Exception e) {
+ // Expected
+ }
+
+ assertEquals(1, throttle.getAvailablePermits());
+ assertEquals(0, throttle.getInFlightRequests());
+ }
+
+ @Test
+ @DisplayName("Should increase permits when adjusting max upwards")
+ void testAdjustMaxUpwards() {
+ RequestThrottle throttle = new RequestThrottle(2);
+
+ throttle.adjustMaxConcurrentRequests(5);
+
+ assertEquals(5, throttle.getMaxConcurrentRequests());
+ assertEquals(5, throttle.getAvailablePermits());
+ }
+
+ @Test
+ @DisplayName("Should decrease permits when adjusting max downwards")
+ void testAdjustMaxDownwards() {
+ RequestThrottle throttle = new RequestThrottle(5);
+
+ throttle.adjustMaxConcurrentRequests(2);
+
+ assertEquals(2, throttle.getMaxConcurrentRequests());
+ assertEquals(2, throttle.getAvailablePermits());
+ }
+
+ @Test
+ @DisplayName("Should reject invalid adjustment")
+ void testInvalidAdjustment() {
+ RequestThrottle throttle = new RequestThrottle(2);
+ assertThrows(IllegalArgumentException.class, () ->
throttle.adjustMaxConcurrentRequests(0));
+ }
+
+ @Test
+ @DisplayName("Should handle adjustment with in-flight requests")
+ void testAdjustWithInFlightRequests() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(3);
+
+ // Start 2 long-running requests
+ CompletableFuture<Void> blocker1 = new CompletableFuture<>();
+ CompletableFuture<Void> blocker2 = new CompletableFuture<>();
+
+ throttle.execute(() -> blocker1);
+ throttle.execute(() -> blocker2);
+
+ // Should have 1 permit available, 2 in flight
+ assertEquals(1, throttle.getAvailablePermits());
+ assertEquals(2, throttle.getInFlightRequests());
+
+ // Adjust down to 2 (should drain the 1 available permit)
+ throttle.adjustMaxConcurrentRequests(2);
+
+ assertEquals(2, throttle.getMaxConcurrentRequests());
+ assertEquals(0, throttle.getAvailablePermits());
+ assertEquals(2, throttle.getInFlightRequests());
+
+ // Complete the requests
+ blocker1.complete(null);
+ blocker2.complete(null);
+
+ // Give a moment for cleanup
+ Thread.sleep(10);
+
+ assertEquals(0, throttle.getInFlightRequests());
+ // After adjustment down to 2 and completion of 2 requests, we should
have 2 permits
+ assertEquals(2, throttle.getAvailablePermits());
+ }
+
+ @Test
+ @DisplayName("Should track in-flight requests correctly")
+ void testInFlightTracking() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(2);
+
+ CompletableFuture<Void> blocker = new CompletableFuture<>();
+
+ throttle.execute(() -> blocker);
+
+ assertTrue(throttle.getInFlightRequests() > 0);
+ assertEquals(1, throttle.getInFlightRequests());
+ assertEquals(1, throttle.getAvailablePermits());
+
+ blocker.complete(null);
+
+ // Give a moment for cleanup
+ Thread.sleep(10);
+
+ assertEquals(0, throttle.getInFlightRequests());
+ assertEquals(2, throttle.getAvailablePermits());
+ }
+
+ @Test
+ @DisplayName("Should release permit even when the supplier throws")
+ void testSupplierException() throws Exception {
+ RequestThrottle throttle = new RequestThrottle(1);
+
+ CompletableFuture<String> future = throttle.execute(() -> {
+ throw new RuntimeException("Supplier failed");
+ });
+
+ try {
+ future.get();
+ fail("Should have thrown exception");
+ } catch (Exception e) {
+ assertInstanceOf(RuntimeException.class, e.getCause());
+ assertEquals("Supplier failed", e.getCause().getMessage());
+ }
+
+ // Permit should be released even though supplier threw
+ assertEquals(1, throttle.getAvailablePermits());
+ assertEquals(0, throttle.getInFlightRequests());
+ }
+}