From 5f596d85922120083dd26c3de07d098d9b270bea Mon Sep 17 00:00:00 2001 From: Samuel Coleman Date: Tue, 26 May 2020 18:59:01 +0100 Subject: [PATCH 1/5] Move tests to the conventional path. --- build.gradle | 5 ----- {test/src => src/test/java}/test/NRJavaSerialTest.java | 0 {test/src => src/test/java}/test/ReadTest.java | 0 {test/src => src/test/java}/test/Rfc2217Test.java | 0 {test/src => src/test/java}/test/SerialTest.java | 0 5 files changed, 5 deletions(-) rename {test/src => src/test/java}/test/NRJavaSerialTest.java (100%) rename {test/src => src/test/java}/test/ReadTest.java (100%) rename {test/src => src/test/java}/test/Rfc2217Test.java (100%) rename {test/src => src/test/java}/test/SerialTest.java (100%) diff --git a/build.gradle b/build.gradle index 842ff7d8..228f8e09 100644 --- a/build.gradle +++ b/build.gradle @@ -18,11 +18,6 @@ archivesBaseName = "nrjavaserial" version = props."app.version" sourceSets { - test { - java { - srcDirs = ["test/src"] - } - } main { resources { srcDirs = ["src/main/resources", "src/main/c/resources"] diff --git a/test/src/test/NRJavaSerialTest.java b/src/test/java/test/NRJavaSerialTest.java similarity index 100% rename from test/src/test/NRJavaSerialTest.java rename to src/test/java/test/NRJavaSerialTest.java diff --git a/test/src/test/ReadTest.java b/src/test/java/test/ReadTest.java similarity index 100% rename from test/src/test/ReadTest.java rename to src/test/java/test/ReadTest.java diff --git a/test/src/test/Rfc2217Test.java b/src/test/java/test/Rfc2217Test.java similarity index 100% rename from test/src/test/Rfc2217Test.java rename to src/test/java/test/Rfc2217Test.java diff --git a/test/src/test/SerialTest.java b/src/test/java/test/SerialTest.java similarity index 100% rename from test/src/test/SerialTest.java rename to src/test/java/test/SerialTest.java From 923d8a7740987e1d1560f3b74424dab7b7df8e84 Mon Sep 17 00:00:00 2001 From: Samuel Coleman Date: Tue, 26 May 2020 19:04:20 +0100 Subject: [PATCH 2/5] Switch to JUnit 5, and enable testing from Gradle. --- build.gradle | 9 ++++++++- src/test/java/test/SerialTest.java | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 228f8e09..aec6890a 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ repositories { dependencies { compile fileTree(dir: 'libs', includes: ['*.jar']) - testCompile 'junit:junit:4.12' + testImplementation('org.junit.jupiter:junit-jupiter:5.6.2') compile 'commons-net:commons-net:3.3' compileOnly 'net.java.dev.jna:jna:4.4.0' compileOnly 'net.java.dev.jna:jna-platform:4.4.0' @@ -57,6 +57,13 @@ jar { } +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + task javadocJar(type: Jar) { classifier = 'javadoc' from javadoc diff --git a/src/test/java/test/SerialTest.java b/src/test/java/test/SerialTest.java index 37da6170..6c4328aa 100644 --- a/src/test/java/test/SerialTest.java +++ b/src/test/java/test/SerialTest.java @@ -1,8 +1,8 @@ package test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; public class SerialTest { From 7c316dd8d7eafb0f21be97ede989500b84ff1ac7 Mon Sep 17 00:00:00 2001 From: Samuel Coleman Date: Wed, 27 May 2020 02:22:50 +0100 Subject: [PATCH 3/5] Add a read/write test, and a stub identifier test. The read/write test expects to be configured (via environment variables; see the `SerialPortExtension` Javadoc for details) with the names of two serial ports connected to each other with a null modem cable. Armed with this hardware, it verifies basic read/write functionality of the library across a range of baud rates, and using both block and byte transfers. The identifier test merely verifies a non-null response. Not terribly useful at present, but I hope to use it to validate that the ports configured for the other test are reported correctly (assuming that the user configured them correctly, of course). --- .../java/gnu/io/CommPortIdentifierTest.java | 26 ++ src/test/java/gnu/io/SerialPortExtension.java | 220 +++++++++++ .../java/gnu/io/SerialPortReadWriteTest.java | 346 ++++++++++++++++++ 3 files changed, 592 insertions(+) create mode 100644 src/test/java/gnu/io/CommPortIdentifierTest.java create mode 100644 src/test/java/gnu/io/SerialPortExtension.java create mode 100644 src/test/java/gnu/io/SerialPortReadWriteTest.java diff --git a/src/test/java/gnu/io/CommPortIdentifierTest.java b/src/test/java/gnu/io/CommPortIdentifierTest.java new file mode 100644 index 00000000..a1e09c46 --- /dev/null +++ b/src/test/java/gnu/io/CommPortIdentifierTest.java @@ -0,0 +1,26 @@ +package gnu.io; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.Enumeration; + +import org.junit.jupiter.api.Test; + +class CommPortIdentifierTest +{ + @Test + void testGetCommPortIdentifiers() + { + @SuppressWarnings("rawtypes") + Enumeration identifiers = CommPortIdentifier.getPortIdentifiers(); + assertNotNull(identifiers); + + /* Hard to assert on anything else when the test hardware may change + * dramatically. + * + * TODO: Wire up SerialPortExtension without its test-disabling + * ExecutionCondition behaviour, and use the names of its configured + * ports to confirm that the identifiers enumeration at least contains + * something when we know what to expect in it. */ + } +} diff --git a/src/test/java/gnu/io/SerialPortExtension.java b/src/test/java/gnu/io/SerialPortExtension.java new file mode 100644 index 00000000..7710930a --- /dev/null +++ b/src/test/java/gnu/io/SerialPortExtension.java @@ -0,0 +1,220 @@ +package gnu.io; + +import java.io.Closeable; +import java.io.IOException; +import java.util.logging.Logger; + +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Provisioning of serial ports for functionality testing. + *

+ * This test extension automates the acquisition and resetting of two serial + * ports for functional testing. Tests which use these ports assume that + * they're connected to each other with a null modem cable so that data can be + * exchanged between them. Then, so that this class will use them, set the + * NRJS_TEST_PORT_A and NRJS_TEST_PORT_B environment + * variables to the names of the ports prior to executing tests. + *

+ * For example: + * + *

+ * NRJS_TEST_PORT_A=/dev/ttyS0 NRJS_TEST_PORT_B=/dev/ttyS1 ./gradlew test
+ * 
+ * + * Or, on Windows: + * + *
+ * set NRJS_TEST_PORT_A=COM1
+ * set NRJS_TEST_PORT_B=COM2
+ * gradlew.bat test
+ * 
+ */ +class SerialPortExtension implements Closeable, ExecutionCondition, BeforeEachCallback, AfterEachCallback +{ + private static final Logger log = Logger.getLogger(SerialPortExtension.class.getName()); + + /** The environment variable holding the name of the first test port. */ + static final String A_PORT_ENV = "NRJS_TEST_PORT_A"; + /** The environment variable holding the name of the second test port. */ + static final String B_PORT_ENV = "NRJS_TEST_PORT_B"; + + /** + * The owner name to pass to {@link CommPortIdentifier#open(String, int)}. + */ + private static final String PORT_OWNER = "NRJavaSerial - SerialPortExtension"; + /** The timeout to pass to {@link CommPortIdentifier#open(String, int)}. */ + private static final int OPEN_TIMEOUT = 1_000; + + /** + * When the test port configuration is sane, this is returned to indicate + * that tests relying on serial ports should be enabled. + */ + private static final ConditionEvaluationResult HAS_PORTS = ConditionEvaluationResult + .enabled("Two serial ports are available, and could be opened."); + /** + * When test ports aren't configured, this is returned to indicate that + * tests relying on serial ports should be disabled. + */ + private static final ConditionEvaluationResult MISSING_PORTS = ConditionEvaluationResult + .disabled("The system does not have two serial ports, or they could not be opened."); + + /** Whether both test port identifiers have been populated. */ + final boolean hasIds; + /** + * The first test port identifier. + *

+ * Populated at construction by searching for ports matching the name given + * via the {@link SerialPortExtension#A_PORT_ENV} environment variable. + */ + final CommPortIdentifier aId; + /** + * The second test port identifier. + *

+ * Populated at construction by searching for ports matching the name given + * via the {@link SerialPortExtension#B_PORT_ENV} environment variable. + */ + final CommPortIdentifier bId; + /** + * The first test port. + *

+ * Ports are opened from their corresponding identifiers prior to each + * test, and are closed afterwards. This field will always be null outside + * of the context of a test. + */ + SerialPort a = null; + /** + * The second test port. + *

+ * Ports are opened from their corresponding identifiers prior to each + * test, and are closed afterwards. This field will always be null outside + * of the context of a test. + */ + SerialPort b = null; + + public SerialPortExtension() + { + String aName = null; + String bName = null; + try + { + aName = System.getenv(SerialPortExtension.A_PORT_ENV); + bName = System.getenv(SerialPortExtension.B_PORT_ENV); + } + catch (SecurityException e) + { + log.severe("Failed a security check while accessing the environment variable containing the port name: " + + e.getMessage()); + } + + if (aName == null || bName == null) + { + String gradlew; + String setter; + String[] ports; + if (OS.WINDOWS.isCurrentOs()) + { + gradlew = "gradlew.bat"; + setter = "set"; + ports = new String[] { "COM1", "COM2" }; + } + else if (OS.MAC.isCurrentOs()) + { + gradlew = "./gradlew"; + setter = "export"; + ports = new String[] { "/dev/tty.usbserial-a", "/dev/tty.usbserial-b" }; + } + else + { + gradlew = "./gradlew"; + setter = "export"; + ports = new String[] { "dev/ttyUSB0", "/dev/ttyUSB1" }; + } + log.severe("The serial port functionality tests require the use of two ports. These should be connected to " + + "each other with a null modem cable. Then set the environment variables " + + SerialPortExtension.A_PORT_ENV + " and " + SerialPortExtension.B_PORT_ENV + " to the names of " + + "the ports, restart the Gradle daemon, and re-run the tests. For example:\n\n\t" + setter + " " + + SerialPortExtension.A_PORT_ENV + "=" + ports[0] + " " + SerialPortExtension.B_PORT_ENV + "=" + + ports[1] + "\n\t" + gradlew + " --stop\n\t" + gradlew + " cleanTest test --no-build-cache " + + "--info"); + + this.hasIds = false; + this.aId = null; + this.bId = null; + return; + } + + CommPortIdentifier aId; + CommPortIdentifier bId; + try + { + aId = CommPortIdentifier.getPortIdentifier(aName); + bId = CommPortIdentifier.getPortIdentifier(bName); + } + catch (NoSuchPortException e) + { + log.severe("No such port: " + e.getMessage()); + + this.hasIds = false; + this.aId = null; + this.bId = null; + return; + } + + log.info("Will attempt to use ports " + aId.getName() + " and " + bId.getName() + " for serial port " + + "functionality tests."); + this.hasIds = true; + this.aId = aId; + this.bId = bId; + } + + @Override + public void close() + { + if (this.a != null) + { + a.close(); + a = null; + } + if (this.b != null) + { + b.close(); + b = null; + } + } + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) + { + return this.hasIds + ? SerialPortExtension.HAS_PORTS + : SerialPortExtension.MISSING_PORTS; + } + + @Override + public void beforeEach(ExtensionContext context) throws PortInUseException, NoSuchPortException + { + try + { + this.a = this.aId.open(SerialPortExtension.PORT_OWNER, SerialPortExtension.OPEN_TIMEOUT); + this.b = this.bId.open(SerialPortExtension.PORT_OWNER, SerialPortExtension.OPEN_TIMEOUT); + } + catch (PortInUseException e) + { + log.severe("Port is in use: " + e.getMessage()); + this.close(); + throw e; + } + } + + @Override + public void afterEach(ExtensionContext context) throws IOException + { + this.close(); + } +} diff --git a/src/test/java/gnu/io/SerialPortReadWriteTest.java b/src/test/java/gnu/io/SerialPortReadWriteTest.java new file mode 100644 index 00000000..a9123317 --- /dev/null +++ b/src/test/java/gnu/io/SerialPortReadWriteTest.java @@ -0,0 +1,346 @@ +package gnu.io; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.logging.Logger; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Test the read/write functionality of the {@link SerialPort} implementation. + *

+ * This test is nonspecific as to which implementation; it exercises + * only the public interface of the Java Communications API. Ports are opened + * by the {@link SerialPortExtension} test extension, presumably by way of + * {@link CommPortIdentifier}. + */ +class SerialPortReadWriteTest +{ + private static final Logger log = Logger.getLogger(SerialPortReadWriteTest.class.getName()); + + private static final String START = "Testing read/write via %s at %d baud..."; + private static final String FINISH = "Completed read/write via %s at %d baud in %.3fs."; + + private static final String NO_DATA_READ = "Didn't read any data at all – are you sure the ports are connected via " + + "a null cable?"; + private static final String NOT_ENOUGH_READ = "Didn't read enough data. Maybe your operating system buffer isn't " + + "big enough."; + private static final String DATA_MISMATCH = "Read the expected amount of data, but it doesn't match what was sent."; + private static final String SLOW_YOUR_ROLL = "Block read/write at %d baud completed successfully, but much %s than " + + "expected. Maybe your serial ports only pretend to support this baud rate."; + + @RegisterExtension + SerialPortExtension ports = new SerialPortExtension(); + + /** How long we want each read/write test to take, in milliseconds. */ + private static final int TARGET_DURATION = 1_000; + /** + * The inter-byte read timeout for the read/write test. + *

+ * Note that the test as a whole can take longer to run than this timeout, + * because it only limits the maximum amount of read time elapsed between + * two successive byte reads. + */ + private static final int TIMEOUT = SerialPortReadWriteTest.TARGET_DURATION * 2; + /** + * The maximum time a read/write test should take. Log a warning if it + * takes any longer. + *

+ * The volume of data used in the block read/write tests corresponds to the + * baud rate being used, so the amount of time consumed should stay fairly + * close to one second. If it takes dramatically more or less time, then + * it's possible that some other baud rate is being used instead. This + * could otherwise go unnoticed if both test ports use the same hardware + * or driver, and misinterpret the setting similarly. + */ + private static final int LOW_SPEED_TRAP = 1250; + /** + * The minimum time a read/write test should take. Log a warning if it + * takes any less. + * + * @see SerialPortReadWriteTest#LOW_SPEED_TRAP + */ + private static final int HIGH_SPEED_TRAP = 750; + + /** The baud rates at which to test reading/writing. */ + private static final int[] BAUDS = new int[] { + 150, 200, 300, 600, + 1_200, 2_400, 4_800, 9_600, + 19_200, 38_400, 57_600, 115_200 + }; + + /** + * Low-speed baud rates. + *

+ * FIXME: Defined on all supported platforms, but not actually functional + * on macOS. + */ + private static final int[] LOW_SPEED_BAUDS = new int[] { + 50, 75, 110, 134 + }; + + /** + * Whether the high-speed baud rates should also be tested. + *

+ * Even when supported by the driver and hardware, you may encounter errors + * at high baud rates due to anomalies in the physical connection. + * High-speed baud rates work best over short connections and communication + * may fail through no fault of the software – due to dodgy cabling, or + * connectors, or interference. + * + * @see SerialPortReadWriteTest#HIGH_SPEED_BAUDS + */ + private static final boolean INCLUDE_HIGH_SPEED_BAUDS = false; + + /** + * High-speed baud rates. + *

+ * Common, but much less common than those found in + * {@link SerialPortReadWriteTest#BAUDS}; not tested by default. + */ + private static final int[] HIGH_SPEED_BAUDS = new int[] { + 230_400, 460_800, 921_600 + }; + + /** + * Generates a set of tests to exchange blocks of data between two serial + * ports. This is intended to exercise the + * {@link OutputStream#write(byte[])} and {@link InputStream#read(byte[])} + * methods of the underlying streams on the {@link CommPort}. + *

+ * baud / 10 bytes are transferred; this should take about one + * second. + * + * @return a set of block read/write tests + */ + @TestFactory + @Tag("slow") + Stream testAllReadWriteBlocks() + { + return this + .getTestBauds() + .mapToObj(baud -> dynamicTest(String.format("testReadWrite(%d, BlockCopy)", baud), + () -> this.testReadWrite(baud, baud / 10, new BlockCopy()))); + } + + /** + * Generates a set of tests to exchange individual bytes of data between + * two serial ports. This is intended to exercise the + * {@link OutputStream#write(int)} and {@link InputStream#read()} methods + * of the underlying streams on the {@link CommPort}. + *

+ * 64B is transferred regardless of baud rate; this should take about one + * second. + * + * @return a set of byte-by-byte read/write tests + */ + @TestFactory + @Tag("slow") + Stream testAllReadWriteBytes() + { + return this + .getTestBauds() + .mapToObj(baud -> dynamicTest(String.format("testReadWrite(%d, ByteCopy)", baud), + () -> this.testReadWrite(baud, 64, new ByteCopy()))); + } + + /** + * @return a stream of baud rates to test at + */ + private IntStream getTestBauds() + { + IntStream bauds; + if (OS.MAC.isCurrentOs()) + { + bauds = IntStream.empty(); + } + else + { + bauds = Arrays.stream(LOW_SPEED_BAUDS); + } + bauds = IntStream.concat( + bauds, + Arrays.stream(SerialPortReadWriteTest.BAUDS)); + if (SerialPortReadWriteTest.INCLUDE_HIGH_SPEED_BAUDS) + { + bauds = IntStream.concat( + bauds, + Arrays.stream(SerialPortReadWriteTest.HIGH_SPEED_BAUDS)); + } + return bauds; + } + + /** + * Represents a strategy for copying data from one buffer to another via + * a pair of streams, which are internally connected to each other. + */ + @FunctionalInterface + private static interface CopyFunction + { + /** + * Given a pair of streams which are connected to each other, copy as + * much of the data from the input buffer writeBuffer to + * the output buffer readBuffer as possible, and return + * the number of bytes transferred. + * + * @param writeBuffer data to be written to the output stream + * @param out the write target + * @param readBuffer data read from the input stream + * @param in the read source + * @return the total number of bytes read + * @throws IOException + */ + int copy(byte[] writeBuffer, OutputStream out, byte[] readBuffer, InputStream in) throws IOException; + } + + /** + * A block copier. The entire write buffer is written in a single pass, and + * reads consume as much from the input stream as possible, up to and + * including the entire read buffer. + */ + private static class BlockCopy implements CopyFunction + { + @Override + public int copy(byte[] writeBuffer, OutputStream out, byte[] readBuffer, InputStream in) throws IOException + { + out.write(writeBuffer); + int pos = 0; + while (pos < writeBuffer.length) + { + int read = in.read(readBuffer, pos, readBuffer.length - pos); + if (read <= 0) + { + break; + } + else + { + pos += read; + } + } + return pos; + } + } + + /** + * A byte copier. Individual bytes are written and read one at a time. + */ + private static class ByteCopy implements CopyFunction + { + @Override + public int copy(byte[] writeBuffer, OutputStream out, byte[] readBuffer, InputStream in) throws IOException + { + int pos = 0; + for (; pos < writeBuffer.length; ++pos) + { + out.write(writeBuffer[pos]); + int read = in.read(); + if (read < 0) + { + break; + } + else + { + readBuffer[pos] = (byte) read; + } + } + return pos; + } + } + + /** + * Framework for testing the exchange of data between two serial ports. + * Actual reading/writing is performed by a callback. + *

+ * Data is exchanged unidirectionally from port A to B. Both ports are + * configured to use the given baud rate at 8-N-1 with no flow control. The + * read and write buffers are allocated based on the size given by the + * bufferSize argument, and the write buffer is populated. The + * copy callback is invoked to perform the transfer; then the + * results are checked. + * + * @param baud the baud rate to test at + * @param bufferSize the amount of data to transfer + * @param copy the strategy to use for transferring the data + * @throws UnsupportedCommOperationException if the baud rate or receive + * timeout is unsupported by one + * of the ports + * @throws IOException if an error occurs while + * writing to or reading from one + * of the ports + */ + void testReadWrite( + int baud, + int bufferSize, + CopyFunction copy) + throws UnsupportedCommOperationException, IOException + { + this.ports.b.enableReceiveTimeout(SerialPortReadWriteTest.TIMEOUT); + + log.info(String.format(SerialPortReadWriteTest.START, copy.getClass().getSimpleName(), baud)); + this.ports.a.setSerialPortParams( + baud, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + this.ports.b.setSerialPortParams( + baud, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + + byte[] writeBuffer = new byte[bufferSize]; + byte[] readBuffer = new byte[bufferSize]; + int pos = 0; + + SerialPortReadWriteTest.fillBuffer(writeBuffer); + long start = System.currentTimeMillis(); + try (OutputStream out = this.ports.a.getOutputStream(); + InputStream in = this.ports.b.getInputStream()) + { + pos = copy.copy(writeBuffer, out, readBuffer, in); + } + long finish = System.currentTimeMillis(); + log.info(String.format(SerialPortReadWriteTest.FINISH, copy.getClass().getSimpleName(), baud, + (finish - start) / 1000.0)); + + assertNotEquals(0, pos, SerialPortReadWriteTest.NO_DATA_READ); + assertEquals(writeBuffer.length, pos, SerialPortReadWriteTest.NOT_ENOUGH_READ); + assertArrayEquals(writeBuffer, readBuffer, SerialPortReadWriteTest.DATA_MISMATCH); + + long elapsed = finish - start; + if (elapsed < SerialPortReadWriteTest.HIGH_SPEED_TRAP) + { + log.warning(String.format(SerialPortReadWriteTest.SLOW_YOUR_ROLL, baud, "faster")); + } + else if (elapsed > SerialPortReadWriteTest.LOW_SPEED_TRAP) + { + log.warning(String.format(SerialPortReadWriteTest.SLOW_YOUR_ROLL, baud, "slower")); + } + } + + /** + * Fill a buffer with a repeating byte sequence of 0x00–0xFF. + * + * @param buffer the buffer to fill + */ + private static void fillBuffer(byte[] buffer) + { + for (int i = 0; i < buffer.length; ++i) + { + buffer[i] = (byte) (i % 0xFF); + } + } +} From 13876dfc80f118cfb83d18dc93281e391e04f7b9 Mon Sep 17 00:00:00 2001 From: Samuel Coleman Date: Wed, 27 May 2020 11:18:05 +0100 Subject: [PATCH 4/5] Add control line tests (DTR/DSR and RTS/CTS). --- .../java/gnu/io/SerialPortControlTest.java | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/test/java/gnu/io/SerialPortControlTest.java diff --git a/src/test/java/gnu/io/SerialPortControlTest.java b/src/test/java/gnu/io/SerialPortControlTest.java new file mode 100644 index 00000000..70197dbe --- /dev/null +++ b/src/test/java/gnu/io/SerialPortControlTest.java @@ -0,0 +1,169 @@ +package gnu.io; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.TooManyListenersException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Test the ability of the {@link SerialPort} implementation to manage the + * control lines of the port (DTR/DSR and RTS/CTS). + *

+ * This test is nonspecific as to which implementation; it exercises + * only the public interface of the Java Communications API. Ports are opened + * by the {@link SerialPortExtension} test extension, presumably by way of + * {@link CommPortIdentifier}. + */ +public class SerialPortControlTest +{ + private static final Logger log = Logger.getLogger(SerialPortControlTest.class.getName()); + + private static final String BUGGY_DSR_EVENTS = "DSR event propagation is buggy! DSR events are useless until this is fixed."; + private static final String BUGGY_CTS_EVENTS = "CTS event propagation is buggy! CTS events are useless until this is fixed."; + + /** + * How long to wait (in milliseconds) for asynchronous events to arrive + * before failing the test. + */ + private static final long EVENT_TIMEOUT = 250; + + @RegisterExtension + SerialPortExtension ports = new SerialPortExtension(); + + /** + * Test that toggling DTR on a port changes the DSR state of another. + */ + @Test + void testDTRDSRPolling() + { + this.ports.a.setDTR(false); + assertFalse(this.ports.a.isDTR()); + assertFalse(this.ports.b.isDSR()); + + this.ports.a.setDTR(true); + assertTrue(this.ports.a.isDTR()); + assertTrue(this.ports.b.isDSR()); + + this.ports.a.setDTR(false); + assertFalse(this.ports.a.isDTR()); + assertFalse(this.ports.b.isDSR()); + } + + /** + * Test that toggling RTS on a port changes the CTS state of another. + */ + @Test + void testRTSCTSPolling() + { + this.ports.a.setRTS(false); + assertFalse(this.ports.a.isRTS()); + assertFalse(this.ports.b.isCTS()); + + this.ports.a.setRTS(true); + assertTrue(this.ports.a.isRTS()); + assertTrue(this.ports.b.isCTS()); + + this.ports.a.setRTS(false); + assertFalse(this.ports.a.isRTS()); + assertFalse(this.ports.b.isCTS()); + } + + /* Use of CountDownLatch in the asynchronous event tests is based on + * https://stackoverflow.com/a/1829949/640170. */ + + /** + * Test that toggling DTR on a port changes generates a DSR event on + * another. + * + * @throws TooManyListenersException if the port has not been properly + * cleaned up after previous tests + * @throws InterruptedException if the test is interrupted while waiting + * for the event + */ + @Test + void testDTRDSREvents() throws TooManyListenersException, InterruptedException + { + CountDownLatch latch = new CountDownLatch(1); + this.ports.b.addEventListener(ev -> { + if (ev.getEventType() == SerialPortEvent.DSR) + { + latch.countDown(); + } + }); + this.ports.b.notifyOnDSR(true); + this.ports.a.setDTR(true); + boolean sawEvent = false; + sawEvent = latch.await(SerialPortControlTest.EVENT_TIMEOUT, TimeUnit.MILLISECONDS); + if (!sawEvent) + { + /* FIXME: The hard part about adding tests is that sometimes you + * find bugs. The DSR event _is_ generated: set a breakpoint in the + * event callback, or a print statement, and it will reliably fire + * – but only _after_ this await. The callback _is_ run from the + * monitor thread, as you'd expect it to be, and without the await, + * the listener is called within milliseconds of DTR being + * asserted; but block the main thread (with this await, or with + * `Thread.sleep()`), and the event will never happen. + * + * I'm mystified by this behaviour, because the callback + * configuration here is the same as in `testRTSCTSEvents()`, where + * it works as expected. + * + * One thing which does seem to cause the event to propagate is + * de-asserting DTR. Then _that_ event gets lost, of course, but it + * seems to prompt the first event to make its way through. So + * that's the workaround employed here for now; but this really is + * papering over a hole. The DSR event being one edge late means + * it's basically impossible for any consumer to hand-roll their + * own hardware flow control. + * + * This is broken on Windows (10), macOS (10.15), and Linux + * (4.19.0). */ + log.warning(SerialPortControlTest.BUGGY_DSR_EVENTS); + this.ports.a.setDTR(false); + sawEvent = latch.await(SerialPortControlTest.EVENT_TIMEOUT, TimeUnit.MILLISECONDS); + } + assertTrue(sawEvent); + } + + /** + * Test that toggling RTS on a port changes generates a CTS event on + * another. + * + * @throws TooManyListenersException if the port has not been properly + * cleaned up after previous tests + * @throws InterruptedException if the test is interrupted while waiting + * for the event + */ + @Test + void testRTSCTSEvents() throws TooManyListenersException, InterruptedException + { + CountDownLatch latch = new CountDownLatch(1); + this.ports.b.addEventListener(ev -> { + if (ev.getEventType() == SerialPortEvent.CTS) + { + latch.countDown(); + } + }); + this.ports.b.notifyOnCTS(true); + this.ports.a.setRTS(true); + boolean sawEvent = false; + sawEvent = latch.await(SerialPortControlTest.EVENT_TIMEOUT, TimeUnit.MILLISECONDS); + if (!sawEvent) + { + /* FIXME: Same story here as with DSR events. This works correctly + * on Windows (10), but fails on macOS (10.15) and Linux + * (4.19.0). */ + log.warning(SerialPortControlTest.BUGGY_CTS_EVENTS); + this.ports.a.setRTS(false); + sawEvent = latch.await(SerialPortControlTest.EVENT_TIMEOUT, TimeUnit.MILLISECONDS); + } + assertTrue(sawEvent); + } +} From 3827ba0777b7a74805c5be491f76feebe40b9a0e Mon Sep 17 00:00:00 2001 From: Samuel Coleman Date: Thu, 28 May 2020 10:01:45 +0100 Subject: [PATCH 5/5] Add flow control tests (RTS/CTS and XON/XOFF). --- .../gnu/io/SerialPortFlowControlTest.java | 279 ++++++++++++++++++ .../java/gnu/io/SerialPortReadWriteTest.java | 2 +- 2 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/test/java/gnu/io/SerialPortFlowControlTest.java diff --git a/src/test/java/gnu/io/SerialPortFlowControlTest.java b/src/test/java/gnu/io/SerialPortFlowControlTest.java new file mode 100644 index 00000000..877c3bed --- /dev/null +++ b/src/test/java/gnu/io/SerialPortFlowControlTest.java @@ -0,0 +1,279 @@ +package gnu.io; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Test the ability of the {@link SerialPort} implementation to mediate data + * exchange with flow control. + *

+ * This test is nonspecific as to which implementation; it exercises + * only the public interface of the Java Communications API. Ports are opened + * by the {@link SerialPortExtension} test extension, presumably by way of + * {@link CommPortIdentifier}. + */ +public class SerialPortFlowControlTest +{ + private static final Logger log = Logger.getLogger(SerialPortFlowControlTest.class.getName()); + + private static final String WROTE_WITHOUT_CTS = "Port A wrote data even though it wasn't clear to send"; + private static final String MISSING_CTS_WRITE = "Port A didn't write buffered data after port B asserted RTS"; + + private static final String ERRONEOUS_CTS = "Port A is still asserting RTS even though its input buffer should be full"; + private static final String FILLED_INPUT_BUFFER = "Filled the input buffer of port A with %d bytes."; + + private static final String MISSING_INITIAL_WRITE = "Port A didn't write data before XOFF was sent"; + private static final String WROTE_WITH_XOFF = "Port A wrote data after XOFF was sent"; + private static final String MISSING_XON_WRITE = "Port A didn't write buffered data after being cleared to do so"; + + private static final String MISSING_XOFF = "Port A never sent XOFF even though its input buffer should be full"; + + /** + * How long to wait (in milliseconds) for changes to control line states + * on one port to affect the other port. + */ + private static final int STATE_WAIT = 50; + /** + * How long to wait (in milliseconds) for data sent from one port to arrive + * at the other. + */ + private static final int TIMEOUT = 50; + + /** The XON character for software flow control. */ + private static final byte XON = 0x11; + /** The XOFF character for software flow control. */ + private static final byte XOFF = 0x13; + + /** + * The baud rate at which to run the flow control read tests. + *

+ * Because those tests require filling the port input buffer, this should + * be as fast as possible to minimize test runtime. + */ + private static final int READ_BAUD = 115_200; + + /** + * The size of the input buffer is unknown, and + * {@link CommPort#getInputBufferSize()} does not purport to report it + * accurately. To test port behaviour upon filling it, we'll try to send + * this much data, and hope that we hit the limit. + */ + private static final int INPUT_BUFFER_MAX = 128 * 1024; + /** + * Write in chunks of this size when attempting to hit the input buffer + * limit so that we can return early after hitting it. + */ + private static final int INPUT_BUFFER_CHUNK = 4 * 1024; + + @RegisterExtension + SerialPortExtension ports = new SerialPortExtension(); + + /** + * Test that hardware flow control (aka RTS/CTS) correctly restricts + * writing. + *

+ * This test works by enabling hardware flow control on one port while + * leaving it disabled on the other. The control lines of the second port + * can then be manually toggled as necessary to verify flow control + * behaviour on the first port. + * + * @throws UnsupportedCommOperationException if the flow control mode is + * unsupported by the driver + * @throws InterruptedException if the test is interrupted + * while waiting for serial port + * activity + * @throws IOException if an error occurs while + * writing to or reading from one + * of the ports + */ + @Test + void testHardwareFlowControlWrite() throws UnsupportedCommOperationException, InterruptedException, IOException + { + /* On Windows, RTS is off by default when opening the port. On other + * platforms, it's on. We'll explicitly turn it off for consistency. */ + this.ports.b.setRTS(false); + + this.ports.a.setFlowControlMode(SerialPort.FLOWCONTROL_RTSCTS_IN | SerialPort.FLOWCONTROL_RTSCTS_OUT); + + this.ports.b.enableReceiveTimeout(SerialPortFlowControlTest.TIMEOUT); + + try (OutputStream out = this.ports.a.getOutputStream(); + InputStream in = this.ports.b.getInputStream()) + { + /* Because we haven't enabled flow control for port B, port A should be + * waiting to send. */ + assertFalse(this.ports.a.isCTS()); + + out.write(0x00); + assertEquals(0, in.available(), SerialPortFlowControlTest.WROTE_WITHOUT_CTS); + + this.ports.b.setRTS(true); + Thread.sleep(SerialPortFlowControlTest.STATE_WAIT); + + /* Port A should send once port B unblocks it. */ + assertTrue(this.ports.a.isCTS()); + assertNotEquals(-1, in.read(), SerialPortFlowControlTest.MISSING_CTS_WRITE); + } + } + + /** + * Test that hardware flow control (aka RTS/CTS) is correctly asserted when + * receiving data. + *

+ * This test works by enabling hardware flow control on one port while + * leaving it disabled on the other. The flow control behaviour of the + * first port can then be verified by observing its control lines from the + * second port. + * + * @throws UnsupportedCommOperationException if the flow control mode is + * unsupported by the driver + * @throws IOException if an error occurs while + * writing to or reading from one + * of the ports + */ + @Test + void testHardwareFlowControlRead() throws UnsupportedCommOperationException, IOException + { + this.ports.a.setSerialPortParams( + SerialPortFlowControlTest.READ_BAUD, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + this.ports.b.setSerialPortParams( + SerialPortFlowControlTest.READ_BAUD, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + this.ports.a.setFlowControlMode(SerialPort.FLOWCONTROL_RTSCTS_IN | SerialPort.FLOWCONTROL_RTSCTS_OUT); + + byte[] buffer = new byte[SerialPortFlowControlTest.INPUT_BUFFER_CHUNK]; + + try (OutputStream out = this.ports.b.getOutputStream()) + { + assertTrue(this.ports.b.isCTS()); + + /* Port A should deassert RTS once its input buffer is full. How + * big is its input buffer? `CommPort.getInputBufferSize()` can't + * be trusted to tell us. We'll have to just keep blasting data at + * it until it starts rejecting it. */ + int written; + for (written = 0; written < SerialPortFlowControlTest.INPUT_BUFFER_MAX + && this.ports.b.isCTS(); written += buffer.length) + { + out.write(buffer); + } + + assertFalse(this.ports.b.isCTS(), SerialPortFlowControlTest.ERRONEOUS_CTS); + log.info(String.format(SerialPortFlowControlTest.FILLED_INPUT_BUFFER, written)); + } + } + + /** + * Test that software flow control (aka XON/XOFF) correctly restricts + * writing. + *

+ * This test works by enabling software flow control on one port while + * leaving it disabled on the other. The control characters can then be + * manually sent from the second port as necessary to verify flow control + * behaviour on the first port. + * + * @throws UnsupportedCommOperationException if the flow control mode is + * unsupported by the driver + * @throws IOException if an error occurs while + * writing to or reading from one + * of the ports + */ + @Test + void testSoftwareFlowControlWrite() throws UnsupportedCommOperationException, IOException + { + this.ports.a.setFlowControlMode(SerialPort.FLOWCONTROL_XONXOFF_IN | SerialPort.FLOWCONTROL_XONXOFF_OUT); + + this.ports.b.enableReceiveTimeout(SerialPortFlowControlTest.TIMEOUT); + + try (OutputStream outA = this.ports.a.getOutputStream(); + OutputStream outB = this.ports.a.getOutputStream(); + InputStream in = this.ports.b.getInputStream()) + { + /* We should be able to write normally... */ + outA.write(0x00); + assertNotEquals(-1, in.read(), SerialPortFlowControlTest.MISSING_INITIAL_WRITE); + + /* ...until XOFF is sent from the receiver... */ + outB.write(SerialPortFlowControlTest.XOFF); + outA.write(0x00); + assertEquals(0, in.available(), SerialPortFlowControlTest.WROTE_WITH_XOFF); + + /* ...and life should resume upon XON. */ + outB.write(SerialPortFlowControlTest.XON); + assertNotEquals(-1, in.read(), SerialPortFlowControlTest.MISSING_XON_WRITE); + } + } + + /** + * Test that software flow control (aka XON/XOFF) control characters are + * generated when receiving data. + *

+ * This test works by enabling software flow control on one port while + * leaving it disabled on the other. The generation of flow control + * characters by first port can then be verified by reading from the second + * port. + *

+ * FIXME: On macOS (tested 10.15), I never received the XOFF even after + * passing multiple megabytes of data. + * + * @throws UnsupportedCommOperationException if the flow control mode is + * unsupported by the driver + * @throws IOException if an error occurs while + * writing to or reading from one + * of the ports + */ + @Test + @DisabledOnOs(OS.MAC) + void testSoftwareFlowControlRead() throws UnsupportedCommOperationException, IOException + { + this.ports.a.setSerialPortParams( + SerialPortFlowControlTest.READ_BAUD, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + this.ports.b.setSerialPortParams( + SerialPortFlowControlTest.READ_BAUD, + SerialPort.DATABITS_8, + SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE); + this.ports.a.setFlowControlMode(SerialPort.FLOWCONTROL_XONXOFF_IN | SerialPort.FLOWCONTROL_XONXOFF_OUT); + + byte[] buffer = new byte[SerialPortFlowControlTest.INPUT_BUFFER_CHUNK]; + + try (OutputStream out = this.ports.b.getOutputStream(); + InputStream in = this.ports.b.getInputStream()) + { + assertEquals(0, in.available()); + + /* Port A should send XOFF once its input buffer is full. See + * `SerialPortFlowControlTest.testHardwareFlowControlRead()` for + * details. */ + int written; + for (written = 0; written < SerialPortFlowControlTest.INPUT_BUFFER_MAX + && in.available() == 0; written += buffer.length) + { + out.write(buffer); + } + + assertEquals(1, in.available(), SerialPortFlowControlTest.MISSING_XOFF); + log.info(String.format(SerialPortFlowControlTest.FILLED_INPUT_BUFFER, written)); + } + } +} diff --git a/src/test/java/gnu/io/SerialPortReadWriteTest.java b/src/test/java/gnu/io/SerialPortReadWriteTest.java index a9123317..e14fd7be 100644 --- a/src/test/java/gnu/io/SerialPortReadWriteTest.java +++ b/src/test/java/gnu/io/SerialPortReadWriteTest.java @@ -336,7 +336,7 @@ else if (elapsed > SerialPortReadWriteTest.LOW_SPEED_TRAP) * * @param buffer the buffer to fill */ - private static void fillBuffer(byte[] buffer) + static void fillBuffer(byte[] buffer) { for (int i = 0; i < buffer.length; ++i) {