diff --git a/build.gradle b/build.gradle index 842ff7d8..aec6890a 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"] @@ -40,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' @@ -62,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/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/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); + } +} 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/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 new file mode 100644 index 00000000..e14fd7be --- /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}. + *
+ *
+ * 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
+ * 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
+ * baud / 10
bytes are transferred; this should take about one
+ * second.
+ *
+ * @return a set of block read/write tests
+ */
+ @TestFactory
+ @Tag("slow")
+ StreamwriteBuffer
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.
+ * 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
+ */
+ static void fillBuffer(byte[] buffer)
+ {
+ for (int i = 0; i < buffer.length; ++i)
+ {
+ buffer[i] = (byte) (i % 0xFF);
+ }
+ }
+}
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 67%
rename from test/src/test/SerialTest.java
rename to src/test/java/test/SerialTest.java
index 37da6170..6c4328aa 100644
--- a/test/src/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 {