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());
+    }
+}


Reply via email to