From a5d528a1f6a94cd7925e17b28bf7fed4d0b1614e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:18:45 +0000 Subject: [PATCH 01/18] Add Error Prone and NullAway to the compiler pipeline Enables Error Prone 2.30.0 (last release supporting -source 8) and NullAway 0.10.26 as javac plugins. NullAway is enforced as ERROR on the main package (net.ladenthin) and disabled for test compilation where JMH @Setup fields and intentional null arguments would otherwise trip it. .mvn/jvm.config supplies the --add-exports / --add-opens required by Error Prone when running on JDK 16+. --- .mvn/jvm.config | 10 ++++++++++ pom.xml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .mvn/jvm.config diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..504456f --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,10 @@ +--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/pom.xml b/pom.xml index 1994f38..5d81e5d 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,8 @@ SPDX-License-Identifier: Apache-2.0 21 0.16 1.9.2 + 2.30.0 + 0.10.26 ${git.commit.time} 2014 @@ -147,7 +149,37 @@ SPDX-License-Identifier: Apache-2.0 ${java.test.version} ${java.test.version} ${project.build.sourceEncoding} + true + + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=net.ladenthin + + + + com.google.errorprone + error_prone_core + ${errorprone.version} + + + com.uber.nullaway + nullaway + ${nullaway.version} + + + + + default-testCompile + + + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -Xplugin:ErrorProne -Xep:NullAway:OFF + + + + org.apache.maven.plugins From 005fc65cfb08a36a3c1251310113764bde7794ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:21:37 +0000 Subject: [PATCH 02/18] Upgrade Error Prone to 2.49.0 and NullAway to 0.13.4 Empirically verified that current Error Prone supports compiling -source 8/-target 8 on JDK 21 when -XDaddTypeAnnotationsToSymbol=true is passed, contrary to my earlier assumption that 2.30.0 was the last Java-8-capable release. Only the JDK required to *run* Error Prone has moved forward (see google/error-prone#4867). --- pom.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 5d81e5d..23c9123 100644 --- a/pom.xml +++ b/pom.xml @@ -23,8 +23,8 @@ SPDX-License-Identifier: Apache-2.0 21 0.16 1.9.2 - 2.30.0 - 0.10.26 + 2.49.0 + 0.13.4 ${git.commit.time} 2014 @@ -151,6 +151,7 @@ SPDX-License-Identifier: Apache-2.0 ${project.build.sourceEncoding} true + -XDaddTypeAnnotationsToSymbol=true -XDcompilePolicy=simple --should-stop=ifError=FLOW -Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=net.ladenthin @@ -173,6 +174,7 @@ SPDX-License-Identifier: Apache-2.0 default-testCompile + -XDaddTypeAnnotationsToSymbol=true -XDcompilePolicy=simple --should-stop=ifError=FLOW -Xplugin:ErrorProne -Xep:NullAway:OFF From 2539a03e4fc20479c83dca56a553d99b52211991 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:31:04 +0000 Subject: [PATCH 03/18] Add REUSE sidecar license for .mvn/jvm.config The jvm.config format doesn't allow comments, so the SPDX header lives in a .license sidecar per REUSE specification. --- .mvn/jvm.config.license | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .mvn/jvm.config.license diff --git a/.mvn/jvm.config.license b/.mvn/jvm.config.license new file mode 100644 index 0000000..6ef5e77 --- /dev/null +++ b/.mvn/jvm.config.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin + +SPDX-License-Identifier: Apache-2.0 From f9a010bc27976a1af9e18f17a6aff84a7b88a9ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:42:59 +0000 Subject: [PATCH 04/18] Fix CodeQL autobuild and restore jcstress/JMH annotation processors CodeQL's autobuild runs 'mvn package -DskipTests' which previously still triggered the exec-maven-plugin jcstress execution bound to the test phase, failing because test classes weren't compiled. Adding ${skipTests} to that execution propagates -DskipTests. Separately, the earlier Error Prone commit set which makes the compiler ignore classpath-based processors. That broke jcstress and JMH annotation processing during test-compile. Adding both processors to the test-compile path with combine.children="append" restores them while preserving Error Prone + NullAway. --- pom.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pom.xml b/pom.xml index 23c9123..adb428d 100644 --- a/pom.xml +++ b/pom.xml @@ -179,6 +179,18 @@ SPDX-License-Identifier: Apache-2.0 --should-stop=ifError=FLOW -Xplugin:ErrorProne -Xep:NullAway:OFF + + + org.openjdk.jcstress + jcstress-core + ${jcstress.version} + + + org.openjdk.jmh + jmh-generator-annprocess + 1.37 + + @@ -278,6 +290,7 @@ SPDX-License-Identifier: Apache-2.0 test exec + ${skipTests} ${java.home}/bin/java test From edce1e888b27308f7b5fd0c61dd649b2e269d178 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:20:19 +0000 Subject: [PATCH 05/18] Add Spotless with Palantir Java Format Enforces Palantir Java Format 2.66.0 on all main and test sources via the spotless-maven-plugin. The spotless:check goal is bound to the verify phase so CI fails on formatting drift; run 'mvn spotless:apply' to fix. The initial apply touches most files and is mechanical. --- pom.xml | 30 + .../ladenthin/streambuffer/StreamBuffer.java | 48 +- .../streambuffer/StreamBufferProperties.java | 28 +- .../streambuffer/StreamBufferTest.java | 9607 ++++++++--------- .../ladenthin/streambuffer/WriteMethod.java | 4 +- .../StreamBufferThroughputBenchmark.java | 9 +- .../jcstress/CloseDuringReadRace.java | 7 +- .../jcstress/ConcurrentWriteRace.java | 30 +- .../jcstress/WriteUnblocksReadRace.java | 9 +- 9 files changed, 4889 insertions(+), 4883 deletions(-) diff --git a/pom.xml b/pom.xml index adb428d..d5a8b6e 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,8 @@ SPDX-License-Identifier: Apache-2.0 1.9.2 2.49.0 0.13.4 + 2.46.1 + 2.66.0 ${git.commit.time} 2014 @@ -305,6 +307,34 @@ SPDX-License-Identifier: Apache-2.0 + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + src/main/java/**/*.java + src/test/java/**/*.java + + + ${palantir-java-format.version} + + + + + + + + + spotless-check + verify + + check + + + + org.pitest pitest-maven diff --git a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java index e70d71f..51c0c22 100644 --- a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java +++ b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java @@ -12,7 +12,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Semaphore; - /** * A stream buffer is a class to buffer data that has been written to an * {@link OutputStream} and provide the data in an {@link InputStream}. There is @@ -24,7 +23,8 @@ */ public class StreamBuffer implements Closeable { - final static String EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION = "Invalid offset or length given to correctOffsetAndLengthToWrite."; + static final String EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION = + "Invalid offset or length given to correctOffsetAndLengthToWrite."; /** * An object to get an unique access to the {@link #buffer}. It is needed to @@ -383,9 +383,9 @@ public static boolean correctOffsetAndLengthToRead(byte[] b, int off, int len) { public static boolean correctOffsetAndLengthToWrite(byte[] b, int off, int len) { if (b == null) { throw new NullPointerException(); - } else if ((off < 0) || (off > b.length) || (len < 0) - || ((off + len) > b.length) || ((off + len) < 0)) { - throw new IndexOutOfBoundsException(EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION); + } else if ((off < 0) || (off > b.length) || (len < 0) || ((off + len) > b.length) || ((off + len) < 0)) { + throw new IndexOutOfBoundsException( + EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION); } else if (len == 0) { return false; } @@ -404,7 +404,7 @@ private void signalModification() { } } } - + /** * Use {@link #tryWaitForEnoughBytes(long)}. * @@ -580,12 +580,7 @@ boolean isTrimShouldBeExecuted() { final int maxBufferElements = getMaxBufferElements(); // Delegate to pure decision function - return decideTrimExecution( - buffer.size(), - maxBufferElements, - availableBytes, - getMaxAllocationSize() - ); + return decideTrimExecution(buffer.size(), maxBufferElements, availableBytes, getMaxAllocationSize()); } /** @@ -700,8 +695,8 @@ boolean isMaxAllocSizeLessThanAvailable(long maxAllocSize, long availableBytes) * Package-private for direct unit testing of boundary conditions. */ boolean shouldCheckEdgeCase(long availableBytes, long maxAllocSize) { - return isAvailableBytesPositive(availableBytes) && - isMaxAllocSizeLessThanAvailable(maxAllocSize, availableBytes); + return isAvailableBytesPositive(availableBytes) + && isMaxAllocSizeLessThanAvailable(maxAllocSize, availableBytes); } /** @@ -787,15 +782,14 @@ public void close() throws IOException { @Override public int read() throws IOException { - try{ + try { // we wait for enough bytes (one byte) if (tryWaitForEnoughBytes(1) < 1) { // try to wait, but not enough bytes available // return the end of stream is reached return -1; } - } - catch (InterruptedException e) { + } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); } @@ -867,7 +861,7 @@ public int read(final byte b[], final int off, final int len) throws IOException // some or enough bytes are available, lock and modify the FIFO synchronized (bufferLock) { - for (;;) { + for (; ; ) { if (noMoreMissingBytes(missingBytes)) { return copiedBytes; @@ -877,16 +871,15 @@ public int read(final byte b[], final int off, final int len) throws IOException final byte[] first = buffer.getFirst(); // get the maximum bytes which can be copied // from the first element - final int maximumBytesToCopy - = first.length - positionAtCurrentBufferEntry; + final int maximumBytesToCopy = first.length - positionAtCurrentBufferEntry; // this element can be copied fully to the destination if (missingBytes >= maximumBytesToCopy) { // copy the complete byte[] to the destination - System.arraycopy(first, positionAtCurrentBufferEntry, b, - copiedBytes + off, maximumBytesToCopy); + System.arraycopy(first, positionAtCurrentBufferEntry, b, copiedBytes + off, maximumBytesToCopy); copiedBytes += maximumBytesToCopy; - maximumAvailableBytes = decrementAvailableBytesBudget(maximumAvailableBytes, maximumBytesToCopy); + maximumAvailableBytes = + decrementAvailableBytesBudget(maximumAvailableBytes, maximumBytesToCopy); availableBytes -= maximumBytesToCopy; recordReadStatistics(maximumBytesToCopy); missingBytes -= maximumBytesToCopy; @@ -896,8 +889,7 @@ public int read(final byte b[], final int off, final int len) throws IOException positionAtCurrentBufferEntry = 0; } else { // copy only a part of byte[] to the destination - System.arraycopy(first, positionAtCurrentBufferEntry, b, - copiedBytes + off, missingBytes); + System.arraycopy(first, positionAtCurrentBufferEntry, b, copiedBytes + off, missingBytes); // add the offset positionAtCurrentBufferEntry += missingBytes; copiedBytes += missingBytes; @@ -923,7 +915,6 @@ private boolean noMoreMissingBytes(int missingBytes) { // check if we don't need to copy further bytes anymore return missingBytes == 0; } - } private class SBOutputStream extends OutputStream { @@ -936,7 +927,7 @@ public void close() throws IOException { public void write(final int b) throws IOException { try { ignoreSafeWrite = true; - write(new byte[]{(byte) b}); + write(new byte[] {(byte) b}); } finally { ignoreSafeWrite = false; } @@ -945,8 +936,7 @@ public void write(final int b) throws IOException { // please do not override the method "void write(final byte[] b)" // the method calls internal "write(b, 0, b.length);" @Override - public void write(final byte[] b, final int off, final int len) - throws IOException { + public void write(final byte[] b, final int off, final int len) throws IOException { if (!correctOffsetAndLengthToWrite(b, off, len)) { return; } diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferProperties.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferProperties.java index e36aeba..c6e1eda 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferProperties.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferProperties.java @@ -3,23 +3,21 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.constraints.IntRange; -import net.jqwik.api.constraints.Size; - import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.List; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.constraints.IntRange; +import net.jqwik.api.constraints.Size; public class StreamBufferProperties { @Property boolean writeAllThenReadAvailableYieldsConcatenation( - @ForAll @Size(min = 0, max = 32) List<@Size(min = 0, max = 256) byte[]> chunks - ) throws IOException { + @ForAll @Size(min = 0, max = 32) List<@Size(min = 0, max = 256) byte[]> chunks) throws IOException { StreamBuffer sb = new StreamBuffer(); OutputStream os = sb.getOutputStream(); InputStream is = sb.getInputStream(); @@ -45,8 +43,8 @@ boolean writeAllThenReadAvailableYieldsConcatenation( boolean readChunkSizeDoesNotAffectContent( @ForAll @Size(min = 1, max = 1024) byte[] payload, @ForAll @IntRange(min = 1, max = 64) int writeChunk, - @ForAll @IntRange(min = 1, max = 64) int readChunk - ) throws IOException { + @ForAll @IntRange(min = 1, max = 64) int readChunk) + throws IOException { StreamBuffer sb = new StreamBuffer(); OutputStream os = sb.getOutputStream(); InputStream is = sb.getInputStream(); @@ -71,8 +69,8 @@ boolean readChunkSizeDoesNotAffectContent( @Property boolean counterAccountingIsConsistent( @ForAll @Size(min = 0, max = 16) List<@Size(min = 0, max = 128) byte[]> writes, - @ForAll @IntRange(min = 0, max = 16) int readChunk - ) throws IOException { + @ForAll @IntRange(min = 0, max = 16) int readChunk) + throws IOException { StreamBuffer sb = new StreamBuffer(); OutputStream os = sb.getOutputStream(); InputStream is = sb.getInputStream(); @@ -99,9 +97,7 @@ boolean counterAccountingIsConsistent( } @Property - boolean safeWriteIsolatesFromExternalMutation( - @ForAll @Size(min = 1, max = 256) byte[] payload - ) throws IOException { + boolean safeWriteIsolatesFromExternalMutation(@ForAll @Size(min = 1, max = 256) byte[] payload) throws IOException { StreamBuffer sb = new StreamBuffer(); sb.setSafeWrite(true); OutputStream os = sb.getOutputStream(); @@ -121,8 +117,8 @@ boolean safeWriteIsolatesFromExternalMutation( @Property boolean trimPreservesContentAcrossMaxBufferElements( @ForAll @Size(min = 1, max = 64) List<@Size(min = 0, max = 64) byte[]> chunks, - @ForAll @IntRange(min = 0, max = 16) int maxBufferElements - ) throws IOException { + @ForAll @IntRange(min = 0, max = 16) int maxBufferElements) + throws IOException { StreamBuffer sb = new StreamBuffer(); sb.setMaxBufferElements(maxBufferElements); OutputStream os = sb.getOutputStream(); diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java index 11d2042..6316745 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java @@ -3,14 +3,16 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.number.OrderingComparison.greaterThan; +import static org.hamcrest.number.OrderingComparison.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.*; import java.util.Arrays; @@ -20,30 +22,24 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.number.OrderingComparison.greaterThan; -import static org.hamcrest.number.OrderingComparison.greaterThanOrEqualTo; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; @Timeout(value = 20, unit = TimeUnit.SECONDS) public class StreamBufferTest { static Stream writeMethods() { return Stream.of( - Arguments.of(WriteMethod.ByteArray), - Arguments.of(WriteMethod.Int), - Arguments.of(WriteMethod.ByteArrayWithParameter) - ); + Arguments.of(WriteMethod.ByteArray), + Arguments.of(WriteMethod.Int), + Arguments.of(WriteMethod.ByteArrayWithParameter)); } /** @@ -54,850 +50,862 @@ static Stream writeMethods() { @Nested @DisplayName("roundtrip") class RoundtripTests { - @DisplayName("simple round trip") - @Test - public void testSimpleRoundTrip() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(0); - byte[] b0 = new byte[10]; - for (int i = 0; i < b0.length; ++i) { - b0[i] = anyValue; - } - os.write(b0); - os.write(0); - - assertEquals(12, is.available()); - - // act - byte[] target = new byte[12]; - is.read(target); - - // assert - byte[] expected = new byte[12]; - for (int i = 1; i < 11; ++i) { - expected[i] = anyValue; - } - assertArrayEquals(expected, target); - } + @DisplayName("simple round trip") + @Test + public void testSimpleRoundTrip() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(0); + byte[] b0 = new byte[10]; + for (int i = 0; i < b0.length; ++i) { + b0[i] = anyValue; + } + os.write(b0); + os.write(0); - /** - * This test verifies that the input stream's read method places bytes at a specific offset. - * @throws IOException - */ - @DisplayName("safe write simple offset") - @Test - public void testSafeWriteSimpleOffset() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - sb.setSafeWrite(false); - // the initial value - final byte anyNumber = (byte) 4; - - // Create a new array with initial values. - final byte[] content = new byte[]{anyNumber, anyNumber, anyNumber}; - - // Write the array to the stream. - os.write(content); - - // Ensure the array was completely written to the stream. - assertEquals(3, is.available()); - - // A buffer to read the content from the stream. - final byte[] fromMemory = new byte[4]; - - // act — read 2 bytes at an offset of 2 bytes - final int read = is.read(fromMemory, 2, 2); - - // assert — first 2 slots unwritten (0), last 2 slots filled with anyNumber - assertAll( - () -> assertEquals(2, read, "should have read 2 values"), - () -> assertEquals(1, is.available(), "1 value should remain in the stream"), - () -> assertArrayEquals(new byte[]{0, 0, anyNumber, anyNumber}, fromMemory, "first 2 values unwritten, last 2 filled at the given offset") - ); - } + assertEquals(12, is.available()); - @DisplayName("multiple array") - @Test - public void testMultipleArray() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - byte[] b4 = new byte[]{4, 4, 4, 4}; - byte[] b5 = new byte[]{5, 5, 5, 5, 5}; - byte[] b6 = new byte[]{6, 6, 6, 6, 6, 6}; - os.write(b4); - os.write(b5); - os.write(b6); - assertEquals(4 + 5 + 6, is.available()); - - // act - byte[] t0 = new byte[6]; - byte[] t1 = new byte[6]; - byte[] t2 = new byte[3]; - is.read(t0); - is.read(t1); - is.read(t2); - - // assert - assertEquals((4 + 5 + 6) - (6 + 6 + 3), is.available()); - - assertAll( - () -> assertArrayEquals(new byte[]{4, 4, 4, 4, 5, 5}, t0), - () -> assertArrayEquals(new byte[]{5, 5, 5, 6, 6, 6}, t1), - () -> assertArrayEquals(new byte[]{6, 6, 6}, t2) - ); - } + // act + byte[] target = new byte[12]; + is.read(target); - @DisplayName("looped roundtrip") - @Test - public void testLoopedRoundtrip() throws IOException { - // arrange - final int size = 32640; //255/2*(255+1) - ByteArrayOutputStream baosOriginalData = new ByteArrayOutputStream(size); - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - for (int i = 1; i <= 255; ++i) { - byte[] array = new byte[i]; - if (i >= 250) { - System.out.println(); - } - // Fill the array with content - for (int j = 0; j < array.length; ++j) { - array[j] = (byte) i; + // assert + byte[] expected = new byte[12]; + for (int i = 1; i < 11; ++i) { + expected[i] = anyValue; } + assertArrayEquals(expected, target); + } + + /** + * This test verifies that the input stream's read method places bytes at a specific offset. + * @throws IOException + */ + @DisplayName("safe write simple offset") + @Test + public void testSafeWriteSimpleOffset() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + sb.setSafeWrite(false); + // the initial value + final byte anyNumber = (byte) 4; + + // Create a new array with initial values. + final byte[] content = new byte[] {anyNumber, anyNumber, anyNumber}; + + // Write the array to the stream. + os.write(content); + + // Ensure the array was completely written to the stream. + assertEquals(3, is.available()); + + // A buffer to read the content from the stream. + final byte[] fromMemory = new byte[4]; + + // act — read 2 bytes at an offset of 2 bytes + final int read = is.read(fromMemory, 2, 2); + + // assert — first 2 slots unwritten (0), last 2 slots filled with anyNumber + assertAll( + () -> assertEquals(2, read, "should have read 2 values"), + () -> assertEquals(1, is.available(), "1 value should remain in the stream"), + () -> assertArrayEquals( + new byte[] {0, 0, anyNumber, anyNumber}, + fromMemory, + "first 2 values unwritten, last 2 filled at the given offset")); + } - os.write(array); - baosOriginalData.write(array); + @DisplayName("multiple array") + @Test + public void testMultipleArray() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + byte[] b4 = new byte[] {4, 4, 4, 4}; + byte[] b5 = new byte[] {5, 5, 5, 5, 5}; + byte[] b6 = new byte[] {6, 6, 6, 6, 6, 6}; + os.write(b4); + os.write(b5); + os.write(b6); + assertEquals(4 + 5 + 6, is.available()); + + // act + byte[] t0 = new byte[6]; + byte[] t1 = new byte[6]; + byte[] t2 = new byte[3]; + is.read(t0); + is.read(t1); + is.read(t2); + + // assert + assertEquals((4 + 5 + 6) - (6 + 6 + 3), is.available()); + + assertAll( + () -> assertArrayEquals(new byte[] {4, 4, 4, 4, 5, 5}, t0), + () -> assertArrayEquals(new byte[] {5, 5, 5, 6, 6, 6}, t1), + () -> assertArrayEquals(new byte[] {6, 6, 6}, t2)); } - final byte[] originalData = baosOriginalData.toByteArray(); + @DisplayName("looped roundtrip") + @Test + public void testLoopedRoundtrip() throws IOException { + // arrange + final int size = 32640; // 255/2*(255+1) + ByteArrayOutputStream baosOriginalData = new ByteArrayOutputStream(size); + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + for (int i = 1; i <= 255; ++i) { + byte[] array = new byte[i]; + if (i >= 250) { + System.out.println(); + } + // Fill the array with content + for (int j = 0; j < array.length; ++j) { + array[j] = (byte) i; + } - assertEquals(size, is.available()); + os.write(array); + baosOriginalData.write(array); + } - // act - ByteArrayOutputStream baosReadFromTwist = new ByteArrayOutputStream(size); - long readBytes = 0; - for (int i = 255; i >= 1; --i) { - readBytes += i; - byte[] array = new byte[i]; + final byte[] originalData = baosOriginalData.toByteArray(); - is.read(array); - assertEquals(size - readBytes, is.available()); - baosReadFromTwist.write(array); - } + assertEquals(size, is.available()); - final long finalReadBytes = readBytes; - byte[] byteChain = baosReadFromTwist.toByteArray(); + // act + ByteArrayOutputStream baosReadFromTwist = new ByteArrayOutputStream(size); + long readBytes = 0; + for (int i = 255; i >= 1; --i) { + readBytes += i; + byte[] array = new byte[i]; - // assert - assertAll( - () -> assertEquals(size, finalReadBytes, "total bytes read should equal size"), - () -> assertEquals(0, is.available(), "stream should be empty after reading all bytes"), - () -> assertEquals(size, byteChain.length, "reconstructed byte chain length should match size"), - () -> assertArrayEquals(originalData, byteChain, "reconstructed byte chain should match original data") - ); - } + is.read(array); + assertEquals(size - readBytes, is.available()); + baosReadFromTwist.write(array); + } + + final long finalReadBytes = readBytes; + byte[] byteChain = baosReadFromTwist.toByteArray(); + + // assert + assertAll( + () -> assertEquals(size, finalReadBytes, "total bytes read should equal size"), + () -> assertEquals(0, is.available(), "stream should be empty after reading all bytes"), + () -> assertEquals(size, byteChain.length, "reconstructed byte chain length should match size"), + () -> assertArrayEquals( + originalData, byteChain, "reconstructed byte chain should match original data")); + } - @DisplayName("data input output") - @Test - public void testDataInputOutput() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - DataInput din = new DataInputStream(is); - DataOutput dout = new DataOutputStream(os); + @DisplayName("data input output") + @Test + public void testDataInputOutput() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + DataInput din = new DataInputStream(is); + DataOutput dout = new DataOutputStream(os); - final String testString = "test string"; + final String testString = "test string"; - dout.writeUTF(testString); + dout.writeUTF(testString); - // act - String readUTF = din.readUTF(); + // act + String readUTF = din.readUTF(); - // assert - assertEquals(testString, readUTF); - } + // assert + assertEquals(testString, readUTF); + } } @Nested @DisplayName("constructor") class ConstructorTests { - @DisplayName("constructor(): no arguments — no exception thrown") - @Test - public void constructor_noArguments_NoExceptionThrown() { - // arrange - // act - new StreamBuffer(); - // assert — no exception thrown - } + @DisplayName("constructor(): no arguments — no exception thrown") + @Test + public void constructor_noArguments_NoExceptionThrown() { + // arrange + // act + new StreamBuffer(); + // assert — no exception thrown + } } @Nested @DisplayName("getMaxBufferElements()") class GetMaxBufferElementsTests { - @DisplayName("getMaxBufferElements(): initial value — greater zero") - @Test - public void getMaxBufferElements_initialValue_GreaterZero() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.getMaxBufferElements(), is(greaterThan(0))); - } + @DisplayName("getMaxBufferElements(): initial value — greater zero") + @Test + public void getMaxBufferElements_initialValue_GreaterZero() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.getMaxBufferElements(), is(greaterThan(0))); + } - @DisplayName("getMaxBufferElements(): after set — zero") - @Test - public void getMaxBufferElements_afterSet_Zero() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("getMaxBufferElements(): after set — zero") + @Test + public void getMaxBufferElements_afterSet_Zero() { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.setMaxBufferElements(0); + // act + sb.setMaxBufferElements(0); - // assert - assertThat(sb.getMaxBufferElements(), is(0)); - } + // assert + assertThat(sb.getMaxBufferElements(), is(0)); + } } @Nested @DisplayName("setMaxBufferElements()") class SetMaxBufferElementsTests { - @DisplayName("setMaxBufferElements(): write negative value — equals to getter") - @Test - public void setMaxBufferElements_writeNegativeValue_equalsToGetter() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("setMaxBufferElements(): write negative value — equals to getter") + @Test + public void setMaxBufferElements_writeNegativeValue_equalsToGetter() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.setMaxBufferElements(-1); + // act + sb.setMaxBufferElements(-1); - // assert - assertThat(-1, is(sb.getMaxBufferElements())); - } + // assert + assertThat(-1, is(sb.getMaxBufferElements())); + } - @DisplayName("setMaxBufferElements(): use negative value — trim not called") - @Test - public void setMaxBufferElements_useNegativeValue_trimNotCalled() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("setMaxBufferElements(): use negative value — trim not called") + @Test + public void setMaxBufferElements_useNegativeValue_trimNotCalled() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.setMaxBufferElements(-1); + // act + sb.setMaxBufferElements(-1); - // Write fewer than four elements to the stream. - // The trim method should not be called. - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); + // Write fewer than four elements to the stream. + // The trim method should not be called. + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); - // assert - assertThat(sb.getBufferSize(), is(3)); - } + // assert + assertThat(sb.getBufferSize(), is(3)); + } } @Nested @DisplayName("isSafeWrite()") class IsSafeWriteTests { - @DisplayName("isSafeWrite(): initial value — false") - @Test - public void isSafeWrite_initialValue_false() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.isSafeWrite(), is(false)); - } + @DisplayName("isSafeWrite(): initial value — false") + @Test + public void isSafeWrite_initialValue_false() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.isSafeWrite(), is(false)); + } - @DisplayName("isSafeWrite(): after set — true") - @Test - public void isSafeWrite_afterSet_true() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("isSafeWrite(): after set — true") + @Test + public void isSafeWrite_afterSet_true() { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.setSafeWrite(true); + // act + sb.setSafeWrite(true); - // assert - assertThat(sb.isSafeWrite(), is(true)); - } + // assert + assertThat(sb.isSafeWrite(), is(true)); + } } @Nested @DisplayName("isClosed()") class IsClosedTests { - @DisplayName("isClosed(): after construct — false") - @Test - public void isClosed_afterConstruct_false() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.isClosed(), is(false)); - } + @DisplayName("isClosed(): after construct — false") + @Test + public void isClosed_afterConstruct_false() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.isClosed(), is(false)); + } - @DisplayName("isClosed(): after close — true") - @Test - public void isClosed_afterClose_true() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("isClosed(): after close — true") + @Test + public void isClosed_afterClose_true() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.close(); + // act + sb.close(); - // assert - assertThat(sb.isClosed(), is(true)); - } + // assert + assertThat(sb.isClosed(), is(true)); + } } @Nested @DisplayName("read() — external buffer mutation") class ReadChangeBufferFromOutsideTests { - /** - * This test verifies that when the option safeWrite is disabled, the buffer - * can be modified externally (the write method does not create clones of the - * written arrays). - * - * @throws IOException - */ - @DisplayName("read(): change buffer from outside — has changed") - @Test - public void read_changeBufferFromOutside_hasChanged() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - // Disable the safe write option. - sb.setSafeWrite(false); - // Create a new byte array which is not immutable. - byte[] notImmutable = new byte[]{anyValue}; - // Write the byte array. - sb.getOutputStream().write(notImmutable); - // Change the byte array. - notImmutable[0]++; - // A new byte array for the read method. - byte[] fromStream = new byte[1]; - - // act - // Read the content out of the stream. - sb.getInputStream().read(fromStream); - - // assert - assertThat(fromStream[0], is(not((byte) anyValue))); - } + /** + * This test verifies that when the option safeWrite is disabled, the buffer + * can be modified externally (the write method does not create clones of the + * written arrays). + * + * @throws IOException + */ + @DisplayName("read(): change buffer from outside — has changed") + @Test + public void read_changeBufferFromOutside_hasChanged() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + // Disable the safe write option. + sb.setSafeWrite(false); + // Create a new byte array which is not immutable. + byte[] notImmutable = new byte[] {anyValue}; + // Write the byte array. + sb.getOutputStream().write(notImmutable); + // Change the byte array. + notImmutable[0]++; + // A new byte array for the read method. + byte[] fromStream = new byte[1]; + + // act + // Read the content out of the stream. + sb.getInputStream().read(fromStream); + + // assert + assertThat(fromStream[0], is(not((byte) anyValue))); + } - /** - * This test verifies that when the option safeWrite is enabled, the buffer - * cannot be changed from outside (the write method creates clones of the - * written arrays). - * - * @throws IOException - */ - @DisplayName("read(): change buffer from outside — not changed") - @Test - public void read_changeBufferFromOutside_notChanged() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - // Disable the safe write option. - sb.setSafeWrite(true); - // Create a new byte array which is not immutable. - byte[] notImmutable = new byte[]{anyValue}; - // Write the byte array. - sb.getOutputStream().write(notImmutable); - // Change the byte array. - notImmutable[0]++; - // A new byte array for the read method. - byte[] fromStream = new byte[1]; - - // act - // Read the content out of the stream. - sb.getInputStream().read(fromStream); - - // assert - assertThat(fromStream[0], is((byte) anyValue)); - } + /** + * This test verifies that when the option safeWrite is enabled, the buffer + * cannot be changed from outside (the write method creates clones of the + * written arrays). + * + * @throws IOException + */ + @DisplayName("read(): change buffer from outside — not changed") + @Test + public void read_changeBufferFromOutside_notChanged() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + // Disable the safe write option. + sb.setSafeWrite(true); + // Create a new byte array which is not immutable. + byte[] notImmutable = new byte[] {anyValue}; + // Write the byte array. + sb.getOutputStream().write(notImmutable); + // Change the byte array. + notImmutable[0]++; + // A new byte array for the read method. + byte[] fromStream = new byte[1]; + + // act + // Read the content out of the stream. + sb.getInputStream().read(fromStream); + + // assert + assertThat(fromStream[0], is((byte) anyValue)); + } } @Nested @DisplayName("getBufferSize()") class GetBufferSizeTests { - @DisplayName("getBufferSize(): reach max buffer elements — trim called") - @Test - public void getBufferSize_reachMaxBufferElements_trimCalled() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - - // act - // Write more than one element to the stream to force a trim call. - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - - // assert - int result = sb.getBufferSize(); - assertThat(result, is(1)); - } - - @DisplayName("getBufferSize(): reach max buffer elements — trim buffer right values") - @Test - public void getBufferSize_reachMaxBufferElements_trimBufferRightValues() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - sb.setMaxBufferElements(2); - - // act - // Write more than one element to the stream to force a trim call. - sb.getOutputStream().write(1); - sb.getOutputStream().write(new byte[]{2, 3}); - sb.getOutputStream().write(new byte[]{4, 5, 6}); - - // assert - byte[] read = new byte[is.available()]; - is.read(read); - assertThat(read, is(new byte[]{1, 2, 3, 4, 5, 6})); - } + @DisplayName("getBufferSize(): reach max buffer elements — trim called") + @Test + public void getBufferSize_reachMaxBufferElements_trimCalled() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + + // act + // Write more than one element to the stream to force a trim call. + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); - @DisplayName("getBufferSize(): write some elements — trim not called") - @Test - public void getBufferSize_writeSomeElements_trimNotCalled() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(4); - - // act - // Write fewer than four elements to the stream. - // The trim method should not be called. - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - - // assert - assertThat(sb.getBufferSize(), is(3)); - } - } + // assert + int result = sb.getBufferSize(); + assertThat(result, is(1)); + } - @Nested - @DisplayName("read()") - class ReadTests { - @DisplayName("read(): closed stream before write — return minus one") - @Test - public void read_closedStreamBeforeWrite_ReturnMinusOne() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - - // act - os.close(); - - // assert - assertThat(is.read(), is(-1)); - } + @DisplayName("getBufferSize(): reach max buffer elements — trim buffer right values") + @Test + public void getBufferSize_reachMaxBufferElements_trimBufferRightValues() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + sb.setMaxBufferElements(2); + + // act + // Write more than one element to the stream to force a trim call. + sb.getOutputStream().write(1); + sb.getOutputStream().write(new byte[] {2, 3}); + sb.getOutputStream().write(new byte[] {4, 5, 6}); + + // assert + byte[] read = new byte[is.available()]; + is.read(read); + assertThat(read, is(new byte[] {1, 2, 3, 4, 5, 6})); + } - @DisplayName("read(): closed stream after write — return minus one") - @Test - public void read_closedStreamAfterWrite_ReturnMinusOne() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(anyValue); - os.close(); - // Read the previously written value from the buffer. - is.read(); - - // act - // assert - assertThat(is.read(), is(-1)); - } + @DisplayName("getBufferSize(): write some elements — trim not called") + @Test + public void getBufferSize_writeSomeElements_trimNotCalled() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(4); - @DisplayName("read(): read with offset — use offset") - @Test - public void read_readWithOffset_useOffset() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[]{anyValue, anyValue, anyValue}); - - // act - byte[] dest = new byte[9]; - is.read(dest, 3, 3); - - // assert - assertThat(dest, is(new byte[]{0, 0, 0, anyValue, anyValue, anyValue, 0, 0, 0})); - } + // act + // Write fewer than four elements to the stream. + // The trim method should not be called. + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); - @DisplayName("read(): zero length — unmodified byte array") - @Test - public void read_zeroLength_unmodifiedByteArray() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[]{anyValue, anyValue, anyValue}); - - // act - byte[] dest = new byte[1]; - is.read(dest, 0, 0); - - // assert - assertThat(dest, is(new byte[]{0})); + // assert + assertThat(sb.getBufferSize(), is(3)); + } } - @DisplayName("read(): nothing written — return minus one") - @Test - public void read_nothingWritten_returnMinusOne() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.close(); - - // act - byte[] dest = new byte[1]; - int read = is.read(dest, 0, 1); - - // assert - assertThat(read, is(-1)); - } + @Nested + @DisplayName("read()") + class ReadTests { + @DisplayName("read(): closed stream before write — return minus one") + @Test + public void read_closedStreamBeforeWrite_ReturnMinusOne() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + + // act + os.close(); - @DisplayName("read(): null dest given — throw null pointer exception") - @Test - public void read_nullDestGiven_throwNullPointerException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - // act - // assert - assertThrows(NullPointerException.class, () -> is.read(null, 0, 0)); - } + // assert + assertThat(is.read(), is(-1)); + } - @DisplayName("read(): use invalid offset — throw index out of bounds exception") - @Test - public void read_useInvalidOffset_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - byte[] dest = new byte[1]; - // act - // assert - assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 3, 1)); - } + @DisplayName("read(): closed stream after write — return minus one") + @Test + public void read_closedStreamAfterWrite_ReturnMinusOne() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(anyValue); + os.close(); + // Read the previously written value from the buffer. + is.read(); - @DisplayName("read(): length greater than destination — throw index out of bounds exception") - @Test - public void read_lengthGreaterThanDestination_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - byte[] dest = new byte[1]; - // act - // assert - assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 0, 2)); - } + // act + // assert + assertThat(is.read(), is(-1)); + } - @DisplayName("read(): negative length — throw index out of bounds exception") - @Test - public void read_negativeLength_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - byte[] dest = new byte[1]; - // act - // assert - assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 0, -1)); - } + @DisplayName("read(): read with offset — use offset") + @Test + public void read_readWithOffset_useOffset() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[] {anyValue, anyValue, anyValue}); + + // act + byte[] dest = new byte[9]; + is.read(dest, 3, 3); + + // assert + assertThat(dest, is(new byte[] {0, 0, 0, anyValue, anyValue, anyValue, 0, 0, 0})); + } - @DisplayName("read(): negative offset — throw index out of bounds exception") - @Test - public void read_negativeOffset_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - byte[] dest = new byte[1]; - // act - // assert - assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, -1, 1)); - } + @DisplayName("read(): zero length — unmodified byte array") + @Test + public void read_zeroLength_unmodifiedByteArray() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[] {anyValue, anyValue, anyValue}); + + // act + byte[] dest = new byte[1]; + is.read(dest, 0, 0); + + // assert + assertThat(dest, is(new byte[] {0})); + } - @DisplayName("read(): close stream — returns written bytes") - @Test - public void read_closeStream_returnsWrittenBytes() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final InputStream is = sb.getInputStream(); - final OutputStream os = sb.getOutputStream(); - Thread consumer = new Thread(new Runnable() { + @DisplayName("read(): nothing written — return minus one") + @Test + public void read_nothingWritten_returnMinusOne() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.close(); - public void run() { - try { - sleepOneSecond(); - // first, write a value - os.write(anyValue); - // wait again - sleepOneSecond(); - // close the stream - os.close(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } catch (IOException ex) { - throw new RuntimeException(ex); - } - } - }); + // act + byte[] dest = new byte[1]; + int read = is.read(dest, 0, 1); - // act - consumer.start(); - byte[] dest = new byte[3]; - int read = is.read(dest); + // assert + assertThat(read, is(-1)); + } - // assert - assertThat(read, is(1)); - } + @DisplayName("read(): null dest given — throw null pointer exception") + @Test + public void read_nullDestGiven_throwNullPointerException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + // act + // assert + assertThrows(NullPointerException.class, () -> is.read(null, 0, 0)); + } - @DisplayName("read(): after immediate close — returns eof") - @Test - public void read_afterImmediateClose_returnsEOF() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.close(); - // act - // assert - assertThat(sb.getInputStream().read(), is(-1)); - } + @DisplayName("read(): use invalid offset — throw index out of bounds exception") + @Test + public void read_useInvalidOffset_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + byte[] dest = new byte[1]; + // act + // assert + assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 3, 1)); + } - @DisplayName("read(): parallel close — no deadlock") - @Test - public void read_parallelClose_noDeadlock() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final InputStream is = sb.getInputStream(); + @DisplayName("read(): length greater than destination — throw index out of bounds exception") + @Test + public void read_lengthGreaterThanDestination_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + byte[] dest = new byte[1]; + // act + // assert + assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 0, 2)); + } - Thread reader = new Thread(() -> { - try { - is.read(); // Should block initially, then unblock on close - } catch (IOException e) { - // Expected when stream is closed - } - }); + @DisplayName("read(): negative length — throw index out of bounds exception") + @Test + public void read_negativeLength_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + byte[] dest = new byte[1]; + // act + // assert + assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, 0, -1)); + } - // act - reader.start(); - Thread.sleep(500); // Let the read() call block - sb.close(); // Should unblock the reader - reader.join(); // Ensure thread completes + @DisplayName("read(): negative offset — throw index out of bounds exception") + @Test + public void read_negativeOffset_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + byte[] dest = new byte[1]; + // act + // assert + assertThrows(IndexOutOfBoundsException.class, () -> is.read(dest, -1, 1)); + } - // assert — no deadlock, no exception - } + @DisplayName("read(): close stream — returns written bytes") + @Test + public void read_closeStream_returnsWrittenBytes() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final InputStream is = sb.getInputStream(); + final OutputStream os = sb.getOutputStream(); + Thread consumer = new Thread(new Runnable() { - @DisplayName("read(): after trim and close — returns remaining bytes then eof") - @Test - public void read_afterTrimAndClose_returnsRemainingBytesThenEOF() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - sb.getOutputStream().write(new byte[]{4, 5, 6}); - sb.close(); - - // act - byte[] buffer = new byte[6]; - int read = sb.getInputStream().read(buffer); - - // assert - assertAll( - () -> assertThat("Should read all bytes", read, is(6)), - () -> assertThat("Should return EOF", sb.getInputStream().read(), is(-1)) - ); - } + public void run() { + try { + sleepOneSecond(); + // first, write a value + os.write(anyValue); + // wait again + sleepOneSecond(); + // close the stream + os.close(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + }); - @DisplayName("alternatingReadWrite(): small chunks — correct order") - @Test - public void alternatingReadWrite_smallChunks_correctOrder() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); + // act + consumer.start(); + byte[] dest = new byte[3]; + int read = is.read(dest); - // act - for (int i = 0; i < 100; i++) { - os.write(i); - assertThat(is.read(), is(i)); + // assert + assertThat(read, is(1)); } - // assert - assertThat("Stream should be empty after balanced writes/reads", is.available(), is(0)); - } - } + @DisplayName("read(): after immediate close — returns eof") + @Test + public void read_afterImmediateClose_returnsEOF() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.close(); + // act + // assert + assertThat(sb.getInputStream().read(), is(-1)); + } - @Nested - @DisplayName("write()") - class WriteTests { - @DisplayName("write(): null dest given — throw null pointer exception") - @Test - public void write_nullDestGiven_throwNullPointerException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - // act - // assert - assertThrows(NullPointerException.class, () -> os.write(null, 0, 0)); - } + @DisplayName("read(): parallel close — no deadlock") + @Test + public void read_parallelClose_noDeadlock() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final InputStream is = sb.getInputStream(); - @DisplayName("write(): use invalid offset — throw index out of bounds exception") - @Test - public void write_useInvalidOffset_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] from = new byte[1]; + Thread reader = new Thread(() -> { + try { + is.read(); // Should block initially, then unblock on close + } catch (IOException e) { + // Expected when stream is closed + } + }); - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> os.write(from, 3, 1)); + // act + reader.start(); + Thread.sleep(500); // Let the read() call block + sb.close(); // Should unblock the reader + reader.join(); // Ensure thread completes - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + // assert — no deadlock, no exception + } - @DisplayName("write(): length greater than destination — throw index out of bounds exception") - @Test - public void write_lengthGreaterThanDestination_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] from = new byte[1]; + @DisplayName("read(): after trim and close — returns remaining bytes then eof") + @Test + public void read_afterTrimAndClose_returnsRemainingBytesThenEOF() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + sb.getOutputStream().write(new byte[] {4, 5, 6}); + sb.close(); + + // act + byte[] buffer = new byte[6]; + int read = sb.getInputStream().read(buffer); + + // assert + assertAll( + () -> assertThat("Should read all bytes", read, is(6)), + () -> assertThat("Should return EOF", sb.getInputStream().read(), is(-1))); + } - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> os.write(from, 0, 2)); + @DisplayName("alternatingReadWrite(): small chunks — correct order") + @Test + public void alternatingReadWrite_smallChunks_correctOrder() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + // act + for (int i = 0; i < 100; i++) { + os.write(i); + assertThat(is.read(), is(i)); + } - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + // assert + assertThat("Stream should be empty after balanced writes/reads", is.available(), is(0)); + } } - @DisplayName("write(): negative length — throw index out of bounds exception") - @Test - public void write_negativeLength_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] from = new byte[1]; - - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> os.write(from, 0, -1)); + @Nested + @DisplayName("write()") + class WriteTests { + @DisplayName("write(): null dest given — throw null pointer exception") + @Test + public void write_nullDestGiven_throwNullPointerException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + // act + // assert + assertThrows(NullPointerException.class, () -> os.write(null, 0, 0)); + } - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName("write(): use invalid offset — throw index out of bounds exception") + @Test + public void write_useInvalidOffset_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] from = new byte[1]; + + // act + IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, () -> os.write(from, 3, 1)); + + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("write(): negative offset — throw index out of bounds exception") - @Test - public void write_negativeOffset_throwIndexOutOfBoundsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] from = new byte[1]; + @DisplayName("write(): length greater than destination — throw index out of bounds exception") + @Test + public void write_lengthGreaterThanDestination_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] from = new byte[1]; + + // act + IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, () -> os.write(from, 0, 2)); + + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> os.write(from, -1, 1)); + @DisplayName("write(): negative length — throw index out of bounds exception") + @Test + public void write_negativeLength_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] from = new byte[1]; + + // act + IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, () -> os.write(from, 0, -1)); + + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName("write(): negative offset — throw index out of bounds exception") + @Test + public void write_negativeOffset_throwIndexOutOfBoundsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] from = new byte[1]; + + // act + IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, () -> os.write(from, -1, 1)); + + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("write(): with valid offset — partial write successful") - @Test - public void write_withValidOffset_partialWriteSuccessful() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - byte[] from = new byte[]{anyValue, anyValue}; + @DisplayName("write(): with valid offset — partial write successful") + @Test + public void write_withValidOffset_partialWriteSuccessful() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + byte[] from = new byte[] {anyValue, anyValue}; - // act - os.write(from, 1, 1); + // act + os.write(from, 1, 1); - // assert - assertThat(is.available(), is(1)); - } + // assert + assertThat(is.available(), is(1)); + } - @DisplayName("write(): closed stream — throw io exception") - @ParameterizedTest - @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") - public void write_closedStream_throwIOException(WriteMethod writeMethod) { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - // act - // assert - assertThrows(IOException.class, () -> { - os.close(); - writeAnyValue(writeMethod, os); - }); - } + @DisplayName("write(): closed stream — throw io exception") + @ParameterizedTest + @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") + public void write_closedStream_throwIOException(WriteMethod writeMethod) { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + // act + // assert + assertThrows(IOException.class, () -> { + os.close(); + writeAnyValue(writeMethod, os); + }); + } - @DisplayName("write(): invalid offset — not written") - @Test - public void write_invalidOffset_notWritten() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - int invalidOffset = 1; + @DisplayName("write(): invalid offset — not written") + @Test + public void write_invalidOffset_notWritten() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + int invalidOffset = 1; - // act - os.write(new byte[]{anyValue}, invalidOffset, 0); + // act + os.write(new byte[] {anyValue}, invalidOffset, 0); - // assert - assertThat(sb.getBufferSize(), is(0)); - } + // assert + assertThat(sb.getBufferSize(), is(0)); + } - @DisplayName("write(): invalid length — not written") - @Test - public void write_invalidLength_notWritten() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - int invalidLength = 0; + @DisplayName("write(): invalid length — not written") + @Test + public void write_invalidLength_notWritten() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + int invalidLength = 0; - // act - os.write(new byte[]{anyValue}, 0, invalidLength); + // act + os.write(new byte[] {anyValue}, 0, invalidLength); - // assert - assertThat(sb.getBufferSize(), is(0)); - } + // assert + assertThat(sb.getBufferSize(), is(0)); + } - @DisplayName("write(): null array with offset — throws npe") - @Test - public void write_nullArrayWithOffset_throwsNPE() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThrows(NullPointerException.class, () -> sb.getOutputStream().write(null, 0, 1)); - } + @DisplayName("write(): null array with offset — throws npe") + @Test + public void write_nullArrayWithOffset_throwsNPE() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThrows(NullPointerException.class, () -> sb.getOutputStream().write(null, 0, 1)); + } } private void writeAnyValue(WriteMethod writeMethod, OutputStream os) throws IOException { - switch(writeMethod) { + switch (writeMethod) { case ByteArray: - os.write(new byte[]{anyValue}); + os.write(new byte[] {anyValue}); break; case Int: os.write(anyValue); break; case ByteArrayWithParameter: - os.write(new byte[]{anyValue, 0, 1}); + os.write(new byte[] {anyValue, 0, 1}); break; default: throw new IllegalArgumentException("Unknown WriteMethod: " + writeMethod); @@ -907,52 +915,52 @@ private void writeAnyValue(WriteMethod writeMethod, OutputStream os) throws IOEx @Nested @DisplayName("available() — large buffer / overflow") class AvailableLargeBufferTests { - @DisplayName("available(): buffer contains more bytes as max int — return max value") - @Test - public void available_bufferContainsMoreBytesAsMaxInt_returnMaxValue() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - - int chunks = 16; - - // It's not a good idea to allocate a very big array at once. - // Allocate a small piece instead and write this again and again to the stream. - // I have chosen 16 pieces and written this value 17 times - // to trigger an overflow in the available() method. - byte[] chunk = new byte[Integer.MAX_VALUE / chunks]; - - // act - for (int i = 0; i < chunks; i++) { + @DisplayName("available(): buffer contains more bytes as max int — return max value") + @Test + public void available_bufferContainsMoreBytesAsMaxInt_returnMaxValue() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + + int chunks = 16; + + // It's not a good idea to allocate a very big array at once. + // Allocate a small piece instead and write this again and again to the stream. + // I have chosen 16 pieces and written this value 17 times + // to trigger an overflow in the available() method. + byte[] chunk = new byte[Integer.MAX_VALUE / chunks]; + + // act + for (int i = 0; i < chunks; i++) { + os.write(chunk); + } + // write one additional os.write(chunk); - } - // write one additional - os.write(chunk); - // assert - assertThat(is.available(), is(Integer.MAX_VALUE)); - } + // assert + assertThat(is.available(), is(Integer.MAX_VALUE)); + } - @DisplayName("available(): after multiple writes — correct count") - @Test - public void available_afterMultipleWrites_correctCount() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); + @DisplayName("available(): after multiple writes — correct count") + @Test + public void available_afterMultipleWrites_correctCount() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + // act + // Write 5 chunks, each of 2 bytes + for (int i = 0; i < 5; i++) { + os.write(new byte[] {1, 2}); + } - // act - // Write 5 chunks, each of 2 bytes - for (int i = 0; i < 5; i++) { - os.write(new byte[]{1, 2}); + // assert + assertThat("available() should reflect the correct byte count", is.available(), is(10)); } - - // assert - assertThat("available() should reflect the correct byte count", is.available(), is(10)); - } } - + /** * Brief sleep to allow the method to block the thread correctly. */ @@ -963,4714 +971,4697 @@ private void sleepOneSecond() throws InterruptedException { @Nested @DisplayName("blockDataAvailable()") class BlockDataAvailableTests { - @DisplayName("blockDataAvailable(): data written before — no waiting") - @ParameterizedTest - @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") - public void blockDataAvailable_dataWrittenBefore_noWaiting(WriteMethod writeMethod) throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - final Semaphore after = new Semaphore(0); - Thread consumer = new Thread(new Runnable() { - - public void run() { - try { - sb.blockDataAvailable(); - after.release(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); + @DisplayName("blockDataAvailable(): data written before — no waiting") + @ParameterizedTest + @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") + public void blockDataAvailable_dataWrittenBefore_noWaiting(WriteMethod writeMethod) + throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + final Semaphore after = new Semaphore(0); + Thread consumer = new Thread(new Runnable() { + + public void run() { + try { + sb.blockDataAvailable(); + after.release(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } } - } - }); - writeAnyValue(writeMethod, os); + }); + writeAnyValue(writeMethod, os); - // act - consumer.start(); - sleepOneSecond(); + // act + consumer.start(); + sleepOneSecond(); - // assert - assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); - } + // assert + assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); + } - @DisplayName("blockDataAvailable(): data written before and read afterwards — waiting") - @Test - @Timeout(value = 1, unit = TimeUnit.HOURS) - public void blockDataAvailable_dataWrittenBeforeAndReadAfterwards_waiting() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - final Semaphore after = new Semaphore(0); - Thread consumer = new Thread(new Runnable() { - - public void run() { - try { - sb.blockDataAvailable(); - after.release(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); + @DisplayName("blockDataAvailable(): data written before and read afterwards — waiting") + @Test + @Timeout(value = 1, unit = TimeUnit.HOURS) + public void blockDataAvailable_dataWrittenBeforeAndReadAfterwards_waiting() + throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + final Semaphore after = new Semaphore(0); + Thread consumer = new Thread(new Runnable() { + + public void run() { + try { + sb.blockDataAvailable(); + after.release(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } } - } - }); - writeAnyValue(WriteMethod.Int, os); - is.read(); + }); + writeAnyValue(WriteMethod.Int, os); + is.read(); - // act - consumer.start(); - sleepOneSecond(); + // act + consumer.start(); + sleepOneSecond(); - // assert - assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(false)); - } + // assert + assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(false)); + } - @DisplayName("blockDataAvailable(): stream untouched — waiting") - @Test - @Timeout(value = 1, unit = TimeUnit.HOURS) - public void blockDataAvailable_streamUntouched_waiting() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore after = new Semaphore(0); - Thread consumer = new Thread(new Runnable() { + @DisplayName("blockDataAvailable(): stream untouched — waiting") + @Test + @Timeout(value = 1, unit = TimeUnit.HOURS) + public void blockDataAvailable_streamUntouched_waiting() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore after = new Semaphore(0); + Thread consumer = new Thread(new Runnable() { - public void run() { - try { - sb.blockDataAvailable(); - after.release(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); + public void run() { + try { + sb.blockDataAvailable(); + after.release(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } } - } - }); + }); - // act - consumer.start(); - sleepOneSecond(); - after.drainPermits(); + // act + consumer.start(); + sleepOneSecond(); + after.drainPermits(); - // assert - assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(false)); - } + // assert + assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(false)); + } - @DisplayName("blockDataAvailable(): write to stream — return") - @ParameterizedTest - @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") - public void blockDataAvailable_writeToStream_return(WriteMethod writeMethod) throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - final Semaphore after = new Semaphore(0); - Thread consumer = new Thread(new Runnable() { - - public void run() { - try { - sb.blockDataAvailable(); - after.release(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); + @DisplayName("blockDataAvailable(): write to stream — return") + @ParameterizedTest + @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") + public void blockDataAvailable_writeToStream_return(WriteMethod writeMethod) + throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + final Semaphore after = new Semaphore(0); + Thread consumer = new Thread(new Runnable() { + + public void run() { + try { + sb.blockDataAvailable(); + after.release(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } } - } - }); + }); - // act - consumer.start(); - sleepOneSecond(); - after.drainPermits(); - writeAnyValue(writeMethod, os); + // act + consumer.start(); + sleepOneSecond(); + after.drainPermits(); + writeAnyValue(writeMethod, os); - // assert - assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); - } + // assert + assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); + } - @DisplayName("blockDataAvailable(): close stream — return") - @Test - public void blockDataAvailable_closeStream_return() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - final Semaphore after = new Semaphore(0); - Thread consumer = new Thread(new Runnable() { + @DisplayName("blockDataAvailable(): close stream — return") + @Test + public void blockDataAvailable_closeStream_return() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + final Semaphore after = new Semaphore(0); + Thread consumer = new Thread(new Runnable() { - public void run() { - try { - sb.blockDataAvailable(); - after.release(); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); + public void run() { + try { + sb.blockDataAvailable(); + after.release(); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } } - } - }); + }); - // act - consumer.start(); - sleepOneSecond(); - after.drainPermits(); - os.close(); + // act + consumer.start(); + sleepOneSecond(); + after.drainPermits(); + os.close(); - // assert - assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); - } + // assert + assertThat(after.tryAcquire(10, TimeUnit.SECONDS), is(true)); + } - @DisplayName("blockDataAvailable(): stream already closed — return") - @Test - public void blockDataAvailable_streamAlreadyClosed_return() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("blockDataAvailable(): stream already closed — return") + @Test + public void blockDataAvailable_streamAlreadyClosed_return() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - sb.close(); - sb.blockDataAvailable(); + // act + sb.close(); + sb.blockDataAvailable(); - // assert — no exception thrown - } + // assert — no exception thrown + } - @DisplayName("blockDataAvailable(): data already available — only one wakeup") - @Test - public void blockDataAvailable_dataAlreadyAvailable_onlyOneWakeup() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); + @DisplayName("blockDataAvailable(): data already available — only one wakeup") + @Test + public void blockDataAvailable_dataAlreadyAvailable_onlyOneWakeup() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); - // Write one value before starting threads - os.write(anyValue); + // Write one value before starting threads + os.write(anyValue); - final Semaphore ready = new Semaphore(0); - final Semaphore done = new Semaphore(0); + final Semaphore ready = new Semaphore(0); + final Semaphore done = new Semaphore(0); - Thread consumer1 = new Thread(() -> { - try { - ready.release(); - sb.blockDataAvailable(); - done.release(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); + Thread consumer1 = new Thread(() -> { + try { + ready.release(); + sb.blockDataAvailable(); + done.release(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }); - Thread consumer2 = new Thread(() -> { - try { - ready.release(); - sb.blockDataAvailable(); - done.release(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); - - // act - consumer1.start(); - consumer2.start(); - ready.acquire(2); // wait for both threads to be ready - done.tryAcquire(1, TimeUnit.SECONDS); // one should proceed - Thread.sleep(100); // give it some time - int acquired = done.drainPermits(); // number of threads that actually returned - - // assert - assertThat("Only one thread should proceed due to single permit", acquired, is(1)); - } + Thread consumer2 = new Thread(() -> { + try { + ready.release(); + sb.blockDataAvailable(); + done.release(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }); - @DisplayName("blockDataAvailable(): multiple writes before call — does not block") - @Test - public void blockDataAvailable_multipleWritesBeforeCall_doesNotBlock() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - os.write(anyValue); - os.write(anyValue); + // act + consumer1.start(); + consumer2.start(); + ready.acquire(2); // wait for both threads to be ready + done.tryAcquire(1, TimeUnit.SECONDS); // one should proceed + Thread.sleep(100); // give it some time + int acquired = done.drainPermits(); // number of threads that actually returned - // act - // Should not block since data is already written - sb.blockDataAvailable(); + // assert + assertThat("Only one thread should proceed due to single permit", acquired, is(1)); + } - // assert — does not block - } + @DisplayName("blockDataAvailable(): multiple writes before call — does not block") + @Test + public void blockDataAvailable_multipleWritesBeforeCall_doesNotBlock() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + os.write(anyValue); + os.write(anyValue); - @DisplayName("blockDataAvailable(): after bytes consumed — blocks again") - @Test - public void blockDataAvailable_afterBytesConsumed_blocksAgain() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); + // act + // Should not block since data is already written + sb.blockDataAvailable(); - os.write(anyValue); - is.read(); // consumes byte, availableBytes now 0 + // assert — does not block + } - final Semaphore signal = new Semaphore(0); - Thread thread = new Thread(() -> { - try { - sb.blockDataAvailable(); - signal.release(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); + @DisplayName("blockDataAvailable(): after bytes consumed — blocks again") + @Test + public void blockDataAvailable_afterBytesConsumed_blocksAgain() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + os.write(anyValue); + is.read(); // consumes byte, availableBytes now 0 + + final Semaphore signal = new Semaphore(0); + Thread thread = new Thread(() -> { + try { + sb.blockDataAvailable(); + signal.release(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }); - // act - thread.start(); + // act + thread.start(); - Thread.sleep(500); // give thread time to block + Thread.sleep(500); // give thread time to block - // assert - assertThat("Thread should block since no new data was written", signal.tryAcquire(), is(false)); + // assert + assertThat("Thread should block since no new data was written", signal.tryAcquire(), is(false)); - os.write(anyValue); - assertThat("Thread should wake up after new data", signal.tryAcquire(2, TimeUnit.SECONDS), is(true)); - } + os.write(anyValue); + assertThat("Thread should wake up after new data", signal.tryAcquire(2, TimeUnit.SECONDS), is(true)); + } } @Nested @DisplayName("mark() / BufferedInputStream") class MarkTests { - @DisplayName("mark(): use buffered input stream — reset position") - @Test - public void mark_useBufferedInputStream_resetPosition() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - - int size = 3; - BufferedInputStream bis = new BufferedInputStream(is, size); - for (int i = 0; i < size; i++) { - os.write(anyValue); - } + @DisplayName("mark(): use buffered input stream — reset position") + @Test + public void mark_useBufferedInputStream_resetPosition() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + + int size = 3; + BufferedInputStream bis = new BufferedInputStream(is, size); + for (int i = 0; i < size; i++) { + os.write(anyValue); + } - // act - bis.mark(1); - bis.read(); - bis.reset(); + // act + bis.mark(1); + bis.read(); + bis.reset(); - // assert - int result = bis.available(); - assertThat(result, is(size)); - } + // assert + int result = bis.available(); + assertThat(result, is(size)); + } } @Nested @DisplayName("trim()") class TrimTests { - @DisplayName("trim(): preserves all bytes in correct order") - @Test - public void trim_preservesAllBytesInCorrectOrder() throws Exception { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(2); - - byte[] input = new byte[10]; - for (int i = 0; i < 10; i++) input[i] = (byte) i; - for (int i = 0; i < 10; i++) { - sb.getOutputStream().write(new byte[]{input[i]}); - } + @DisplayName("trim(): preserves all bytes in correct order") + @Test + public void trim_preservesAllBytesInCorrectOrder() throws Exception { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(2); + + byte[] input = new byte[10]; + for (int i = 0; i < 10; i++) input[i] = (byte) i; + for (int i = 0; i < 10; i++) { + sb.getOutputStream().write(new byte[] {input[i]}); + } - // act - byte[] output = new byte[10]; - sb.getInputStream().read(output); + // act + byte[] output = new byte[10]; + sb.getInputStream().read(output); - // assert - assertArrayEquals(input, output, "Trimmed buffer should preserve all byte order"); - } + // assert + assertArrayEquals(input, output, "Trimmed buffer should preserve all byte order"); + } - @DisplayName("trim(): empty buffer — no exception thrown") - @Test - public void trim_emptyBuffer_noExceptionThrown() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); + @DisplayName("trim(): empty buffer — no exception thrown") + @Test + public void trim_emptyBuffer_noExceptionThrown() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); - // act - // nothing written yet, but trim should not fail - sb.getOutputStream().write(new byte[0]); + // act + // nothing written yet, but trim should not fail + sb.getOutputStream().write(new byte[0]); - // assert — no exception thrown - } + // assert — no exception thrown + } } @Nested @DisplayName("close()") class CloseTests { - @DisplayName("close(): multiple calls — no exception thrown") - @Test - public void close_multipleCalls_noExceptionThrown() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("close(): multiple calls — no exception thrown") + @Test + public void close_multipleCalls_noExceptionThrown() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.close(); - sb.close(); // Should not throw + // act + sb.close(); + sb.close(); // Should not throw - // assert — no exception thrown - } + // assert — no exception thrown + } } @Nested @DisplayName("concurrent read/write") class ConcurrentReadWriteTests { - @DisplayName("concurrentReadWrite(): stress test — no crash or inconsistency") - @Test - public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - final int iterations = 1000; - final byte[] written = new byte[iterations]; - final byte[] read = new byte[iterations]; - - Thread writer = new Thread(() -> { - try { - for (int i = 0; i < iterations; i++) { - byte val = (byte) (i % 256); - written[i] = val; - os.write(val); + @DisplayName("concurrentReadWrite(): stress test — no crash or inconsistency") + @Test + public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + final int iterations = 1000; + final byte[] written = new byte[iterations]; + final byte[] read = new byte[iterations]; + + Thread writer = new Thread(() -> { + try { + for (int i = 0; i < iterations; i++) { + byte val = (byte) (i % 256); + written[i] = val; + os.write(val); + } + } catch (IOException e) { + throw new RuntimeException(e); } - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + }); - Thread reader = new Thread(() -> { - try { - for (int i = 0; i < iterations; i++) { - int value = is.read(); - read[i] = (byte) value; + Thread reader = new Thread(() -> { + try { + for (int i = 0; i < iterations; i++) { + int value = is.read(); + read[i] = (byte) value; + } + } catch (IOException e) { + throw new RuntimeException(e); } - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + }); - // act - writer.start(); - reader.start(); - writer.join(); - reader.join(); + // act + writer.start(); + reader.start(); + writer.join(); + reader.join(); - // assert - assertArrayEquals(written, read, "Read data should match written data"); - } + // assert + assertArrayEquals(written, read, "Read data should match written data"); + } } @Nested @DisplayName("getInputStream()") class GetInputStreamTests { - /** - * This test documents that multiple calls to getInputStream() - * return the same shared InputStream instance. - * - * Note: StreamBuffer is designed to support a single consumer. - * Repeated calls return the same instance; independent parallel reads are not supported. - */ - @DisplayName("multipleInputStream(): returns same instance — each call") - @Test - public void multipleInputStream_returnsSameInstance_eachCall() { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream first = sb.getInputStream(); - InputStream second = sb.getInputStream(); - - // act - // assert - assertSame(first, second, "StreamBuffer should return the same InputStream instance"); - } + /** + * This test documents that multiple calls to getInputStream() + * return the same shared InputStream instance. + * + * Note: StreamBuffer is designed to support a single consumer. + * Repeated calls return the same instance; independent parallel reads are not supported. + */ + @DisplayName("multipleInputStream(): returns same instance — each call") + @Test + public void multipleInputStream_returnsSameInstance_eachCall() { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream first = sb.getInputStream(); + InputStream second = sb.getInputStream(); + + // act + // assert + assertSame(first, second, "StreamBuffer should return the same InputStream instance"); + } } - + @Nested @DisplayName("correctOffsetAndLengthToRead()") class CorrectOffsetAndLengthToReadTests { - @DisplayName("correctOffsetAndLengthToRead(): null array — throws null pointer exception") - @Test - public void correctOffsetAndLengthToRead_nullArray_throwsNullPointerException() { - // arrange - // act - // assert — exception thrown is the assertion - assertThrows(NullPointerException.class, - () -> StreamBuffer.correctOffsetAndLengthToRead(null, 0, 1)); - } + @DisplayName("correctOffsetAndLengthToRead(): null array — throws null pointer exception") + @Test + public void correctOffsetAndLengthToRead_nullArray_throwsNullPointerException() { + // arrange + // act + // assert — exception thrown is the assertion + assertThrows(NullPointerException.class, () -> StreamBuffer.correctOffsetAndLengthToRead(null, 0, 1)); + } - @DisplayName("correctOffsetAndLengthToRead(): negative offset — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToRead_negativeOffset_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToRead(): negative offset — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToRead_negativeOffset_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; - // act - assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToRead(b, -1, 1)); - // assert — exception thrown is the assertion - } + // act + assertThrows(IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToRead(b, -1, 1)); + // assert — exception thrown is the assertion + } - @DisplayName("correctOffsetAndLengthToRead(): negative length — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToRead_negativeLength_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToRead(): negative length — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToRead_negativeLength_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; - // act - assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToRead(b, 0, -1)); - // assert — exception thrown is the assertion - } + // act + assertThrows(IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToRead(b, 0, -1)); + // assert — exception thrown is the assertion + } - @DisplayName("correctOffsetAndLengthToRead(): length exceeds remaining array — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToRead_lengthExceedsRemainingArray_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; + @DisplayName( + "correctOffsetAndLengthToRead(): length exceeds remaining array — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToRead_lengthExceedsRemainingArray_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; - // act - assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToRead(b, 3, 3)); - // assert — exception thrown is the assertion - } + // act + assertThrows(IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToRead(b, 3, 3)); + // assert — exception thrown is the assertion + } - @DisplayName("correctOffsetAndLengthToRead(): zero length — returns false") - @Test - public void correctOffsetAndLengthToRead_zeroLength_returnsFalse() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToRead(): zero length — returns false") + @Test + public void correctOffsetAndLengthToRead_zeroLength_returnsFalse() { + // arrange + byte[] b = new byte[5]; - // act - boolean result = StreamBuffer.correctOffsetAndLengthToRead(b, 0, 0); + // act + boolean result = StreamBuffer.correctOffsetAndLengthToRead(b, 0, 0); - // assert - assertThat(result, is(false)); - } + // assert + assertThat(result, is(false)); + } - @DisplayName("correctOffsetAndLengthToRead(): valid parameters — returns true") - @Test - public void correctOffsetAndLengthToRead_validParameters_returnsTrue() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToRead(): valid parameters — returns true") + @Test + public void correctOffsetAndLengthToRead_validParameters_returnsTrue() { + // arrange + byte[] b = new byte[5]; - // act - boolean result = StreamBuffer.correctOffsetAndLengthToRead(b, 1, 3); + // act + boolean result = StreamBuffer.correctOffsetAndLengthToRead(b, 1, 3); - // assert - assertThat(result, is(true)); - } + // assert + assertThat(result, is(true)); + } } @Nested @DisplayName("correctOffsetAndLengthToWrite()") class CorrectOffsetAndLengthToWriteTests { - @DisplayName("correctOffsetAndLengthToWrite(): null array — throws null pointer exception") - @Test - public void correctOffsetAndLengthToWrite_nullArray_throwsNullPointerException() { - // arrange - // act - // assert — exception thrown is the assertion - assertThrows(NullPointerException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(null, 0, 1)); - } + @DisplayName("correctOffsetAndLengthToWrite(): null array — throws null pointer exception") + @Test + public void correctOffsetAndLengthToWrite_nullArray_throwsNullPointerException() { + // arrange + // act + // assert — exception thrown is the assertion + assertThrows(NullPointerException.class, () -> StreamBuffer.correctOffsetAndLengthToWrite(null, 0, 1)); + } - @DisplayName("correctOffsetAndLengthToWrite(): negative offset — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToWrite_negativeOffset_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; - - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(b, -1, 1)); - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName("correctOffsetAndLengthToWrite(): negative offset — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToWrite_negativeOffset_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; + + // act + IndexOutOfBoundsException ex = assertThrows( + IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToWrite(b, -1, 1)); + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("correctOffsetAndLengthToWrite(): negative length — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToWrite_negativeLength_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; - - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 0, -1)); - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName("correctOffsetAndLengthToWrite(): negative length — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToWrite_negativeLength_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; + + // act + IndexOutOfBoundsException ex = assertThrows( + IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 0, -1)); + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("correctOffsetAndLengthToWrite(): offset exceeds array length — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToWrite_offsetExceedsArrayLength_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[1]; - - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 2, 1)); - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName( + "correctOffsetAndLengthToWrite(): offset exceeds array length — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToWrite_offsetExceedsArrayLength_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[1]; + + // act + IndexOutOfBoundsException ex = assertThrows( + IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 2, 1)); + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("correctOffsetAndLengthToWrite(): length exceeds remaining array — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToWrite_lengthExceedsRemainingArray_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[5]; - - // act - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 3, 3)); - // assert - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName( + "correctOffsetAndLengthToWrite(): length exceeds remaining array — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToWrite_lengthExceedsRemainingArray_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[5]; + + // act + IndexOutOfBoundsException ex = assertThrows( + IndexOutOfBoundsException.class, () -> StreamBuffer.correctOffsetAndLengthToWrite(b, 3, 3)); + // assert + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } - @DisplayName("correctOffsetAndLengthToWrite(): zero length — returns false") - @Test - public void correctOffsetAndLengthToWrite_zeroLength_returnsFalse() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToWrite(): zero length — returns false") + @Test + public void correctOffsetAndLengthToWrite_zeroLength_returnsFalse() { + // arrange + byte[] b = new byte[5]; - // act - boolean result = StreamBuffer.correctOffsetAndLengthToWrite(b, 0, 0); + // act + boolean result = StreamBuffer.correctOffsetAndLengthToWrite(b, 0, 0); - // assert - assertThat(result, is(false)); - } + // assert + assertThat(result, is(false)); + } - @DisplayName("correctOffsetAndLengthToWrite(): valid parameters — returns true") - @Test - public void correctOffsetAndLengthToWrite_validParameters_returnsTrue() { - // arrange - byte[] b = new byte[5]; + @DisplayName("correctOffsetAndLengthToWrite(): valid parameters — returns true") + @Test + public void correctOffsetAndLengthToWrite_validParameters_returnsTrue() { + // arrange + byte[] b = new byte[5]; - // act - boolean result = StreamBuffer.correctOffsetAndLengthToWrite(b, 1, 3); + // act + boolean result = StreamBuffer.correctOffsetAndLengthToWrite(b, 1, 3); - // assert - assertThat(result, is(true)); - } + // assert + assertThat(result, is(true)); + } } @Nested @DisplayName("isTrimShouldBeExecuted() — boundary") class IsTrimShouldBeExecutedBoundaryTests { - @DisplayName("getBufferSize(): exactly at max buffer elements — trim not called") - @Test - public void getBufferSize_exactlyAtMaxBufferElements_trimNotCalled() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(2); - - // act - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - - // assert - assertThat(sb.getBufferSize(), is(2)); - } + @DisplayName("getBufferSize(): exactly at max buffer elements — trim not called") + @Test + public void getBufferSize_exactlyAtMaxBufferElements_trimNotCalled() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(2); + + // act + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); + + // assert + assertThat(sb.getBufferSize(), is(2)); + } - @DisplayName("getBufferSize(): one above max buffer elements — trim called") - @Test - public void getBufferSize_oneAboveMaxBufferElements_trimCalled() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(2); + @DisplayName("getBufferSize(): one above max buffer elements — trim called") + @Test + public void getBufferSize_oneAboveMaxBufferElements_trimCalled() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(2); - // act - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); - sb.getOutputStream().write(anyValue); + // act + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); + sb.getOutputStream().write(anyValue); - // assert - assertThat(sb.getBufferSize(), is(1)); - } + // assert + assertThat(sb.getBufferSize(), is(1)); + } } @Nested @DisplayName("getOutputStream()") class GetOutputStreamTests { - @DisplayName("multipleOutputStream(): returns same instance — each call") - @Test - public void multipleOutputStream_returnsSameInstance_eachCall() { - // arrange - StreamBuffer sb = new StreamBuffer(); - - // act - OutputStream first = sb.getOutputStream(); - OutputStream second = sb.getOutputStream(); - - // assert - assertSame(first, second, "StreamBuffer should return the same OutputStream instance"); - } + @DisplayName("multipleOutputStream(): returns same instance — each call") + @Test + public void multipleOutputStream_returnsSameInstance_eachCall() { + // arrange + StreamBuffer sb = new StreamBuffer(); + + // act + OutputStream first = sb.getOutputStream(); + OutputStream second = sb.getOutputStream(); + + // assert + assertSame(first, second, "StreamBuffer should return the same OutputStream instance"); + } } @Nested @DisplayName("requireNonClosed()") class RequireNonClosedTests { - @DisplayName("write(): closed stream — throw io exception with stream closed message") - @Test - public void write_closedStream_throwIOExceptionWithStreamClosedMessage() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.close(); - - // act - IOException ex = assertThrows(IOException.class, - () -> sb.getOutputStream().write(new byte[]{anyValue}, 0, 1)); - assertThat(ex.getMessage(), is("Stream closed.")); - } + @DisplayName("write(): closed stream — throw io exception with stream closed message") + @Test + public void write_closedStream_throwIOExceptionWithStreamClosedMessage() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.close(); + + // act + IOException ex = + assertThrows(IOException.class, () -> sb.getOutputStream().write(new byte[] {anyValue}, 0, 1)); + assertThat(ex.getMessage(), is("Stream closed.")); + } } @Nested @DisplayName("write() — inherited overloads") class WriteInheritedTests { - @DisplayName("write(): full array without offset parameter — all bytes written") - @Test - public void write_fullArrayWithoutOffsetParameter_allBytesWritten() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - byte[] source = new byte[]{1, 2, 3}; - - // act - sb.getOutputStream().write(source); - - // assert - assertThat(is.available(), is(3)); - byte[] dest = new byte[3]; - is.read(dest); - assertThat(dest, is(new byte[]{1, 2, 3})); - } + @DisplayName("write(): full array without offset parameter — all bytes written") + @Test + public void write_fullArrayWithoutOffsetParameter_allBytesWritten() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + byte[] source = new byte[] {1, 2, 3}; + + // act + sb.getOutputStream().write(source); + + // assert + assertThat(is.available(), is(3)); + byte[] dest = new byte[3]; + is.read(dest); + assertThat(dest, is(new byte[] {1, 2, 3})); + } } @Nested @DisplayName("read() — inherited overloads") class ReadInheritedTests { - @DisplayName("read(): full array without offset parameter — all bytes read") - @Test - public void read_fullArrayWithoutOffsetParameter_allBytesRead() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - - // act - byte[] dest = new byte[3]; - int bytesRead = sb.getInputStream().read(dest); - - // assert - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertThat(dest, is(new byte[]{1, 2, 3})) - ); - } + @DisplayName("read(): full array without offset parameter — all bytes read") + @Test + public void read_fullArrayWithoutOffsetParameter_allBytesRead() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + + // act + byte[] dest = new byte[3]; + int bytesRead = sb.getInputStream().read(dest); + + // assert + assertAll(() -> assertThat(bytesRead, is(3)), () -> assertThat(dest, is(new byte[] {1, 2, 3}))); + } } @Nested @DisplayName("trim() — partial buffer entry") class TrimWithPartialBufferEntryTests { - @DisplayName("trim(): with partially consumed buffer entry — remaining bytes correct after trim") - @Test - public void trim_withPartiallyConsumedBufferEntry_remainingBytesCorrectAfterTrim() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - // Read one byte to advance positionAtCurrentBufferEntry to 1 - assertThat(sb.getInputStream().read(), is(1)); - - // act — write a second entry to trigger trim while positionAtCurrentBufferEntry == 1 - sb.getOutputStream().write(new byte[]{4, 5, 6}); - - // assert — remaining 5 bytes should be 2,3,4,5,6 in order - byte[] dest = new byte[5]; - int bytesRead = sb.getInputStream().read(dest); - assertAll( - () -> assertThat(bytesRead, is(5)), - () -> assertThat(dest, is(new byte[]{2, 3, 4, 5, 6})) - ); - } + @DisplayName("trim(): with partially consumed buffer entry — remaining bytes correct after trim") + @Test + public void trim_withPartiallyConsumedBufferEntry_remainingBytesCorrectAfterTrim() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + // Read one byte to advance positionAtCurrentBufferEntry to 1 + assertThat(sb.getInputStream().read(), is(1)); + + // act — write a second entry to trigger trim while positionAtCurrentBufferEntry == 1 + sb.getOutputStream().write(new byte[] {4, 5, 6}); + + // assert — remaining 5 bytes should be 2,3,4,5,6 in order + byte[] dest = new byte[5]; + int bytesRead = sb.getInputStream().read(dest); + assertAll(() -> assertThat(bytesRead, is(5)), () -> assertThat(dest, is(new byte[] {2, 3, 4, 5, 6}))); + } } @Nested @DisplayName("available()") class AvailableTests { - @DisplayName("available(): empty buffer — returns zero") - @Test - public void available_emptyBuffer_returnsZero() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("available(): empty buffer — returns zero") + @Test + public void available_emptyBuffer_returnsZero() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - int result = sb.getInputStream().available(); + // act + int result = sb.getInputStream().available(); - // assert - assertThat(result, is(0)); - } + // assert + assertThat(result, is(0)); + } } @Nested @DisplayName("safeWrite — partial range") class SafeWritePartialRangeTests { - @DisplayName("write(): partial range with safe write disabled — external mutation not affecting read value") - @Test - public void write_partialRangeWithSafeWriteDisabled_externalMutationNotAffectingReadValue() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setSafeWrite(false); - byte[] source = new byte[]{0, anyValue, 0}; - - // act — partial write always copies via System.arraycopy, regardless of safeWrite flag - sb.getOutputStream().write(source, 1, 1); - source[1]++; - - // assert — the buffered byte is a copy; mutation of source must not affect it - byte[] dest = new byte[1]; - sb.getInputStream().read(dest); - assertThat(dest[0], is(anyValue)); - } + @DisplayName("write(): partial range with safe write disabled — external mutation not affecting read value") + @Test + public void write_partialRangeWithSafeWriteDisabled_externalMutationNotAffectingReadValue() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setSafeWrite(false); + byte[] source = new byte[] {0, anyValue, 0}; + + // act — partial write always copies via System.arraycopy, regardless of safeWrite flag + sb.getOutputStream().write(source, 1, 1); + source[1]++; + + // assert — the buffered byte is a copy; mutation of source must not affect it + byte[] dest = new byte[1]; + sb.getInputStream().read(dest); + assertThat(dest[0], is(anyValue)); + } } @Nested @DisplayName("read() — partial return on closed stream") class ReadPartialReturnOnClosedStreamTests { - @DisplayName("read(): byte array when stream closed after one byte — returns one byte") - @Test - public void read_byteArrayWhenStreamClosedAfterOneByte_returnsOneByte() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(anyValue); - sb.close(); - - // act — only 1 byte available; stream closed; read(dest, 0, 2) must return 1, not -1 - byte[] dest = new byte[2]; - int bytesRead = sb.getInputStream().read(dest, 0, 2); - - // assert - assertAll( - () -> assertThat(bytesRead, is(1)), - () -> assertThat(dest[0], is(anyValue)) - ); - } + @DisplayName("read(): byte array when stream closed after one byte — returns one byte") + @Test + public void read_byteArrayWhenStreamClosedAfterOneByte_returnsOneByte() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(anyValue); + sb.close(); - @DisplayName("read(): request more bytes than available on closed stream — returns available bytes") - @Test - public void read_requestMoreBytesThanAvailableOnClosedStream_returnsAvailableBytes() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - sb.close(); - - // act — 3 bytes available but 5 requested; must return 3, not throw NoSuchElementException - byte[] dest = new byte[5]; - int bytesRead = sb.getInputStream().read(dest, 0, 5); - - // assert - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertArrayEquals(new byte[]{1, 2, 3}, Arrays.copyOf(dest, 3)) - ); - } + // act — only 1 byte available; stream closed; read(dest, 0, 2) must return 1, not -1 + byte[] dest = new byte[2]; + int bytesRead = sb.getInputStream().read(dest, 0, 2); - @DisplayName("read(): concurrent write close with insufficient bytes — returns available bytes") - @Test - public void read_concurrentWriteCloseWithInsufficientBytes_returnsAvailableBytes() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - final int[] bytesReadHolder = new int[1]; - final byte[] dest = new byte[10]; - final Semaphore readerStarted = new Semaphore(0); - - // reader requests 10 bytes but only 3 will ever be written - Thread reader = new Thread(() -> { - try { - readerStarted.release(); - bytesReadHolder[0] = is.read(dest, 0, 10); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - - // act - reader.start(); - readerStarted.acquire(); - Thread.sleep(500); // let reader block in tryWaitForEnoughBytes - os.write(new byte[]{1, 2, 3}); - os.close(); // unblocks reader with only 3 bytes available - - reader.join(); - - // assert - assertAll( - () -> assertThat(bytesReadHolder[0], is(3)), - () -> assertArrayEquals(new byte[]{1, 2, 3}, Arrays.copyOf(dest, 3)) - ); - } + // assert + assertAll(() -> assertThat(bytesRead, is(1)), () -> assertThat(dest[0], is(anyValue))); + } - @DisplayName("read(): multiple deque entries on closed stream — returns available bytes") - @Test - public void read_multipleDequeEntriesOnClosedStream_returnsAvailableBytes() throws IOException { - // arrange — three separate writes create three deque entries - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - os.write(new byte[]{1}); - os.write(new byte[]{2}); - os.write(new byte[]{3}); - sb.close(); - - // act — request 10 bytes, only 3 available across 3 entries - byte[] dest = new byte[10]; - int bytesRead = is.read(dest, 0, 10); - - // assert - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertArrayEquals(new byte[]{1, 2, 3}, Arrays.copyOf(dest, 3)) - ); - } + @DisplayName("read(): request more bytes than available on closed stream — returns available bytes") + @Test + public void read_requestMoreBytesThanAvailableOnClosedStream_returnsAvailableBytes() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + sb.close(); - @DisplayName("read(): partial entry consumed then close and over read — returns remaining bytes") - @Test - public void read_partialEntryConsumedThenCloseAndOverRead_returnsRemainingBytes() throws IOException { - // arrange — write 5 bytes, read 2 to advance positionAtCurrentBufferEntry - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - os.write(new byte[]{1, 2, 3, 4, 5}); - assertThat(is.read(), is(1)); - assertThat(is.read(), is(2)); - sb.close(); - - // act — 3 bytes remain, request 8 - byte[] dest = new byte[8]; - int bytesRead = is.read(dest, 0, 8); - - // assert - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertArrayEquals(new byte[]{3, 4, 5}, Arrays.copyOf(dest, 3)) - ); - } + // act — 3 bytes available but 5 requested; must return 3, not throw NoSuchElementException + byte[] dest = new byte[5]; + int bytesRead = sb.getInputStream().read(dest, 0, 5); - @DisplayName("read(): request exactly one more than available — returns available bytes") - @Test - public void read_requestExactlyOneMoreThanAvailable_returnsAvailableBytes() throws IOException { - // arrange — 4 bytes written, request 5 (after first internal read: 3 remain, missingBytes=4) - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{10, 20, 30, 40}); - sb.close(); - - // act - byte[] dest = new byte[5]; - int bytesRead = sb.getInputStream().read(dest, 0, 5); - - // assert - assertAll( - () -> assertThat(bytesRead, is(4)), - () -> assertArrayEquals(new byte[]{10, 20, 30, 40}, Arrays.copyOf(dest, 4)) - ); - } + // assert + assertAll( + () -> assertThat(bytesRead, is(3)), + () -> assertArrayEquals(new byte[] {1, 2, 3}, Arrays.copyOf(dest, 3))); + } - @DisplayName("read(): non zero offset with over read on closed stream — returns available bytes at offset") - @Test - public void read_nonZeroOffsetWithOverReadOnClosedStream_returnsAvailableBytesAtOffset() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - sb.close(); - - // act — read into offset 3 of a 10-byte array, requesting 7 - byte[] dest = new byte[10]; - int bytesRead = sb.getInputStream().read(dest, 3, 7); - - // assert — leading/trailing zeros prove the offset was respected - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertArrayEquals(new byte[]{0, 0, 0, 1, 2, 3, 0, 0, 0, 0}, dest) - ); - } + @DisplayName("read(): concurrent write close with insufficient bytes — returns available bytes") + @Test + public void read_concurrentWriteCloseWithInsufficientBytes_returnsAvailableBytes() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + final int[] bytesReadHolder = new int[1]; + final byte[] dest = new byte[10]; + final Semaphore readerStarted = new Semaphore(0); + + // reader requests 10 bytes but only 3 will ever be written + Thread reader = new Thread(() -> { + try { + readerStarted.release(); + bytesReadHolder[0] = is.read(dest, 0, 10); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); - @DisplayName("read(): trim then close and over read — returns available bytes") - @Test - public void read_trimThenCloseAndOverRead_returnsAvailableBytes() throws IOException { - // arrange — low maxBufferElements triggers trim during writes - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - os.write(new byte[]{1}); - os.write(new byte[]{2}); - os.write(new byte[]{3}); - os.write(new byte[]{4, 5}); - sb.close(); - - // act — 5 bytes available (post-trim), request 10 - byte[] dest = new byte[10]; - int bytesRead = is.read(dest, 0, 10); - - // assert - assertAll( - () -> assertThat(bytesRead, is(5)), - () -> assertArrayEquals(new byte[]{1, 2, 3, 4, 5}, Arrays.copyOf(dest, 5)) - ); - } + // act + reader.start(); + readerStarted.acquire(); + Thread.sleep(500); // let reader block in tryWaitForEnoughBytes + os.write(new byte[] {1, 2, 3}); + os.close(); // unblocks reader with only 3 bytes available - @DisplayName("read(): concurrent multiple writes then close — returns available bytes") - @Test - public void read_concurrentMultipleWritesThenClose_returnsAvailableBytes() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - final int[] bytesReadHolder = new int[1]; - final byte[] dest = new byte[20]; - final Semaphore readerStarted = new Semaphore(0); - - Thread reader = new Thread(() -> { - try { - readerStarted.release(); - bytesReadHolder[0] = is.read(dest, 0, 20); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - - // act — reader blocks, writer sends 3 separate chunks then closes - reader.start(); - readerStarted.acquire(); - Thread.sleep(500); // let reader block - os.write(new byte[]{1, 2}); - os.write(new byte[]{3, 4}); - os.write(new byte[]{5}); - os.close(); - - reader.join(); - - // assert - assertAll( - () -> assertThat(bytesReadHolder[0], is(5)), - () -> assertArrayEquals(new byte[]{1, 2, 3, 4, 5}, Arrays.copyOf(dest, 5)) - ); - } + reader.join(); + + // assert + assertAll( + () -> assertThat(bytesReadHolder[0], is(3)), + () -> assertArrayEquals(new byte[] {1, 2, 3}, Arrays.copyOf(dest, 3))); + } + + @DisplayName("read(): multiple deque entries on closed stream — returns available bytes") + @Test + public void read_multipleDequeEntriesOnClosedStream_returnsAvailableBytes() throws IOException { + // arrange — three separate writes create three deque entries + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + os.write(new byte[] {1}); + os.write(new byte[] {2}); + os.write(new byte[] {3}); + sb.close(); + + // act — request 10 bytes, only 3 available across 3 entries + byte[] dest = new byte[10]; + int bytesRead = is.read(dest, 0, 10); + + // assert + assertAll( + () -> assertThat(bytesRead, is(3)), + () -> assertArrayEquals(new byte[] {1, 2, 3}, Arrays.copyOf(dest, 3))); + } + + @DisplayName("read(): partial entry consumed then close and over read — returns remaining bytes") + @Test + public void read_partialEntryConsumedThenCloseAndOverRead_returnsRemainingBytes() throws IOException { + // arrange — write 5 bytes, read 2 to advance positionAtCurrentBufferEntry + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + os.write(new byte[] {1, 2, 3, 4, 5}); + assertThat(is.read(), is(1)); + assertThat(is.read(), is(2)); + sb.close(); + + // act — 3 bytes remain, request 8 + byte[] dest = new byte[8]; + int bytesRead = is.read(dest, 0, 8); + + // assert + assertAll( + () -> assertThat(bytesRead, is(3)), + () -> assertArrayEquals(new byte[] {3, 4, 5}, Arrays.copyOf(dest, 3))); + } + + @DisplayName("read(): request exactly one more than available — returns available bytes") + @Test + public void read_requestExactlyOneMoreThanAvailable_returnsAvailableBytes() throws IOException { + // arrange — 4 bytes written, request 5 (after first internal read: 3 remain, missingBytes=4) + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {10, 20, 30, 40}); + sb.close(); + + // act + byte[] dest = new byte[5]; + int bytesRead = sb.getInputStream().read(dest, 0, 5); + + // assert + assertAll( + () -> assertThat(bytesRead, is(4)), + () -> assertArrayEquals(new byte[] {10, 20, 30, 40}, Arrays.copyOf(dest, 4))); + } + + @DisplayName("read(): non zero offset with over read on closed stream — returns available bytes at offset") + @Test + public void read_nonZeroOffsetWithOverReadOnClosedStream_returnsAvailableBytesAtOffset() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + sb.close(); + + // act — read into offset 3 of a 10-byte array, requesting 7 + byte[] dest = new byte[10]; + int bytesRead = sb.getInputStream().read(dest, 3, 7); + + // assert — leading/trailing zeros prove the offset was respected + assertAll( + () -> assertThat(bytesRead, is(3)), + () -> assertArrayEquals(new byte[] {0, 0, 0, 1, 2, 3, 0, 0, 0, 0}, dest)); + } + + @DisplayName("read(): trim then close and over read — returns available bytes") + @Test + public void read_trimThenCloseAndOverRead_returnsAvailableBytes() throws IOException { + // arrange — low maxBufferElements triggers trim during writes + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + os.write(new byte[] {1}); + os.write(new byte[] {2}); + os.write(new byte[] {3}); + os.write(new byte[] {4, 5}); + sb.close(); + + // act — 5 bytes available (post-trim), request 10 + byte[] dest = new byte[10]; + int bytesRead = is.read(dest, 0, 10); + + // assert + assertAll( + () -> assertThat(bytesRead, is(5)), + () -> assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, Arrays.copyOf(dest, 5))); + } + + @DisplayName("read(): concurrent multiple writes then close — returns available bytes") + @Test + public void read_concurrentMultipleWritesThenClose_returnsAvailableBytes() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + final int[] bytesReadHolder = new int[1]; + final byte[] dest = new byte[20]; + final Semaphore readerStarted = new Semaphore(0); + + Thread reader = new Thread(() -> { + try { + readerStarted.release(); + bytesReadHolder[0] = is.read(dest, 0, 20); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + // act — reader blocks, writer sends 3 separate chunks then closes + reader.start(); + readerStarted.acquire(); + Thread.sleep(500); // let reader block + os.write(new byte[] {1, 2}); + os.write(new byte[] {3, 4}); + os.write(new byte[] {5}); + os.close(); + + reader.join(); + + // assert + assertAll( + () -> assertThat(bytesReadHolder[0], is(5)), + () -> assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, Arrays.copyOf(dest, 5))); + } } @Nested @DisplayName("concurrent trim and write") class ConcurrentTrimAndWriteTests { - @DisplayName("concurrentTrimAndWrite(): no crash or corruption") - @Test - public void concurrentTrimAndWrite_noCrashOrCorruption() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - final int iterations = 10_000; - final byte value = 42; - - Thread writer = new Thread(() -> { - try { - for (int i = 0; i < iterations; i++) { - os.write(value); + @DisplayName("concurrentTrimAndWrite(): no crash or corruption") + @Test + public void concurrentTrimAndWrite_noCrashOrCorruption() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + final int iterations = 10_000; + final byte value = 42; + + Thread writer = new Thread(() -> { + try { + for (int i = 0; i < iterations; i++) { + os.write(value); + } + } catch (IOException e) { + throw new RuntimeException("Writer thread failed", e); } - } catch (IOException e) { - throw new RuntimeException("Writer thread failed", e); - } - }); + }); - Thread trimmer = new Thread(() -> { - try { - for (int i = 0; i < iterations / 100; i++) { - sb.setMaxBufferElements((i % 10) + 1); // dynamically shrink/grow - Thread.sleep(1); + Thread trimmer = new Thread(() -> { + try { + for (int i = 0; i < iterations / 100; i++) { + sb.setMaxBufferElements((i % 10) + 1); // dynamically shrink/grow + Thread.sleep(1); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); + }); - Thread reader = new Thread(() -> { - try { - while (!sb.isClosed() || is.available() > 0) { - is.read(); // consume and keep buffer flowing + Thread reader = new Thread(() -> { + try { + while (!sb.isClosed() || is.available() > 0) { + is.read(); // consume and keep buffer flowing + } + } catch (IOException e) { + throw new RuntimeException("Reader thread failed", e); } - } catch (IOException e) { - throw new RuntimeException("Reader thread failed", e); - } - }); + }); - // act - writer.start(); - trimmer.start(); - reader.start(); + // act + writer.start(); + trimmer.start(); + reader.start(); - writer.join(); - os.close(); // gracefully signal end of writing - trimmer.join(); - reader.join(); + writer.join(); + os.close(); // gracefully signal end of writing + trimmer.join(); + reader.join(); - // assert — no crash or data corruption - } + // assert — no crash or data corruption + } } @Nested @DisplayName("signal/slot") class SignalSlotTests { - @DisplayName("signal(): add signal and write — signal released") - @Test - public void signal_addSignalAndWrite_signalReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - sb.addSignal(signal); - - // act - sb.getOutputStream().write(anyValue); - - // assert - assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); - } + @DisplayName("signal(): add signal and write — signal released") + @Test + public void signal_addSignalAndWrite_signalReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + sb.addSignal(signal); + + // act + sb.getOutputStream().write(anyValue); - @DisplayName("signal(): add signal and close — signal released") - @Test - public void signal_addSignalAndClose_signalReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - sb.addSignal(signal); + // assert + assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); + } - // act - sb.close(); + @DisplayName("signal(): add signal and close — signal released") + @Test + public void signal_addSignalAndClose_signalReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + sb.addSignal(signal); - // assert - assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); - } + // act + sb.close(); - @DisplayName("signal(): multiple signals — all released") - @Test - public void signal_multipleSignals_allReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal1 = new Semaphore(0); - final Semaphore signal2 = new Semaphore(0); - final Semaphore signal3 = new Semaphore(0); - sb.addSignal(signal1); - sb.addSignal(signal2); - sb.addSignal(signal3); - - // act - sb.getOutputStream().write(anyValue); - - // assert - assertAll( - () -> assertThat(signal1.tryAcquire(5, TimeUnit.SECONDS), is(true)), - () -> assertThat(signal2.tryAcquire(5, TimeUnit.SECONDS), is(true)), - () -> assertThat(signal3.tryAcquire(5, TimeUnit.SECONDS), is(true)) - ); - } + // assert + assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); + } - @DisplayName("signal(): remove signal — not released") - @Test - public void signal_removeSignal_notReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - sb.addSignal(signal); - - // act - boolean removed = sb.removeSignal(signal); - sb.getOutputStream().write(anyValue); - - // assert - assertAll( - () -> assertThat(removed, is(true)), - () -> assertThat(signal.tryAcquire(1, TimeUnit.SECONDS), is(false)) - ); - } + @DisplayName("signal(): multiple signals — all released") + @Test + public void signal_multipleSignals_allReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal1 = new Semaphore(0); + final Semaphore signal2 = new Semaphore(0); + final Semaphore signal3 = new Semaphore(0); + sb.addSignal(signal1); + sb.addSignal(signal2); + sb.addSignal(signal3); + + // act + sb.getOutputStream().write(anyValue); + + // assert + assertAll( + () -> assertThat(signal1.tryAcquire(5, TimeUnit.SECONDS), is(true)), + () -> assertThat(signal2.tryAcquire(5, TimeUnit.SECONDS), is(true)), + () -> assertThat(signal3.tryAcquire(5, TimeUnit.SECONDS), is(true))); + } - @DisplayName("signal(): remove non existent signal — returns false") - @Test - public void signal_removeNonExistentSignal_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); + @DisplayName("signal(): remove signal — not released") + @Test + public void signal_removeSignal_notReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + sb.addSignal(signal); - // act - boolean removed = sb.removeSignal(signal); + // act + boolean removed = sb.removeSignal(signal); + sb.getOutputStream().write(anyValue); - // assert - assertThat(removed, is(false)); - } + // assert + assertAll( + () -> assertThat(removed, is(true)), + () -> assertThat(signal.tryAcquire(1, TimeUnit.SECONDS), is(false))); + } - @DisplayName("signal(): add null signal — throws null pointer exception") - @Test - public void signal_addNullSignal_throwsNullPointerException() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("signal(): remove non existent signal — returns false") + @Test + public void signal_removeNonExistentSignal_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); - // act - // assert - assertThrows(NullPointerException.class, () -> sb.addSignal(null)); - } + // act + boolean removed = sb.removeSignal(signal); - @DisplayName("signal(): thread barrier — observer wakes in own thread") - @Test - public void signal_threadBarrier_observerWakesInOwnThread() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - final Semaphore observerDone = new Semaphore(0); - final Thread[] observerThreadHolder = new Thread[1]; - sb.addSignal(signal); - - // observer runs in its own thread, blocked on the signal - Thread observer = new Thread(new Runnable() { - @Override - public void run() { - try { - signal.acquire(); - observerThreadHolder[0] = Thread.currentThread(); - observerDone.release(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + // assert + assertThat(removed, is(false)); + } + + @DisplayName("signal(): add null signal — throws null pointer exception") + @Test + public void signal_addNullSignal_throwsNullPointerException() { + // arrange + final StreamBuffer sb = new StreamBuffer(); + + // act + // assert + assertThrows(NullPointerException.class, () -> sb.addSignal(null)); + } + + @DisplayName("signal(): thread barrier — observer wakes in own thread") + @Test + public void signal_threadBarrier_observerWakesInOwnThread() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + final Semaphore observerDone = new Semaphore(0); + final Thread[] observerThreadHolder = new Thread[1]; + sb.addSignal(signal); + + // observer runs in its own thread, blocked on the signal + Thread observer = new Thread(new Runnable() { + @Override + public void run() { + try { + signal.acquire(); + observerThreadHolder[0] = Thread.currentThread(); + observerDone.release(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } - } - }); - observer.start(); - - // act - // writer writes from the main thread - sb.getOutputStream().write(anyValue); - - // assert - // observer should wake up in its own thread - assertAll( - () -> assertThat(observerDone.tryAcquire(5, TimeUnit.SECONDS), is(true)), - () -> assertThat(observerThreadHolder[0], is(observer)), - () -> assertThat(observerThreadHolder[0], is(not(Thread.currentThread()))) - ); - - observer.join(5000); - } + }); + observer.start(); - @DisplayName("signal(): close via output stream — signal released") - @Test - public void signal_closeViaOutputStream_signalReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - sb.addSignal(signal); + // act + // writer writes from the main thread + sb.getOutputStream().write(anyValue); - // act - sb.getOutputStream().close(); + // assert + // observer should wake up in its own thread + assertAll( + () -> assertThat(observerDone.tryAcquire(5, TimeUnit.SECONDS), is(true)), + () -> assertThat(observerThreadHolder[0], is(observer)), + () -> assertThat(observerThreadHolder[0], is(not(Thread.currentThread())))); - // assert - assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); - } + observer.join(5000); + } - @DisplayName("signal(): close via input stream — signal released") - @Test - public void signal_closeViaInputStream_signalReleased() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - sb.addSignal(signal); + @DisplayName("signal(): close via output stream — signal released") + @Test + public void signal_closeViaOutputStream_signalReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + sb.addSignal(signal); - // act - sb.getInputStream().close(); + // act + sb.getOutputStream().close(); - // assert - assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); - } + // assert + assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); + } - @DisplayName("signal(): remove null — returns false") - @Test - public void signal_removeNull_returnsFalse() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("signal(): close via input stream — signal released") + @Test + public void signal_closeViaInputStream_signalReleased() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + sb.addSignal(signal); - // act - boolean result = sb.removeSignal(null); + // act + sb.getInputStream().close(); - // assert - assertThat(result, is(false)); - } + // assert + assertThat(signal.tryAcquire(5, TimeUnit.SECONDS), is(true)); + } + + @DisplayName("signal(): remove null — returns false") + @Test + public void signal_removeNull_returnsFalse() { + // arrange + StreamBuffer sb = new StreamBuffer(); + + // act + boolean result = sb.removeSignal(null); + + // assert + assertThat(result, is(false)); + } } @Nested @DisplayName("read() — unsigned byte round-trip") class ReadUnsignedByteRoundTripTests { - @DisplayName("read(): write byte0x ff — returns255") - @Test - public void read_writeByte0xFF_returns255() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - - // act - sb.getOutputStream().write(0xFF); - int result = sb.getInputStream().read(); - - // assert - assertThat(result, is(255)); - } - - @DisplayName("read(): write byte0x80 — returns128") - @Test - public void read_writeByte0x80_returns128() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("read(): write byte0x ff — returns255") + @Test + public void read_writeByte0xFF_returns255() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + + // act + sb.getOutputStream().write(0xFF); + int result = sb.getInputStream().read(); + + // assert + assertThat(result, is(255)); + } - // act - sb.getOutputStream().write(0x80); - int result = sb.getInputStream().read(); + @DisplayName("read(): write byte0x80 — returns128") + @Test + public void read_writeByte0x80_returns128() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); - // assert - assertThat(result, is(128)); - } + // act + sb.getOutputStream().write(0x80); + int result = sb.getInputStream().read(); - @DisplayName("read(): write negative byte value — returns unsigned") - @Test - public void read_writeNegativeByteValue_returnsUnsigned() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - byte negativeByte = -1; // 0xFF as signed byte + // assert + assertThat(result, is(128)); + } - // act - sb.getOutputStream().write(new byte[]{negativeByte}); - int result = sb.getInputStream().read(); + @DisplayName("read(): write negative byte value — returns unsigned") + @Test + public void read_writeNegativeByteValue_returnsUnsigned() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + byte negativeByte = -1; // 0xFF as signed byte - // assert — must be 255, not -1 (which would signal EOF) - assertThat(result, is(255)); - } + // act + sb.getOutputStream().write(new byte[] {negativeByte}); + int result = sb.getInputStream().read(); - @DisplayName("read(): write all high byte values — returns correct unsigned") - @Test - public void read_writeAllHighByteValues_returnsCorrectUnsigned() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); + // assert — must be 255, not -1 (which would signal EOF) + assertThat(result, is(255)); + } - // act & assert — write and read values 128..255 - for (int i = 128; i <= 255; i++) { - os.write(i); - assertThat(is.read(), is(i)); + @DisplayName("read(): write all high byte values — returns correct unsigned") + @Test + public void read_writeAllHighByteValues_returnsCorrectUnsigned() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + // act & assert — write and read values 128..255 + for (int i = 128; i <= 255; i++) { + os.write(i); + assertThat(is.read(), is(i)); + } } } - } @Nested @DisplayName("correctOffsetAndLengthToWrite() — integer overflow") class CorrectOffsetAndLengthToWriteIntegerOverflowTests { - @DisplayName("correctOffsetAndLengthToWrite(): integer overflow — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToWrite_integerOverflow_throwsIndexOutOfBoundsException() { - // arrange - byte[] b = new byte[10]; - - // act — Integer.MAX_VALUE + 1 overflows to negative - IndexOutOfBoundsException ex = assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToWrite(b, Integer.MAX_VALUE, 1)); - assertThat(ex.getMessage(), is(StreamBuffer.EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); - } + @DisplayName("correctOffsetAndLengthToWrite(): integer overflow — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToWrite_integerOverflow_throwsIndexOutOfBoundsException() { + // arrange + byte[] b = new byte[10]; + + // act — Integer.MAX_VALUE + 1 overflows to negative + IndexOutOfBoundsException ex = assertThrows( + IndexOutOfBoundsException.class, + () -> StreamBuffer.correctOffsetAndLengthToWrite(b, Integer.MAX_VALUE, 1)); + assertThat( + ex.getMessage(), + is( + StreamBuffer + .EXCEPTION_MESSAGE_CORRECT_OFFSET_AND_LENGTH_TO_WRITE_INDEX_OUT_OF_BOUNDS_EXCEPTION)); + } } @Nested @DisplayName("close() via InputStream") class CloseViaInputStreamTests { - @DisplayName("write(): closed via input stream — throws io exception") - @Test - public void write_closedViaInputStream_throwsIOException() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getInputStream().close(); - - // act - assertThrows(IOException.class, () -> sb.getOutputStream().write(anyValue)); - } + @DisplayName("write(): closed via input stream — throws io exception") + @Test + public void write_closedViaInputStream_throwsIOException() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getInputStream().close(); + + // act + assertThrows(IOException.class, () -> sb.getOutputStream().write(anyValue)); + } - @DisplayName("isClosed(): closed via input stream — true") - @Test - public void isClosed_closedViaInputStream_true() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("isClosed(): closed via input stream — true") + @Test + public void isClosed_closedViaInputStream_true() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - sb.getInputStream().close(); + // act + sb.getInputStream().close(); - // assert - assertThat(sb.isClosed(), is(true)); - } + // assert + assertThat(sb.isClosed(), is(true)); + } - @DisplayName("read(): closed via output stream — returns minus one") - @Test - public void read_closedViaOutputStream_returnsMinusOne() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().close(); + @DisplayName("read(): closed via output stream — returns minus one") + @Test + public void read_closedViaOutputStream_returnsMinusOne() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().close(); - // act - int result = sb.getInputStream().read(); + // act + int result = sb.getInputStream().read(); - // assert - assertThat(result, is(-1)); - } + // assert + assertThat(result, is(-1)); + } } - @Nested @DisplayName("trim() with safeWrite enabled") class TrimWithSafeWriteTests { - @DisplayName("trim(): with safe write enabled — preserves data integrity") - @Test - public void trim_withSafeWriteEnabled_preservesDataIntegrity() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setSafeWrite(true); - sb.setMaxBufferElements(1); - - // act — write multiple entries to trigger trim while safeWrite is on - sb.getOutputStream().write(new byte[]{1, 2, 3}); - sb.getOutputStream().write(new byte[]{4, 5, 6}); - - // assert — all bytes preserved in correct order after trim - byte[] dest = new byte[6]; - int bytesRead = sb.getInputStream().read(dest); - assertAll( - () -> assertThat(bytesRead, is(6)), - () -> assertThat(dest, is(new byte[]{1, 2, 3, 4, 5, 6})) - ); - } + @DisplayName("trim(): with safe write enabled — preserves data integrity") + @Test + public void trim_withSafeWriteEnabled_preservesDataIntegrity() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setSafeWrite(true); + sb.setMaxBufferElements(1); + + // act — write multiple entries to trigger trim while safeWrite is on + sb.getOutputStream().write(new byte[] {1, 2, 3}); + sb.getOutputStream().write(new byte[] {4, 5, 6}); + + // assert — all bytes preserved in correct order after trim + byte[] dest = new byte[6]; + int bytesRead = sb.getInputStream().read(dest); + assertAll(() -> assertThat(bytesRead, is(6)), () -> assertThat(dest, is(new byte[] {1, 2, 3, 4, 5, 6}))); + } } @Nested @DisplayName("maxBufferElements=0 disables trim") class MaxBufferElementsZeroDisablesTrimTests { - @DisplayName("setMaxBufferElements(): zero — trim not called") - @Test - public void setMaxBufferElements_zero_trimNotCalled() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(0); - - // act — write many individual entries - for (int i = 0; i < 200; i++) { - sb.getOutputStream().write(anyValue); - } + @DisplayName("setMaxBufferElements(): zero — trim not called") + @Test + public void setMaxBufferElements_zero_trimNotCalled() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(0); + + // act — write many individual entries + for (int i = 0; i < 200; i++) { + sb.getOutputStream().write(anyValue); + } - // assert — all entries remain un-trimmed (each write(int) adds one entry) - assertThat(sb.getBufferSize(), is(greaterThan(1))); - } + // assert — all entries remain un-trimmed (each write(int) adds one entry) + assertThat(sb.getBufferSize(), is(greaterThan(1))); + } } - @Nested @DisplayName("read() — single byte via array") class ReadSingleByteViaArrayTests { - @DisplayName("read(): array with length one — returns single byte") - @Test - public void read_arrayWithLengthOne_returnsSingleByte() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(anyValue); - - // act - byte[] dest = new byte[1]; - int bytesRead = sb.getInputStream().read(dest, 0, 1); - - // assert - assertAll( - () -> assertThat(bytesRead, is(1)), - () -> assertThat(dest[0], is(anyValue)) - ); - } + @DisplayName("read(): array with length one — returns single byte") + @Test + public void read_arrayWithLengthOne_returnsSingleByte() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(anyValue); + + // act + byte[] dest = new byte[1]; + int bytesRead = sb.getInputStream().read(dest, 0, 1); + + // assert + assertAll(() -> assertThat(bytesRead, is(1)), () -> assertThat(dest[0], is(anyValue))); + } } @Nested @DisplayName("available() after close with buffered data") class AvailableAfterCloseWithBufferedDataTests { - @DisplayName("available(): closed with data remaining — returns correct count") - @Test - public void available_closedWithDataRemaining_returnsCorrectCount() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3, 4, 5}); - sb.close(); - - // act - int result = sb.getInputStream().available(); - - // assert - assertThat(result, is(5)); - } + @DisplayName("available(): closed with data remaining — returns correct count") + @Test + public void available_closedWithDataRemaining_returnsCorrectCount() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3, 4, 5}); + sb.close(); + + // act + int result = sb.getInputStream().available(); + + // assert + assertThat(result, is(5)); + } } @Nested @DisplayName("thread interruption during read()") class ThreadInterruptionDuringReadTests { - @DisplayName("read(): thread interrupted — throws io exception") - @Test - public void read_threadInterrupted_throwsIOException() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final InputStream is = sb.getInputStream(); - final Semaphore started = new Semaphore(0); - final Throwable[] caught = new Throwable[1]; - final AtomicBoolean interruptFlagAfterCatch = new AtomicBoolean(false); - - Thread reader = new Thread(() -> { - try { - started.release(); - is.read(); // will block — no data, not closed - } catch (IOException e) { - caught[0] = e; - interruptFlagAfterCatch.set(Thread.currentThread().isInterrupted()); - } - }); - - // act - reader.start(); - started.acquire(); // wait for thread to start - Thread.sleep(500); // let it block on read() - reader.interrupt(); - reader.join(); - - // assert - assertThat("IOException should be thrown wrapping InterruptedException", - caught[0] instanceof IOException, is(true)); - assertThat("Thread interrupt flag must be preserved after wrapping InterruptedException", - interruptFlagAfterCatch.get(), is(true)); - } + @DisplayName("read(): thread interrupted — throws io exception") + @Test + public void read_threadInterrupted_throwsIOException() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final InputStream is = sb.getInputStream(); + final Semaphore started = new Semaphore(0); + final Throwable[] caught = new Throwable[1]; + final AtomicBoolean interruptFlagAfterCatch = new AtomicBoolean(false); + + Thread reader = new Thread(() -> { + try { + started.release(); + is.read(); // will block — no data, not closed + } catch (IOException e) { + caught[0] = e; + interruptFlagAfterCatch.set(Thread.currentThread().isInterrupted()); + } + }); - @DisplayName("read(): array thread interrupted while waiting for second byte — throws io exception") - @Test - public void read_arrayThreadInterruptedWhileWaitingForSecondByte_throwsIOException() throws Exception { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final InputStream is = sb.getInputStream(); - final OutputStream os = sb.getOutputStream(); - final Semaphore started = new Semaphore(0); - final Throwable[] caught = new Throwable[1]; - final AtomicBoolean interruptFlagAfterCatch = new AtomicBoolean(false); - - // write exactly 1 byte so the internal read() at the start of read(b,off,len) - // succeeds immediately, then tryWaitForEnoughBytes blocks waiting for the second byte - os.write(42); - - Thread reader = new Thread(() -> { - try { - started.release(); - is.read(new byte[2], 0, 2); - } catch (IOException e) { - caught[0] = e; - interruptFlagAfterCatch.set(Thread.currentThread().isInterrupted()); - } - }); - - // act - reader.start(); - started.acquire(); // wait for thread to start - Thread.sleep(500); // let it block inside tryWaitForEnoughBytes - reader.interrupt(); - reader.join(); - - // assert - assertThat("IOException should be thrown wrapping InterruptedException", - caught[0] instanceof IOException, is(true)); - assertThat("Thread interrupt flag must be preserved after wrapping InterruptedException", - interruptFlagAfterCatch.get(), is(true)); - } + // act + reader.start(); + started.acquire(); // wait for thread to start + Thread.sleep(500); // let it block on read() + reader.interrupt(); + reader.join(); + + // assert + assertThat( + "IOException should be thrown wrapping InterruptedException", + caught[0] instanceof IOException, + is(true)); + assertThat( + "Thread interrupt flag must be preserved after wrapping InterruptedException", + interruptFlagAfterCatch.get(), + is(true)); + } + + @DisplayName("read(): array thread interrupted while waiting for second byte — throws io exception") + @Test + public void read_arrayThreadInterruptedWhileWaitingForSecondByte_throwsIOException() throws Exception { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final InputStream is = sb.getInputStream(); + final OutputStream os = sb.getOutputStream(); + final Semaphore started = new Semaphore(0); + final Throwable[] caught = new Throwable[1]; + final AtomicBoolean interruptFlagAfterCatch = new AtomicBoolean(false); + + // write exactly 1 byte so the internal read() at the start of read(b,off,len) + // succeeds immediately, then tryWaitForEnoughBytes blocks waiting for the second byte + os.write(42); + + Thread reader = new Thread(() -> { + try { + started.release(); + is.read(new byte[2], 0, 2); + } catch (IOException e) { + caught[0] = e; + interruptFlagAfterCatch.set(Thread.currentThread().isInterrupted()); + } + }); + + // act + reader.start(); + started.acquire(); // wait for thread to start + Thread.sleep(500); // let it block inside tryWaitForEnoughBytes + reader.interrupt(); + reader.join(); + + // assert + assertThat( + "IOException should be thrown wrapping InterruptedException", + caught[0] instanceof IOException, + is(true)); + assertThat( + "Thread interrupt flag must be preserved after wrapping InterruptedException", + interruptFlagAfterCatch.get(), + is(true)); + } } @Nested @DisplayName("correctOffsetAndLengthToRead() — empty array") class CorrectOffsetAndLengthToReadEmptyArrayTests { - @DisplayName("correctOffsetAndLengthToRead(): empty array with positive length — throws index out of bounds exception") - @Test - public void correctOffsetAndLengthToRead_emptyArrayWithPositiveLength_throwsIndexOutOfBoundsException() { - // arrange - // act - // assert — exception thrown is the assertion - assertThrows(IndexOutOfBoundsException.class, - () -> StreamBuffer.correctOffsetAndLengthToRead(new byte[0], 0, 1)); - } + @DisplayName( + "correctOffsetAndLengthToRead(): empty array with positive length — throws index out of bounds exception") + @Test + public void correctOffsetAndLengthToRead_emptyArrayWithPositiveLength_throwsIndexOutOfBoundsException() { + // arrange + // act + // assert — exception thrown is the assertion + assertThrows( + IndexOutOfBoundsException.class, + () -> StreamBuffer.correctOffsetAndLengthToRead(new byte[0], 0, 1)); + } } @Nested @DisplayName("correctOffsetAndLengthToWrite() — empty array") class CorrectOffsetAndLengthToWriteEmptyArrayTests { - @DisplayName("correctOffsetAndLengthToWrite(): empty array zero length — returns false") - @Test - public void correctOffsetAndLengthToWrite_emptyArrayZeroLength_returnsFalse() { - // arrange - // act - boolean result = StreamBuffer.correctOffsetAndLengthToWrite(new byte[0], 0, 0); - - // assert - assertThat(result, is(false)); - } + @DisplayName("correctOffsetAndLengthToWrite(): empty array zero length — returns false") + @Test + public void correctOffsetAndLengthToWrite_emptyArrayZeroLength_returnsFalse() { + // arrange + // act + boolean result = StreamBuffer.correctOffsetAndLengthToWrite(new byte[0], 0, 0); + + // assert + assertThat(result, is(false)); + } } @Nested @DisplayName("getBufferSize() — initial state") class GetBufferSizeInitialTests { - @DisplayName("getBufferSize(): empty buffer — returns zero") - @Test - public void getBufferSize_emptyBuffer_returnsZero() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("getBufferSize(): empty buffer — returns zero") + @Test + public void getBufferSize_emptyBuffer_returnsZero() { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - int result = sb.getBufferSize(); + // act + int result = sb.getBufferSize(); - // assert - assertThat(result, is(0)); - } + // assert + assertThat(result, is(0)); + } } @Nested @DisplayName("isTrimShouldBeExecuted() — zero boundary") class IsTrimShouldBeExecutedZeroBoundaryTests { - @DisplayName("getBufferSize(): max buffer elements zero with multiple entries — no trim executed") - @Test - public void getBufferSize_maxBufferElementsZeroWithMultipleEntries_noTrimExecuted() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(0); - - // act - sb.getOutputStream().write(new byte[]{1}); - sb.getOutputStream().write(new byte[]{2}); - sb.getOutputStream().write(new byte[]{3}); - - // assert - assertThat(sb.getBufferSize(), is(3)); - } + @DisplayName("getBufferSize(): max buffer elements zero with multiple entries — no trim executed") + @Test + public void getBufferSize_maxBufferElementsZeroWithMultipleEntries_noTrimExecuted() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(0); + + // act + sb.getOutputStream().write(new byte[] {1}); + sb.getOutputStream().write(new byte[] {2}); + sb.getOutputStream().write(new byte[] {3}); + + // assert + assertThat(sb.getBufferSize(), is(3)); + } } @Nested @DisplayName("tryWaitForEnoughBytes() — closed stream") class TryWaitForEnoughBytesClosedStreamTests { - @DisplayName("read(): closed stream with two bytes — read array returns both bytes") - @Test - public void read_closedStreamWithTwoBytes_readArrayReturnsBothBytes() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{10, 20}); - sb.close(); - - // act - byte[] dest = new byte[4]; - int bytesRead = sb.getInputStream().read(dest, 0, 4); - - // assert - assertAll( - () -> assertThat(bytesRead, is(2)), - () -> assertArrayEquals(new byte[]{10, 20}, Arrays.copyOf(dest, 2)) - ); - } + @DisplayName("read(): closed stream with two bytes — read array returns both bytes") + @Test + public void read_closedStreamWithTwoBytes_readArrayReturnsBothBytes() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {10, 20}); + sb.close(); + + // act + byte[] dest = new byte[4]; + int bytesRead = sb.getInputStream().read(dest, 0, 4); + + // assert + assertAll( + () -> assertThat(bytesRead, is(2)), + () -> assertArrayEquals(new byte[] {10, 20}, Arrays.copyOf(dest, 2))); + } } @Nested @DisplayName("read() — exact full-entry consumption") class ReadExactFullEntryConsumptionTests { - @DisplayName("read(): exact full entry consumption — available and buffer size are zero") - @Test - public void read_exactFullEntryConsumption_availableAndBufferSizeAreZero() throws IOException { - // arrange — write 3 bytes as a single entry; after the internal read() call consumes byte 0, - // positionAtCurrentBufferEntry=1, missingBytes=2, maximumBytesToCopy=3-1=2 → exactly equal. - // This hits the if-branch boundary: missingBytes >= maximumBytesToCopy (both == 2). - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3}); - - // act - byte[] dest = new byte[3]; - int bytesRead = sb.getInputStream().read(dest, 0, 3); - - // assert - // ConditionalsBoundary mutant (>= → >): routes to else-branch → entry NOT removed → bufferSize = 1 - // MathMutator on availableBytes in if-branch: availableBytes += 2 → available() = 4, not 0 - assertAll( - () -> assertThat(bytesRead, is(3)), - () -> assertThat(dest, is(new byte[]{1, 2, 3})), - () -> assertThat(sb.getBufferSize(), is(0)), - () -> assertThat(sb.getInputStream().available(), is(0)) - ); - } + @DisplayName("read(): exact full entry consumption — available and buffer size are zero") + @Test + public void read_exactFullEntryConsumption_availableAndBufferSizeAreZero() throws IOException { + // arrange — write 3 bytes as a single entry; after the internal read() call consumes byte 0, + // positionAtCurrentBufferEntry=1, missingBytes=2, maximumBytesToCopy=3-1=2 → exactly equal. + // This hits the if-branch boundary: missingBytes >= maximumBytesToCopy (both == 2). + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3}); + + // act + byte[] dest = new byte[3]; + int bytesRead = sb.getInputStream().read(dest, 0, 3); + + // assert + // ConditionalsBoundary mutant (>= → >): routes to else-branch → entry NOT removed → bufferSize = 1 + // MathMutator on availableBytes in if-branch: availableBytes += 2 → available() = 4, not 0 + assertAll( + () -> assertThat(bytesRead, is(3)), + () -> assertThat(dest, is(new byte[] {1, 2, 3})), + () -> assertThat(sb.getBufferSize(), is(0)), + () -> assertThat(sb.getInputStream().available(), is(0))); + } } @Nested @DisplayName("isTrimShouldBeExecuted() — direct") class IsTrimShouldBeExecutedDirectTests { - @DisplayName("isTrimShouldBeExecuted(): buffer size two max elements one — returns true") - @Test - public void isTrimShouldBeExecuted_bufferSizeTwoMaxElementsOne_returnsTrue() throws IOException { - // arrange — disable trim while writing so we can control buffer.size() independently - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(0); - sb.getOutputStream().write(new byte[]{1}); - sb.getOutputStream().write(new byte[]{2}); - // buffer.size() == 2; now enable trim condition - sb.setMaxBufferElements(1); - - // act + assert — original: (2 >= 2) && (2 > 1) = true - // mutant: (2 > 2) && (2 > 1) = false → mutation killed - assertThat(sb.isTrimShouldBeExecuted(), is(true)); - } + @DisplayName("isTrimShouldBeExecuted(): buffer size two max elements one — returns true") + @Test + public void isTrimShouldBeExecuted_bufferSizeTwoMaxElementsOne_returnsTrue() throws IOException { + // arrange — disable trim while writing so we can control buffer.size() independently + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(0); + sb.getOutputStream().write(new byte[] {1}); + sb.getOutputStream().write(new byte[] {2}); + // buffer.size() == 2; now enable trim condition + sb.setMaxBufferElements(1); + + // act + assert — original: (2 >= 2) && (2 > 1) = true + // mutant: (2 > 2) && (2 > 1) = false → mutation killed + assertThat(sb.isTrimShouldBeExecuted(), is(true)); + } } @Nested @DisplayName("clampToMaxInt() — direct") class ClampToMaxIntDirectTests { - @DisplayName("clampToMaxInt(): value above max int — returns max int") - @Test - public void clampToMaxInt_valueAboveMaxInt_returnsMaxInt() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE + 1), is(Integer.MAX_VALUE)); - } + @DisplayName("clampToMaxInt(): value above max int — returns max int") + @Test + public void clampToMaxInt_valueAboveMaxInt_returnsMaxInt() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE + 1), is(Integer.MAX_VALUE)); + } - @DisplayName("clampToMaxInt(): value equal to max int — returns max int") - @Test - public void clampToMaxInt_valueEqualToMaxInt_returnsMaxInt() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE), is(Integer.MAX_VALUE)); - } + @DisplayName("clampToMaxInt(): value equal to max int — returns max int") + @Test + public void clampToMaxInt_valueEqualToMaxInt_returnsMaxInt() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE), is(Integer.MAX_VALUE)); + } - @DisplayName("clampToMaxInt(): small value — returns value") - @Test - public void clampToMaxInt_smallValue_returnsValue() { - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.clampToMaxInt(42L), is(42)); - } + @DisplayName("clampToMaxInt(): small value — returns value") + @Test + public void clampToMaxInt_smallValue_returnsValue() { + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.clampToMaxInt(42L), is(42)); + } } @Nested @DisplayName("decrementAvailableBytesBudget() — direct") class DecrementAvailableBytesBudgetDirectTests { - @DisplayName("decrementAvailableBytesBudget(): subtracts decrement") - @Test - public void decrementAvailableBytesBudget_subtractsDecrement() { - // original: current - decrement = 9 - 4 = 5 - // mutant: current + decrement = 9 + 4 = 13 → mutation killed - // arrange - StreamBuffer sb = new StreamBuffer(); - // act - // assert - assertThat(sb.decrementAvailableBytesBudget(9L, 4L), is(5L)); - } + @DisplayName("decrementAvailableBytesBudget(): subtracts decrement") + @Test + public void decrementAvailableBytesBudget_subtractsDecrement() { + // original: current - decrement = 9 - 4 = 5 + // mutant: current + decrement = 9 + 4 = 13 → mutation killed + // arrange + StreamBuffer sb = new StreamBuffer(); + // act + // assert + assertThat(sb.decrementAvailableBytesBudget(9L, 4L), is(5L)); + } } @Nested @DisplayName("isTrimShouldBeExecuted() — size-two boundary") class IsTrimShouldBeExecutedSizeTwoBoundaryTests { - @DisplayName("getBufferSize(): two entries with max buffer elements one — trim called") - @Test - public void getBufferSize_twoEntriesWithMaxBufferElementsOne_trimCalled() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - - // act — exactly two separate entries: buffer.size() == 2 > maxBufferElements == 1 - // original: buffer.size() >= 2 → true → trim fires - // mutant: buffer.size() > 2 → false → trim skipped → getBufferSize() stays 2 - sb.getOutputStream().write(new byte[]{1}); - sb.getOutputStream().write(new byte[]{2}); - - // assert - assertThat(sb.getBufferSize(), is(1)); - } + @DisplayName("getBufferSize(): two entries with max buffer elements one — trim called") + @Test + public void getBufferSize_twoEntriesWithMaxBufferElementsOne_trimCalled() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + + // act — exactly two separate entries: buffer.size() == 2 > maxBufferElements == 1 + // original: buffer.size() >= 2 → true → trim fires + // mutant: buffer.size() > 2 → false → trim skipped → getBufferSize() stays 2 + sb.getOutputStream().write(new byte[] {1}); + sb.getOutputStream().write(new byte[] {2}); + + // assert + assertThat(sb.getBufferSize(), is(1)); + } } @Nested @DisplayName("tryWaitForEnoughBytes() — open stream") class TryWaitForEnoughBytesOpenStreamTests { - @DisplayName("read(): multiple bytes single entry open stream — returns all requested bytes") - @Test - public void read_multipleBytesSingleEntryOpenStream_returnsAllRequestedBytes() throws IOException { - // arrange — write 5 bytes as one entry; stream left open - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3, 4, 5}); - - // act — tryWaitForEnoughBytes(4) takes the "already enough" path and must return availableBytes (4) - // mutant returns 0 → read(b, 0, 5) short-circuits and returns only 1 (the first byte) - byte[] dest = new byte[5]; - int bytesRead = sb.getInputStream().read(dest, 0, 5); - - // assert - assertAll( - () -> assertThat(bytesRead, is(5)), - () -> assertThat(dest, is(new byte[]{1, 2, 3, 4, 5})) - ); - } + @DisplayName("read(): multiple bytes single entry open stream — returns all requested bytes") + @Test + public void read_multipleBytesSingleEntryOpenStream_returnsAllRequestedBytes() throws IOException { + // arrange — write 5 bytes as one entry; stream left open + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3, 4, 5}); + + // act — tryWaitForEnoughBytes(4) takes the "already enough" path and must return availableBytes (4) + // mutant returns 0 → read(b, 0, 5) short-circuits and returns only 1 (the first byte) + byte[] dest = new byte[5]; + int bytesRead = sb.getInputStream().read(dest, 0, 5); + + // assert + assertAll(() -> assertThat(bytesRead, is(5)), () -> assertThat(dest, is(new byte[] {1, 2, 3, 4, 5}))); + } } @Nested @DisplayName("available() after partial read from single entry") class AvailableAfterPartialReadTests { - @DisplayName("available(): after partial read from single entry — returns remaining count") - @Test - public void available_afterPartialReadFromSingleEntry_returnsRemainingCount() throws IOException { - // arrange — 10 bytes as a single deque entry - StreamBuffer sb = new StreamBuffer(); - sb.getOutputStream().write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); - - // act — read first 5 bytes (1 via read() then 4 via the partial-copy else branch) - byte[] dest = new byte[5]; - int bytesRead = sb.getInputStream().read(dest, 0, 5); - - // assert — 5 bytes must remain; mutant does availableBytes += 4 instead of -= 4 → reports 13 - assertAll( - () -> assertThat(bytesRead, is(5)), - () -> assertThat(sb.getInputStream().available(), is(5)) - ); - } + @DisplayName("available(): after partial read from single entry — returns remaining count") + @Test + public void available_afterPartialReadFromSingleEntry_returnsRemainingCount() throws IOException { + // arrange — 10 bytes as a single deque entry + StreamBuffer sb = new StreamBuffer(); + sb.getOutputStream().write(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); + + // act — read first 5 bytes (1 via read() then 4 via the partial-copy else branch) + byte[] dest = new byte[5]; + int bytesRead = sb.getInputStream().read(dest, 0, 5); + + // assert — 5 bytes must remain; mutant does availableBytes += 4 instead of -= 4 → reports 13 + assertAll( + () -> assertThat(bytesRead, is(5)), + () -> assertThat(sb.getInputStream().available(), is(5))); + } } @Nested @DisplayName("capMissingBytes() — formula equivalence") class CapMissingBytesEquivalenceTests { - // Extracts the OLD capping formula from the guarded if-block (pre-cb66b68) - private static int capMissingBytesOld(long maximumAvailableBytes, int missingBytes) { - if (maximumAvailableBytes < missingBytes) { - return (int) Math.min(maximumAvailableBytes, Integer.MAX_VALUE); + // Extracts the OLD capping formula from the guarded if-block (pre-cb66b68) + private static int capMissingBytesOld(long maximumAvailableBytes, int missingBytes) { + if (maximumAvailableBytes < missingBytes) { + return (int) Math.min(maximumAvailableBytes, Integer.MAX_VALUE); + } + return missingBytes; } - return missingBytes; - } - - // Extracts the NEW capping formula (unconditional Math.min, post-cb66b68) - private static int capMissingBytesNew(long maximumAvailableBytes, int missingBytes) { - return (int) Math.min(maximumAvailableBytes, (long) missingBytes); - } - static Stream capMissingBytesInputs() { - return Stream.of( - // Case A: maxAvail < missingBytes (old if-branch fires, new Math.min picks maxAvail) - Arguments.of(1L, 5 ), // trivially small - Arguments.of(3L, 5 ), // standard small case - Arguments.of((long) Integer.MAX_VALUE - 1, Integer.MAX_VALUE ), // one below INT_MAX - - // Case B: maxAvail == missingBytes (boundary: old skips if, new Math.min picks either) - Arguments.of(5L, 5 ), // small equality - Arguments.of((long) Integer.MAX_VALUE, Integer.MAX_VALUE ), // equality at INT_MAX - - // Case C: maxAvail > missingBytes (old skips if, new Math.min picks missingBytes) - Arguments.of(9L, 3 ), // standard: more available than needed - Arguments.of((long) Integer.MAX_VALUE + 1L, Integer.MAX_VALUE ), // maxAvail just above INT_MAX - Arguments.of((long) Integer.MAX_VALUE + 1L, 5 ), // maxAvail > INT_MAX, small missing - Arguments.of(3_000_000_000L, 100 ), // large long, small int - Arguments.of(Long.MAX_VALUE, 1 ) // extreme: largest possible long - ); - } + // Extracts the NEW capping formula (unconditional Math.min, post-cb66b68) + private static int capMissingBytesNew(long maximumAvailableBytes, int missingBytes) { + return (int) Math.min(maximumAvailableBytes, (long) missingBytes); + } - @DisplayName("capMissingBytes(): old and new formula — return same result") - @ParameterizedTest - @MethodSource("capMissingBytesInputs") - public void capMissingBytes_oldAndNewFormula_returnSameResult( - long maximumAvailableBytes, int missingBytes) { - // arrange - // act - int oldResult = capMissingBytesOld(maximumAvailableBytes, missingBytes); - int newResult = capMissingBytesNew(maximumAvailableBytes, missingBytes); - // assert - assertThat(newResult, is(oldResult)); - } + static Stream capMissingBytesInputs() { + return Stream.of( + // Case A: maxAvail < missingBytes (old if-branch fires, new Math.min picks maxAvail) + Arguments.of(1L, 5), // trivially small + Arguments.of(3L, 5), // standard small case + Arguments.of((long) Integer.MAX_VALUE - 1, Integer.MAX_VALUE), // one below INT_MAX + + // Case B: maxAvail == missingBytes (boundary: old skips if, new Math.min picks either) + Arguments.of(5L, 5), // small equality + Arguments.of((long) Integer.MAX_VALUE, Integer.MAX_VALUE), // equality at INT_MAX + + // Case C: maxAvail > missingBytes (old skips if, new Math.min picks missingBytes) + Arguments.of(9L, 3), // standard: more available than needed + Arguments.of((long) Integer.MAX_VALUE + 1L, Integer.MAX_VALUE), // maxAvail just above INT_MAX + Arguments.of((long) Integer.MAX_VALUE + 1L, 5), // maxAvail > INT_MAX, small missing + Arguments.of(3_000_000_000L, 100), // large long, small int + Arguments.of(Long.MAX_VALUE, 1) // extreme: largest possible long + ); + } + @DisplayName("capMissingBytes(): old and new formula — return same result") + @ParameterizedTest + @MethodSource("capMissingBytesInputs") + public void capMissingBytes_oldAndNewFormula_returnSameResult(long maximumAvailableBytes, int missingBytes) { + // arrange + // act + int oldResult = capMissingBytesOld(maximumAvailableBytes, missingBytes); + int newResult = capMissingBytesNew(maximumAvailableBytes, missingBytes); + // assert + assertThat(newResult, is(oldResult)); + } } @Nested @DisplayName("statistics: getTotalBytesWritten / Read / getMaxObservedBytes") class StatisticsTests { - @DisplayName("statistics(): initial — all counters zero") - @Test - public void statistics_initial_allCountersZero() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - - // act & assert - assertAll( - () -> assertThat(sb.getTotalBytesWritten(), is(0L)), - () -> assertThat(sb.getTotalBytesRead(), is(0L)), - () -> assertThat(sb.getMaxObservedBytes(), is(0L)) - ); - } + @DisplayName("statistics(): initial — all counters zero") + @Test + public void statistics_initial_allCountersZero() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + + // act & assert + assertAll( + () -> assertThat(sb.getTotalBytesWritten(), is(0L)), + () -> assertThat(sb.getTotalBytesRead(), is(0L)), + () -> assertThat(sb.getMaxObservedBytes(), is(0L))); + } - @DisplayName("statistics(): single write — tracks total bytes written") - @Test - public void statistics_singleWrite_tracksTotalBytesWritten() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] data = new byte[]{1, 2, 3}; + @DisplayName("statistics(): single write — tracks total bytes written") + @Test + public void statistics_singleWrite_tracksTotalBytesWritten() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] data = new byte[] {1, 2, 3}; - // act - os.write(data); + // act + os.write(data); - // assert - assertThat(sb.getTotalBytesWritten(), is(3L)); - } + // assert + assertThat(sb.getTotalBytesWritten(), is(3L)); + } - @DisplayName("statistics(): multiple writes — accumulate") - @Test - public void statistics_multipleWrites_accumulate() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); + @DisplayName("statistics(): multiple writes — accumulate") + @Test + public void statistics_multipleWrites_accumulate() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); - // act - os.write(new byte[]{1, 2}); - os.write(new byte[]{3, 4, 5}); - os.write(new byte[]{6}); + // act + os.write(new byte[] {1, 2}); + os.write(new byte[] {3, 4, 5}); + os.write(new byte[] {6}); - // assert - assertThat(sb.getTotalBytesWritten(), is(6L)); - } + // assert + assertThat(sb.getTotalBytesWritten(), is(6L)); + } - @DisplayName("statistics(): write with offset — counts only offset") - @Test - public void statistics_writeWithOffset_countsOnlyOffset() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - byte[] data = new byte[]{1, 2, 3, 4, 5}; + @DisplayName("statistics(): write with offset — counts only offset") + @Test + public void statistics_writeWithOffset_countsOnlyOffset() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + byte[] data = new byte[] {1, 2, 3, 4, 5}; - // act - os.write(data, 2, 3); // write offset 2, length 3 → writes bytes 3, 4, 5 + // act + os.write(data, 2, 3); // write offset 2, length 3 → writes bytes 3, 4, 5 - // assert - assertThat(sb.getTotalBytesWritten(), is(3L)); - } + // assert + assertThat(sb.getTotalBytesWritten(), is(3L)); + } - @DisplayName("statistics(): write int — counts as one") - @Test - public void statistics_writeInt_countsAsOne() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); + @DisplayName("statistics(): write int — counts as one") + @Test + public void statistics_writeInt_countsAsOne() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); - // act - os.write(42); + // act + os.write(42); - // assert - assertThat(sb.getTotalBytesWritten(), is(1L)); - } + // assert + assertThat(sb.getTotalBytesWritten(), is(1L)); + } - @DisplayName("statistics(): single byte read — tracks total bytes read") - @Test - public void statistics_singleByteRead_tracksTotalBytesRead() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[]{1, 2, 3}); + @DisplayName("statistics(): single byte read — tracks total bytes read") + @Test + public void statistics_singleByteRead_tracksTotalBytesRead() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[] {1, 2, 3}); - // act - is.read(); + // act + is.read(); - // assert - assertThat(sb.getTotalBytesRead(), is(1L)); - } + // assert + assertThat(sb.getTotalBytesRead(), is(1L)); + } - @DisplayName("statistics(): array read — tracks total bytes read") - @Test - public void statistics_arrayRead_tracksTotalBytesRead() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[]{1, 2, 3, 4, 5}); - - // act - byte[] dest = new byte[5]; - is.read(dest); - - // assert - assertThat(sb.getTotalBytesRead(), is(5L)); - } + @DisplayName("statistics(): array read — tracks total bytes read") + @Test + public void statistics_arrayRead_tracksTotalBytesRead() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[] {1, 2, 3, 4, 5}); + + // act + byte[] dest = new byte[5]; + is.read(dest); + + // assert + assertThat(sb.getTotalBytesRead(), is(5L)); + } - @DisplayName("statistics(): partial read — counts actually returned") - @Test - public void statistics_partialRead_countsActuallyReturned() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[]{1, 2, 3}); // only 3 bytes available - os.close(); // signal EOF so read returns partial data instead of blocking - - // act - byte[] dest = new byte[100]; - int read = is.read(dest, 0, 100); // request 100, but only 3 available - - // assert - assertAll( - () -> assertThat(read, is(3)), - () -> assertThat(sb.getTotalBytesRead(), is(3L)) - ); - } + @DisplayName("statistics(): partial read — counts actually returned") + @Test + public void statistics_partialRead_countsActuallyReturned() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[] {1, 2, 3}); // only 3 bytes available + os.close(); // signal EOF so read returns partial data instead of blocking + + // act + byte[] dest = new byte[100]; + int read = is.read(dest, 0, 100); // request 100, but only 3 available + + // assert + assertAll(() -> assertThat(read, is(3)), () -> assertThat(sb.getTotalBytesRead(), is(3L))); + } - @DisplayName("statistics(): concurrent reads writes — counters consistent") - @Test - public void statistics_concurrentReadsWrites_countersConsistent() throws IOException, InterruptedException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - final int N = 100; - final byte data = anyValue; - - // act — write N bytes, then read N bytes in concurrent threads - Thread writer = new Thread(() -> { - try { - for (int i = 0; i < N; i++) { - os.write(data); + @DisplayName("statistics(): concurrent reads writes — counters consistent") + @Test + public void statistics_concurrentReadsWrites_countersConsistent() throws IOException, InterruptedException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + final int N = 100; + final byte data = anyValue; + + // act — write N bytes, then read N bytes in concurrent threads + Thread writer = new Thread(() -> { + try { + for (int i = 0; i < N; i++) { + os.write(data); + } + } catch (IOException e) { + throw new RuntimeException(e); } - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - Thread reader = new Thread(() -> { - try { - for (int i = 0; i < N; i++) { - is.read(); + }); + Thread reader = new Thread(() -> { + try { + for (int i = 0; i < N; i++) { + is.read(); + } + } catch (IOException e) { + throw new RuntimeException(e); } - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - writer.start(); - reader.start(); - writer.join(); - reader.join(); - - // assert — written == read == N - assertAll( - () -> assertThat(sb.getTotalBytesWritten(), is((long) N)), - () -> assertThat(sb.getTotalBytesRead(), is((long) N)) - ); - } + }); + writer.start(); + reader.start(); + writer.join(); + reader.join(); - @DisplayName("statistics(): max observed bytes — tracks highest available") - @Test - public void statistics_maxObservedBytes_tracksHighestAvailable() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); + // assert — written == read == N + assertAll( + () -> assertThat(sb.getTotalBytesWritten(), is((long) N)), + () -> assertThat(sb.getTotalBytesRead(), is((long) N))); + } - // act - os.write(new byte[100]); // write 100 bytes → available = 100 - is.read(new byte[50]); // read 50 bytes → available = 50 + @DisplayName("statistics(): max observed bytes — tracks highest available") + @Test + public void statistics_maxObservedBytes_tracksHighestAvailable() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); - // assert - assertThat(sb.getMaxObservedBytes(), is(100L)); - } + // act + os.write(new byte[100]); // write 100 bytes → available = 100 + is.read(new byte[50]); // read 50 bytes → available = 50 - @DisplayName("statistics(): max observed bytes — preserves peak") - @Test - public void statistics_maxObservedBytes_preservesPeak() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - - // act - os.write(new byte[100]); // available = 100 (peak) - is.read(new byte[100]); // available = 0 - os.write(new byte[10]); // available = 10 (lower than peak) - - // assert - assertThat(sb.getMaxObservedBytes(), is(100L)); - } + // assert + assertThat(sb.getMaxObservedBytes(), is(100L)); + } - @DisplayName("statistics(): max observed bytes — updated — only during user writes") - @Test - public void statistics_maxObservedBytes_updated_onlyDuringUserWrites() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - os.write(new byte[50]); // write 50 → max = 50 - is.read(); // read 1 byte, availableBytes = 49 - long maxAfterFirstWrite = sb.getMaxObservedBytes(); - - // act — trim's internal operations should not increase maxObservedBytes - // Force a trim by setting maxBufferElements low and writing more - sb.setMaxBufferElements(1); - os.write(new byte[10]); // will trigger trim - long maxAfterTrim = sb.getMaxObservedBytes(); - - // assert — maxObservedBytes should still reflect user peaks, not trim's internal operations - // trim internally reads and writes, but isTrimRunning prevents those from being counted - assertAll( - () -> assertThat(sb.getBufferElementCount(), is(1)), // trim consolidated - () -> assertThat(sb.isTrimRunning(), is(false)), // trim complete - () -> assertThat(maxAfterTrim, greaterThanOrEqualTo(maxAfterFirstWrite)) // peak only increases from user writes - ); - } + @DisplayName("statistics(): max observed bytes — preserves peak") + @Test + public void statistics_maxObservedBytes_preservesPeak() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + + // act + os.write(new byte[100]); // available = 100 (peak) + is.read(new byte[100]); // available = 0 + os.write(new byte[10]); // available = 10 (lower than peak) + + // assert + assertThat(sb.getMaxObservedBytes(), is(100L)); + } - @DisplayName("statistics(): trim — do not affect counters") - @Test - public void statistics_trim_doNotAffectCounters() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - InputStream is = sb.getInputStream(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[100]); - long writtenBeforeTrim = sb.getTotalBytesWritten(); - long readBeforeTrim = sb.getTotalBytesRead(); - - // act — force trim - sb.setMaxBufferElements(1); - os.write(new byte[50]); - - // assert — trim's internal read/write should not affect user counters - // and buffer should be consolidated into one element - assertAll( - () -> assertThat(sb.getTotalBytesWritten(), is(writtenBeforeTrim + 50)), - () -> assertThat(sb.getTotalBytesRead(), is(readBeforeTrim)), - () -> assertThat(sb.getBufferElementCount(), is(1)), - () -> assertThat(sb.isTrimRunning(), is(false)) // trim should be complete - ); - } + @DisplayName("statistics(): max observed bytes — updated — only during user writes") + @Test + public void statistics_maxObservedBytes_updated_onlyDuringUserWrites() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + os.write(new byte[50]); // write 50 → max = 50 + is.read(); // read 1 byte, availableBytes = 49 + long maxAfterFirstWrite = sb.getMaxObservedBytes(); + + // act — trim's internal operations should not increase maxObservedBytes + // Force a trim by setting maxBufferElements low and writing more + sb.setMaxBufferElements(1); + os.write(new byte[10]); // will trigger trim + long maxAfterTrim = sb.getMaxObservedBytes(); + + // assert — maxObservedBytes should still reflect user peaks, not trim's internal operations + // trim internally reads and writes, but isTrimRunning prevents those from being counted + assertAll( + () -> assertThat(sb.getBufferElementCount(), is(1)), // trim consolidated + () -> assertThat(sb.isTrimRunning(), is(false)), // trim complete + () -> assertThat( + maxAfterTrim, + greaterThanOrEqualTo(maxAfterFirstWrite)) // peak only increases from user writes + ); + } + + @DisplayName("statistics(): trim — do not affect counters") + @Test + public void statistics_trim_doNotAffectCounters() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + InputStream is = sb.getInputStream(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[100]); + long writtenBeforeTrim = sb.getTotalBytesWritten(); + long readBeforeTrim = sb.getTotalBytesRead(); + // act — force trim + sb.setMaxBufferElements(1); + os.write(new byte[50]); + + // assert — trim's internal read/write should not affect user counters + // and buffer should be consolidated into one element + assertAll( + () -> assertThat(sb.getTotalBytesWritten(), is(writtenBeforeTrim + 50)), + () -> assertThat(sb.getTotalBytesRead(), is(readBeforeTrim)), + () -> assertThat(sb.getBufferElementCount(), is(1)), + () -> assertThat(sb.isTrimRunning(), is(false)) // trim should be complete + ); + } } @Nested @DisplayName("buffer element count and trim state") class BufferElementCountAndTrimStateTests { - @DisplayName("bufferElementCount(): initial — is zero") - @Test - public void bufferElementCount_initial_isZero() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("bufferElementCount(): initial — is zero") + @Test + public void bufferElementCount_initial_isZero() { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act & assert - assertThat(sb.getBufferElementCount(), is(0)); - } + // act & assert + assertThat(sb.getBufferElementCount(), is(0)); + } - @DisplayName("bufferElementCount(): after writes — increases accordingly") - @Test - public void bufferElementCount_afterWrites_increasesAccordingly() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); + @DisplayName("bufferElementCount(): after writes — increases accordingly") + @Test + public void bufferElementCount_afterWrites_increasesAccordingly() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); - // act & assert first write - os.write(new byte[10]); - assertThat(sb.getBufferElementCount(), is(1)); + // act & assert first write + os.write(new byte[10]); + assertThat(sb.getBufferElementCount(), is(1)); - // act & assert second write - os.write(new byte[20]); - assertThat(sb.getBufferElementCount(), is(2)); - } + // act & assert second write + os.write(new byte[20]); + assertThat(sb.getBufferElementCount(), is(2)); + } - @DisplayName("bufferElementCount(): after trim consolidation — reduces to one") - @Test - public void bufferElementCount_afterTrimConsolidation_reducesToOne() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[100]); - os.write(new byte[100]); - os.write(new byte[100]); - assertThat(sb.getBufferElementCount(), is(3)); - - // act — force trim - sb.setMaxBufferElements(1); - os.write(new byte[50]); - - // assert - assertThat(sb.getBufferElementCount(), is(1)); - } + @DisplayName("bufferElementCount(): after trim consolidation — reduces to one") + @Test + public void bufferElementCount_afterTrimConsolidation_reducesToOne() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[100]); + os.write(new byte[100]); + os.write(new byte[100]); + assertThat(sb.getBufferElementCount(), is(3)); + + // act — force trim + sb.setMaxBufferElements(1); + os.write(new byte[50]); - @DisplayName("isTrimRunning(): after trim complete — is false") - @Test - public void isTrimRunning_afterTrimComplete_isFalse() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - os.write(new byte[100]); + // assert + assertThat(sb.getBufferElementCount(), is(1)); + } - // act — force trim - sb.setMaxBufferElements(1); - os.write(new byte[50]); + @DisplayName("isTrimRunning(): after trim complete — is false") + @Test + public void isTrimRunning_afterTrimComplete_isFalse() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + os.write(new byte[100]); - // assert - assertThat(sb.isTrimRunning(), is(false)); - } + // act — force trim + sb.setMaxBufferElements(1); + os.write(new byte[50]); + // assert + assertThat(sb.isTrimRunning(), is(false)); + } } @Nested @DisplayName("maxAllocationSize: getter, setter, trim behavior") class MaxAllocationSizeTests { - @DisplayName("maxAllocationSize(): default value — is integer max value") - @Test - public void maxAllocationSize_defaultValue_isIntegerMaxValue() { - // arrange - StreamBuffer sb = new StreamBuffer(); + @DisplayName("maxAllocationSize(): default value — is integer max value") + @Test + public void maxAllocationSize_defaultValue_isIntegerMaxValue() { + // arrange + StreamBuffer sb = new StreamBuffer(); - // act - long maxSize = sb.getMaxAllocationSize(); + // act + long maxSize = sb.getMaxAllocationSize(); - // assert - assertThat(maxSize, is((long) Integer.MAX_VALUE)); - } + // assert + assertThat(maxSize, is((long) Integer.MAX_VALUE)); + } - @DisplayName("maxAllocationSize(): set and get — returns set value") - @Test - public void maxAllocationSize_setAndGet_returnsSetValue() { - // arrange - StreamBuffer sb = new StreamBuffer(); - long newMax = 1024; + @DisplayName("maxAllocationSize(): set and get — returns set value") + @Test + public void maxAllocationSize_setAndGet_returnsSetValue() { + // arrange + StreamBuffer sb = new StreamBuffer(); + long newMax = 1024; - // act - sb.setMaxAllocationSize(newMax); + // act + sb.setMaxAllocationSize(newMax); - // assert - assertThat(sb.getMaxAllocationSize(), is(newMax)); - } + // assert + assertThat(sb.getMaxAllocationSize(), is(newMax)); + } - @DisplayName("setMaxAllocationSize(): invalid value — throws exception") - @Test - public void setMaxAllocationSize_invalidValue_throwsException() { - // arrange - StreamBuffer sb = new StreamBuffer(); - - // act & assert - assertAll( - () -> assertThrows(IllegalArgumentException.class, () -> sb.setMaxAllocationSize(0)), - () -> assertThrows(IllegalArgumentException.class, () -> sb.setMaxAllocationSize(-1)) - ); - } + @DisplayName("setMaxAllocationSize(): invalid value — throws exception") + @Test + public void setMaxAllocationSize_invalidValue_throwsException() { + // arrange + StreamBuffer sb = new StreamBuffer(); - @DisplayName("trim(): respects max allocation size — splits large buffer") - @Test - public void trim_respectsMaxAllocationSize_splitsLargeBuffer() throws IOException { - // arrange — write many small chunks so buffer.size() exceeds maxBufferElements, - // then trim consolidates with maxAllocationSize limit. - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - sb.setMaxAllocationSize(300); - sb.setMaxBufferElements(5); - - // act — write 10 chunks of 100 bytes (1000 bytes total) - // After 6th write: buffer.size()=6 > 5 → trim → ceil(600/300)=2 < 6 → consolidates to 2 - // After 10th write: buffer.size()=6 > 5 → trim → ceil(1000/300)=4 < 6 → consolidates to 4 - for (int i = 0; i < 10; i++) { - byte[] chunk = new byte[100]; - Arrays.fill(chunk, anyValue); - os.write(chunk); + // act & assert + assertAll( + () -> assertThrows(IllegalArgumentException.class, () -> sb.setMaxAllocationSize(0)), + () -> assertThrows(IllegalArgumentException.class, () -> sb.setMaxAllocationSize(-1))); } - // assert — after trim with maxAllocationSize=300, buffer has 4 chunks (300,300,300,100) - assertThat(sb.getBufferElementCount(), is(4)); - assertThat(sb.isTrimRunning(), is(false)); + @DisplayName("trim(): respects max allocation size — splits large buffer") + @Test + public void trim_respectsMaxAllocationSize_splitsLargeBuffer() throws IOException { + // arrange — write many small chunks so buffer.size() exceeds maxBufferElements, + // then trim consolidates with maxAllocationSize limit. + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + sb.setMaxAllocationSize(300); + sb.setMaxBufferElements(5); + + // act — write 10 chunks of 100 bytes (1000 bytes total) + // After 6th write: buffer.size()=6 > 5 → trim → ceil(600/300)=2 < 6 → consolidates to 2 + // After 10th write: buffer.size()=6 > 5 → trim → ceil(1000/300)=4 < 6 → consolidates to 4 + for (int i = 0; i < 10; i++) { + byte[] chunk = new byte[100]; + Arrays.fill(chunk, anyValue); + os.write(chunk); + } - // Read all data and verify it's intact - os.close(); - byte[] result = new byte[1000]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 1000 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(1000)); - assertThat(result[0], is(anyValue)); - assertThat(result[999], is(anyValue)); - } + // assert — after trim with maxAllocationSize=300, buffer has 4 chunks (300,300,300,100) + assertThat(sb.getBufferElementCount(), is(4)); + assertThat(sb.isTrimRunning(), is(false)); - @DisplayName("trim(): max allocation size — all data preserved") - @Test - public void trim_maxAllocationSize_allDataPreserved() throws IOException { - // arrange — write multiple small chunks so trim fires with maxAllocationSize limit, - // then verify all data is preserved after consolidation. - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - sb.setMaxAllocationSize(200); - sb.setMaxBufferElements(3); - - // act — write 6 chunks of 100 bytes (600 bytes total) - // After 4th write: buffer.size()=4 > 3 → trim → ceil(400/200)=2 < 4 → consolidates - // After more writes: trim fires again → ceil(600/200)=3 < current → consolidates - byte[] original = new byte[100]; - Arrays.fill(original, anyValue); - for (int i = 0; i < 6; i++) { - os.write(original); - } - - // assert — all 600 bytes should be readable and intact - os.close(); - byte[] result = new byte[600]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 600 - totalRead)) > 0) { - totalRead += bytesRead; - } - final int finalTotalRead = totalRead; - assertAll( - () -> assertThat(finalTotalRead, is(600)), - () -> assertThat(result[0], is(anyValue)), - () -> assertThat(result[599], is(anyValue)) - ); - } + // Read all data and verify it's intact + os.close(); + byte[] result = new byte[1000]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 1000 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(1000)); + assertThat(result[0], is(anyValue)); + assertThat(result[999], is(anyValue)); + } - @DisplayName("trim(): max allocation size — with partial read") - @Test - public void trim_maxAllocationSize_withPartialRead() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - byte[] data = new byte[600]; - Arrays.fill(data, anyValue); - os.write(data); - - // act — read 200 bytes, then trigger trim with allocation limit - byte[] partial = new byte[200]; - is.read(partial); - sb.setMaxAllocationSize(150); - sb.setMaxBufferElements(1); - os.write(new byte[10]); // triggers trim - - // assert — remaining 400 bytes should be readable - byte[] remaining = new byte[400]; - int read = is.read(remaining); - assertThat(read, is(400)); - } + @DisplayName("trim(): max allocation size — all data preserved") + @Test + public void trim_maxAllocationSize_allDataPreserved() throws IOException { + // arrange — write multiple small chunks so trim fires with maxAllocationSize limit, + // then verify all data is preserved after consolidation. + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + sb.setMaxAllocationSize(200); + sb.setMaxBufferElements(3); + + // act — write 6 chunks of 100 bytes (600 bytes total) + // After 4th write: buffer.size()=4 > 3 → trim → ceil(400/200)=2 < 4 → consolidates + // After more writes: trim fires again → ceil(600/200)=3 < current → consolidates + byte[] original = new byte[100]; + Arrays.fill(original, anyValue); + for (int i = 0; i < 6; i++) { + os.write(original); + } - @DisplayName("trim(): recursive trim — on chunk overflow — all data preserved") - @Test - public void trim_recursiveTrim_onChunkOverflow_allDataPreserved() throws IOException { - // arrange — write many small chunks that trigger multiple trims. - // With maxAllocationSize limiting consolidation, trim may produce more chunks - // than maxBufferElements allows. The isTrimRunning guard prevents recursive - // trim, and the edge case check prevents futile re-trim attempts. - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - sb.setMaxAllocationSize(500); - sb.setMaxBufferElements(10); - - // act — write 100 chunks of 100 bytes (10,000 bytes total) - // Trim fires repeatedly as buffer exceeds 10 elements, consolidating with - // maxAllocationSize=500. Each trim consolidates into ceil(N/500) chunks. - byte[] chunk = new byte[100]; - Arrays.fill(chunk, anyValue); - for (int i = 0; i < 100; i++) { - os.write(chunk); + // assert — all 600 bytes should be readable and intact + os.close(); + byte[] result = new byte[600]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 600 - totalRead)) > 0) { + totalRead += bytesRead; + } + final int finalTotalRead = totalRead; + assertAll( + () -> assertThat(finalTotalRead, is(600)), + () -> assertThat(result[0], is(anyValue)), + () -> assertThat(result[599], is(anyValue))); } - // assert — trim completed without stack overflow, data intact - assertThat(sb.isTrimRunning(), is(false)); + @DisplayName("trim(): max allocation size — with partial read") + @Test + public void trim_maxAllocationSize_withPartialRead() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + byte[] data = new byte[600]; + Arrays.fill(data, anyValue); + os.write(data); - // all 10,000 bytes should be readable - os.close(); - byte[] result = new byte[10_000]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 10_000 - totalRead)) > 0) { - totalRead += bytesRead; + // act — read 200 bytes, then trigger trim with allocation limit + byte[] partial = new byte[200]; + is.read(partial); + sb.setMaxAllocationSize(150); + sb.setMaxBufferElements(1); + os.write(new byte[10]); // triggers trim + + // assert — remaining 400 bytes should be readable + byte[] remaining = new byte[400]; + int read = is.read(remaining); + assertThat(read, is(400)); } - assertThat(totalRead, is(10_000)); - assertThat(result[0], is(anyValue)); - assertThat(result[9999], is(anyValue)); - } - @DisplayName("trim(): edge case — skips trim when result still exceeds limit") - @Test - public void trim_edgeCase_skipsTrimWhenResultStillExceedsLimit() throws IOException { - // arrange: Critical edge case where consolidation would NOT reduce chunk count below limit - // maxBufferElements=10, maxAllocationSize=100, availableBytes=1100 - // → Consolidation would create ceil(1100/100)=11 chunks, still violating the 10-chunk limit - // → Trim MUST be skipped to prevent repeated trim calls on every write - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - sb.setMaxBufferElements(10); // limit to 10 chunks - sb.setMaxAllocationSize(100); // chunks of 100 bytes max during consolidation - - // act: Write 11 chunks of 100 bytes each (1100 bytes total) - // When consolidated with maxAllocationSize=100, would result in 11 chunks (ceil(1100/100)) - // This would still exceed maxBufferElements=10, so trim should be skipped - for (int i = 0; i < 11; i++) { - os.write(new byte[100]); + @DisplayName("trim(): recursive trim — on chunk overflow — all data preserved") + @Test + public void trim_recursiveTrim_onChunkOverflow_allDataPreserved() throws IOException { + // arrange — write many small chunks that trigger multiple trims. + // With maxAllocationSize limiting consolidation, trim may produce more chunks + // than maxBufferElements allows. The isTrimRunning guard prevents recursive + // trim, and the edge case check prevents futile re-trim attempts. + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + sb.setMaxAllocationSize(500); + sb.setMaxBufferElements(10); + + // act — write 100 chunks of 100 bytes (10,000 bytes total) + // Trim fires repeatedly as buffer exceeds 10 elements, consolidating with + // maxAllocationSize=500. Each trim consolidates into ceil(N/500) chunks. + byte[] chunk = new byte[100]; + Arrays.fill(chunk, anyValue); + for (int i = 0; i < 100; i++) { + os.write(chunk); + } + + // assert — trim completed without stack overflow, data intact + assertThat(sb.isTrimRunning(), is(false)); + + // all 10,000 bytes should be readable + os.close(); + byte[] result = new byte[10_000]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 10_000 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(10_000)); + assertThat(result[0], is(anyValue)); + assertThat(result[9999], is(anyValue)); } - // assert: Verify trim was skipped (buffer still has 11 elements, not consolidated) - // If trim had run, it would have been consolidated and possibly caused recursive trim attempts - assertThat(sb.getBufferElementCount(), is(11)); // trim was not executed + @DisplayName("trim(): edge case — skips trim when result still exceeds limit") + @Test + public void trim_edgeCase_skipsTrimWhenResultStillExceedsLimit() throws IOException { + // arrange: Critical edge case where consolidation would NOT reduce chunk count below limit + // maxBufferElements=10, maxAllocationSize=100, availableBytes=1100 + // → Consolidation would create ceil(1100/100)=11 chunks, still violating the 10-chunk limit + // → Trim MUST be skipped to prevent repeated trim calls on every write + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + sb.setMaxBufferElements(10); // limit to 10 chunks + sb.setMaxAllocationSize(100); // chunks of 100 bytes max during consolidation + + // act: Write 11 chunks of 100 bytes each (1100 bytes total) + // When consolidated with maxAllocationSize=100, would result in 11 chunks (ceil(1100/100)) + // This would still exceed maxBufferElements=10, so trim should be skipped + for (int i = 0; i < 11; i++) { + os.write(new byte[100]); + } - // Verify data integrity: all 1100 bytes should be readable - InputStream is = sb.getInputStream(); - os.close(); // Signal EOF to the input stream - byte[] result = new byte[1100]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 1100 - totalRead)) > 0) { - totalRead += bytesRead; + // assert: Verify trim was skipped (buffer still has 11 elements, not consolidated) + // If trim had run, it would have been consolidated and possibly caused recursive trim attempts + assertThat(sb.getBufferElementCount(), is(11)); // trim was not executed + + // Verify data integrity: all 1100 bytes should be readable + InputStream is = sb.getInputStream(); + os.close(); // Signal EOF to the input stream + byte[] result = new byte[1100]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 1100 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(1100)); } - assertThat(totalRead, is(1100)); - } - @DisplayName("trim(): edge case — executes when result reduces chunks") - @Test - public void trim_edgeCase_executesWhenResultReducesChunks() throws IOException { - // arrange: Verify that trim DOES execute when consolidation will reduce chunks. - // maxBufferElements=5, maxAllocationSize=200 - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - sb.setMaxBufferElements(5); // limit to 5 chunks - sb.setMaxAllocationSize(200); // chunks of 200 bytes max during consolidation - - // act: Write 5 chunks of 100 bytes (stays at limit), record count, - // then write a 6th to trigger trim. - for (int i = 0; i < 5; i++) { + @DisplayName("trim(): edge case — executes when result reduces chunks") + @Test + public void trim_edgeCase_executesWhenResultReducesChunks() throws IOException { + // arrange: Verify that trim DOES execute when consolidation will reduce chunks. + // maxBufferElements=5, maxAllocationSize=200 + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + sb.setMaxBufferElements(5); // limit to 5 chunks + sb.setMaxAllocationSize(200); // chunks of 200 bytes max during consolidation + + // act: Write 5 chunks of 100 bytes (stays at limit), record count, + // then write a 6th to trigger trim. + for (int i = 0; i < 5; i++) { + os.write(new byte[100]); + } + int beforeTrim = sb.getBufferElementCount(); // 5 (no trim yet: 5 <= 5) os.write(new byte[100]); + // buffer.size()=6 > 5 → trim check: ceil(600/200)=3, 3 < 6 → runs + int afterTrim = sb.getBufferElementCount(); // 3 + + // assert: Verify trim was executed and reduced chunk count + final int fb = beforeTrim; + final int fa = afterTrim; + assertAll( + () -> assertThat(fb, is(5)), + () -> assertThat(fa, is(3)), // ceil(600/200)=3 consolidated chunks + () -> assertThat(fa, not(greaterThan(fb)))); + + // Verify data integrity: all 600 bytes should be readable + InputStream is = sb.getInputStream(); + os.close(); + byte[] result = new byte[600]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 600 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(600)); } - int beforeTrim = sb.getBufferElementCount(); // 5 (no trim yet: 5 <= 5) - os.write(new byte[100]); - // buffer.size()=6 > 5 → trim check: ceil(600/200)=3, 3 < 6 → runs - int afterTrim = sb.getBufferElementCount(); // 3 - - // assert: Verify trim was executed and reduced chunk count - final int fb = beforeTrim; - final int fa = afterTrim; - assertAll( - () -> assertThat(fb, is(5)), - () -> assertThat(fa, is(3)), // ceil(600/200)=3 consolidated chunks - () -> assertThat(fa, not(greaterThan(fb))) - ); - - // Verify data integrity: all 600 bytes should be readable - InputStream is = sb.getInputStream(); - os.close(); - byte[] result = new byte[600]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 600 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(600)); - } - @DisplayName("trim(): edge case — prevents trim loops on every write") - @Test - public void trim_edgeCase_preventsTrimLoopsOnEveryWrite() throws IOException { - // arrange: Verify that repeated writes don't cause trim to loop constantly - // when consolidation would violate the limit again - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - sb.setMaxBufferElements(2); // very low limit - sb.setMaxAllocationSize(50); // very small allocation size - - // act: Write small chunks that individually don't trigger trim, but accumulated would - long trimCountBefore = sb.getTotalBytesWritten(); - for (int i = 0; i < 10; i++) { - os.write(new byte[30]); - // Each write is 30 bytes; if trim were called every time, it would consolidate - // But with the edge case fix, trim should be skipped when result violates limit - } - long trimCountAfter = sb.getTotalBytesWritten(); - - // assert: All 300 bytes should be written without trim loops - assertAll( - () -> assertThat(trimCountAfter, is(trimCountBefore + 300L)), - () -> assertThat(sb.isTrimRunning(), is(false)) // trim should not be running - ); - - // Verify all data is still readable - InputStream is = sb.getInputStream(); - os.close(); // Signal EOF to the input stream - byte[] result = new byte[300]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 300 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(300)); - } + @DisplayName("trim(): edge case — prevents trim loops on every write") + @Test + public void trim_edgeCase_preventsTrimLoopsOnEveryWrite() throws IOException { + // arrange: Verify that repeated writes don't cause trim to loop constantly + // when consolidation would violate the limit again + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + sb.setMaxBufferElements(2); // very low limit + sb.setMaxAllocationSize(50); // very small allocation size + + // act: Write small chunks that individually don't trigger trim, but accumulated would + long trimCountBefore = sb.getTotalBytesWritten(); + for (int i = 0; i < 10; i++) { + os.write(new byte[30]); + // Each write is 30 bytes; if trim were called every time, it would consolidate + // But with the edge case fix, trim should be skipped when result violates limit + } + long trimCountAfter = sb.getTotalBytesWritten(); + // assert: All 300 bytes should be written without trim loops + assertAll( + () -> assertThat(trimCountAfter, is(trimCountBefore + 300L)), + () -> assertThat(sb.isTrimRunning(), is(false)) // trim should not be running + ); + + // Verify all data is still readable + InputStream is = sb.getInputStream(); + os.close(); // Signal EOF to the input stream + byte[] result = new byte[300]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 300 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(300)); + } } @Nested @DisplayName("mutation survivors — boundary conditions and arithmetic") class MutationSurvivorTests { - @DisplayName("maxAllocationSize(): set to one — succeeds") - @Test - public void maxAllocationSize_setToOne_succeeds() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("maxAllocationSize(): set to one — succeeds") + @Test + public void maxAllocationSize_setToOne_succeeds() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act & assert — Boundary: maxSize=1 must be accepted (kills maxSize <= 0 vs < 0) - sb.setMaxAllocationSize(1L); - assertThat(sb.getMaxAllocationSize(), is(1L)); - } - - @DisplayName("trim(): max allocation size — one — with substantial data") - @Test - public void trim_maxAllocationSize_one_withSubstantialData() throws IOException { - // arrange — Verify that trim works correctly even with maxAllocationSize=1 (extreme case) - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - // Write 1000 bytes in 100-byte chunks to trigger trim - byte[] chunk = new byte[100]; - Arrays.fill(chunk, anyValue); - sb.setMaxAllocationSize(1L); // Extreme: 1 byte per allocation - sb.setMaxBufferElements(5); // Low threshold to trigger trim - - // act — write 10 chunks (1000 bytes total) - // With maxAllocationSize=1, each byte is allocated separately: 1000 chunks after trim - for (int i = 0; i < 10; i++) { - os.write(chunk); + // act & assert — Boundary: maxSize=1 must be accepted (kills maxSize <= 0 vs < 0) + sb.setMaxAllocationSize(1L); + assertThat(sb.getMaxAllocationSize(), is(1L)); } - // assert — verify trim completed and all data is readable - assertThat(sb.isTrimRunning(), is(false)); + @DisplayName("trim(): max allocation size — one — with substantial data") + @Test + public void trim_maxAllocationSize_one_withSubstantialData() throws IOException { + // arrange — Verify that trim works correctly even with maxAllocationSize=1 (extreme case) + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); - os.close(); - byte[] result = new byte[1000]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 1000 - totalRead)) > 0) { - totalRead += bytesRead; - } + // Write 1000 bytes in 100-byte chunks to trigger trim + byte[] chunk = new byte[100]; + Arrays.fill(chunk, anyValue); + sb.setMaxAllocationSize(1L); // Extreme: 1 byte per allocation + sb.setMaxBufferElements(5); // Low threshold to trigger trim - // All 1000 bytes should be read and intact - final int finalTotalRead = totalRead; - assertAll( - () -> assertThat(finalTotalRead, is(1000)), - () -> assertThat(result[0], is(anyValue)), - () -> assertThat(result[999], is(anyValue)) - ); - } + // act — write 10 chunks (1000 bytes total) + // With maxAllocationSize=1, each byte is allocated separately: 1000 chunks after trim + for (int i = 0; i < 10; i++) { + os.write(chunk); + } - @DisplayName("decrementAvailableBytesBudget(): subtracts — not adds") - @Test - public void decrementAvailableBytesBudget_subtracts_notAdds() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // assert — verify trim completed and all data is readable + assertThat(sb.isTrimRunning(), is(false)); - // act — Verify arithmetic: 100 - 30 = 70, NOT 100 + 30 = 130 (kills MathMutator on - operator) - final long result = sb.decrementAvailableBytesBudget(100L, 30L); + os.close(); + byte[] result = new byte[1000]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 1000 - totalRead)) > 0) { + totalRead += bytesRead; + } - // assert - assertThat(result, is(70L)); - } + // All 1000 bytes should be read and intact + final int finalTotalRead = totalRead; + assertAll( + () -> assertThat(finalTotalRead, is(1000)), + () -> assertThat(result[0], is(anyValue)), + () -> assertThat(result[999], is(anyValue))); + } - @DisplayName("decrementAvailableBytesBudget(): large values") - @Test - public void decrementAvailableBytesBudget_largeValues() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("decrementAvailableBytesBudget(): subtracts — not adds") + @Test + public void decrementAvailableBytesBudget_subtracts_notAdds() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act — Test with large values to ensure arithmetic doesn't overflow - final long result = sb.decrementAvailableBytesBudget(1_000_000L, 500_000L); + // act — Verify arithmetic: 100 - 30 = 70, NOT 100 + 30 = 130 (kills MathMutator on - operator) + final long result = sb.decrementAvailableBytesBudget(100L, 30L); - // assert - assertThat(result, is(500_000L)); - } + // assert + assertThat(result, is(70L)); + } - @DisplayName("clampToMaxInt(): clamps large values") - @Test - public void clampToMaxInt_clampsLargeValues() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert — Test max int clamping with various boundary values - assertAll( - () -> assertThat(sb.clampToMaxInt(Long.MAX_VALUE), is(Integer.MAX_VALUE)), - () -> assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE), is(Integer.MAX_VALUE)), - () -> assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE - 1), is(Integer.MAX_VALUE - 1)), - () -> assertThat(sb.clampToMaxInt(1000L), is(1000)) - ); - } + @DisplayName("decrementAvailableBytesBudget(): large values") + @Test + public void decrementAvailableBytesBudget_largeValues() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("trimCondition(): max buffer elements zero — never trims") - @Test - public void trimCondition_maxBufferElementsZero_neverTrims() throws IOException { - // arrange — Boundary: maxBufferElements=0 must never trigger trim (kills <= 0 vs < 0) - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(0); - final OutputStream os = sb.getOutputStream(); + // act — Test with large values to ensure arithmetic doesn't overflow + final long result = sb.decrementAvailableBytesBudget(1_000_000L, 500_000L); - // act — Write enough data that would normally trigger trim - for (int i = 0; i < 200; i++) { - os.write(anyValue); + // assert + assertThat(result, is(500_000L)); } - // assert — trim should not execute with maxBufferElements=0 - assertThat(sb.isTrimShouldBeExecuted(), is(false)); - } + @DisplayName("clampToMaxInt(): clamps large values") + @Test + public void clampToMaxInt_clampsLargeValues() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("trimCondition(): all checks pass — returns true") - @Test - public void trimCondition_allChecksPass_returnsTrue() throws IOException { - // arrange — Force all conditions in isTrimShouldBeExecuted to pass - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(3); - sb.setMaxAllocationSize(50); // Small allocation size to prevent consolidating all chunks - final OutputStream os = sb.getOutputStream(); - - // act — Write 8 chunks of 100 bytes (800 bytes total) - // This creates buffer.size() = 8 > maxBufferElements(3) - // When isTrimShouldBeExecuted() is called: - // - buffer.size() (8) > maxBufferElements (3)? YES - // - availableBytes (800) > 0 && maxAllocSize (50) < availableBytes (800)? YES - // - resultingChunks = ceil(800/50) = 16 - // - 16 >= 8? YES, so trim would be skipped by edge case logic - // This test won't work with edge case logic. Let's use a simpler case. - for (int i = 0; i < 8; i++) { - os.write(new byte[100]); + // act & assert — Test max int clamping with various boundary values + assertAll( + () -> assertThat(sb.clampToMaxInt(Long.MAX_VALUE), is(Integer.MAX_VALUE)), + () -> assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE), is(Integer.MAX_VALUE)), + () -> assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE - 1), is(Integer.MAX_VALUE - 1)), + () -> assertThat(sb.clampToMaxInt(1000L), is(1000))); } - // assert — Verify buffer is in state where trim would execute - // Reset maxAllocationSize to default (large) to allow consolidation - sb.setMaxAllocationSize(Integer.MAX_VALUE); - // Now with default maxAllocSize, edge case logic is skipped and returns true - assertThat(sb.isTrimShouldBeExecuted(), is(true)); - } + @DisplayName("trimCondition(): max buffer elements zero — never trims") + @Test + public void trimCondition_maxBufferElementsZero_neverTrims() throws IOException { + // arrange — Boundary: maxBufferElements=0 must never trigger trim (kills <= 0 vs < 0) + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(0); + final OutputStream os = sb.getOutputStream(); - @DisplayName("trimCondition(): available bytes zero — skips trim check") - @Test - public void trimCondition_availableBytesZero_skipsTrimCheck() throws IOException { - // arrange — Boundary: availableBytes=0 must skip trim check (kills > 0 vs >= 0) - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(1); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // act — Write data then read all of it - os.write(anyValue); - is.read(); // Consume the byte - - // assert — No available bytes, so edge case trim check should be skipped - assertThat(sb.isTrimShouldBeExecuted(), is(false)); - } + // act — Write enough data that would normally trigger trim + for (int i = 0; i < 200; i++) { + os.write(anyValue); + } - @DisplayName("trimCondition(): resulting chunks equal buffer size — does not trim") - @Test - public void trimCondition_resultingChunksEqualBufferSize_doesNotTrim() throws IOException { - // arrange — Boundary: resultingChunks == buffer.size() must not trim (kills >= vs >) - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(5); - sb.setMaxAllocationSize(100); - final OutputStream os = sb.getOutputStream(); - - // act — Write exactly 500 bytes to create ~5 chunks of 100 bytes each - for (int i = 0; i < 500; i++) { - os.write(anyValue); + // assert — trim should not execute with maxBufferElements=0 + assertThat(sb.isTrimShouldBeExecuted(), is(false)); } - // assert — resultingChunks = ceil(500/100) = 5, buffer.size() = 5 - // So 5 >= 5, trim should NOT execute (kills >= vs > mutation) - assertThat(sb.isTrimShouldBeExecuted(), is(false)); - } + @DisplayName("trimCondition(): all checks pass — returns true") + @Test + public void trimCondition_allChecksPass_returnsTrue() throws IOException { + // arrange — Force all conditions in isTrimShouldBeExecuted to pass + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(3); + sb.setMaxAllocationSize(50); // Small allocation size to prevent consolidating all chunks + final OutputStream os = sb.getOutputStream(); + + // act — Write 8 chunks of 100 bytes (800 bytes total) + // This creates buffer.size() = 8 > maxBufferElements(3) + // When isTrimShouldBeExecuted() is called: + // - buffer.size() (8) > maxBufferElements (3)? YES + // - availableBytes (800) > 0 && maxAllocSize (50) < availableBytes (800)? YES + // - resultingChunks = ceil(800/50) = 16 + // - 16 >= 8? YES, so trim would be skipped by edge case logic + // This test won't work with edge case logic. Let's use a simpler case. + for (int i = 0; i < 8; i++) { + os.write(new byte[100]); + } - @DisplayName("trimCondition(): max alloc size greater or equal — skips trim check") - @Test - public void trimCondition_maxAllocSizeGreaterOrEqual_skipsTrimCheck() throws IOException { - // arrange — Boundary: maxAllocSize >= availableBytes must skip trim check - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(10); - sb.setMaxAllocationSize(1000); // Larger than any data we'll write - final OutputStream os = sb.getOutputStream(); - - // act — Write 500 bytes with maxAllocSize=1000 (maxAllocSize >= availableBytes) - for (int i = 0; i < 500; i++) { - os.write(anyValue); + // assert — Verify buffer is in state where trim would execute + // Reset maxAllocationSize to default (large) to allow consolidation + sb.setMaxAllocationSize(Integer.MAX_VALUE); + // Now with default maxAllocSize, edge case logic is skipped and returns true + assertThat(sb.isTrimShouldBeExecuted(), is(true)); } - // assert — Since maxAllocSize >= availableBytes, edge case check is skipped - // AND data is small relative to limit, trim should not execute - assertThat(sb.isTrimShouldBeExecuted(), is(false)); - } + @DisplayName("trimCondition(): available bytes zero — skips trim check") + @Test + public void trimCondition_availableBytesZero_skipsTrimCheck() throws IOException { + // arrange — Boundary: availableBytes=0 must skip trim check (kills > 0 vs >= 0) + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(1); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); - @DisplayName("trimCondition(): max alloc size less than available — checks chunks") - @Test - public void trimCondition_maxAllocSizeLessThanAvailable_checksChunks() throws IOException { - // arrange — Both conditions in edge case AND must be tested: - // availableBytes > 0 AND maxAllocSize < availableBytes - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(10); - sb.setMaxAllocationSize(50); - final OutputStream os = sb.getOutputStream(); - - // act — Write 500 bytes with maxAllocSize=50 - // → ceil(500/50) = 10 chunks - // → buffer.size() will be ~10 (depends on write patterns) - // → 10 >= 10, so trim should NOT execute - for (int i = 0; i < 500; i++) { + // act — Write data then read all of it os.write(anyValue); - } + is.read(); // Consume the byte - // assert — Edge case condition triggers, resulting chunks equals or exceeds buffer size - // Verify trim behavior (may or may not execute depending on exact buffer state) - // What matters: the AND condition is fully evaluated (kills NegateConditionalsMutator) - assertThat(sb.isTrimRunning(), is(false)); // Not currently trimming - } + // assert — No available bytes, so edge case trim check should be skipped + assertThat(sb.isTrimShouldBeExecuted(), is(false)); + } - @DisplayName("ceilingDivisionFormula(): calculates correctly") - @Test - public void ceilingDivisionFormula_calculatesCorrectly() { - // arrange — Verify the ceiling division formula: (n + d - 1) / d - final StreamBuffer sb = new StreamBuffer(); - - // act & assert — Test various n, d pairs where the formula matters - // ceil(1001 / 1000) = 2 - // Using formula: (1001 + 1000 - 1) / 1000 = 2000 / 1000 = 2 ✓ - // If mutated to (1001 - 1000 - 1) / 1000 = 0 ✗ - assertAll( - () -> { - // Test: ceil(1001 / 1000) = 2 - long resultingChunks = sb.calculateResultingChunks(1001L, 1000L); - assertThat(resultingChunks, is(2L)); // Kills + vs - mutation - }, - () -> { - // Test: ceil(500 / 100) = 5 - long resultingChunks = sb.calculateResultingChunks(500L, 100L); - assertThat(resultingChunks, is(5L)); + @DisplayName("trimCondition(): resulting chunks equal buffer size — does not trim") + @Test + public void trimCondition_resultingChunksEqualBufferSize_doesNotTrim() throws IOException { + // arrange — Boundary: resultingChunks == buffer.size() must not trim (kills >= vs >) + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(5); + sb.setMaxAllocationSize(100); + final OutputStream os = sb.getOutputStream(); + + // act — Write exactly 500 bytes to create ~5 chunks of 100 bytes each + for (int i = 0; i < 500; i++) { + os.write(anyValue); } - ); - } - @DisplayName("shouldSkipTrimDueToEdgeCase(): bounds comparison") - @Test - public void shouldSkipTrimDueToEdgeCase_boundsComparison() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert - // Test >= boundary: when resultingChunks >= currentBufferSize, should skip - assertAll( - () -> { - // When equal: 10 >= 10 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(10L, 10); - assertThat(shouldSkip, is(true)); // Kills >= vs > mutation - }, - () -> { - // When greater: 11 >= 10 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(11L, 10); - assertThat(shouldSkip, is(true)); - }, - () -> { - // When less: 9 >= 10 → should not skip (return false) - boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(9L, 10); - assertThat(shouldSkip, is(false)); // Kills >= vs > mutation - } - ); - } + // assert — resultingChunks = ceil(500/100) = 5, buffer.size() = 5 + // So 5 >= 5, trim should NOT execute (kills >= vs > mutation) + assertThat(sb.isTrimShouldBeExecuted(), is(false)); + } - @DisplayName("shouldSkipTrimDueToInvalidMaxBufferElements(): bounds comparison") - @Test - public void shouldSkipTrimDueToInvalidMaxBufferElements_boundsComparison() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert - // Test <= boundary: when maxBufferElements <= 0, should skip - assertAll( - () -> { - // When zero: 0 <= 0 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(0); - assertThat(shouldSkip, is(true)); // Kills <= vs < mutation - }, - () -> { - // When negative: -1 <= 0 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(-1); - assertThat(shouldSkip, is(true)); - }, - () -> { - // When positive: 1 <= 0 → should not skip (return false) - boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(1); - assertThat(shouldSkip, is(false)); // Kills <= vs < mutation + @DisplayName("trimCondition(): max alloc size greater or equal — skips trim check") + @Test + public void trimCondition_maxAllocSizeGreaterOrEqual_skipsTrimCheck() throws IOException { + // arrange — Boundary: maxAllocSize >= availableBytes must skip trim check + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(10); + sb.setMaxAllocationSize(1000); // Larger than any data we'll write + final OutputStream os = sb.getOutputStream(); + + // act — Write 500 bytes with maxAllocSize=1000 (maxAllocSize >= availableBytes) + for (int i = 0; i < 500; i++) { + os.write(anyValue); } - ); - } - @DisplayName("shouldSkipTrimDueToSmallBuffer(): bounds comparison") - @Test - public void shouldSkipTrimDueToSmallBuffer_boundsComparison() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert - // Test < boundary: when buffer.size() < 2, should skip - assertAll( - () -> { - // When zero: 0 < 2 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(0); - assertThat(shouldSkip, is(true)); - }, - () -> { - // When one: 1 < 2 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(1); - assertThat(shouldSkip, is(true)); // Kills < vs <= mutation - }, - () -> { - // When two: 2 < 2 → should not skip (return false) - boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(2); - assertThat(shouldSkip, is(false)); // Kills < vs <= mutation - }, - () -> { - // When three: 3 < 2 → should not skip (return false) - boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(3); - assertThat(shouldSkip, is(false)); - } - ); - } + // assert — Since maxAllocSize >= availableBytes, edge case check is skipped + // AND data is small relative to limit, trim should not execute + assertThat(sb.isTrimShouldBeExecuted(), is(false)); + } - @DisplayName("shouldSkipTrimDueToSufficientBuffer(): bounds comparison") - @Test - public void shouldSkipTrimDueToSufficientBuffer_boundsComparison() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert - // Test <= boundary: when buffer.size() <= maxBufferElements, should skip - assertAll( - () -> { - // When equal: 10 <= 10 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(10, 10); - assertThat(shouldSkip, is(true)); // Kills <= vs < mutation - }, - () -> { - // When greater: 11 <= 10 → should not skip (return false) - boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(11, 10); - assertThat(shouldSkip, is(false)); // Kills <= vs < mutation - }, - () -> { - // When less: 9 <= 10 → should skip (return true) - boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(9, 10); - assertThat(shouldSkip, is(true)); + @DisplayName("trimCondition(): max alloc size less than available — checks chunks") + @Test + public void trimCondition_maxAllocSizeLessThanAvailable_checksChunks() throws IOException { + // arrange — Both conditions in edge case AND must be tested: + // availableBytes > 0 AND maxAllocSize < availableBytes + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(10); + sb.setMaxAllocationSize(50); + final OutputStream os = sb.getOutputStream(); + + // act — Write 500 bytes with maxAllocSize=50 + // → ceil(500/50) = 10 chunks + // → buffer.size() will be ~10 (depends on write patterns) + // → 10 >= 10, so trim should NOT execute + for (int i = 0; i < 500; i++) { + os.write(anyValue); } - ); - } - @DisplayName("shouldCheckEdgeCase(): and condition boundaries") - @Test - public void shouldCheckEdgeCase_andConditionBoundaries() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act & assert - // Test AND condition: both availableBytes > 0 AND maxAllocSize < availableBytes - assertAll( - () -> { - // Both true: 100 > 0 AND 50 < 100 → should check (return true) - boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 50L); - assertThat(shouldCheck, is(true)); - }, - () -> { - // availableBytes zero: 0 > 0 AND 50 < 0 → should not check (return false) - boolean shouldCheck = sb.shouldCheckEdgeCase(0L, 50L); - assertThat(shouldCheck, is(false)); // Kills > vs >= mutation on availableBytes - }, - () -> { - // maxAllocSize >= availableBytes: 100 > 0 AND 100 < 100 → should not check (return false) - boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 100L); - assertThat(shouldCheck, is(false)); // Kills < vs <= mutation on maxAllocSize - }, - () -> { - // maxAllocSize > availableBytes: 100 > 0 AND 150 < 100 → should not check (return false) - boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 150L); - assertThat(shouldCheck, is(false)); // Kills < vs <= mutation - }, - () -> { - // availableBytes negative: -100 > 0 AND 50 < -100 → should not check (return false) - boolean shouldCheck = sb.shouldCheckEdgeCase(-100L, 50L); - assertThat(shouldCheck, is(false)); // Kills > vs >= mutation - } - ); - } + // assert — Edge case condition triggers, resulting chunks equals or exceeds buffer size + // Verify trim behavior (may or may not execute depending on exact buffer state) + // What matters: the AND condition is fully evaluated (kills NegateConditionalsMutator) + assertThat(sb.isTrimRunning(), is(false)); // Not currently trimming + } - @DisplayName("isTrimShouldBeExecuted(): all conditions pass — returns true") - @Test - public void isTrimShouldBeExecuted_allConditionsPass_returnsTrue() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); + @DisplayName("ceilingDivisionFormula(): calculates correctly") + @Test + public void ceilingDivisionFormula_calculatesCorrectly() { + // arrange — Verify the ceiling division formula: (n + d - 1) / d + final StreamBuffer sb = new StreamBuffer(); - // Write data first with default maxBufferElements (100) - // This creates multiple chunks without triggering trim - for (int i = 0; i < 400; i++) { - os.write(42); + // act & assert — Test various n, d pairs where the formula matters + // ceil(1001 / 1000) = 2 + // Using formula: (1001 + 1000 - 1) / 1000 = 2000 / 1000 = 2 ✓ + // If mutated to (1001 - 1000 - 1) / 1000 = 0 ✗ + assertAll( + () -> { + // Test: ceil(1001 / 1000) = 2 + long resultingChunks = sb.calculateResultingChunks(1001L, 1000L); + assertThat(resultingChunks, is(2L)); // Kills + vs - mutation + }, + () -> { + // Test: ceil(500 / 100) = 5 + long resultingChunks = sb.calculateResultingChunks(500L, 100L); + assertThat(resultingChunks, is(5L)); + }); } - // Now lower maxBufferElements to trigger trim condition - // Buffer should have ~4 chunks, maxBufferElements is now 2 - // This makes: buffer.size() (4) > maxBufferElements (2) - sb.setMaxBufferElements(2); - sb.setMaxAllocationSize(Integer.MAX_VALUE); - - // act & assert - // All conditions pass: isTrimRunning=false, buffer has enough chunks, edge case ok - assertThat(sb.isTrimShouldBeExecuted(), is(true)); // Kills mutation of final return true - } + @DisplayName("shouldSkipTrimDueToEdgeCase(): bounds comparison") + @Test + public void shouldSkipTrimDueToEdgeCase_boundsComparison() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("isTrimShouldBeExecuted(): or condition first check — returns false") - @Test - public void isTrimShouldBeExecuted_orConditionFirstCheck_returnsFalse() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // act & assert + // Test >= boundary: when resultingChunks >= currentBufferSize, should skip + assertAll( + () -> { + // When equal: 10 >= 10 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(10L, 10); + assertThat(shouldSkip, is(true)); // Kills >= vs > mutation + }, + () -> { + // When greater: 11 >= 10 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(11L, 10); + assertThat(shouldSkip, is(true)); + }, + () -> { + // When less: 9 >= 10 → should not skip (return false) + boolean shouldSkip = sb.shouldSkipTrimDueToEdgeCase(9L, 10); + assertThat(shouldSkip, is(false)); // Kills >= vs > mutation + }); + } - // Set maxBufferElements to 0 (triggers first OR condition) - sb.setMaxBufferElements(0); + @DisplayName("shouldSkipTrimDueToInvalidMaxBufferElements(): bounds comparison") + @Test + public void shouldSkipTrimDueToInvalidMaxBufferElements_boundsComparison() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // Write some data (would normally trigger trim) - final OutputStream os = sb.getOutputStream(); - for (int i = 0; i < 100; i++) { - os.write(42); + // act & assert + // Test <= boundary: when maxBufferElements <= 0, should skip + assertAll( + () -> { + // When zero: 0 <= 0 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(0); + assertThat(shouldSkip, is(true)); // Kills <= vs < mutation + }, + () -> { + // When negative: -1 <= 0 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(-1); + assertThat(shouldSkip, is(true)); + }, + () -> { + // When positive: 1 <= 0 → should not skip (return false) + boolean shouldSkip = sb.shouldSkipTrimDueToInvalidMaxBufferElements(1); + assertThat(shouldSkip, is(false)); // Kills <= vs < mutation + }); } - // act & assert - // First OR condition is true: maxBufferElements <= 0 - assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills first return false in OR - } + @DisplayName("shouldSkipTrimDueToSmallBuffer(): bounds comparison") + @Test + public void shouldSkipTrimDueToSmallBuffer_boundsComparison() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("isTrimShouldBeExecuted(): edge case returns false") - @Test - public void isTrimShouldBeExecuted_edgeCaseReturnsFalse() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(3); - sb.setMaxAllocationSize(50); - - final OutputStream os = sb.getOutputStream(); - // Write 200 bytes: with maxAllocSize=50, this creates ceil(200/50)=4 chunks - // But buffer.size() should be ~2 initially, then grows to 4 - // Edge case: resultingChunks (4) >= buffer.size() (2) -> should return false - for (int i = 0; i < 200; i++) { - os.write(42); + // act & assert + // Test < boundary: when buffer.size() < 2, should skip + assertAll( + () -> { + // When zero: 0 < 2 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(0); + assertThat(shouldSkip, is(true)); + }, + () -> { + // When one: 1 < 2 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(1); + assertThat(shouldSkip, is(true)); // Kills < vs <= mutation + }, + () -> { + // When two: 2 < 2 → should not skip (return false) + boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(2); + assertThat(shouldSkip, is(false)); // Kills < vs <= mutation + }, + () -> { + // When three: 3 < 2 → should not skip (return false) + boolean shouldSkip = sb.shouldSkipTrimDueToSmallBuffer(3); + assertThat(shouldSkip, is(false)); + }); } - // act & assert - // Edge case check should trigger and return false - assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills edge case return false - } + @DisplayName("shouldSkipTrimDueToSufficientBuffer(): bounds comparison") + @Test + public void shouldSkipTrimDueToSufficientBuffer_boundsComparison() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("isTrimShouldBeExecuted(): or condition second check — returns false") - @Test - public void isTrimShouldBeExecuted_orConditionSecondCheck_returnsFalse() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // act & assert + // Test <= boundary: when buffer.size() <= maxBufferElements, should skip + assertAll( + () -> { + // When equal: 10 <= 10 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(10, 10); + assertThat(shouldSkip, is(true)); // Kills <= vs < mutation + }, + () -> { + // When greater: 11 <= 10 → should not skip (return false) + boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(11, 10); + assertThat(shouldSkip, is(false)); // Kills <= vs < mutation + }, + () -> { + // When less: 9 <= 10 → should skip (return true) + boolean shouldSkip = sb.shouldSkipTrimDueToSufficientBuffer(9, 10); + assertThat(shouldSkip, is(true)); + }); + } - // Write only 1 byte to keep buffer.size() < 2 - final OutputStream os = sb.getOutputStream(); - os.write(42); + @DisplayName("shouldCheckEdgeCase(): and condition boundaries") + @Test + public void shouldCheckEdgeCase_andConditionBoundaries() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act & assert - // Second OR condition is true: buffer.size() < 2 - assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills second return false in OR - } + // act & assert + // Test AND condition: both availableBytes > 0 AND maxAllocSize < availableBytes + assertAll( + () -> { + // Both true: 100 > 0 AND 50 < 100 → should check (return true) + boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 50L); + assertThat(shouldCheck, is(true)); + }, + () -> { + // availableBytes zero: 0 > 0 AND 50 < 0 → should not check (return false) + boolean shouldCheck = sb.shouldCheckEdgeCase(0L, 50L); + assertThat(shouldCheck, is(false)); // Kills > vs >= mutation on availableBytes + }, + () -> { + // maxAllocSize >= availableBytes: 100 > 0 AND 100 < 100 → should not check (return false) + boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 100L); + assertThat(shouldCheck, is(false)); // Kills < vs <= mutation on maxAllocSize + }, + () -> { + // maxAllocSize > availableBytes: 100 > 0 AND 150 < 100 → should not check (return false) + boolean shouldCheck = sb.shouldCheckEdgeCase(100L, 150L); + assertThat(shouldCheck, is(false)); // Kills < vs <= mutation + }, + () -> { + // availableBytes negative: -100 > 0 AND 50 < -100 → should not check (return false) + boolean shouldCheck = sb.shouldCheckEdgeCase(-100L, 50L); + assertThat(shouldCheck, is(false)); // Kills > vs >= mutation + }); + } - @DisplayName("isTrimShouldBeExecuted(): or condition third check — returns false") - @Test - public void isTrimShouldBeExecuted_orConditionThirdCheck_returnsFalse() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - sb.setMaxBufferElements(100); // Set high enough to not trigger trim + @DisplayName("isTrimShouldBeExecuted(): all conditions pass — returns true") + @Test + public void isTrimShouldBeExecuted_allConditionsPass_returnsTrue() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + + // Write data first with default maxBufferElements (100) + // This creates multiple chunks without triggering trim + for (int i = 0; i < 400; i++) { + os.write(42); + } - final OutputStream os = sb.getOutputStream(); - // Write only 2 chunks (low number < maxBufferElements) - for (int i = 0; i < 200; i++) { - os.write(42); - } + // Now lower maxBufferElements to trigger trim condition + // Buffer should have ~4 chunks, maxBufferElements is now 2 + // This makes: buffer.size() (4) > maxBufferElements (2) + sb.setMaxBufferElements(2); + sb.setMaxAllocationSize(Integer.MAX_VALUE); - // act & assert - // Third OR condition is true: buffer.size() (likely 2) <= maxBufferElements (100) - assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills third condition return false - } + // act & assert + // All conditions pass: isTrimRunning=false, buffer has enough chunks, edge case ok + assertThat(sb.isTrimShouldBeExecuted(), is(true)); // Kills mutation of final return true + } - @DisplayName("shouldCheckEdgeCase(): boundary available bytes — zero") - @Test - public void shouldCheckEdgeCase_boundaryAvailableBytes_zero() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act - availableBytes == 0, maxAllocSize < availableBytes means both parts must be false - // Test specifically for availableBytes > 0 boundary: when == 0, should be false - // Even if maxAllocSize < availableBytes, availableBytes > 0 must be evaluated - final boolean result = sb.shouldCheckEdgeCase(0, Long.MAX_VALUE); - - // assert - should return false when availableBytes == 0 (boundary: > 0) - // Mutated to >= would give: 0 >= 0 && MAX < 0 = true && false = false (same) - // So we need a different approach: test with positive but not meeting second condition - assertThat(result, is(false)); - } + @DisplayName("isTrimShouldBeExecuted(): or condition first check — returns false") + @Test + public void isTrimShouldBeExecuted_orConditionFirstCheck_returnsFalse() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("shouldCheckEdgeCase(): boundary available bytes — positive") - @Test - public void shouldCheckEdgeCase_boundaryAvailableBytes_positive() { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act - availableBytes == 1 (positive), maxAllocSize >= availableBytes (not <) - // Tests: 1 > 0 (true) && 100 < 1 (false) = false - // Mutated to >= 0: 1 >= 0 (true) && 100 < 1 (false) = false (same, still doesn't help) - // Better approach: make BOTH conditions evaluate - final boolean result = sb.shouldCheckEdgeCase(1, 0); - - // Test: 1 > 0 (true) && 0 < 1 (true) = true - // This actually tests the positive case - assertThat(result, is(true)); - } + // Set maxBufferElements to 0 (triggers first OR condition) + sb.setMaxBufferElements(0); - @DisplayName("shouldCheckEdgeCase(): boundary max alloc size — less than") - @Test - public void shouldCheckEdgeCase_boundaryMaxAllocSize_lessThan() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // Write some data (would normally trigger trim) + final OutputStream os = sb.getOutputStream(); + for (int i = 0; i < 100; i++) { + os.write(42); + } - // act - maxAllocSize == availableBytes means NOT < - // 100 > 0 (true) && 100 < 100 (false) = false - // Mutated to <=: 100 > 0 (true) && 100 <= 100 (true) = true - // This mutation would be KILLED because result changes! - final boolean result = sb.shouldCheckEdgeCase(100, 100); + // act & assert + // First OR condition is true: maxBufferElements <= 0 + assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills first return false in OR + } - // assert - should return false when maxAllocSize == availableBytes (boundary: <) - assertThat(result, is(false)); - } + @DisplayName("isTrimShouldBeExecuted(): edge case returns false") + @Test + public void isTrimShouldBeExecuted_edgeCaseReturnsFalse() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(3); + sb.setMaxAllocationSize(50); + + final OutputStream os = sb.getOutputStream(); + // Write 200 bytes: with maxAllocSize=50, this creates ceil(200/50)=4 chunks + // But buffer.size() should be ~2 initially, then grows to 4 + // Edge case: resultingChunks (4) >= buffer.size() (2) -> should return false + for (int i = 0; i < 200; i++) { + os.write(42); + } - @DisplayName("shouldCheckEdgeCase(): boundary max alloc size — greater") - @Test - public void shouldCheckEdgeCase_boundaryMaxAllocSize_greater() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // act & assert + // Edge case check should trigger and return false + assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills edge case return false + } - // act - maxAllocSize > availableBytes means NOT < - // 100 > 0 (true) && 101 < 100 (false) = false - final boolean result = sb.shouldCheckEdgeCase(100, 101); + @DisplayName("isTrimShouldBeExecuted(): or condition second check — returns false") + @Test + public void isTrimShouldBeExecuted_orConditionSecondCheck_returnsFalse() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // assert - should return false (maxAllocSize is greater, not less) - assertThat(result, is(false)); - } + // Write only 1 byte to keep buffer.size() < 2 + final OutputStream os = sb.getOutputStream(); + os.write(42); - @DisplayName("maxObservedBytes(): boundary equal — not updated") - @Test - public void maxObservedBytes_boundaryEqual_notUpdated() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); + // act & assert + // Second OR condition is true: buffer.size() < 2 + assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills second return false in OR + } - // Set maxBufferElements very high to prevent automatic trim - sb.setMaxBufferElements(10000); + @DisplayName("isTrimShouldBeExecuted(): or condition third check — returns false") + @Test + public void isTrimShouldBeExecuted_orConditionThirdCheck_returnsFalse() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + sb.setMaxBufferElements(100); // Set high enough to not trigger trim + + final OutputStream os = sb.getOutputStream(); + // Write only 2 chunks (low number < maxBufferElements) + for (int i = 0; i < 200; i++) { + os.write(42); + } - // Write exactly 100 bytes to set maxObservedBytes to 100 - for (int i = 0; i < 100; i++) { - os.write(42); + // act & assert + // Third OR condition is true: buffer.size() (likely 2) <= maxBufferElements (100) + assertThat(sb.isTrimShouldBeExecuted(), is(false)); // Kills third condition return false } - long firstMax = sb.getMaxObservedBytes(); - assertThat(firstMax, is(100L)); - // Read all 100 bytes to bring availableBytes to 0 - for (int i = 0; i < 100; i++) { - is.read(); + @DisplayName("shouldCheckEdgeCase(): boundary available bytes — zero") + @Test + public void shouldCheckEdgeCase_boundaryAvailableBytes_zero() { + // arrange + final StreamBuffer sb = new StreamBuffer(); + + // act - availableBytes == 0, maxAllocSize < availableBytes means both parts must be false + // Test specifically for availableBytes > 0 boundary: when == 0, should be false + // Even if maxAllocSize < availableBytes, availableBytes > 0 must be evaluated + final boolean result = sb.shouldCheckEdgeCase(0, Long.MAX_VALUE); + + // assert - should return false when availableBytes == 0 (boundary: > 0) + // Mutated to >= would give: 0 >= 0 && MAX < 0 = true && false = false (same) + // So we need a different approach: test with positive but not meeting second condition + assertThat(result, is(false)); } - // act - write exactly 100 bytes again to make availableBytes == maxObservedBytes == 100 - // This is the boundary test: > vs >= - // With >: condition is false, no update - // With >=: condition is true, updates (unnecessary) - for (int i = 0; i < 100; i++) { - os.write(42); + @DisplayName("shouldCheckEdgeCase(): boundary available bytes — positive") + @Test + public void shouldCheckEdgeCase_boundaryAvailableBytes_positive() { + // arrange + final StreamBuffer sb = new StreamBuffer(); + + // act - availableBytes == 1 (positive), maxAllocSize >= availableBytes (not <) + // Tests: 1 > 0 (true) && 100 < 1 (false) = false + // Mutated to >= 0: 1 >= 0 (true) && 100 < 1 (false) = false (same, still doesn't help) + // Better approach: make BOTH conditions evaluate + final boolean result = sb.shouldCheckEdgeCase(1, 0); + + // Test: 1 > 0 (true) && 0 < 1 (true) = true + // This actually tests the positive case + assertThat(result, is(true)); } - // assert - maxObservedBytes should stay 100 (not updated when equal) - long secondMax = sb.getMaxObservedBytes(); - assertThat(secondMax, is(100L)); // Kills: availableBytes >= maxObservedBytes - } + @DisplayName("shouldCheckEdgeCase(): boundary max alloc size — less than") + @Test + public void shouldCheckEdgeCase_boundaryMaxAllocSize_lessThan() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("maxObservedBytes(): boundary greater — updated") - @Test - public void maxObservedBytes_boundaryGreater_updated() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); + // act - maxAllocSize == availableBytes means NOT < + // 100 > 0 (true) && 100 < 100 (false) = false + // Mutated to <=: 100 > 0 (true) && 100 <= 100 (true) = true + // This mutation would be KILLED because result changes! + final boolean result = sb.shouldCheckEdgeCase(100, 100); - // Set maxBufferElements very high to prevent automatic trim - sb.setMaxBufferElements(10000); + // assert - should return false when maxAllocSize == availableBytes (boundary: <) + assertThat(result, is(false)); + } - // Write exactly 100 bytes to set maxObservedBytes to 100 - for (int i = 0; i < 100; i++) { - os.write(42); + @DisplayName("shouldCheckEdgeCase(): boundary max alloc size — greater") + @Test + public void shouldCheckEdgeCase_boundaryMaxAllocSize_greater() { + // arrange + final StreamBuffer sb = new StreamBuffer(); + + // act - maxAllocSize > availableBytes means NOT < + // 100 > 0 (true) && 101 < 100 (false) = false + final boolean result = sb.shouldCheckEdgeCase(100, 101); + + // assert - should return false (maxAllocSize is greater, not less) + assertThat(result, is(false)); } - long firstMax = sb.getMaxObservedBytes(); - assertThat(firstMax, is(100L)); - // Read all 100 bytes to bring availableBytes to 0 - for (int i = 0; i < 100; i++) { - is.read(); + @DisplayName("maxObservedBytes(): boundary equal — not updated") + @Test + public void maxObservedBytes_boundaryEqual_notUpdated() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // Set maxBufferElements very high to prevent automatic trim + sb.setMaxBufferElements(10000); + + // Write exactly 100 bytes to set maxObservedBytes to 100 + for (int i = 0; i < 100; i++) { + os.write(42); + } + long firstMax = sb.getMaxObservedBytes(); + assertThat(firstMax, is(100L)); + + // Read all 100 bytes to bring availableBytes to 0 + for (int i = 0; i < 100; i++) { + is.read(); + } + + // act - write exactly 100 bytes again to make availableBytes == maxObservedBytes == 100 + // This is the boundary test: > vs >= + // With >: condition is false, no update + // With >=: condition is true, updates (unnecessary) + for (int i = 0; i < 100; i++) { + os.write(42); + } + + // assert - maxObservedBytes should stay 100 (not updated when equal) + long secondMax = sb.getMaxObservedBytes(); + assertThat(secondMax, is(100L)); // Kills: availableBytes >= maxObservedBytes } - // act - write 101 bytes to make availableBytes (101) > maxObservedBytes (100) - for (int i = 0; i < 101; i++) { - os.write(42); + @DisplayName("maxObservedBytes(): boundary greater — updated") + @Test + public void maxObservedBytes_boundaryGreater_updated() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // Set maxBufferElements very high to prevent automatic trim + sb.setMaxBufferElements(10000); + + // Write exactly 100 bytes to set maxObservedBytes to 100 + for (int i = 0; i < 100; i++) { + os.write(42); + } + long firstMax = sb.getMaxObservedBytes(); + assertThat(firstMax, is(100L)); + + // Read all 100 bytes to bring availableBytes to 0 + for (int i = 0; i < 100; i++) { + is.read(); + } + + // act - write 101 bytes to make availableBytes (101) > maxObservedBytes (100) + for (int i = 0; i < 101; i++) { + os.write(42); + } + + // assert - maxObservedBytes should be updated to 101 + long secondMax = sb.getMaxObservedBytes(); + assertThat(secondMax, is(101L)); // Positive test: both > and >= work here } - // assert - maxObservedBytes should be updated to 101 - long secondMax = sb.getMaxObservedBytes(); - assertThat(secondMax, is(101L)); // Positive test: both > and >= work here - } + @DisplayName("trimStartSignal(): released when trim begins") + @Test + public void trimStartSignal_releasedWhenTrimBegins() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore trimStarted = new Semaphore(0); + sb.addTrimStartSignal(trimStarted); - @DisplayName("trimStartSignal(): released when trim begins") - @Test - public void trimStartSignal_releasedWhenTrimBegins() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore trimStarted = new Semaphore(0); - sb.addTrimStartSignal(trimStarted); + sb.setMaxBufferElements(1); + final OutputStream os = sb.getOutputStream(); - sb.setMaxBufferElements(1); - final OutputStream os = sb.getOutputStream(); + // act - write data to trigger trim + for (int i = 0; i < 200; i++) { + os.write(42); + } - // act - write data to trigger trim - for (int i = 0; i < 200; i++) { - os.write(42); + // assert - trim start signal was released (has permits available) + assertThat(trimStarted.availablePermits(), greaterThanOrEqualTo(1)); + + // cleanup + sb.removeTrimStartSignal(trimStarted); } - // assert - trim start signal was released (has permits available) - assertThat(trimStarted.availablePermits(), greaterThanOrEqualTo(1)); + @DisplayName("trimEndSignal(): released when trim completes") + @Test + public void trimEndSignal_releasedWhenTrimCompletes() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore trimEnded = new Semaphore(0); + sb.addTrimEndSignal(trimEnded); - // cleanup - sb.removeTrimStartSignal(trimStarted); - } + sb.setMaxBufferElements(1); + final OutputStream os = sb.getOutputStream(); - @DisplayName("trimEndSignal(): released when trim completes") - @Test - public void trimEndSignal_releasedWhenTrimCompletes() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore trimEnded = new Semaphore(0); - sb.addTrimEndSignal(trimEnded); + // act - write data to trigger trim + for (int i = 0; i < 200; i++) { + os.write(42); + } - sb.setMaxBufferElements(1); - final OutputStream os = sb.getOutputStream(); + // assert - trim end signal was released (has permits available) + assertThat(trimEnded.availablePermits(), greaterThanOrEqualTo(1)); - // act - write data to trigger trim - for (int i = 0; i < 200; i++) { - os.write(42); + // cleanup + sb.removeTrimEndSignal(trimEnded); } - // assert - trim end signal was released (has permits available) - assertThat(trimEnded.availablePermits(), greaterThanOrEqualTo(1)); + @DisplayName("isTrimRunning(): true when trim start signal fires") + @Test + public void isTrimRunning_trueWhenTrimStartSignalFires() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final AtomicBoolean trimWasRunning = new AtomicBoolean(false); + + // Create custom semaphore to capture state when trim starts + final Semaphore trimStartObserver = new Semaphore(0) { + @Override + public void release() { + // Called when trim starts - check flag is true + trimWasRunning.set(sb.isTrimRunning()); + super.release(); + } + }; - // cleanup - sb.removeTrimEndSignal(trimEnded); - } + sb.addTrimStartSignal(trimStartObserver); + sb.setMaxBufferElements(1); + final OutputStream os = sb.getOutputStream(); - @DisplayName("isTrimRunning(): true when trim start signal fires") - @Test - public void isTrimRunning_trueWhenTrimStartSignalFires() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final AtomicBoolean trimWasRunning = new AtomicBoolean(false); - - // Create custom semaphore to capture state when trim starts - final Semaphore trimStartObserver = new Semaphore(0) { - @Override - public void release() { - // Called when trim starts - check flag is true - trimWasRunning.set(sb.isTrimRunning()); - super.release(); + // act - trigger trim by writing enough data + for (int i = 0; i < 200; i++) { + os.write(42); } - }; - sb.addTrimStartSignal(trimStartObserver); - sb.setMaxBufferElements(1); - final OutputStream os = sb.getOutputStream(); + // assert - isTrimRunning was true when trim start signal fired + assertThat(trimWasRunning.get(), is(true)); // Kills: isTrimRunning returned false - // act - trigger trim by writing enough data - for (int i = 0; i < 200; i++) { - os.write(42); + // cleanup + sb.removeTrimStartSignal(trimStartObserver); } - // assert - isTrimRunning was true when trim start signal fired - assertThat(trimWasRunning.get(), is(true)); // Kills: isTrimRunning returned false + @DisplayName("isTrimRunning(): flag visible via observer") + @Test + public void isTrimRunning_flagVisibleViaObserver() throws IOException, InterruptedException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + + final AtomicBoolean trimWasObserved = new AtomicBoolean(false); + + // Register observer that confirms trim ran + final Semaphore trimEndObserver = new Semaphore(0) { + @Override + public void release() { + // If we got here, trim completed successfully + trimWasObserved.set(true); + super.release(); + } + }; - // cleanup - sb.removeTrimStartSignal(trimStartObserver); - } + sb.addTrimEndSignal(trimEndObserver); + sb.setMaxBufferElements(1); - @DisplayName("isTrimRunning(): flag visible via observer") - @Test - public void isTrimRunning_flagVisibleViaObserver() throws IOException, InterruptedException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - - final AtomicBoolean trimWasObserved = new AtomicBoolean(false); - - // Register observer that confirms trim ran - final Semaphore trimEndObserver = new Semaphore(0) { - @Override - public void release() { - // If we got here, trim completed successfully - trimWasObserved.set(true); - super.release(); + // act - write data to force trim + for (int i = 0; i < 200; i++) { + os.write(42); } - }; - sb.addTrimEndSignal(trimEndObserver); - sb.setMaxBufferElements(1); + // assert - trim was observed and completed + assertThat(trimWasObserved.get(), is(true)); // Proves trim ran and flag was managed correctly - // act - write data to force trim - for (int i = 0; i < 200; i++) { - os.write(42); + // cleanup + sb.removeTrimEndSignal(trimEndObserver); } - // assert - trim was observed and completed - assertThat(trimWasObserved.get(), is(true)); // Proves trim ran and flag was managed correctly + @DisplayName("trimSignals(): can be added and removed") + @Test + public void trimSignals_canBeAddedAndRemoved() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final Semaphore signal = new Semaphore(0); + + // act & assert - add and remove trim start signal + sb.addTrimStartSignal(signal); + assertThat(sb.removeTrimStartSignal(signal), is(true)); + assertThat(sb.removeTrimStartSignal(signal), is(false)); + + // act & assert - add and remove trim end signal + sb.addTrimEndSignal(signal); + assertThat(sb.removeTrimEndSignal(signal), is(true)); + assertThat(sb.removeTrimEndSignal(signal), is(false)); + } - // cleanup - sb.removeTrimEndSignal(trimEndObserver); - } + @DisplayName("trimSignals(): null throws exception") + @Test + public void trimSignals_nullThrowsException() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("trimSignals(): can be added and removed") - @Test - public void trimSignals_canBeAddedAndRemoved() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final Semaphore signal = new Semaphore(0); - - // act & assert - add and remove trim start signal - sb.addTrimStartSignal(signal); - assertThat(sb.removeTrimStartSignal(signal), is(true)); - assertThat(sb.removeTrimStartSignal(signal), is(false)); - - // act & assert - add and remove trim end signal - sb.addTrimEndSignal(signal); - assertThat(sb.removeTrimEndSignal(signal), is(true)); - assertThat(sb.removeTrimEndSignal(signal), is(false)); - } + // act & assert - addTrimStartSignal with null + assertThrows(NullPointerException.class, () -> sb.addTrimStartSignal(null)); - @DisplayName("trimSignals(): null throws exception") - @Test - public void trimSignals_nullThrowsException() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // act & assert - addTrimEndSignal with null + assertThrows(NullPointerException.class, () -> sb.addTrimEndSignal(null)); + } - // act & assert - addTrimStartSignal with null - assertThrows(NullPointerException.class, () -> sb.addTrimStartSignal(null)); + /** + * CRITICAL TEST: Exception during trim start signal release doesn't deadlock stream + * + * REQUIREMENT: If releaseTrimStartSignals() throws an exception (line 442), + * the isTrimRunning flag MUST still be properly managed and the stream MUST recover. + * Without this, the flag stays true forever, blocking all future trim operations. + * + * IMPLEMENTATION RISK: + * releaseTrimStartSignals() is called OUTSIDE the try block: + * ``` + * if (isTrimShouldBeExecuted()) { + * isTrimRunning = true; + * releaseTrimStartSignals(); // ← OUTSIDE try-finally (line 442) + * try { + * // trim logic + * } finally { + * isTrimRunning = false; + * } + * } + * ``` + * + * If releaseTrimStartSignals() throws: + * - isTrimRunning stays true (never reset) + * - Subsequent trim attempts blocked + * - Stream enters permanent deadlock + * + * TEST APPROACH: + * 1. Create custom semaphore that throws on release() + * 2. Add as trim start signal to trigger exception during trim + * 3. Trigger trim by writing data + * 4. Verify stream recovers (can still write/read, trim flag reset) + * 5. Verify second trim can execute (not deadlocked) + */ + /** + * CRITICAL TEST: Exception during trim start signal release doesn't deadlock stream + * + * REQUIREMENT: If releaseTrimStartSignals() throws an exception, + * the isTrimRunning flag MUST still be properly managed via finally block. + * Without this, the flag stays true forever, blocking all future trim operations. + * + * IMPLEMENTATION FIX (applied): + * Moved releaseTrimStartSignals() INSIDE the try-finally block (line 443): + * ``` + * try { + * releaseTrimStartSignals(); // ← NOW INSIDE try-finally + * // trim logic + * } finally { + * isTrimRunning = false; // ← Always executes + * } + * ``` + * + * TEST APPROACH: + * 1. Set up buffer with enough data to trigger trim on next write + * 2. Add throwing semaphore AFTER data setup (so initial setup doesn't fire trim) + * 3. Trigger trim by writing one more element → signal release throws + * 4. Catch the exception outside of assertAll + * 5. Verify recovery: flag reset, stream still usable, exception was thrown + */ + @DisplayName("trim(): signal release exception during start — stream recoverable") + @Test + public void trim_signalReleaseExceptionDuringStart_streamRecoverable() throws IOException { + // arrange — Track when throwing semaphore is called + final AtomicBoolean exceptionThrown = new AtomicBoolean(false); + final Semaphore throwingSemaphore = new Semaphore(0) { + @Override + public void release() { + exceptionThrown.set(true); + throw new RuntimeException("Simulated signal release failure"); + } + }; - // act & assert - addTrimEndSignal with null - assertThrows(NullPointerException.class, () -> sb.addTrimEndSignal(null)); - } + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); - /** - * CRITICAL TEST: Exception during trim start signal release doesn't deadlock stream - * - * REQUIREMENT: If releaseTrimStartSignals() throws an exception (line 442), - * the isTrimRunning flag MUST still be properly managed and the stream MUST recover. - * Without this, the flag stays true forever, blocking all future trim operations. - * - * IMPLEMENTATION RISK: - * releaseTrimStartSignals() is called OUTSIDE the try block: - * ``` - * if (isTrimShouldBeExecuted()) { - * isTrimRunning = true; - * releaseTrimStartSignals(); // ← OUTSIDE try-finally (line 442) - * try { - * // trim logic - * } finally { - * isTrimRunning = false; - * } - * } - * ``` - * - * If releaseTrimStartSignals() throws: - * - isTrimRunning stays true (never reset) - * - Subsequent trim attempts blocked - * - Stream enters permanent deadlock - * - * TEST APPROACH: - * 1. Create custom semaphore that throws on release() - * 2. Add as trim start signal to trigger exception during trim - * 3. Trigger trim by writing data - * 4. Verify stream recovers (can still write/read, trim flag reset) - * 5. Verify second trim can execute (not deadlocked) - */ - /** - * CRITICAL TEST: Exception during trim start signal release doesn't deadlock stream - * - * REQUIREMENT: If releaseTrimStartSignals() throws an exception, - * the isTrimRunning flag MUST still be properly managed via finally block. - * Without this, the flag stays true forever, blocking all future trim operations. - * - * IMPLEMENTATION FIX (applied): - * Moved releaseTrimStartSignals() INSIDE the try-finally block (line 443): - * ``` - * try { - * releaseTrimStartSignals(); // ← NOW INSIDE try-finally - * // trim logic - * } finally { - * isTrimRunning = false; // ← Always executes - * } - * ``` - * - * TEST APPROACH: - * 1. Set up buffer with enough data to trigger trim on next write - * 2. Add throwing semaphore AFTER data setup (so initial setup doesn't fire trim) - * 3. Trigger trim by writing one more element → signal release throws - * 4. Catch the exception outside of assertAll - * 5. Verify recovery: flag reset, stream still usable, exception was thrown - */ - @DisplayName("trim(): signal release exception during start — stream recoverable") - @Test - public void trim_signalReleaseExceptionDuringStart_streamRecoverable() throws IOException { - // arrange — Track when throwing semaphore is called - final AtomicBoolean exceptionThrown = new AtomicBoolean(false); - final Semaphore throwingSemaphore = new Semaphore(0) { - @Override - public void release() { - exceptionThrown.set(true); - throw new RuntimeException("Simulated signal release failure"); + // Set a high threshold initially so setup writes don't trigger trim + sb.setMaxBufferElements(1000); + + // Write data to build up buffer (no trim yet) + byte[] testData = new byte[100]; + Arrays.fill(testData, (byte) 42); + for (int i = 0; i < 50; i++) { + os.write(testData); } - }; - - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // Set a high threshold initially so setup writes don't trigger trim - sb.setMaxBufferElements(1000); - - // Write data to build up buffer (no trim yet) - byte[] testData = new byte[100]; - Arrays.fill(testData, (byte) 42); - for (int i = 0; i < 50; i++) { - os.write(testData); - } - - // NOW add the throwing semaphore and lower threshold to trigger trim on next write - sb.addTrimStartSignal(throwingSemaphore); - sb.setMaxBufferElements(5); - - // act — Trigger trim (next write exceeds maxBufferElements → trim → throws) - RuntimeException caughtException = null; - try { - os.write(testData); - } catch (RuntimeException e) { - caughtException = e; - } - - // Remove throwing semaphore so recovery tests don't retrigger exception - sb.removeTrimStartSignal(throwingSemaphore); - - // assert — Verify exception occurred and stream recovered - final RuntimeException finalCaught = caughtException; - assertAll( - () -> { - // Exception was thrown from signal release - assertThat("Signal release exception should propagate", - finalCaught, not((RuntimeException) null)); - assertThat("Exception message correct", - finalCaught.getMessage(), is("Simulated signal release failure")); - }, - () -> { - // Throwing semaphore was actually invoked - assertThat("Throwing semaphore's release() should have been called", - exceptionThrown.get(), is(true)); - }, - () -> { - // CRITICAL: isTrimRunning must be false (finally block executed) - // If the fix weren't applied, this would be true → deadlock - assertThat("isTrimRunning must be false after exception (finally executed)", - sb.isTrimRunning(), is(false)); - }, - () -> { - // Stream still usable - can write - byte[] moreData = new byte[50]; - Arrays.fill(moreData, (byte) 99); - os.write(moreData); - }, - () -> { - // Stream still usable - can read - byte[] buffer = new byte[100]; - int bytesRead = is.read(buffer); - assertThat("Should be able to read after signal exception", - bytesRead, greaterThan(0)); + + // NOW add the throwing semaphore and lower threshold to trigger trim on next write + sb.addTrimStartSignal(throwingSemaphore); + sb.setMaxBufferElements(5); + + // act — Trigger trim (next write exceeds maxBufferElements → trim → throws) + RuntimeException caughtException = null; + try { + os.write(testData); + } catch (RuntimeException e) { + caughtException = e; } - ); - } - /** - * CRITICAL TEST: Exception during trim write phase resets ignoreSafeWrite flag - * - * REQUIREMENT: If IOException occurs while trim is writing consolidated data back, - * the ignoreSafeWrite flag MUST be reset in the finally block (lines 476-478). - * Without this, external code could mutate the buffer while ignoreSafeWrite is true, - * causing data corruption or unsafe behavior. - * - * IMPLEMENTATION FIX (already in place): - * Nested try-finally protects the ignoreSafeWrite flag: - * ``` - * try { - * ignoreSafeWrite = true; - * while (!tmpBuffer.isEmpty()) { - * os.write(tmpBuffer.pollFirst()); // ← If IOException here - * } - * } finally { - * ignoreSafeWrite = false; // ← Always executes - * } - * ``` - * - * TEST APPROACH: - * 1. Create custom StreamBuffer that returns throwing OutputStream - * 2. Set up conditions to trigger trim - * 3. Add throwing semaphore to force trim execution - * 4. When trim runs and calls os.write() on consolidated data, throws - * 5. Verify ignoreSafeWrite is reset despite exception - * 6. Verify stream still usable (flag not stuck in true state) - */ - @DisplayName("trim(): ignore safe write flag reset during write exception — stream recoverable") - @Test - public void trim_ignoreSafeWriteFlagResetDuringWriteException_streamRecoverable() throws IOException { - // arrange — Custom StreamBuffer with throwing output stream - class FailingWriteStreamBuffer extends StreamBuffer { - private boolean shouldThrowOnWrite = false; - - @Override - public OutputStream getOutputStream() { - final OutputStream wrapped = super.getOutputStream(); - return new OutputStream() { - @Override - public void write(int b) throws IOException { - if (shouldThrowOnWrite) { - throw new IOException("Simulated write failure during trim consolidation"); + // Remove throwing semaphore so recovery tests don't retrigger exception + sb.removeTrimStartSignal(throwingSemaphore); + + // assert — Verify exception occurred and stream recovered + final RuntimeException finalCaught = caughtException; + assertAll( + () -> { + // Exception was thrown from signal release + assertThat( + "Signal release exception should propagate", finalCaught, not((RuntimeException) null)); + assertThat( + "Exception message correct", + finalCaught.getMessage(), + is("Simulated signal release failure")); + }, + () -> { + // Throwing semaphore was actually invoked + assertThat( + "Throwing semaphore's release() should have been called", + exceptionThrown.get(), + is(true)); + }, + () -> { + // CRITICAL: isTrimRunning must be false (finally block executed) + // If the fix weren't applied, this would be true → deadlock + assertThat( + "isTrimRunning must be false after exception (finally executed)", + sb.isTrimRunning(), + is(false)); + }, + () -> { + // Stream still usable - can write + byte[] moreData = new byte[50]; + Arrays.fill(moreData, (byte) 99); + os.write(moreData); + }, + () -> { + // Stream still usable - can read + byte[] buffer = new byte[100]; + int bytesRead = is.read(buffer); + assertThat("Should be able to read after signal exception", bytesRead, greaterThan(0)); + }); + } + + /** + * CRITICAL TEST: Exception during trim write phase resets ignoreSafeWrite flag + * + * REQUIREMENT: If IOException occurs while trim is writing consolidated data back, + * the ignoreSafeWrite flag MUST be reset in the finally block (lines 476-478). + * Without this, external code could mutate the buffer while ignoreSafeWrite is true, + * causing data corruption or unsafe behavior. + * + * IMPLEMENTATION FIX (already in place): + * Nested try-finally protects the ignoreSafeWrite flag: + * ``` + * try { + * ignoreSafeWrite = true; + * while (!tmpBuffer.isEmpty()) { + * os.write(tmpBuffer.pollFirst()); // ← If IOException here + * } + * } finally { + * ignoreSafeWrite = false; // ← Always executes + * } + * ``` + * + * TEST APPROACH: + * 1. Create custom StreamBuffer that returns throwing OutputStream + * 2. Set up conditions to trigger trim + * 3. Add throwing semaphore to force trim execution + * 4. When trim runs and calls os.write() on consolidated data, throws + * 5. Verify ignoreSafeWrite is reset despite exception + * 6. Verify stream still usable (flag not stuck in true state) + */ + @DisplayName("trim(): ignore safe write flag reset during write exception — stream recoverable") + @Test + public void trim_ignoreSafeWriteFlagResetDuringWriteException_streamRecoverable() throws IOException { + // arrange — Custom StreamBuffer with throwing output stream + class FailingWriteStreamBuffer extends StreamBuffer { + private boolean shouldThrowOnWrite = false; + + @Override + public OutputStream getOutputStream() { + final OutputStream wrapped = super.getOutputStream(); + return new OutputStream() { + @Override + public void write(int b) throws IOException { + if (shouldThrowOnWrite) { + throw new IOException("Simulated write failure during trim consolidation"); + } + wrapped.write(b); } - wrapped.write(b); - } - @Override - public void write(byte[] b, int off, int len) throws IOException { - if (shouldThrowOnWrite) { - throw new IOException("Simulated write failure during trim consolidation"); + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (shouldThrowOnWrite) { + throw new IOException("Simulated write failure during trim consolidation"); + } + wrapped.write(b, off, len); } - wrapped.write(b, off, len); - } - @Override - public void close() throws IOException { - wrapped.close(); - } - }; + @Override + public void close() throws IOException { + wrapped.close(); + } + }; + } } - } - final FailingWriteStreamBuffer sb = new FailingWriteStreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // Set high threshold initially — no trim during setup - sb.setMaxBufferElements(1000); - - // Write data to build up buffer (no trim yet) - byte[] testData = new byte[100]; - Arrays.fill(testData, (byte) 42); - for (int i = 0; i < 50; i++) { - os.write(testData); - } - - // Enable throwing behavior and lower threshold to trigger trim on next write - sb.shouldThrowOnWrite = true; - sb.setMaxBufferElements(5); - - // act — Trigger trim write phase with exception - IOException caughtException = null; - try { - os.write(testData); - } catch (IOException e) { - caughtException = e; - } - - // Disable throwing for recovery tests - sb.shouldThrowOnWrite = false; - - // assert — Verify exception occurred and ignoreSafeWrite was reset - final IOException finalException = caughtException; - assertAll( - () -> { - // IOException was thrown from write phase - assertThat("Write phase exception should be thrown", - finalException, not((IOException) null)); - assertThat("Exception message correct", - finalException.getMessage(), - is("Simulated write failure during trim consolidation")); - }, - () -> { - // CRITICAL: ignoreSafeWrite must be false (finally executed) - // If the flag stayed true, external code could unsafely mutate buffer - // Check by attempting a write with safeWrite enabled - sb.setSafeWrite(true); - byte[] safeData = new byte[50]; - Arrays.fill(safeData, (byte) 99); - os.write(safeData); // Should use safe write (not throw) - // If ignoreSafeWrite was stuck true, this would have different behavior - }, - () -> { - // Stream still usable - can write - byte[] moreData = new byte[50]; - Arrays.fill(moreData, (byte) 88); - os.write(moreData); - }, - () -> { - // Stream still usable - can read - byte[] buffer = new byte[100]; - int bytesRead = is.read(buffer); - assertThat("Should be able to read after write exception", - bytesRead, greaterThan(0)); - } - ); - } + final FailingWriteStreamBuffer sb = new FailingWriteStreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); - /** - * CRITICAL TEST: Exception during trim end signal release - flag already reset but exception propagates - * - * REQUIREMENT: If exception occurs in releaseTrimEndSignals() (line 481 finally block), - * the isTrimRunning flag MUST already be false (line 480 executes first). - * However, the exception can suppress notification to signal observers. - * - * IMPLEMENTATION ANALYSIS: - * Finally block order matters: - * ``` - * } finally { - * isTrimRunning = false; // ← Line 480: executes FIRST - * releaseTrimEndSignals(); // ← Line 481: executes SECOND - * } - * ``` - * - * Key difference from trim start test: - * - Trim start exception (line 443): flag ALREADY true, exception before reset → DANGEROUS - * - Trim end exception (line 481): flag ALREADY reset, exception after → SAFE for flag - * - But: Signal observers may not be notified if exception occurs - * - * TEST APPROACH: - * 1. Create throwing semaphore for trim end signal - * 2. Setup: high threshold, write data, add throwing signal - * 3. Lower threshold and write more → trim runs → signal release throws - * 4. Verify: - * - isTrimRunning is false (flag was reset before exception) - * - Exception propagates to caller (signal notification failure) - * - Stream still works (exception doesn't break stream state) - */ - @DisplayName("trim(): signal release exception during end — flag already reset exception propagates") - @Test - public void trim_signalReleaseExceptionDuringEnd_flagAlreadyResetExceptionPropagates() throws IOException { - // arrange — Create throwing semaphore for trim end signal - final AtomicBoolean endSignalCalled = new AtomicBoolean(false); - final Semaphore throwingEndSemaphore = new Semaphore(0) { - @Override - public void release() { - endSignalCalled.set(true); - throw new RuntimeException("Simulated trim end signal release failure"); - } - }; - - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // Set high threshold initially — no trim during setup - sb.setMaxBufferElements(1000); - - // Write data to build up buffer (no trim yet) - byte[] testData = new byte[100]; - Arrays.fill(testData, (byte) 42); - for (int i = 0; i < 50; i++) { - os.write(testData); - } - - // NOW add the throwing trim end signal and lower threshold to trigger trim on next write - sb.addTrimEndSignal(throwingEndSemaphore); - sb.setMaxBufferElements(5); - - // act — Trigger trim (next write exceeds maxBufferElements → trim → signal release throws) - RuntimeException caughtException = null; - try { - os.write(testData); - } catch (RuntimeException e) { - caughtException = e; - } - - // Remove throwing semaphore so subsequent operations don't retrigger exception - sb.removeTrimEndSignal(throwingEndSemaphore); - - // assert — Verify exception occurred and stream recovered - final RuntimeException finalCaught = caughtException; - assertAll( - () -> { - // Exception was thrown from trim end signal release - assertThat("Trim end signal release exception should propagate", - finalCaught, not((RuntimeException) null)); - assertThat("Exception message correct", - finalCaught.getMessage(), is("Simulated trim end signal release failure")); - }, - () -> { - // Throwing end semaphore was actually invoked - assertThat("Throwing trim end semaphore's release() should have been called", - endSignalCalled.get(), is(true)); - }, - () -> { - // CRITICAL: isTrimRunning must be false (flag was reset BEFORE signal exception) - // Line 480 executes before line 481, so flag is already false - // This is different from trim start where flag would be stuck true - assertThat("isTrimRunning must be false (flag reset before signal release)", - sb.isTrimRunning(), is(false)); - }, - () -> { - // Stream still usable - can write (trim end exception doesn't break stream) - byte[] moreData = new byte[50]; - Arrays.fill(moreData, (byte) 99); - os.write(moreData); - }, - () -> { - // Stream still usable - can read - byte[] buffer = new byte[100]; - int bytesRead = is.read(buffer); - assertThat("Should be able to read after trim end signal exception", - bytesRead, greaterThan(0)); + // Set high threshold initially — no trim during setup + sb.setMaxBufferElements(1000); + + // Write data to build up buffer (no trim yet) + byte[] testData = new byte[100]; + Arrays.fill(testData, (byte) 42); + for (int i = 0; i < 50; i++) { + os.write(testData); } - ); - } - /** - * CRITICAL TEST: Close called while trim is active - race condition safety - * - * REQUIREMENT: If close() is called while trim() is executing, - * both methods must complete safely without exceptions, deadlocks, - * or data corruption. Both use bufferLock synchronization. - * - * IMPLEMENTATION ANALYSIS: - * Race condition scenario: - * - Thread 1: trim() acquired bufferLock, reading/writing internal streams - * - Thread 2: close() calls bufferLock, closes output/input streams - * - * Both methods synchronize on bufferLock: - * - trim() (line 443-484): synchronized operations on is/os - * - close() (line 995-1010): closes streams, synchronizes access - * - * Potential issues: - * - close() could close streams while trim() is using them - * - Could cause IOException during trim read/write - * - Could cause NullPointerException if streams become null - * - Could lose signal notifications if close() interrupts trim - * - * TEST APPROACH: - * 1. Use ExecutorService to run threads concurrently - * 2. Thread 1: Write large data to trigger trim (will take time) - * 3. Use CountDownLatch to synchronize: wait for trim to start - * 4. Thread 2: Call close() after trim has started - * 5. Both threads should complete without exceptions - * 6. Verify: no exceptions, stream closed, data can still be read - */ - @DisplayName("trim(): close called during trim — handles gracefully") - @Test - public void trim_closeCalledDuringTrim_handlesGracefully() throws IOException, InterruptedException { - // arrange — Setup concurrent test infrastructure - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - final ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(2); - - // Use latch to coordinate: signal when trim starts - final CountDownLatch trimStarted = new CountDownLatch(1); - final Semaphore trimStartSignal = new Semaphore(0) { - @Override - public void release() { - trimStarted.countDown(); // Signal that trim is executing - super.release(); + // Enable throwing behavior and lower threshold to trigger trim on next write + sb.shouldThrowOnWrite = true; + sb.setMaxBufferElements(5); + + // act — Trigger trim write phase with exception + IOException caughtException = null; + try { + os.write(testData); + } catch (IOException e) { + caughtException = e; } - }; - - sb.addTrimStartSignal(trimStartSignal); - sb.setMaxBufferElements(5); - - // Create large data to trigger trim consolidation (takes time) - byte[] largeData = new byte[1000]; - Arrays.fill(largeData, (byte) 42); - - // Track exceptions from threads - final AtomicReference thread1Exception = new AtomicReference<>(null); - final AtomicReference thread2Exception = new AtomicReference<>(null); - - try { - // act — Thread 1: Write data to trigger trim. - // Once close() races in, writes correctly throw IOException("Stream closed.") - // from the public requireNonClosed() guard — that is expected behaviour and - // must not be treated as a test failure. Only unexpected exceptions are - // captured so the assertion below stays meaningful. - java.util.concurrent.Future trimTask = executor.submit(() -> { - for (int i = 0; i < 100; i++) { - try { - os.write(largeData); - } catch (IOException e) { - if ("Stream closed.".equals(e.getMessage())) { - break; // expected: close() raced ahead — not a bug - } - thread1Exception.set(e); - break; - } catch (Exception e) { - thread1Exception.set(e); - break; - } - } - }); - // Wait for trim to actually start executing - boolean trimStartedInTime = trimStarted.await(5, java.util.concurrent.TimeUnit.SECONDS); - assertThat("Trim should start within timeout", trimStartedInTime, is(true)); + // Disable throwing for recovery tests + sb.shouldThrowOnWrite = false; - // act — Thread 2: Call close() while trim is running - java.util.concurrent.Future closeTask = executor.submit(() -> { - try { - sb.close(); - } catch (Exception e) { - thread2Exception.set(e); + // assert — Verify exception occurred and ignoreSafeWrite was reset + final IOException finalException = caughtException; + assertAll( + () -> { + // IOException was thrown from write phase + assertThat("Write phase exception should be thrown", finalException, not((IOException) null)); + assertThat( + "Exception message correct", + finalException.getMessage(), + is("Simulated write failure during trim consolidation")); + }, + () -> { + // CRITICAL: ignoreSafeWrite must be false (finally executed) + // If the flag stayed true, external code could unsafely mutate buffer + // Check by attempting a write with safeWrite enabled + sb.setSafeWrite(true); + byte[] safeData = new byte[50]; + Arrays.fill(safeData, (byte) 99); + os.write(safeData); // Should use safe write (not throw) + // If ignoreSafeWrite was stuck true, this would have different behavior + }, + () -> { + // Stream still usable - can write + byte[] moreData = new byte[50]; + Arrays.fill(moreData, (byte) 88); + os.write(moreData); + }, + () -> { + // Stream still usable - can read + byte[] buffer = new byte[100]; + int bytesRead = is.read(buffer); + assertThat("Should be able to read after write exception", bytesRead, greaterThan(0)); + }); + } + + /** + * CRITICAL TEST: Exception during trim end signal release - flag already reset but exception propagates + * + * REQUIREMENT: If exception occurs in releaseTrimEndSignals() (line 481 finally block), + * the isTrimRunning flag MUST already be false (line 480 executes first). + * However, the exception can suppress notification to signal observers. + * + * IMPLEMENTATION ANALYSIS: + * Finally block order matters: + * ``` + * } finally { + * isTrimRunning = false; // ← Line 480: executes FIRST + * releaseTrimEndSignals(); // ← Line 481: executes SECOND + * } + * ``` + * + * Key difference from trim start test: + * - Trim start exception (line 443): flag ALREADY true, exception before reset → DANGEROUS + * - Trim end exception (line 481): flag ALREADY reset, exception after → SAFE for flag + * - But: Signal observers may not be notified if exception occurs + * + * TEST APPROACH: + * 1. Create throwing semaphore for trim end signal + * 2. Setup: high threshold, write data, add throwing signal + * 3. Lower threshold and write more → trim runs → signal release throws + * 4. Verify: + * - isTrimRunning is false (flag was reset before exception) + * - Exception propagates to caller (signal notification failure) + * - Stream still works (exception doesn't break stream state) + */ + @DisplayName("trim(): signal release exception during end — flag already reset exception propagates") + @Test + public void trim_signalReleaseExceptionDuringEnd_flagAlreadyResetExceptionPropagates() throws IOException { + // arrange — Create throwing semaphore for trim end signal + final AtomicBoolean endSignalCalled = new AtomicBoolean(false); + final Semaphore throwingEndSemaphore = new Semaphore(0) { + @Override + public void release() { + endSignalCalled.set(true); + throw new RuntimeException("Simulated trim end signal release failure"); } - }); + }; + + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // Set high threshold initially — no trim during setup + sb.setMaxBufferElements(1000); + + // Write data to build up buffer (no trim yet) + byte[] testData = new byte[100]; + Arrays.fill(testData, (byte) 42); + for (int i = 0; i < 50; i++) { + os.write(testData); + } + + // NOW add the throwing trim end signal and lower threshold to trigger trim on next write + sb.addTrimEndSignal(throwingEndSemaphore); + sb.setMaxBufferElements(5); - // Wait for both tasks to complete + // act — Trigger trim (next write exceeds maxBufferElements → trim → signal release throws) + RuntimeException caughtException = null; try { - trimTask.get(10, java.util.concurrent.TimeUnit.SECONDS); - closeTask.get(10, java.util.concurrent.TimeUnit.SECONDS); - } catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException e) { - // Task threw exception or timed out (already captured in thread*Exception) + os.write(testData); + } catch (RuntimeException e) { + caughtException = e; } - // assert — No exceptions from either thread + // Remove throwing semaphore so subsequent operations don't retrigger exception + sb.removeTrimEndSignal(throwingEndSemaphore); + + // assert — Verify exception occurred and stream recovered + final RuntimeException finalCaught = caughtException; assertAll( - () -> { - assertThat("Trim should not throw exception", - thread1Exception.get(), is((Exception) null)); - }, - () -> { - assertThat("Close should not throw exception", - thread2Exception.get(), is((Exception) null)); - }, - () -> { - // Verify stream closed properly - assertThat("Stream should be closed", - sb.isClosed(), is(true)); - }, - () -> { - // Verify data can still be read (no corruption) - byte[] buffer = new byte[1000]; - int bytesRead = 0; - int totalRead = 0; - try { - while ((bytesRead = is.read(buffer)) >= 0 && totalRead < 100000) { - totalRead += bytesRead; + () -> { + // Exception was thrown from trim end signal release + assertThat( + "Trim end signal release exception should propagate", + finalCaught, + not((RuntimeException) null)); + assertThat( + "Exception message correct", + finalCaught.getMessage(), + is("Simulated trim end signal release failure")); + }, + () -> { + // Throwing end semaphore was actually invoked + assertThat( + "Throwing trim end semaphore's release() should have been called", + endSignalCalled.get(), + is(true)); + }, + () -> { + // CRITICAL: isTrimRunning must be false (flag was reset BEFORE signal exception) + // Line 480 executes before line 481, so flag is already false + // This is different from trim start where flag would be stuck true + assertThat( + "isTrimRunning must be false (flag reset before signal release)", + sb.isTrimRunning(), + is(false)); + }, + () -> { + // Stream still usable - can write (trim end exception doesn't break stream) + byte[] moreData = new byte[50]; + Arrays.fill(moreData, (byte) 99); + os.write(moreData); + }, + () -> { + // Stream still usable - can read + byte[] buffer = new byte[100]; + int bytesRead = is.read(buffer); + assertThat("Should be able to read after trim end signal exception", bytesRead, greaterThan(0)); + }); + } + + /** + * CRITICAL TEST: Close called while trim is active - race condition safety + * + * REQUIREMENT: If close() is called while trim() is executing, + * both methods must complete safely without exceptions, deadlocks, + * or data corruption. Both use bufferLock synchronization. + * + * IMPLEMENTATION ANALYSIS: + * Race condition scenario: + * - Thread 1: trim() acquired bufferLock, reading/writing internal streams + * - Thread 2: close() calls bufferLock, closes output/input streams + * + * Both methods synchronize on bufferLock: + * - trim() (line 443-484): synchronized operations on is/os + * - close() (line 995-1010): closes streams, synchronizes access + * + * Potential issues: + * - close() could close streams while trim() is using them + * - Could cause IOException during trim read/write + * - Could cause NullPointerException if streams become null + * - Could lose signal notifications if close() interrupts trim + * + * TEST APPROACH: + * 1. Use ExecutorService to run threads concurrently + * 2. Thread 1: Write large data to trigger trim (will take time) + * 3. Use CountDownLatch to synchronize: wait for trim to start + * 4. Thread 2: Call close() after trim has started + * 5. Both threads should complete without exceptions + * 6. Verify: no exceptions, stream closed, data can still be read + */ + @DisplayName("trim(): close called during trim — handles gracefully") + @Test + public void trim_closeCalledDuringTrim_handlesGracefully() throws IOException, InterruptedException { + // arrange — Setup concurrent test infrastructure + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + final ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(2); + + // Use latch to coordinate: signal when trim starts + final CountDownLatch trimStarted = new CountDownLatch(1); + final Semaphore trimStartSignal = new Semaphore(0) { + @Override + public void release() { + trimStarted.countDown(); // Signal that trim is executing + super.release(); + } + }; + + sb.addTrimStartSignal(trimStartSignal); + sb.setMaxBufferElements(5); + + // Create large data to trigger trim consolidation (takes time) + byte[] largeData = new byte[1000]; + Arrays.fill(largeData, (byte) 42); + + // Track exceptions from threads + final AtomicReference thread1Exception = new AtomicReference<>(null); + final AtomicReference thread2Exception = new AtomicReference<>(null); + + try { + // act — Thread 1: Write data to trigger trim. + // Once close() races in, writes correctly throw IOException("Stream closed.") + // from the public requireNonClosed() guard — that is expected behaviour and + // must not be treated as a test failure. Only unexpected exceptions are + // captured so the assertion below stays meaningful. + java.util.concurrent.Future trimTask = executor.submit(() -> { + for (int i = 0; i < 100; i++) { + try { + os.write(largeData); + } catch (IOException e) { + if ("Stream closed.".equals(e.getMessage())) { + break; // expected: close() raced ahead — not a bug + } + thread1Exception.set(e); + break; + } catch (Exception e) { + thread1Exception.set(e); + break; } - assertThat("Should be able to read data despite concurrent close", - totalRead, greaterThan(0)); - } catch (IOException ignored) { - // Reading from closed stream is acceptable } - } - ); + }); - } finally { - executor.shutdownNow(); - sb.removeTrimStartSignal(trimStartSignal); - } - } + // Wait for trim to actually start executing + boolean trimStartedInTime = trimStarted.await(5, java.util.concurrent.TimeUnit.SECONDS); + assertThat("Trim should start within timeout", trimStartedInTime, is(true)); - /** - * DETERMINISTIC reproduction of the race condition exercised flakily by - * {@link #trim_closeCalledDuringTrim_handlesGracefully()}. - * - *

Strategy: hook {@code addTrimStartSignal} with a semaphore whose - * {@code release()} synchronously calls {@code sb.close()}. The hook runs - * on the trim thread itself, BEFORE the read/write-back phases. By the - * time trim reaches its write-back phase, {@code streamClosed} is already - * {@code true}, so any internal {@code os.write()} call inside trim will - * fail at {@code requireNonClosed()}. - * - *

This is single-threaded — no executor, no latches, no sleeps, - * no thread-scheduling timing windows. - * - *

Asserts the two real bugs: - *

    - *
  1. The triggering write must not throw — trim's internal - * reorganization should be insulated from a concurrent close.
  2. - *
  3. No data loss — every byte written before the close must remain - * readable from the input stream.
  4. - *
- */ - @DisplayName("trim(): close during trim — write-back must not throw and must not lose data") - @Test - public void trim_closeDuringTrim_writeBackDoesNotThrowAndPreservesData() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // Hook fires synchronously on the trim thread, BEFORE read/write-back. - // Calling close() here makes streamClosed=true deterministically before - // trim's internal os.write() runs requireNonClosed(). - final Semaphore closeOnTrimStart = new Semaphore(0) { - private boolean fired = false; - @Override - public void release() { - if (!fired) { - fired = true; + // act — Thread 2: Call close() while trim is running + java.util.concurrent.Future closeTask = executor.submit(() -> { try { sb.close(); - } catch (IOException e) { - throw new RuntimeException(e); + } catch (Exception e) { + thread2Exception.set(e); } + }); + + // Wait for both tasks to complete + try { + trimTask.get(10, java.util.concurrent.TimeUnit.SECONDS); + closeTask.get(10, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException e) { + // Task threw exception or timed out (already captured in thread*Exception) } - super.release(); - } - }; - sb.addTrimStartSignal(closeOnTrimStart); - sb.setMaxBufferElements(2); - - // Pre-populate buffer with two chunks (at limit, no trim yet). - os.write(new byte[]{1, 2, 3}); - os.write(new byte[]{4, 5, 6}); - - // act — this third write pushes element count over maxBufferElements, - // trim() runs, hook closes the stream, write-back hits the bug. - IOException thrown = null; - try { - os.write(new byte[]{7, 8, 9}); - } catch (IOException e) { - thrown = e; - } - - // assert — bug 1: trim's internal write-back must not surface as a - // user-visible IOException on a write that was issued while the stream - // was still open. - assertThat("Triggering write must not throw — trim's write-back leaked " - + "an IOException from its internal os.write() requireNonClosed() check", - thrown, is((IOException) null)); - - // assert — bug 2: every byte must still be readable. If trim's - // write-back aborted, tmpBuffer was discarded and all 9 bytes are lost. - final byte[] expected = {1, 2, 3, 4, 5, 6, 7, 8, 9}; - final byte[] actual = new byte[expected.length]; - int total = 0; - while (total < expected.length) { - final int n = is.read(actual, total, expected.length - total); - if (n < 0) { - break; + + // assert — No exceptions from either thread + assertAll( + () -> { + assertThat("Trim should not throw exception", thread1Exception.get(), is((Exception) null)); + }, + () -> { + assertThat( + "Close should not throw exception", thread2Exception.get(), is((Exception) null)); + }, + () -> { + // Verify stream closed properly + assertThat("Stream should be closed", sb.isClosed(), is(true)); + }, + () -> { + // Verify data can still be read (no corruption) + byte[] buffer = new byte[1000]; + int bytesRead = 0; + int totalRead = 0; + try { + while ((bytesRead = is.read(buffer)) >= 0 && totalRead < 100000) { + totalRead += bytesRead; + } + assertThat( + "Should be able to read data despite concurrent close", + totalRead, + greaterThan(0)); + } catch (IOException ignored) { + // Reading from closed stream is acceptable + } + }); + + } finally { + executor.shutdownNow(); + sb.removeTrimStartSignal(trimStartSignal); } - total += n; } - assertThat("All bytes written before close must be preserved through trim", - total, is(expected.length)); - assertThat("Buffered bytes must round-trip unchanged", - Arrays.equals(actual, expected), is(true)); - sb.removeTrimStartSignal(closeOnTrimStart); - } - - @DisplayName("isTrimShouldBeExecuted(): nested trim attempt during running trim — guard returns false") - @Test - public void isTrimShouldBeExecuted_nestedAttemptDuringRunningTrim_isBlockedByGuard() throws IOException { - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - sb.setMaxBufferElements(2); - - final AtomicInteger trimStartCount = new AtomicInteger(); - - // Hook runs synchronously on the trim thread inside releaseTrimStartSignals(), - // while isTrimRunning == true. On its first invocation it adds more data and - // forces a nested write/trim attempt. Without the isTrimRunning guard, the - // nested trim would re-fire releaseTrimStartSignals() and count -> 2. - final Semaphore hook = new Semaphore(0) { - @Override - public void release() { - if (trimStartCount.incrementAndGet() == 1) { - try { - os.write(new byte[]{10, 11, 12}); // attempts to retrigger trim - } catch (IOException e) { - throw new RuntimeException(e); + /** + * DETERMINISTIC reproduction of the race condition exercised flakily by + * {@link #trim_closeCalledDuringTrim_handlesGracefully()}. + * + *

Strategy: hook {@code addTrimStartSignal} with a semaphore whose + * {@code release()} synchronously calls {@code sb.close()}. The hook runs + * on the trim thread itself, BEFORE the read/write-back phases. By the + * time trim reaches its write-back phase, {@code streamClosed} is already + * {@code true}, so any internal {@code os.write()} call inside trim will + * fail at {@code requireNonClosed()}. + * + *

This is single-threaded — no executor, no latches, no sleeps, + * no thread-scheduling timing windows. + * + *

Asserts the two real bugs: + *

    + *
  1. The triggering write must not throw — trim's internal + * reorganization should be insulated from a concurrent close.
  2. + *
  3. No data loss — every byte written before the close must remain + * readable from the input stream.
  4. + *
+ */ + @DisplayName("trim(): close during trim — write-back must not throw and must not lose data") + @Test + public void trim_closeDuringTrim_writeBackDoesNotThrowAndPreservesData() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // Hook fires synchronously on the trim thread, BEFORE read/write-back. + // Calling close() here makes streamClosed=true deterministically before + // trim's internal os.write() runs requireNonClosed(). + final Semaphore closeOnTrimStart = new Semaphore(0) { + private boolean fired = false; + + @Override + public void release() { + if (!fired) { + fired = true; + try { + sb.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } } + super.release(); } - super.release(); - } - }; - sb.addTrimStartSignal(hook); + }; + sb.addTrimStartSignal(closeOnTrimStart); + sb.setMaxBufferElements(2); - os.write(new byte[]{1, 2, 3}); - os.write(new byte[]{4, 5, 6}); // at limit, no trim yet - os.write(new byte[]{7, 8, 9}); // triggers outer trim + // Pre-populate buffer with two chunks (at limit, no trim yet). + os.write(new byte[] {1, 2, 3}); + os.write(new byte[] {4, 5, 6}); - assertThat("isTrimRunning guard must block recursive trim", - trimStartCount.get(), is(1)); + // act — this third write pushes element count over maxBufferElements, + // trim() runs, hook closes the stream, write-back hits the bug. + IOException thrown = null; + try { + os.write(new byte[] {7, 8, 9}); + } catch (IOException e) { + thrown = e; + } + + // assert — bug 1: trim's internal write-back must not surface as a + // user-visible IOException on a write that was issued while the stream + // was still open. + assertThat( + "Triggering write must not throw — trim's write-back leaked " + + "an IOException from its internal os.write() requireNonClosed() check", + thrown, + is((IOException) null)); + + // assert — bug 2: every byte must still be readable. If trim's + // write-back aborted, tmpBuffer was discarded and all 9 bytes are lost. + final byte[] expected = {1, 2, 3, 4, 5, 6, 7, 8, 9}; + final byte[] actual = new byte[expected.length]; + int total = 0; + while (total < expected.length) { + final int n = is.read(actual, total, expected.length - total); + if (n < 0) { + break; + } + total += n; + } + assertThat("All bytes written before close must be preserved through trim", total, is(expected.length)); + assertThat("Buffered bytes must round-trip unchanged", Arrays.equals(actual, expected), is(true)); - // Round-trip sanity: every byte readable in order. - final byte[] expected = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; - final byte[] actual = new byte[expected.length]; - int n, total = 0; - while (total < expected.length && (n = is.read(actual, total, expected.length - total)) > 0) { - total += n; + sb.removeTrimStartSignal(closeOnTrimStart); } - assertThat(total, is(expected.length)); - assertThat(Arrays.equals(actual, expected), is(true)); - sb.removeTrimStartSignal(hook); - } + @DisplayName("isTrimShouldBeExecuted(): nested trim attempt during running trim — guard returns false") + @Test + public void isTrimShouldBeExecuted_nestedAttemptDuringRunningTrim_isBlockedByGuard() throws IOException { + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + sb.setMaxBufferElements(2); + + final AtomicInteger trimStartCount = new AtomicInteger(); + + // Hook runs synchronously on the trim thread inside releaseTrimStartSignals(), + // while isTrimRunning == true. On its first invocation it adds more data and + // forces a nested write/trim attempt. Without the isTrimRunning guard, the + // nested trim would re-fire releaseTrimStartSignals() and count -> 2. + final Semaphore hook = new Semaphore(0) { + @Override + public void release() { + if (trimStartCount.incrementAndGet() == 1) { + try { + os.write(new byte[] {10, 11, 12}); // attempts to retrigger trim + } catch (IOException e) { + throw new RuntimeException(e); + } + } + super.release(); + } + }; + sb.addTrimStartSignal(hook); - // Test extracted boundary checking methods + os.write(new byte[] {1, 2, 3}); + os.write(new byte[] {4, 5, 6}); // at limit, no trim yet + os.write(new byte[] {7, 8, 9}); // triggers outer trim - @DisplayName("isAvailableBytesPositive(): zero — returns false") - @Test - public void isAvailableBytesPositive_zero_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + assertThat("isTrimRunning guard must block recursive trim", trimStartCount.get(), is(1)); - // act - final boolean result = sb.isAvailableBytesPositive(0); + // Round-trip sanity: every byte readable in order. + final byte[] expected = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; + final byte[] actual = new byte[expected.length]; + int n, total = 0; + while (total < expected.length && (n = is.read(actual, total, expected.length - total)) > 0) { + total += n; + } + assertThat(total, is(expected.length)); + assertThat(Arrays.equals(actual, expected), is(true)); - // assert - boundary: > 0 means 0 is false - assertThat(result, is(false)); - } + sb.removeTrimStartSignal(hook); + } - @DisplayName("isAvailableBytesPositive(): one — returns true") - @Test - public void isAvailableBytesPositive_one_returnsTrue() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // Test extracted boundary checking methods - // act - final boolean result = sb.isAvailableBytesPositive(1); + @DisplayName("isAvailableBytesPositive(): zero — returns false") + @Test + public void isAvailableBytesPositive_zero_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // assert - boundary: > 0 means 1 is true - assertThat(result, is(true)); - } + // act + final boolean result = sb.isAvailableBytesPositive(0); - @DisplayName("isAvailableBytesPositive(): negative — returns false") - @Test - public void isAvailableBytesPositive_negative_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + // assert - boundary: > 0 means 0 is false + assertThat(result, is(false)); + } - // act - final boolean result = sb.isAvailableBytesPositive(-100); + @DisplayName("isAvailableBytesPositive(): one — returns true") + @Test + public void isAvailableBytesPositive_one_returnsTrue() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // assert - boundary: > 0 means negative is false - assertThat(result, is(false)); - } + // act + final boolean result = sb.isAvailableBytesPositive(1); + + // assert - boundary: > 0 means 1 is true + assertThat(result, is(true)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): equal — returns false") - @Test - public void isMaxAllocSizeLessThanAvailable_equal_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("isAvailableBytesPositive(): negative — returns false") + @Test + public void isAvailableBytesPositive_negative_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when maxAllocSize == availableBytes - final boolean result = sb.isMaxAllocSizeLessThanAvailable(100, 100); + // act + final boolean result = sb.isAvailableBytesPositive(-100); - // assert - boundary: < means equal is false - assertThat(result, is(false)); - } + // assert - boundary: > 0 means negative is false + assertThat(result, is(false)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): less than — returns true") - @Test - public void isMaxAllocSizeLessThanAvailable_lessThan_returnsTrue() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("isMaxAllocSizeLessThanAvailable(): equal — returns false") + @Test + public void isMaxAllocSizeLessThanAvailable_equal_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when maxAllocSize < availableBytes - final boolean result = sb.isMaxAllocSizeLessThanAvailable(50, 100); + // act - when maxAllocSize == availableBytes + final boolean result = sb.isMaxAllocSizeLessThanAvailable(100, 100); - // assert - boundary: < means less than is true - assertThat(result, is(true)); - } + // assert - boundary: < means equal is false + assertThat(result, is(false)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): greater than — returns false") - @Test - public void isMaxAllocSizeLessThanAvailable_greaterThan_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("isMaxAllocSizeLessThanAvailable(): less than — returns true") + @Test + public void isMaxAllocSizeLessThanAvailable_lessThan_returnsTrue() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when maxAllocSize > availableBytes - final boolean result = sb.isMaxAllocSizeLessThanAvailable(100, 50); + // act - when maxAllocSize < availableBytes + final boolean result = sb.isMaxAllocSizeLessThanAvailable(50, 100); - // assert - boundary: < means greater than is false - assertThat(result, is(false)); - } + // assert - boundary: < means less than is true + assertThat(result, is(true)); + } - @DisplayName("shouldUpdateMaxObservedBytes(): equal — returns false") - @Test - public void shouldUpdateMaxObservedBytes_equal_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("isMaxAllocSizeLessThanAvailable(): greater than — returns false") + @Test + public void isMaxAllocSizeLessThanAvailable_greaterThan_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when availableBytes == currentMax - final boolean result = sb.shouldUpdateMaxObservedBytes(100, 100); + // act - when maxAllocSize > availableBytes + final boolean result = sb.isMaxAllocSizeLessThanAvailable(100, 50); - // assert - boundary: > means equal is false - assertThat(result, is(false)); - } + // assert - boundary: < means greater than is false + assertThat(result, is(false)); + } - @DisplayName("shouldUpdateMaxObservedBytes(): greater than — returns true") - @Test - public void shouldUpdateMaxObservedBytes_greaterThan_returnsTrue() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("shouldUpdateMaxObservedBytes(): equal — returns false") + @Test + public void shouldUpdateMaxObservedBytes_equal_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when availableBytes > currentMax - final boolean result = sb.shouldUpdateMaxObservedBytes(150, 100); + // act - when availableBytes == currentMax + final boolean result = sb.shouldUpdateMaxObservedBytes(100, 100); - // assert - boundary: > means greater than is true - assertThat(result, is(true)); - } + // assert - boundary: > means equal is false + assertThat(result, is(false)); + } - @DisplayName("shouldUpdateMaxObservedBytes(): less than — returns false") - @Test - public void shouldUpdateMaxObservedBytes_lessThan_returnsFalse() { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("shouldUpdateMaxObservedBytes(): greater than — returns true") + @Test + public void shouldUpdateMaxObservedBytes_greaterThan_returnsTrue() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - when availableBytes < currentMax - final boolean result = sb.shouldUpdateMaxObservedBytes(50, 100); + // act - when availableBytes > currentMax + final boolean result = sb.shouldUpdateMaxObservedBytes(150, 100); - // assert - boundary: > means less than is false - assertThat(result, is(false)); - } + // assert - boundary: > means greater than is true + assertThat(result, is(true)); + } - @DisplayName("updateMaxObservedBytesIfNeeded(): exceeds current max") - @Test - public void updateMaxObservedBytesIfNeeded_exceedsCurrentMax() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - assertThat(sb.getMaxObservedBytes(), is(0L)); + @DisplayName("shouldUpdateMaxObservedBytes(): less than — returns false") + @Test + public void shouldUpdateMaxObservedBytes_lessThan_returnsFalse() { + // arrange + final StreamBuffer sb = new StreamBuffer(); - // act - update with value exceeding current max - sb.updateMaxObservedBytesIfNeeded(100); + // act - when availableBytes < currentMax + final boolean result = sb.shouldUpdateMaxObservedBytes(50, 100); - // assert - assertThat(sb.getMaxObservedBytes(), is(100L)); - } + // assert - boundary: > means less than is false + assertThat(result, is(false)); + } - @DisplayName("updateMaxObservedBytesIfNeeded(): equal to current max") - @Test - public void updateMaxObservedBytesIfNeeded_equalToCurrentMax() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - sb.updateMaxObservedBytesIfNeeded(100); - assertThat(sb.getMaxObservedBytes(), is(100L)); + @DisplayName("updateMaxObservedBytesIfNeeded(): exceeds current max") + @Test + public void updateMaxObservedBytesIfNeeded_exceedsCurrentMax() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + assertThat(sb.getMaxObservedBytes(), is(0L)); - // act - try to update with equal value - sb.updateMaxObservedBytesIfNeeded(100); + // act - update with value exceeding current max + sb.updateMaxObservedBytesIfNeeded(100); - // assert - should not change - assertThat(sb.getMaxObservedBytes(), is(100L)); - } + // assert + assertThat(sb.getMaxObservedBytes(), is(100L)); + } - @DisplayName("recordReadStatistics(): updates counter when trim not running") - @Test - public void recordReadStatistics_updatesCounterWhenTrimNotRunning() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - assertThat(sb.getTotalBytesRead(), is(0L)); + @DisplayName("updateMaxObservedBytesIfNeeded(): equal to current max") + @Test + public void updateMaxObservedBytesIfNeeded_equalToCurrentMax() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + sb.updateMaxObservedBytesIfNeeded(100); + assertThat(sb.getMaxObservedBytes(), is(100L)); - // act - record read statistics (trim is not running by default) - sb.recordReadStatistics(50); + // act - try to update with equal value + sb.updateMaxObservedBytesIfNeeded(100); - // assert - assertThat(sb.getTotalBytesRead(), is(50L)); - } + // assert - should not change + assertThat(sb.getMaxObservedBytes(), is(100L)); + } - @DisplayName("recordReadStatistics(): accumulates multiple calls") - @Test - public void recordReadStatistics_accumulatesMultipleCalls() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); + @DisplayName("recordReadStatistics(): updates counter when trim not running") + @Test + public void recordReadStatistics_updatesCounterWhenTrimNotRunning() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + assertThat(sb.getTotalBytesRead(), is(0L)); - // act - multiple calls - sb.recordReadStatistics(50); - sb.recordReadStatistics(30); - sb.recordReadStatistics(20); + // act - record read statistics (trim is not running by default) + sb.recordReadStatistics(50); - // assert - assertThat(sb.getTotalBytesRead(), is(100L)); - } + // assert + assertThat(sb.getTotalBytesRead(), is(50L)); + } - // Integration tests: Verify statistics are updated during actual read operations - // These tests ensure recordReadStatistics() is actually called in the read path - - @DisplayName("statistics(): array read — updates counter during integration") - @Test - public void statistics_arrayRead_updatesCounterDuringIntegration() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final byte[] writeData = new byte[]{1, 2, 3, 4, 5}; - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // act - write data and read it back - os.write(writeData); - final byte[] readBuffer = new byte[5]; - final int bytesRead = is.read(readBuffer); - - // assert - verify statistics were updated by the read operation - assertThat(bytesRead, is(5)); - assertThat(sb.getTotalBytesRead(), is(5L)); - } + @DisplayName("recordReadStatistics(): accumulates multiple calls") + @Test + public void recordReadStatistics_accumulatesMultipleCalls() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); - @DisplayName("statistics(): single byte read — updates counter during integration") - @Test - public void statistics_singleByteRead_updatesCounterDuringIntegration() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // act - write and read single bytes - os.write(42); - os.write(43); - final int byte1 = is.read(); - final int byte2 = is.read(); - - // assert - verify statistics were updated by single-byte read operations - assertThat(byte1, is(42)); - assertThat(byte2, is(43)); - assertThat(sb.getTotalBytesRead(), is(2L)); - } + // act - multiple calls + sb.recordReadStatistics(50); + sb.recordReadStatistics(30); + sb.recordReadStatistics(20); - @DisplayName("statistics(): partial array read — updates counter correctly") - @Test - public void statistics_partialArrayRead_updatesCounterCorrectly() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final byte[] writeData = new byte[]{10, 20, 30, 40, 50}; - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // act - write data and read with offset and length - os.write(writeData); - final byte[] readBuffer = new byte[3]; - final int bytesRead = is.read(readBuffer, 0, 3); - - // assert - verify only the requested bytes are counted - assertThat(bytesRead, is(3)); - assertThat(sb.getTotalBytesRead(), is(3L)); - } + // assert + assertThat(sb.getTotalBytesRead(), is(100L)); + } - @DisplayName("statistics(): multiple reads — accumulate correctly") - @Test - public void statistics_multipleReads_accumulateCorrectly() throws IOException { - // arrange - final StreamBuffer sb = new StreamBuffer(); - final OutputStream os = sb.getOutputStream(); - final InputStream is = sb.getInputStream(); - - // act - write and perform multiple reads - os.write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}); - final byte[] buf1 = new byte[3]; - final byte[] buf2 = new byte[3]; - final byte[] buf3 = new byte[2]; - - final int read1 = is.read(buf1); - final int read2 = is.read(buf2); - final int read3 = is.read(buf3); - - // assert - verify cumulative count - assertThat(read1, is(3)); - assertThat(read2, is(3)); - assertThat(read3, is(2)); - assertThat(sb.getTotalBytesRead(), is(8L)); - } + // Integration tests: Verify statistics are updated during actual read operations + // These tests ensure recordReadStatistics() is actually called in the read path + + @DisplayName("statistics(): array read — updates counter during integration") + @Test + public void statistics_arrayRead_updatesCounterDuringIntegration() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final byte[] writeData = new byte[] {1, 2, 3, 4, 5}; + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // act - write data and read it back + os.write(writeData); + final byte[] readBuffer = new byte[5]; + final int bytesRead = is.read(readBuffer); + + // assert - verify statistics were updated by the read operation + assertThat(bytesRead, is(5)); + assertThat(sb.getTotalBytesRead(), is(5L)); + } - // Comprehensive trim decision logic test using data provider - // This documents the critical requirement: trim only executes when it makes sense - - @ParameterizedTest(name = "bufferSize={0}, maxBufferElements={1}, availableBytes={2}, maxAllocSize={3} → shouldTrim={4}") - @MethodSource("trimDecisionTestCases") - public void decideTrimExecution_pureFunction_withAllParameters( - int bufferSize, - int maxBufferElements, - long availableBytes, - long maxAllocSize, - boolean expectedShouldTrim) { - // arrange - final StreamBuffer sb = new StreamBuffer(); - - // act - call the pure decision function directly with parameters - final boolean actualShouldTrim = sb.decideTrimExecution( - bufferSize, - maxBufferElements, - availableBytes, - maxAllocSize - ); - - // assert - assertThat(actualShouldTrim, is(expectedShouldTrim)); - } + @DisplayName("statistics(): single byte read — updates counter during integration") + @Test + public void statistics_singleByteRead_updatesCounterDuringIntegration() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); - /** - * Data provider for trim decision logic test. - * - * Requirement: Trim should only execute when consolidating the buffer - * would actually reduce the number of elements to below maxBufferElements. - * - * Each row represents: bufferSize, maxBufferElements, availableBytes, maxAllocSize → shouldTrim - * - * Critical cases: - * 1. Trim should EXECUTE: Buffer exceeds max and consolidation reduces it - * 2. Trim should SKIP: Buffer already within limit (no action needed) - * 3. Trim should SKIP: maxBufferElements invalid (≤ 0) - * 4. Trim should SKIP: Buffer too small (< 2) - * 5. Trim should SKIP: Edge case where consolidation still exceeds max - */ - private static java.util.stream.Stream trimDecisionTestCases() { - return java.util.stream.Stream.of( - // ============ SKIP CASES: maxBufferElements Invalid ============ - // When maxBufferElements <= 0, trim is nonsensical - Arguments.of(2, 0, 100, 50, false), // maxBufferElements=0 → invalid, skip - Arguments.of(2, -1, 100, 50, false), // maxBufferElements=-1 → invalid, skip - - // ============ SKIP CASES: Buffer Too Small ============ - // Trim requires at least 2 elements to consolidate - Arguments.of(0, 10, 0, 50, false), // empty buffer - Arguments.of(1, 10, 1, 50, false), // buffer size = 1, trim needs >= 2 - - // ============ SKIP CASES: Buffer Within Limit ============ - // When buffer.size() <= maxBufferElements, no trim needed - Arguments.of(5, 10, 50, 100, false), // 5 <= 10 (within limit) - Arguments.of(10, 10, 100, 100, false), // 10 <= 10 (at limit, no trim needed) - - // ============ EXECUTE CASES: Buffer Exceeds Limit ============ - // Buffer > maxBufferElements AND trim will help - Arguments.of(101, 100, 1010, 100, true), // 101 > 100, ceil(1010/100)=11 < 101, will trim - Arguments.of(15, 10, 150, 100, true), // 15 > 10, consolidation reduces chunks - - // ============ EDGE CASE: Trim Pointless (Would Not Reduce Size) ============ - // resultingChunks >= bufferSize: trim wouldn't actually consolidate - // Example: maxBufferElements=10, maxAllocSize=100, availableBytes=1100 - // → ceil(1100/100) = 11 chunks, still >= buffer size of 11 - Arguments.of(11, 10, 1100, 100, false), // resulting 11 >= current 11 → skip - Arguments.of(12, 10, 1200, 100, false), // resulting 12 >= current 12 → skip - Arguments.of(20, 10, 2000, 100, false), // resulting 20 >= current 20 → skip - - // Edge case where trim WOULD reduce: 99 elements, max 100, no trim - Arguments.of(99, 100, 990, 100, false), // 99 <= 100 (within limit) - - // Edge case: 101 elements, max 100, trim reduces from 101 to 11 - Arguments.of(101, 100, 1010, 100, true), // 101 > 100, result 11 < 101 → trim - - // Very large buffer needing consolidation - Arguments.of(1000, 100, 10000, 1000, true), // 1000 > 100, ceil(10000/1000)=10 < 1000 → trim - - // ============ KILL SURVIVING MUTATIONS ============ - // Kill arithmetic mutation: subtraction vs addition in ceiling division - // Need: maxAllocationSize < availableBytes to ENTER edge case, where ceiling formula is executed - // Correct: (200 + 100 - 1) / 100 = 2, so 2 >= 3 is false, return true (trim executes) - // Mutated (+1): (200 + 100 + 1) / 100 = 3, so 3 >= 3 is true, return false (skip trim) - Arguments.of(3, 1, 200, 100, true), - - // Kill boundary mutation on < vs <=: test maxAllocationSize == availableBytes - // Edge case: when maxAllocationSize == availableBytes, currently skipped (< is false) - // With <= mutation, edge case would be entered, but ceiling = 1, doesn't change result - Arguments.of(2, 1, 100, 100, true), // maxAllocSize=availableBytes, edge case skipped (< false) - - // Kill boundary mutation on > vs >=: test availableBytes == 0 - // If availableBytes == 0, currently skip edge case (> is false) - // With >= mutation, would enter but maxAllocationSize < 0 is never true - Arguments.of(2, 1, 0, 100, true), // availableBytes=0, edge case skipped (> false) - - // Additional edge cases to expose mutations - Arguments.of(2, 1, 99, 100, true), // 99 < 100, edge case skipped (< false) - Arguments.of(2, 1, 101, 100, false), // 101 > 100, edge case entered, ceil=(101+100-1)/100=2, 2>=2 true, skip trim - Arguments.of(4, 1, 200, 100, true) // bufferSize=4: ceiling=(200+100-1)/100=2, 2>=4 false, return true - ); - } + // act - write and read single bytes + os.write(42); + os.write(43); + final int byte1 = is.read(); + final int byte2 = is.read(); + + // assert - verify statistics were updated by single-byte read operations + assertThat(byte1, is(42)); + assertThat(byte2, is(43)); + assertThat(sb.getTotalBytesRead(), is(2L)); + } + + @DisplayName("statistics(): partial array read — updates counter correctly") + @Test + public void statistics_partialArrayRead_updatesCounterCorrectly() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final byte[] writeData = new byte[] {10, 20, 30, 40, 50}; + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // act - write data and read with offset and length + os.write(writeData); + final byte[] readBuffer = new byte[3]; + final int bytesRead = is.read(readBuffer, 0, 3); + + // assert - verify only the requested bytes are counted + assertThat(bytesRead, is(3)); + assertThat(sb.getTotalBytesRead(), is(3L)); + } + + @DisplayName("statistics(): multiple reads — accumulate correctly") + @Test + public void statistics_multipleReads_accumulateCorrectly() throws IOException { + // arrange + final StreamBuffer sb = new StreamBuffer(); + final OutputStream os = sb.getOutputStream(); + final InputStream is = sb.getInputStream(); + + // act - write and perform multiple reads + os.write(new byte[] {1, 2, 3, 4, 5, 6, 7, 8}); + final byte[] buf1 = new byte[3]; + final byte[] buf2 = new byte[3]; + final byte[] buf3 = new byte[2]; + + final int read1 = is.read(buf1); + final int read2 = is.read(buf2); + final int read3 = is.read(buf3); + + // assert - verify cumulative count + assertThat(read1, is(3)); + assertThat(read2, is(3)); + assertThat(read3, is(2)); + assertThat(sb.getTotalBytesRead(), is(8L)); + } + + // Comprehensive trim decision logic test using data provider + // This documents the critical requirement: trim only executes when it makes sense + + @ParameterizedTest( + name = "bufferSize={0}, maxBufferElements={1}, availableBytes={2}, maxAllocSize={3} → shouldTrim={4}") + @MethodSource("trimDecisionTestCases") + public void decideTrimExecution_pureFunction_withAllParameters( + int bufferSize, + int maxBufferElements, + long availableBytes, + long maxAllocSize, + boolean expectedShouldTrim) { + // arrange + final StreamBuffer sb = new StreamBuffer(); + + // act - call the pure decision function directly with parameters + final boolean actualShouldTrim = + sb.decideTrimExecution(bufferSize, maxBufferElements, availableBytes, maxAllocSize); + + // assert + assertThat(actualShouldTrim, is(expectedShouldTrim)); + } + /** + * Data provider for trim decision logic test. + * + * Requirement: Trim should only execute when consolidating the buffer + * would actually reduce the number of elements to below maxBufferElements. + * + * Each row represents: bufferSize, maxBufferElements, availableBytes, maxAllocSize → shouldTrim + * + * Critical cases: + * 1. Trim should EXECUTE: Buffer exceeds max and consolidation reduces it + * 2. Trim should SKIP: Buffer already within limit (no action needed) + * 3. Trim should SKIP: maxBufferElements invalid (≤ 0) + * 4. Trim should SKIP: Buffer too small (< 2) + * 5. Trim should SKIP: Edge case where consolidation still exceeds max + */ + private static java.util.stream.Stream trimDecisionTestCases() { + return java.util.stream.Stream.of( + // ============ SKIP CASES: maxBufferElements Invalid ============ + // When maxBufferElements <= 0, trim is nonsensical + Arguments.of(2, 0, 100, 50, false), // maxBufferElements=0 → invalid, skip + Arguments.of(2, -1, 100, 50, false), // maxBufferElements=-1 → invalid, skip + + // ============ SKIP CASES: Buffer Too Small ============ + // Trim requires at least 2 elements to consolidate + Arguments.of(0, 10, 0, 50, false), // empty buffer + Arguments.of(1, 10, 1, 50, false), // buffer size = 1, trim needs >= 2 + + // ============ SKIP CASES: Buffer Within Limit ============ + // When buffer.size() <= maxBufferElements, no trim needed + Arguments.of(5, 10, 50, 100, false), // 5 <= 10 (within limit) + Arguments.of(10, 10, 100, 100, false), // 10 <= 10 (at limit, no trim needed) + + // ============ EXECUTE CASES: Buffer Exceeds Limit ============ + // Buffer > maxBufferElements AND trim will help + Arguments.of(101, 100, 1010, 100, true), // 101 > 100, ceil(1010/100)=11 < 101, will trim + Arguments.of(15, 10, 150, 100, true), // 15 > 10, consolidation reduces chunks + + // ============ EDGE CASE: Trim Pointless (Would Not Reduce Size) ============ + // resultingChunks >= bufferSize: trim wouldn't actually consolidate + // Example: maxBufferElements=10, maxAllocSize=100, availableBytes=1100 + // → ceil(1100/100) = 11 chunks, still >= buffer size of 11 + Arguments.of(11, 10, 1100, 100, false), // resulting 11 >= current 11 → skip + Arguments.of(12, 10, 1200, 100, false), // resulting 12 >= current 12 → skip + Arguments.of(20, 10, 2000, 100, false), // resulting 20 >= current 20 → skip + + // Edge case where trim WOULD reduce: 99 elements, max 100, no trim + Arguments.of(99, 100, 990, 100, false), // 99 <= 100 (within limit) + + // Edge case: 101 elements, max 100, trim reduces from 101 to 11 + Arguments.of(101, 100, 1010, 100, true), // 101 > 100, result 11 < 101 → trim + + // Very large buffer needing consolidation + Arguments.of(1000, 100, 10000, 1000, true), // 1000 > 100, ceil(10000/1000)=10 < 1000 → trim + + // ============ KILL SURVIVING MUTATIONS ============ + // Kill arithmetic mutation: subtraction vs addition in ceiling division + // Need: maxAllocationSize < availableBytes to ENTER edge case, where ceiling formula is executed + // Correct: (200 + 100 - 1) / 100 = 2, so 2 >= 3 is false, return true (trim executes) + // Mutated (+1): (200 + 100 + 1) / 100 = 3, so 3 >= 3 is true, return false (skip trim) + Arguments.of(3, 1, 200, 100, true), + + // Kill boundary mutation on < vs <=: test maxAllocationSize == availableBytes + // Edge case: when maxAllocationSize == availableBytes, currently skipped (< is false) + // With <= mutation, edge case would be entered, but ceiling = 1, doesn't change result + Arguments.of(2, 1, 100, 100, true), // maxAllocSize=availableBytes, edge case skipped (< false) + + // Kill boundary mutation on > vs >=: test availableBytes == 0 + // If availableBytes == 0, currently skip edge case (> is false) + // With >= mutation, would enter but maxAllocationSize < 0 is never true + Arguments.of(2, 1, 0, 100, true), // availableBytes=0, edge case skipped (> false) + + // Additional edge cases to expose mutations + Arguments.of(2, 1, 99, 100, true), // 99 < 100, edge case skipped (< false) + Arguments.of( + 2, 1, 101, 100, + false), // 101 > 100, edge case entered, ceil=(101+100-1)/100=2, 2>=2 true, skip trim + Arguments.of( + 4, 1, 200, 100, true) // bufferSize=4: ceiling=(200+100-1)/100=2, 2>=4 false, return true + ); + } } @Nested @DisplayName("boundary condition functions — direct") class BoundaryConditionFunctionTests { - @DisplayName("isAvailableBytesPositive(): with zero — returns false") - @Test - public void isAvailableBytesPositive_withZero_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isAvailableBytesPositive(0), is(false)); - } - - @DisplayName("isAvailableBytesPositive(): with one — returns true") - @Test - public void isAvailableBytesPositive_withOne_returnsTrue() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isAvailableBytesPositive(1), is(true)); - } + @DisplayName("isAvailableBytesPositive(): with zero — returns false") + @Test + public void isAvailableBytesPositive_withZero_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isAvailableBytesPositive(0), is(false)); + } - @DisplayName("isAvailableBytesPositive(): with negative — returns false") - @Test - public void isAvailableBytesPositive_withNegative_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isAvailableBytesPositive(-1), is(false)); - } + @DisplayName("isAvailableBytesPositive(): with one — returns true") + @Test + public void isAvailableBytesPositive_withOne_returnsTrue() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isAvailableBytesPositive(1), is(true)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): with less — returns true") - @Test - public void isMaxAllocSizeLessThanAvailable_withLess_returnsTrue() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isMaxAllocSizeLessThanAvailable(100, 200), is(true)); - } + @DisplayName("isAvailableBytesPositive(): with negative — returns false") + @Test + public void isAvailableBytesPositive_withNegative_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isAvailableBytesPositive(-1), is(false)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): with equal — returns false") - @Test - public void isMaxAllocSizeLessThanAvailable_withEqual_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isMaxAllocSizeLessThanAvailable(100, 100), is(false)); - } + @DisplayName("isMaxAllocSizeLessThanAvailable(): with less — returns true") + @Test + public void isMaxAllocSizeLessThanAvailable_withLess_returnsTrue() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isMaxAllocSizeLessThanAvailable(100, 200), is(true)); + } - @DisplayName("isMaxAllocSizeLessThanAvailable(): with greater — returns false") - @Test - public void isMaxAllocSizeLessThanAvailable_withGreater_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - assertThat(sb.isMaxAllocSizeLessThanAvailable(200, 100), is(false)); - } + @DisplayName("isMaxAllocSizeLessThanAvailable(): with equal — returns false") + @Test + public void isMaxAllocSizeLessThanAvailable_withEqual_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isMaxAllocSizeLessThanAvailable(100, 100), is(false)); + } - @DisplayName("shouldCheckEdgeCase(): with both conditions true — returns true") - @Test - public void shouldCheckEdgeCase_withBothConditionsTrue_returnsTrue() { - StreamBuffer sb = new StreamBuffer(); - // availableBytes > 0 AND maxAllocSize < availableBytes - assertThat(sb.shouldCheckEdgeCase(200, 100), is(true)); - } + @DisplayName("isMaxAllocSizeLessThanAvailable(): with greater — returns false") + @Test + public void isMaxAllocSizeLessThanAvailable_withGreater_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.isMaxAllocSizeLessThanAvailable(200, 100), is(false)); + } - @DisplayName("shouldCheckEdgeCase(): with available bytes zero — returns false") - @Test - public void shouldCheckEdgeCase_withAvailableBytesZero_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - // availableBytes > 0 is false - assertThat(sb.shouldCheckEdgeCase(0, 100), is(false)); - } + @DisplayName("shouldCheckEdgeCase(): with both conditions true — returns true") + @Test + public void shouldCheckEdgeCase_withBothConditionsTrue_returnsTrue() { + StreamBuffer sb = new StreamBuffer(); + // availableBytes > 0 AND maxAllocSize < availableBytes + assertThat(sb.shouldCheckEdgeCase(200, 100), is(true)); + } - @DisplayName("shouldCheckEdgeCase(): with max alloc size equal — returns false") - @Test - public void shouldCheckEdgeCase_withMaxAllocSizeEqual_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - // maxAllocSize < availableBytes is false - assertThat(sb.shouldCheckEdgeCase(100, 100), is(false)); - } + @DisplayName("shouldCheckEdgeCase(): with available bytes zero — returns false") + @Test + public void shouldCheckEdgeCase_withAvailableBytesZero_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + // availableBytes > 0 is false + assertThat(sb.shouldCheckEdgeCase(0, 100), is(false)); + } - @DisplayName("shouldCheckEdgeCase(): with max alloc size greater — returns false") - @Test - public void shouldCheckEdgeCase_withMaxAllocSizeGreater_returnsFalse() { - StreamBuffer sb = new StreamBuffer(); - // maxAllocSize < availableBytes is false - assertThat(sb.shouldCheckEdgeCase(50, 100), is(false)); - } + @DisplayName("shouldCheckEdgeCase(): with max alloc size equal — returns false") + @Test + public void shouldCheckEdgeCase_withMaxAllocSizeEqual_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + // maxAllocSize < availableBytes is false + assertThat(sb.shouldCheckEdgeCase(100, 100), is(false)); + } + @DisplayName("shouldCheckEdgeCase(): with max alloc size greater — returns false") + @Test + public void shouldCheckEdgeCase_withMaxAllocSizeGreater_returnsFalse() { + StreamBuffer sb = new StreamBuffer(); + // maxAllocSize < availableBytes is false + assertThat(sb.shouldCheckEdgeCase(50, 100), is(false)); + } } @Nested @DisplayName("exception safety and signal management") class ExceptionSafetyAndSignalManagementTests { - /** - * CRITICAL TEST SECTION: Exception Safety During Trim Operations - * - * These tests verify that StreamBuffer handles exceptions safely during trim, - * maintaining proper flag state and stream usability even when errors occur. - * - * Key Requirements (verified by tests in this section): - * 1. isTrimRunning flag MUST reset via finally block (even if exceptions occur) - * - Implementation: Lines 440-484 in StreamBuffer.java (try-finally) - * 2. ignoreSafeWrite flag MUST reset despite write exceptions - * - Implementation: Lines 470-478 (nested try-finally) - * 3. Signal release exceptions must not deadlock stream - * - Implementation: releaseTrimStartSignals() moved INSIDE try block (line 443) - * 4. Concurrent close() during trim must not cause race conditions - * - Implementation: bufferLock synchronization prevents interleaving - * 5. Configuration changes must not affect running trim - * - Implementation: Configuration values cached before trim execution - * - * Each test includes detailed inline documentation explaining the specific - * exception scenario and why the fix prevents problems. - */ + /** + * CRITICAL TEST SECTION: Exception Safety During Trim Operations + * + * These tests verify that StreamBuffer handles exceptions safely during trim, + * maintaining proper flag state and stream usability even when errors occur. + * + * Key Requirements (verified by tests in this section): + * 1. isTrimRunning flag MUST reset via finally block (even if exceptions occur) + * - Implementation: Lines 440-484 in StreamBuffer.java (try-finally) + * 2. ignoreSafeWrite flag MUST reset despite write exceptions + * - Implementation: Lines 470-478 (nested try-finally) + * 3. Signal release exceptions must not deadlock stream + * - Implementation: releaseTrimStartSignals() moved INSIDE try block (line 443) + * 4. Concurrent close() during trim must not cause race conditions + * - Implementation: bufferLock synchronization prevents interleaving + * 5. Configuration changes must not affect running trim + * - Implementation: Configuration values cached before trim execution + * + * Each test includes detailed inline documentation explaining the specific + * exception scenario and why the fix prevents problems. + */ + @DisplayName("trim(): exception during read — flag resets in finally") + @Test + public void trim_exceptionDuringRead_flagResetsInFinally() throws IOException { + // arrange — Verify that isTrimRunning is reset even if exception occurs during is.read() + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + + // Write enough data to trigger trim + for (int i = 0; i < 150; i++) { + os.write(anyValue); + } + sb.setMaxBufferElements(10); - @DisplayName("trim(): exception during read — flag resets in finally") - @Test - public void trim_exceptionDuringRead_flagResetsInFinally() throws IOException { - // arrange — Verify that isTrimRunning is reset even if exception occurs during is.read() - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); + // act — trigger trim by writing more data + // The trim operation should reset isTrimRunning even if exceptions occur + os.write(new byte[100]); - // Write enough data to trigger trim - for (int i = 0; i < 150; i++) { - os.write(anyValue); + // assert — isTrimRunning should be false after trim completes (or fails safely) + assertThat(sb.isTrimRunning(), is(false)); } - sb.setMaxBufferElements(10); - // act — trigger trim by writing more data - // The trim operation should reset isTrimRunning even if exceptions occur - os.write(new byte[100]); + @DisplayName("trim(): exception during write — flag resets in finally") + @Test + public void trim_exceptionDuringWrite_flagResetsInFinally() throws IOException { + // arrange — Similar to above but focused on write phase of trim + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); - // assert — isTrimRunning should be false after trim completes (or fails safely) - assertThat(sb.isTrimRunning(), is(false)); - } + // Write data to create multiple chunks + byte[] chunk = new byte[50]; + Arrays.fill(chunk, anyValue); + for (int i = 0; i < 15; i++) { + os.write(chunk); + } + sb.setMaxBufferElements(5); - @DisplayName("trim(): exception during write — flag resets in finally") - @Test - public void trim_exceptionDuringWrite_flagResetsInFinally() throws IOException { - // arrange — Similar to above but focused on write phase of trim - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - // Write data to create multiple chunks - byte[] chunk = new byte[50]; - Arrays.fill(chunk, anyValue); - for (int i = 0; i < 15; i++) { + // act — write more to trigger trim os.write(chunk); - } - sb.setMaxBufferElements(5); - // act — write more to trigger trim - os.write(chunk); - - // assert — flag should be reset - assertThat(sb.isTrimRunning(), is(false)); - - // Verify data integrity despite trim - os.close(); - byte[] result = new byte[800]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 800 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(800)); - } + // assert — flag should be reset + assertThat(sb.isTrimRunning(), is(false)); - @DisplayName("setMaxAllocationSize(): during normal operation — applies immediately") - @Test - public void setMaxAllocationSize_duringNormalOperation_appliesImmediately() throws IOException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - // act — change maxAllocationSize between operations - sb.setMaxAllocationSize(50); - for (int i = 0; i < 100; i++) { - os.write(anyValue); + // Verify data integrity despite trim + os.close(); + byte[] result = new byte[800]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 800 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(800)); } - // Change it again mid-stream - sb.setMaxAllocationSize(25); - sb.setMaxBufferElements(2); // trigger trim with new limit - for (int i = 0; i < 50; i++) { - os.write(anyValue); - } + @DisplayName("setMaxAllocationSize(): during normal operation — applies immediately") + @Test + public void setMaxAllocationSize_duringNormalOperation_appliesImmediately() throws IOException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + // act — change maxAllocationSize between operations + sb.setMaxAllocationSize(50); + for (int i = 0; i < 100; i++) { + os.write(anyValue); + } - // assert — all data should be readable - os.close(); - byte[] result = new byte[150]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 150 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(150)); - } + // Change it again mid-stream + sb.setMaxAllocationSize(25); + sb.setMaxBufferElements(2); // trigger trim with new limit + for (int i = 0; i < 50; i++) { + os.write(anyValue); + } - @DisplayName("trim(): signal operations concurrent — handles safely") - @Test - public void trim_signalOperationsConcurrent_handlesSafely() throws IOException, InterruptedException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - Semaphore trimStarted = new Semaphore(0); - Semaphore trimEnded = new Semaphore(0); - - // add signals to be released when trim occurs - sb.addTrimStartSignal(trimStarted); - sb.addTrimEndSignal(trimEnded); - - // Write data to trigger trim - byte[] chunk = new byte[100]; - Arrays.fill(chunk, anyValue); - sb.setMaxBufferElements(2); - - // act — write enough to trigger trim - for (int i = 0; i < 5; i++) { - os.write(chunk); + // assert — all data should be readable + os.close(); + byte[] result = new byte[150]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 150 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(150)); } - // assert — signals were released - assertThat(trimStarted.tryAcquire(1, TimeUnit.SECONDS), is(true)); - assertThat(trimEnded.tryAcquire(1, TimeUnit.SECONDS), is(true)); + @DisplayName("trim(): signal operations concurrent — handles safely") + @Test + public void trim_signalOperationsConcurrent_handlesSafely() throws IOException, InterruptedException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + Semaphore trimStarted = new Semaphore(0); + Semaphore trimEnded = new Semaphore(0); + + // add signals to be released when trim occurs + sb.addTrimStartSignal(trimStarted); + sb.addTrimEndSignal(trimEnded); + + // Write data to trigger trim + byte[] chunk = new byte[100]; + Arrays.fill(chunk, anyValue); + sb.setMaxBufferElements(2); - // Clean up - sb.removeTrimStartSignal(trimStarted); - sb.removeTrimEndSignal(trimEnded); - } + // act — write enough to trigger trim + for (int i = 0; i < 5; i++) { + os.write(chunk); + } - @DisplayName("ignoreSafeWrite(): reset after trim") - @Test - public void ignoreSafeWrite_resetAfterTrim() throws IOException { - // arrange — Verify that ignoreSafeWrite flag is properly reset after trim - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); + // assert — signals were released + assertThat(trimStarted.tryAcquire(1, TimeUnit.SECONDS), is(true)); + assertThat(trimEnded.tryAcquire(1, TimeUnit.SECONDS), is(true)); - // Enable safe write to test the ignoreSafeWrite flag - sb.setSafeWrite(true); + // Clean up + sb.removeTrimStartSignal(trimStarted); + sb.removeTrimEndSignal(trimEnded); + } - // Write data to trigger trim - byte[] data = new byte[50]; - Arrays.fill(data, anyValue); - sb.setMaxBufferElements(2); + @DisplayName("ignoreSafeWrite(): reset after trim") + @Test + public void ignoreSafeWrite_resetAfterTrim() throws IOException { + // arrange — Verify that ignoreSafeWrite flag is properly reset after trim + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + // Enable safe write to test the ignoreSafeWrite flag + sb.setSafeWrite(true); + + // Write data to trigger trim + byte[] data = new byte[50]; + Arrays.fill(data, anyValue); + sb.setMaxBufferElements(2); + + // act — write enough to trigger trim with safe write enabled + for (int i = 0; i < 5; i++) { + os.write(data); + } - // act — write enough to trigger trim with safe write enabled - for (int i = 0; i < 5; i++) { - os.write(data); + // assert — all data should be preserved correctly + os.close(); + byte[] result = new byte[250]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 250 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(250)); + assertThat(result[0], is(anyValue)); + assertThat(result[249], is(anyValue)); } - // assert — all data should be preserved correctly - os.close(); - byte[] result = new byte[250]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 250 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat(totalRead, is(250)); - assertThat(result[0], is(anyValue)); - assertThat(result[249], is(anyValue)); - } + @DisplayName("largeBuffer(): with small allocation size — handles correctly") + @Test + public void largeBuffer_withSmallAllocationSize_handlesCorrectly() throws IOException { + // arrange — Test buffer overflow scenario with extreme constraints + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); - @DisplayName("largeBuffer(): with small allocation size — handles correctly") - @Test - public void largeBuffer_withSmallAllocationSize_handlesCorrectly() throws IOException { - // arrange — Test buffer overflow scenario with extreme constraints - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - sb.setMaxAllocationSize(10); // Very small chunks - sb.setMaxBufferElements(3); // Very restrictive buffer limit - - // act — write substantial data (5000 bytes) - byte[] chunk = new byte[500]; - Arrays.fill(chunk, anyValue); - for (int i = 0; i < 10; i++) { - os.write(chunk); - } + sb.setMaxAllocationSize(10); // Very small chunks + sb.setMaxBufferElements(3); // Very restrictive buffer limit + + // act — write substantial data (5000 bytes) + byte[] chunk = new byte[500]; + Arrays.fill(chunk, anyValue); + for (int i = 0; i < 10; i++) { + os.write(chunk); + } - // assert — all data should be readable despite extreme constraints - os.close(); - byte[] result = new byte[5000]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 5000 - totalRead)) > 0) { - totalRead += bytesRead; + // assert — all data should be readable despite extreme constraints + os.close(); + byte[] result = new byte[5000]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 5000 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat(totalRead, is(5000)); } - assertThat(totalRead, is(5000)); - } - /** - * CRITICAL CORRECTNESS TEST: Config changes during trim don't affect running trim - * - * REQUIREMENT: When trim is already executing, changes to configuration parameters - * (maxBufferElements, maxAllocationSize) must NOT affect the currently running trim - * operation. Configuration should only influence trim DECISIONS, not trim EXECUTION. - * - * IMPLEMENTATION VERIFICATION: - * The trim decision logic caches configuration values BEFORE trim starts: - * - isTrimShouldBeExecuted() calls: final int maxBufferElements = getMaxBufferElements() - * - This cached value is passed to decideTrimExecution() as a parameter - * - During trim execution, only the cached value is used, not the volatile field - * - * RISK IF NOT CORRECT: - * If trim re-read configuration during execution, a concurrent config change could: - * - Cause trim to terminate prematurely (if maxBufferElements changed) - * - Change chunk allocation mid-operation (if maxAllocationSize changed) - * - Corrupt internal state (data loss, inconsistent buffer state) - * - * TEST APPROACH: - * 1. Register semaphore observer to detect when trim STARTS executing - * 2. Continuously write data in writer thread to trigger trim - * 3. Main thread waits for trim to start - * 4. While trim is running, change configuration - * 5. Verify trim completes successfully and data is intact - * 6. Verify that new configuration takes effect in subsequent operations - * - * SYNCHRONIZATION MECHANISM: - * Uses StreamBuffer's built-in semaphore observer pattern: - * - addTrimStartSignal(Semaphore) releases semaphore when trim() begins - * - addTrimEndSignal(Semaphore) releases semaphore when trim() completes - * This allows precise test synchronization without mocking or instrumentation. - */ - @DisplayName("setMaxBufferElements(): during trim execution — does not affect running trim") - @Test - @Timeout(10) // 10 second timeout to prevent hanging if sync fails - public void setMaxBufferElements_duringTrimExecution_doesNotAffectRunningTrim() throws IOException, InterruptedException { - // arrange — Set up continuous writer and trim observers - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - sb.setMaxBufferElements(100); // Initial high threshold - sb.setMaxAllocationSize(50); // Reasonable chunk size - - // Semaphores for synchronization - Semaphore trimStarted = new Semaphore(0); // Released when trim() begins - Semaphore trimEnded = new Semaphore(0); // Released when trim() ends - - // Register observers to detect trim lifecycle - sb.addTrimStartSignal(trimStarted); - sb.addTrimEndSignal(trimEnded); - - // Writer thread: continuously write data to trigger trim - Thread writerThread = new Thread(() -> { - try { - byte[] chunk = new byte[10]; - Arrays.fill(chunk, anyValue); - // Write 200 chunks (2000 bytes) — exceeds maxBufferElements(100), triggers trim - for (int i = 0; i < 200; i++) { - os.write(chunk); - Thread.sleep(2); // Small delay to allow trim to execute + /** + * CRITICAL CORRECTNESS TEST: Config changes during trim don't affect running trim + * + * REQUIREMENT: When trim is already executing, changes to configuration parameters + * (maxBufferElements, maxAllocationSize) must NOT affect the currently running trim + * operation. Configuration should only influence trim DECISIONS, not trim EXECUTION. + * + * IMPLEMENTATION VERIFICATION: + * The trim decision logic caches configuration values BEFORE trim starts: + * - isTrimShouldBeExecuted() calls: final int maxBufferElements = getMaxBufferElements() + * - This cached value is passed to decideTrimExecution() as a parameter + * - During trim execution, only the cached value is used, not the volatile field + * + * RISK IF NOT CORRECT: + * If trim re-read configuration during execution, a concurrent config change could: + * - Cause trim to terminate prematurely (if maxBufferElements changed) + * - Change chunk allocation mid-operation (if maxAllocationSize changed) + * - Corrupt internal state (data loss, inconsistent buffer state) + * + * TEST APPROACH: + * 1. Register semaphore observer to detect when trim STARTS executing + * 2. Continuously write data in writer thread to trigger trim + * 3. Main thread waits for trim to start + * 4. While trim is running, change configuration + * 5. Verify trim completes successfully and data is intact + * 6. Verify that new configuration takes effect in subsequent operations + * + * SYNCHRONIZATION MECHANISM: + * Uses StreamBuffer's built-in semaphore observer pattern: + * - addTrimStartSignal(Semaphore) releases semaphore when trim() begins + * - addTrimEndSignal(Semaphore) releases semaphore when trim() completes + * This allows precise test synchronization without mocking or instrumentation. + */ + @DisplayName("setMaxBufferElements(): during trim execution — does not affect running trim") + @Test + @Timeout(10) // 10 second timeout to prevent hanging if sync fails + public void setMaxBufferElements_duringTrimExecution_doesNotAffectRunningTrim() + throws IOException, InterruptedException { + // arrange — Set up continuous writer and trim observers + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + sb.setMaxBufferElements(100); // Initial high threshold + sb.setMaxAllocationSize(50); // Reasonable chunk size + + // Semaphores for synchronization + Semaphore trimStarted = new Semaphore(0); // Released when trim() begins + Semaphore trimEnded = new Semaphore(0); // Released when trim() ends + + // Register observers to detect trim lifecycle + sb.addTrimStartSignal(trimStarted); + sb.addTrimEndSignal(trimEnded); + + // Writer thread: continuously write data to trigger trim + Thread writerThread = new Thread(() -> { + try { + byte[] chunk = new byte[10]; + Arrays.fill(chunk, anyValue); + // Write 200 chunks (2000 bytes) — exceeds maxBufferElements(100), triggers trim + for (int i = 0; i < 200; i++) { + os.write(chunk); + Thread.sleep(2); // Small delay to allow trim to execute + } + os.close(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); } - os.close(); - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); - } - }); + }); - writerThread.start(); + writerThread.start(); - // act — Wait for trim to start, then change config mid-trim - trimStarted.acquire(); // Block until trim() is executing + // act — Wait for trim to start, then change config mid-trim + trimStarted.acquire(); // Block until trim() is executing - // At this point: trim is running, isTrimRunning == true - // Change configuration while trim is in progress - sb.setMaxBufferElements(0); // Change to invalid value while trim runs - sb.setMaxAllocationSize(100); // Also change allocation size + // At this point: trim is running, isTrimRunning == true + // Change configuration while trim is in progress + sb.setMaxBufferElements(0); // Change to invalid value while trim runs + sb.setMaxAllocationSize(100); // Also change allocation size - // Wait for trim to complete - trimEnded.acquire(); // Block until trim() finishes + // Wait for trim to complete + trimEnded.acquire(); // Block until trim() finishes - // assert — Verify trim completed successfully despite config changes - assertAll( - // 1. Trim finished (flag reset) - () -> assertThat("Trim should complete and reset flag", sb.isTrimRunning(), is(false)), + // assert — Verify trim completed successfully despite config changes + assertAll( + // 1. Trim finished (flag reset) + () -> assertThat("Trim should complete and reset flag", sb.isTrimRunning(), is(false)), + + // 2. Data integrity preserved (all written data readable) + () -> { + byte[] result = new byte[2000]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 2000 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat("All 2000 written bytes should be readable", totalRead, is(2000)); + assertThat("First byte intact", result[0], is(anyValue)); + assertThat("Last byte intact", result[1999], is(anyValue)); + }, + + // 3. New configuration takes effect in next operation + () -> { + // maxBufferElements=0 is invalid, should prevent further trims + // Write more data and verify it doesn't trigger another trim + // (trim won't execute because maxBufferElements <= 0 is invalid) + assertThat( + "Invalid maxBufferElements prevents trim", + sb.decideTrimExecution(150, 0, 1500, 50), + is(false)); + }); + + writerThread.join(2000); // Wait for writer thread to finish + } - // 2. Data integrity preserved (all written data readable) - () -> { - byte[] result = new byte[2000]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 2000 - totalRead)) > 0) { - totalRead += bytesRead; + /** + * CORRECTNESS TEST: maxAllocationSize changes during trim don't affect running trim + * + * Similar to the maxBufferElements test, but verifies that changes to maxAllocationSize + * (the chunk size limit during consolidation) don't affect the currently executing trim. + * + * IMPLEMENTATION DETAIL: + * maxAllocationSize is also only read once via getMaxAllocationSize() in isTrimShouldBeExecuted(), + * so trim execution is isolated from config changes. + */ + @DisplayName("setMaxAllocationSize(): during trim execution — does not affect running trim") + @Test + @Timeout(10) + public void setMaxAllocationSize_duringTrimExecution_doesNotAffectRunningTrim() + throws IOException, InterruptedException { + // arrange + StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); + InputStream is = sb.getInputStream(); + + sb.setMaxBufferElements(50); + sb.setMaxAllocationSize(30); // Initial allocation size + + Semaphore trimStarted = new Semaphore(0); + Semaphore trimEnded = new Semaphore(0); + + sb.addTrimStartSignal(trimStarted); + sb.addTrimEndSignal(trimEnded); + + // Writer thread + Thread writerThread = new Thread(() -> { + try { + byte[] chunk = new byte[5]; + Arrays.fill(chunk, anyValue); + // Write 500 chunks (2500 bytes) to trigger trim + for (int i = 0; i < 500; i++) { + os.write(chunk); + Thread.sleep(1); + } + os.close(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); } - assertThat("All 2000 written bytes should be readable", totalRead, is(2000)); - assertThat("First byte intact", result[0], is(anyValue)); - assertThat("Last byte intact", result[1999], is(anyValue)); - }, - - // 3. New configuration takes effect in next operation - () -> { - // maxBufferElements=0 is invalid, should prevent further trims - // Write more data and verify it doesn't trigger another trim - // (trim won't execute because maxBufferElements <= 0 is invalid) - assertThat("Invalid maxBufferElements prevents trim", - sb.decideTrimExecution(150, 0, 1500, 50), is(false)); - } - ); + }); - writerThread.join(2000); // Wait for writer thread to finish - } + writerThread.start(); - /** - * CORRECTNESS TEST: maxAllocationSize changes during trim don't affect running trim - * - * Similar to the maxBufferElements test, but verifies that changes to maxAllocationSize - * (the chunk size limit during consolidation) don't affect the currently executing trim. - * - * IMPLEMENTATION DETAIL: - * maxAllocationSize is also only read once via getMaxAllocationSize() in isTrimShouldBeExecuted(), - * so trim execution is isolated from config changes. - */ - @DisplayName("setMaxAllocationSize(): during trim execution — does not affect running trim") - @Test - @Timeout(10) - public void setMaxAllocationSize_duringTrimExecution_doesNotAffectRunningTrim() throws IOException, InterruptedException { - // arrange - StreamBuffer sb = new StreamBuffer(); - OutputStream os = sb.getOutputStream(); - InputStream is = sb.getInputStream(); - - sb.setMaxBufferElements(50); - sb.setMaxAllocationSize(30); // Initial allocation size - - Semaphore trimStarted = new Semaphore(0); - Semaphore trimEnded = new Semaphore(0); - - sb.addTrimStartSignal(trimStarted); - sb.addTrimEndSignal(trimEnded); - - // Writer thread - Thread writerThread = new Thread(() -> { - try { - byte[] chunk = new byte[5]; - Arrays.fill(chunk, anyValue); - // Write 500 chunks (2500 bytes) to trigger trim - for (int i = 0; i < 500; i++) { - os.write(chunk); - Thread.sleep(1); - } - os.close(); - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); - } - }); - - writerThread.start(); - - // act — Change maxAllocationSize while trim is executing - trimStarted.acquire(); - sb.setMaxAllocationSize(100); // Change to larger chunks mid-trim - trimEnded.acquire(); - - // assert - assertAll( - () -> assertThat("Trim should complete", sb.isTrimRunning(), is(false)), - () -> assertThat("New allocation size is set", sb.getMaxAllocationSize(), is(100L)), - // Verify data integrity - () -> { - byte[] result = new byte[2500]; - int totalRead = 0; - int bytesRead; - while ((bytesRead = is.read(result, totalRead, 2500 - totalRead)) > 0) { - totalRead += bytesRead; - } - assertThat("All data preserved", totalRead, is(2500)); - } - ); + // act — Change maxAllocationSize while trim is executing + trimStarted.acquire(); + sb.setMaxAllocationSize(100); // Change to larger chunks mid-trim + trimEnded.acquire(); - writerThread.join(2000); - } + // assert + assertAll( + () -> assertThat("Trim should complete", sb.isTrimRunning(), is(false)), + () -> assertThat("New allocation size is set", sb.getMaxAllocationSize(), is(100L)), + // Verify data integrity + () -> { + byte[] result = new byte[2500]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = is.read(result, totalRead, 2500 - totalRead)) > 0) { + totalRead += bytesRead; + } + assertThat("All data preserved", totalRead, is(2500)); + }); + writerThread.join(2000); + } } - /** * CORRECTNESS TESTS: Configuration changes don't affect running trim operations * @@ -5689,8 +5680,6 @@ public void setMaxAllocationSize_duringTrimExecution_doesNotAffectRunningTrim() * - New configuration takes effect only in subsequent trim operations */ - - /** * ROBUSTNESS TESTS: Edge cases and stress scenarios for trim operation * @@ -5704,6 +5693,4 @@ public void setMaxAllocationSize_duringTrimExecution_doesNotAffectRunningTrim() * These tests ensure trim is robust against unusual conditions while * maintaining data integrity and flag consistency. */ - - } diff --git a/src/test/java/net/ladenthin/streambuffer/WriteMethod.java b/src/test/java/net/ladenthin/streambuffer/WriteMethod.java index bc6f2d4..7e2a9e2 100644 --- a/src/test/java/net/ladenthin/streambuffer/WriteMethod.java +++ b/src/test/java/net/ladenthin/streambuffer/WriteMethod.java @@ -4,5 +4,7 @@ package net.ladenthin.streambuffer; public enum WriteMethod { - ByteArray, Int, ByteArrayWithParameter; + ByteArray, + Int, + ByteArrayWithParameter; } diff --git a/src/test/java/net/ladenthin/streambuffer/benchmark/StreamBufferThroughputBenchmark.java b/src/test/java/net/ladenthin/streambuffer/benchmark/StreamBufferThroughputBenchmark.java index 783eb39..8363f27 100644 --- a/src/test/java/net/ladenthin/streambuffer/benchmark/StreamBufferThroughputBenchmark.java +++ b/src/test/java/net/ladenthin/streambuffer/benchmark/StreamBufferThroughputBenchmark.java @@ -3,6 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer.benchmark; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.TimeUnit; import net.ladenthin.streambuffer.StreamBuffer; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; @@ -19,11 +23,6 @@ import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.infra.Blackhole; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.concurrent.TimeUnit; - /** * Throughput benchmark for {@link StreamBuffer}. * diff --git a/src/test/java/net/ladenthin/streambuffer/jcstress/CloseDuringReadRace.java b/src/test/java/net/ladenthin/streambuffer/jcstress/CloseDuringReadRace.java index 94d755f..f1f40e9 100644 --- a/src/test/java/net/ladenthin/streambuffer/jcstress/CloseDuringReadRace.java +++ b/src/test/java/net/ladenthin/streambuffer/jcstress/CloseDuringReadRace.java @@ -3,6 +3,8 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer.jcstress; +import java.io.IOException; +import java.io.InputStream; import net.ladenthin.streambuffer.StreamBuffer; import org.openjdk.jcstress.annotations.Actor; import org.openjdk.jcstress.annotations.Description; @@ -13,13 +15,10 @@ import org.openjdk.jcstress.annotations.Signal; import org.openjdk.jcstress.annotations.State; -import java.io.IOException; -import java.io.InputStream; - @JCStressTest(Mode.Termination) @Description("A reader blocked in read() must be unblocked when close() is invoked.") @Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "close() unblocked the reader") -@Outcome(id = "STALE", expect = Expect.FORBIDDEN, desc = "Reader stuck after close()") +@Outcome(id = "STALE", expect = Expect.FORBIDDEN, desc = "Reader stuck after close()") @State public class CloseDuringReadRace { diff --git a/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java b/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java index b801f4d..cc09eba 100644 --- a/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java +++ b/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java @@ -3,6 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer.jcstress; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; import net.ladenthin.streambuffer.StreamBuffer; import org.openjdk.jcstress.annotations.Actor; import org.openjdk.jcstress.annotations.Arbiter; @@ -13,20 +17,15 @@ import org.openjdk.jcstress.annotations.State; import org.openjdk.jcstress.infra.results.Z_Result; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.Arrays; - @JCStressTest @Description("Two concurrent writers must each appear contiguously in the FIFO; bytes must not interleave.") -@Outcome(id = "true", expect = Expect.ACCEPTABLE, desc = "Both payloads intact in some order") -@Outcome(id = "false", expect = Expect.FORBIDDEN, desc = "Torn / interleaved write") +@Outcome(id = "true", expect = Expect.ACCEPTABLE, desc = "Both payloads intact in some order") +@Outcome(id = "false", expect = Expect.FORBIDDEN, desc = "Torn / interleaved write") @State public class ConcurrentWriteRace { - private static final byte[] A = new byte[]{1, 2}; - private static final byte[] B = new byte[]{3, 4}; + private static final byte[] A = new byte[] {1, 2}; + private static final byte[] B = new byte[] {3, 4}; private final StreamBuffer sb = new StreamBuffer(); private final OutputStream os = sb.getOutputStream(); @@ -34,12 +33,18 @@ public class ConcurrentWriteRace { @Actor public void writerA() { - try { os.write(A); } catch (IOException ignored) { } + try { + os.write(A); + } catch (IOException ignored) { + } } @Actor public void writerB() { - try { os.write(B); } catch (IOException ignored) { } + try { + os.write(B); + } catch (IOException ignored) { + } } @Arbiter @@ -49,8 +54,7 @@ public void check(Z_Result r) { byte[] all = new byte[available]; int n = (available == 0) ? 0 : is.read(all, 0, available); byte[] got = Arrays.copyOf(all, Math.max(n, 0)); - r.r1 = Arrays.equals(got, new byte[]{1, 2, 3, 4}) - || Arrays.equals(got, new byte[]{3, 4, 1, 2}); + r.r1 = Arrays.equals(got, new byte[] {1, 2, 3, 4}) || Arrays.equals(got, new byte[] {3, 4, 1, 2}); } catch (IOException e) { r.r1 = false; } diff --git a/src/test/java/net/ladenthin/streambuffer/jcstress/WriteUnblocksReadRace.java b/src/test/java/net/ladenthin/streambuffer/jcstress/WriteUnblocksReadRace.java index 0dcc3cc..d9c3f23 100644 --- a/src/test/java/net/ladenthin/streambuffer/jcstress/WriteUnblocksReadRace.java +++ b/src/test/java/net/ladenthin/streambuffer/jcstress/WriteUnblocksReadRace.java @@ -3,6 +3,9 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer.jcstress; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import net.ladenthin.streambuffer.StreamBuffer; import org.openjdk.jcstress.annotations.Actor; import org.openjdk.jcstress.annotations.Description; @@ -13,14 +16,10 @@ import org.openjdk.jcstress.annotations.Signal; import org.openjdk.jcstress.annotations.State; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - @JCStressTest(Mode.Termination) @Description("A reader blocked in read() must be unblocked when a writer publishes a byte.") @Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "write() unblocked the reader") -@Outcome(id = "STALE", expect = Expect.FORBIDDEN, desc = "Reader stuck after write()") +@Outcome(id = "STALE", expect = Expect.FORBIDDEN, desc = "Reader stuck after write()") @State public class WriteUnblocksReadRace { From 5d3216f5d95c394543c8dd9fa00757bbed662fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 10:04:48 +0000 Subject: [PATCH 06/18] Document JMH benchmark invocation and -prof gc/async usage Adds a Build Commands subsection covering how to run the existing JMH benchmarks under exec-maven-plugin, with filter syntax, the gc allocation profiler, and async-profiler (flamegraph) invocation. --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bf9af4b..c11f8c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,27 @@ mvn test -Dtest=StreamBufferTest#testSimpleRoundTrip mvn org.pitest:pitest-maven:mutationCoverage ``` +**Run JMH benchmarks:** + +JMH benchmarks live in `src/test/java/net/ladenthin/streambuffer/benchmark/` (e.g. `StreamBufferThroughputBenchmark`). They are not executed by `mvn test`; invoke them directly via the `exec-maven-plugin` whose default `mainClass` is `org.openjdk.jmh.Main`: + +```bash +# All benchmarks +mvn test-compile exec:java + +# Filter by regex (class or method name) +mvn test-compile exec:java -Dexec.args="StreamBufferThroughput" + +# Allocation profile (built-in, no extra setup) +mvn test-compile exec:java -Dexec.args="StreamBufferThroughput -prof gc" + +# CPU profile via async-profiler (set ASYNC_PROFILER_LIB to libasyncProfiler.so) +mvn test-compile exec:java \ + -Dexec.args="StreamBufferThroughput -prof async:libPath=$ASYNC_PROFILER_LIB;output=flamegraph" +``` + +`-prof gc` reports `gc.alloc.rate.norm` (bytes allocated per op) — useful for spotting hidden allocations on the read/write hot paths. `-prof async` produces flamegraphs and requires async-profiler installed locally; CI does not run it. + `mvn test` also runs: - **jqwik properties** (`StreamBufferProperties`) — picked up by Surefire as a JUnit 5 engine. - **jcstress** tests under `net.ladenthin.streambuffer.jcstress` — executed in a forked JVM via `exec-maven-plugin` in the `test` phase (`-m quick` mode). From 9bf969aaab5d68c3cea8e0e23bbcd6a33a39f256 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 10:20:51 +0000 Subject: [PATCH 07/18] Add SpotBugs with fb-contrib and findsecbugs plugins Runs SpotBugs at default effort/threshold on main classes during the verify phase, augmented with the fb-contrib and findsecbugs detector plugins. The single initial finding - EI_EXPOSE_REP on getOutputStream/getInputStream - is suppressed via spotbugs-exclude.xml because those streams are the public API of StreamBuffer. --- pom.xml | 36 ++++++++++++++++++++++++++++++++++++ spotbugs-exclude.xml | 25 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 spotbugs-exclude.xml diff --git a/pom.xml b/pom.xml index d5a8b6e..282554b 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,9 @@ SPDX-License-Identifier: Apache-2.0 0.13.4 2.46.1 2.66.0 + 4.8.6.6 + 7.6.4 + 1.13.0 ${git.commit.time} 2014 @@ -307,6 +310,39 @@ SPDX-License-Identifier: Apache-2.0
+ + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.version} + + Default + Default + true + false + spotbugs-exclude.xml + + + com.mebigfatguy.fb-contrib + fb-contrib + ${fb-contrib.version} + + + com.h3xstream.findsecbugs + findsecbugs-plugin + ${findsecbugs.version} + + + + + + spotbugs-check + verify + + check + + + + com.diffplug.spotless spotless-maven-plugin diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..c6c8130 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + From 1d8fe5854f7f8fd86f6e25c2d1507a4fc8539a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 10:55:14 +0000 Subject: [PATCH 08/18] Annotate buffer field and trim path with @GuardedBy("bufferLock") Marks the Deque buffer with @GuardedBy("bufferLock") so Error Prone enforces that every access happens under the bufferLock monitor. Propagates the annotation to the two methods that touch buffer outside an explicit synchronized block - trim() and isTrimShouldBeExecuted() - since their docstrings already require callers to hold bufferLock. Adds error_prone_annotations as a compile dependency. --- pom.xml | 5 +++++ src/main/java/net/ladenthin/streambuffer/StreamBuffer.java | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/pom.xml b/pom.xml index 282554b..6779199 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,11 @@ SPDX-License-Identifier: Apache-2.0 + + com.google.errorprone + error_prone_annotations + ${errorprone.version} + org.junit.jupiter junit-jupiter diff --git a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java index 51c0c22..4933936 100644 --- a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java +++ b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer; +import com.google.errorprone.annotations.concurrent.GuardedBy; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -35,6 +36,7 @@ public class StreamBuffer implements Closeable { /** * The buffer which contains the raw data. */ + @GuardedBy("bufferLock") private final Deque buffer = new LinkedList<>(); /** @@ -423,6 +425,7 @@ public void blockDataAvailable() throws InterruptedException { * Sets {@link #isTrimRunning} volatile flag to prevent statistics updates during internal I/O. * Respects {@link #maxAllocationSize} limit when allocating byte arrays. */ + @GuardedBy("bufferLock") private void trim() throws IOException { if (isTrimShouldBeExecuted()) { isTrimRunning = true; @@ -564,6 +567,7 @@ boolean decideTrimExecution( return true; } + @GuardedBy("bufferLock") boolean isTrimShouldBeExecuted() { /** * Prevent recursive trim: if trim is already running, its internal From 896a16fb6aad2e3fd471e7e14e6e666f49042a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:01:25 +0000 Subject: [PATCH 09/18] Disable GuardedBy check for test compilation Whitebox unit tests intentionally call isTrimShouldBeExecuted() without holding bufferLock to verify the predicate logic in isolation. GuardedBy stays enforced for main code. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6779199..ae7c588 100644 --- a/pom.xml +++ b/pom.xml @@ -187,7 +187,7 @@ SPDX-License-Identifier: Apache-2.0 -XDaddTypeAnnotationsToSymbol=true -XDcompilePolicy=simple --should-stop=ifError=FLOW - -Xplugin:ErrorProne -Xep:NullAway:OFF + -Xplugin:ErrorProne -Xep:NullAway:OFF -Xep:GuardedBy:OFF From c06b88bf72e3f1414b717b33b882bd334aae72af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:39:10 +0000 Subject: [PATCH 10/18] Add ArchUnit architecture tests Encodes two structural invariants as JUnit tests: - mainCodeStaysLeaf: the published library must only depend on the JDK and the Error Prone GuardedBy annotation; any new runtime dependency will surface as a failing test. - dequeFieldsArePrivate: Deque fields must be private, preventing the internal buffer from being exposed via a future getter or package-private leak. --- pom.xml | 7 ++++ .../StreamBufferArchitectureTest.java | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/test/java/net/ladenthin/streambuffer/StreamBufferArchitectureTest.java diff --git a/pom.xml b/pom.xml index ae7c588..367941c 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ SPDX-License-Identifier: Apache-2.0 4.8.6.6 7.6.4 1.13.0 + 1.3.0 ${git.commit.time} 2014 @@ -121,6 +122,12 @@ SPDX-License-Identifier: Apache-2.0 ${jcstress.version} test + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferArchitectureTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferArchitectureTest.java new file mode 100644 index 0000000..61f9755 --- /dev/null +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferArchitectureTest.java @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 +package net.ladenthin.streambuffer; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +@AnalyzeClasses(packages = "net.ladenthin.streambuffer", importOptions = ImportOption.DoNotIncludeTests.class) +public class StreamBufferArchitectureTest { + + /** + * Main code is a leaf: it must depend only on the JDK and the + * Error Prone annotation used to mark lock invariants. Adding a + * runtime dependency would change the published artifact's + * dependency graph and is a deliberate decision; this rule makes + * such a change visible as a test failure. + */ + @ArchTest + static final ArchRule mainCodeStaysLeaf = classes() + .that() + .resideInAPackage("net.ladenthin.streambuffer..") + .should() + .onlyDependOnClassesThat() + .resideInAnyPackage("net.ladenthin.streambuffer..", "java..", "com.google.errorprone.annotations.."); + + /** + * The internal buffer Deque is an implementation detail; no static + * field of that type should escape the class boundary. + */ + @ArchTest + static final ArchRule dequeFieldsArePrivate = + fields().that().haveRawType(java.util.Deque.class).should().bePrivate(); +} From e4096fc7e869392d224b8f482a4f136aa96731e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:50:36 +0000 Subject: [PATCH 11/18] Add Awaitility and ConcurrentUnit as test dependencies Makes both libraries available for the existing test suite: - Awaitility 4.2.2: replaces Thread.sleep + poll patterns with await().until(...) for deterministic concurrency assertions. - ConcurrentUnit 0.4.6: Waiter.assertEquals/assertTrue surface assertion failures from background threads back to the test thread (JUnit can't see them otherwise). No tests refactored in this commit; libraries are added now so future refactors are local, single-purpose changes. --- pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pom.xml b/pom.xml index 367941c..edd49b7 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,8 @@ SPDX-License-Identifier: Apache-2.0 7.6.4 1.13.0 1.3.0 + 4.2.2 + 0.4.6 ${git.commit.time} 2014 @@ -128,6 +130,18 @@ SPDX-License-Identifier: Apache-2.0 ${archunit.version} test + + org.awaitility + awaitility + ${awaitility.version} + test + + + net.jodah + concurrentunit + ${concurrentunit.version} + test + From b675638d6e9392f0a792f2497ee7ac75b24846f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:15:28 +0000 Subject: [PATCH 12/18] Add Lincheck linearizability test for non-blocking operations Lincheck's model checker enumerates thread schedules for write, close, available and isClosed operations. read() is excluded because blocking reads are incompatible with the schedule enumeration model checker - jcstress remains the test for concurrent read+write races. The test runs 20 iterations of 500 invocations each across 2 threads with 3 actors per thread. Local run: ~9s. --- pom.xml | 7 +++ .../StreamBufferLincheckTest.java | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/test/java/net/ladenthin/streambuffer/StreamBufferLincheckTest.java diff --git a/pom.xml b/pom.xml index edd49b7..675a782 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ SPDX-License-Identifier: Apache-2.0 1.3.0 4.2.2 0.4.6 + 2.39 ${git.commit.time} 2014 @@ -142,6 +143,12 @@ SPDX-License-Identifier: Apache-2.0 ${concurrentunit.version} test + + org.jetbrains.kotlinx + lincheck-jvm + ${lincheck.version} + test + diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferLincheckTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferLincheckTest.java new file mode 100644 index 0000000..81309eb --- /dev/null +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferLincheckTest.java @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 +package net.ladenthin.streambuffer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import org.jetbrains.kotlinx.lincheck.LinChecker; +import org.jetbrains.kotlinx.lincheck.annotations.Operation; +import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.ModelCheckingOptions; +import org.junit.jupiter.api.Test; + +/** + * Linearizability check for the non-blocking subset of the StreamBuffer API: + * write, close, available. read() is blocking and therefore excluded - Lincheck's + * model checker enumerates thread schedules and cannot make progress over a + * thread parked in read(). jcstress already covers concurrent read+write races. + */ +public class StreamBufferLincheckTest { + + private final StreamBuffer sb = new StreamBuffer(); + private final OutputStream os = sb.getOutputStream(); + private final InputStream is = sb.getInputStream(); + + @Operation + public void writeByte(int b) throws IOException { + os.write(b); + } + + @Operation + public int available() throws IOException { + return is.available(); + } + + @Operation + public boolean isClosed() { + return sb.isClosed(); + } + + @Operation + public void closeBuffer() throws IOException { + sb.close(); + } + + @Test + public void modelCheckingTest() { + ModelCheckingOptions options = new ModelCheckingOptions() + .iterations(20) + .invocationsPerIteration(500) + .threads(2) + .actorsPerThread(3); + LinChecker.check(StreamBufferLincheckTest.class, options); + } +} From a7c12102a731962bce8121dc107ea80c8c2155a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 12:35:30 +0000 Subject: [PATCH 13/18] Add opt-in vmlens profile for interleaving analysis Wires com.vmlens:api as a test dependency and the vmlens-maven-plugin inside a 'vmlens' profile. Default 'mvn test' is unchanged; running 'mvn -Pvmlens test' drives tests written against AllInterleavings through every possible thread interleaving. Off by default because vmlens overhead is too high for every build. --- CLAUDE.md | 7 +++++++ pom.xml | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c11f8c6..8c47637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,13 @@ mvn test-compile exec:java \ `mvn test` also runs: - **jqwik properties** (`StreamBufferProperties`) — picked up by Surefire as a JUnit 5 engine. - **jcstress** tests under `net.ladenthin.streambuffer.jcstress` — executed in a forked JVM via `exec-maven-plugin` in the `test` phase (`-m quick` mode). +- **Lincheck** linearizability test (`StreamBufferLincheckTest`) over the non-blocking subset (`write`, `available`, `close`, `isClosed`). + +**Opt-in vmlens interleaving analysis:** +```bash +mvn -Pvmlens test +``` +The `vmlens` profile pulls in `com.vmlens:api` and runs the `vmlens-maven-plugin` during the `test` phase. Tests using `com.vmlens.api.AllInterleavings` are then driven through every possible thread interleaving. The profile is off by default — vmlens overhead is too high for every build. ## Architecture diff --git a/pom.xml b/pom.xml index 675a782..9aa58c7 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ SPDX-License-Identifier: Apache-2.0 4.2.2 0.4.6 2.39 + 1.2.28 ${git.commit.time} 2014 @@ -430,6 +431,34 @@ SPDX-License-Identifier: Apache-2.0 + + vmlens + + + com.vmlens + api + ${vmlens.version} + test + + + + + + com.vmlens + vmlens-maven-plugin + ${vmlens.version} + + + vmlens-test + + test + + + + + + + release From ca36cee80887e6a3b6869d37f2e52867b643f5fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 13:50:08 +0000 Subject: [PATCH 14/18] Run vmlens as a parallel CI job alongside the default test job The vmlens profile now excludes StreamBufferLincheckTest because vmlens load-time instrumentation conflicts with the bytecode Lincheck generates for TestThreadExecution (java.lang.VerifyError, stack map mismatch). The default test job still runs Lincheck. Adds a vmlens job to publish.yml that runs in parallel with the regular Test (JDK 21) job, uploads target/vmlens-report as an artifact, and does not block coverage reporting. --- .github/workflows/publish.yml | 17 +++++++++++++++++ pom.xml | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 102ea3d..03f0ca0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,6 +66,23 @@ jobs: path: target/site/jacoco/jacoco.xml if-no-files-found: ignore + vmlens: + name: Test (vmlens interleavings) + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: { java-version: '21', distribution: temurin, cache: maven } + - name: Test under vmlens + run: mvn --batch-mode -Pvmlens test -Dmaven.javadoc.skip=true + - uses: actions/upload-artifact@v7 + if: always() + with: + name: vmlens-report + path: target/vmlens-report/ + if-no-files-found: ignore + report: name: Report needs: [test] diff --git a/pom.xml b/pom.xml index 9aa58c7..df8b88c 100644 --- a/pom.xml +++ b/pom.xml @@ -443,6 +443,21 @@ SPDX-License-Identifier: Apache-2.0 + + org.apache.maven.plugins + maven-surefire-plugin + + + + **/StreamBufferLincheckTest.java + + + com.vmlens vmlens-maven-plugin From f171ae1204cfa8564a0ec6d87d1c67c4b94a809e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 14:11:09 +0000 Subject: [PATCH 15/18] Refactor two concurrency tests to use Awaitility and ConcurrentUnit - read_parallelClose_noDeadlock: replaces Thread.sleep(500) with await().until(reader.getState() in WAITING/TIMED_WAITING). The test now blocks only as long as it actually takes the reader thread to park inside read(), instead of guessing 500ms. - concurrentReadWrite_stressTest: pipes background-thread IOExceptions through net.jodah.concurrentunit.Waiter so a real failure surfaces with its original stack trace instead of being re-thrown as a RuntimeException and lost to Thread.UncaughtExceptionHandler. --- .../streambuffer/StreamBufferTest.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java index 6316745..7c42181 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.streambuffer; +import static org.awaitility.Awaitility.await; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; @@ -688,7 +689,12 @@ public void read_parallelClose_noDeadlock() throws Exception { // act reader.start(); - Thread.sleep(500); // Let the read() call block + // Deterministic wait: park until the reader is actually blocked + // inside read() rather than guessing a sleep duration. + await().atMost(java.time.Duration.ofSeconds(5)).until(() -> { + Thread.State s = reader.getState(); + return s == Thread.State.WAITING || s == Thread.State.TIMED_WAITING; + }); sb.close(); // Should unblock the reader reader.join(); // Ensure thread completes @@ -1318,7 +1324,7 @@ public void close_multipleCalls_noExceptionThrown() throws IOException { class ConcurrentReadWriteTests { @DisplayName("concurrentReadWrite(): stress test — no crash or inconsistency") @Test - public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Exception { + public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Throwable { // arrange final StreamBuffer sb = new StreamBuffer(); final OutputStream os = sb.getOutputStream(); @@ -1327,6 +1333,11 @@ public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Excep final int iterations = 1000; final byte[] written = new byte[iterations]; final byte[] read = new byte[iterations]; + // Pipe background-thread IOExceptions back to the test thread. + // Without a Waiter, a thrown RuntimeException would only reach + // Thread.UncaughtExceptionHandler and the test would later fail + // on an assertion mismatch with no trace of the real cause. + final net.jodah.concurrentunit.Waiter waiter = new net.jodah.concurrentunit.Waiter(); Thread writer = new Thread(() -> { try { @@ -1336,7 +1347,7 @@ public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Excep os.write(val); } } catch (IOException e) { - throw new RuntimeException(e); + waiter.fail(e); } }); @@ -1347,7 +1358,7 @@ public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Excep read[i] = (byte) value; } } catch (IOException e) { - throw new RuntimeException(e); + waiter.fail(e); } }); @@ -1356,6 +1367,8 @@ public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Excep reader.start(); writer.join(); reader.join(); + // Re-raises any waiter.fail(...) call on the test thread. + waiter.resume(); // assert assertArrayEquals(written, read, "Read data should match written data"); From 4f255816acd0b87f56146c118e4b9c807fcc17b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 14:25:34 +0000 Subject: [PATCH 16/18] Move vmlens Lincheck exclude to the vmlens-maven-plugin config The vmlens-maven-plugin runs its own forked surefire and does not pick up excludes configured on the standalone maven-surefire-plugin. Moving the **/StreamBufferLincheckTest.java exclude into the vmlens plugin's own block (preserving its **/*$* default for inner classes) is what actually skips the Lincheck test under vmlens. Local: mvn -Pvmlens test now passes in ~4 min with 269/270 tests (Lincheck skipped, runs in the default test job). --- pom.xml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index df8b88c..86108c8 100644 --- a/pom.xml +++ b/pom.xml @@ -444,24 +444,22 @@ SPDX-License-Identifier: Apache-2.0 - org.apache.maven.plugins - maven-surefire-plugin + com.vmlens + vmlens-maven-plugin + ${vmlens.version} + **/*$* **/StreamBufferLincheckTest.java - - - com.vmlens - vmlens-maven-plugin - ${vmlens.version} vmlens-test From 65f6c03163e8d99b82345b19adb03744663dc84c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 14:30:05 +0000 Subject: [PATCH 17/18] Narrow concurrentReadWrite_stressTest throws clause from Throwable to Exception Sonar's reliability rule java:S1181 flags methods declaring Throwable because it lumps Errors with checked Exceptions. Waiter.resume() only throws AssertionError (unchecked) and Thread.join() throws InterruptedException; throws Exception is sufficient and matches the original signature. --- src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java index 7c42181..e64ccfa 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java @@ -1324,7 +1324,7 @@ public void close_multipleCalls_noExceptionThrown() throws IOException { class ConcurrentReadWriteTests { @DisplayName("concurrentReadWrite(): stress test — no crash or inconsistency") @Test - public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Throwable { + public void concurrentReadWrite_stressTest_noCrashOrInconsistency() throws Exception { // arrange final StreamBuffer sb = new StreamBuffer(); final OutputStream os = sb.getOutputStream(); From 699abb61d22309e43c039b2c438a1eef3d36e034 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 22:38:09 +0000 Subject: [PATCH 18/18] Address blocking SonarCloud findings in tests - Fix Reliability bug at StreamBufferTest:858: hoist os.close() out of the assertThrows lambda so only writeAnyValue() can produce the expected IOException (java:S5778). - Add explicit assertions to six tests Sonar flagged as having none (assertDoesNotThrow / assertTimeoutPreemptively / state checks). - Refactor write_nullArrayWithOffset_throwsNPE so the assertThrows lambda has exactly one possibly-throwing call. - Rename WriteMethod constants to UPPER_SNAKE_CASE to satisfy the Java naming convention (java:S115). - Explain why the IOException catches in ConcurrentWriteRace are intentionally empty (race with concurrent close()). --- .../streambuffer/StreamBufferTest.java | 67 +++++++++---------- .../ladenthin/streambuffer/WriteMethod.java | 6 +- .../jcstress/ConcurrentWriteRace.java | 3 + 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java index e64ccfa..8c47a68 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java @@ -11,11 +11,14 @@ import static org.hamcrest.number.OrderingComparison.greaterThanOrEqualTo; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import java.io.*; +import java.time.Duration; import java.util.Arrays; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -38,9 +41,9 @@ public class StreamBufferTest { static Stream writeMethods() { return Stream.of( - Arguments.of(WriteMethod.ByteArray), - Arguments.of(WriteMethod.Int), - Arguments.of(WriteMethod.ByteArrayWithParameter)); + Arguments.of(WriteMethod.BYTE_ARRAY), + Arguments.of(WriteMethod.INT), + Arguments.of(WriteMethod.BYTE_ARRAY_WITH_PARAMETER)); } /** @@ -233,8 +236,8 @@ class ConstructorTests { public void constructor_noArguments_NoExceptionThrown() { // arrange // act - new StreamBuffer(); // assert — no exception thrown + assertDoesNotThrow(() -> new StreamBuffer()); } } @@ -849,16 +852,14 @@ public void write_withValidOffset_partialWriteSuccessful() throws IOException { @DisplayName("write(): closed stream — throw io exception") @ParameterizedTest @MethodSource("net.ladenthin.streambuffer.StreamBufferTest#writeMethods") - public void write_closedStream_throwIOException(WriteMethod writeMethod) { + public void write_closedStream_throwIOException(WriteMethod writeMethod) throws IOException { // arrange StreamBuffer sb = new StreamBuffer(); OutputStream os = sb.getOutputStream(); + os.close(); // act // assert - assertThrows(IOException.class, () -> { - os.close(); - writeAnyValue(writeMethod, os); - }); + assertThrows(IOException.class, () -> writeAnyValue(writeMethod, os)); } @DisplayName("write(): invalid offset — not written") @@ -896,21 +897,22 @@ public void write_invalidLength_notWritten() throws IOException { public void write_nullArrayWithOffset_throwsNPE() { // arrange StreamBuffer sb = new StreamBuffer(); + OutputStream os = sb.getOutputStream(); // act // assert - assertThrows(NullPointerException.class, () -> sb.getOutputStream().write(null, 0, 1)); + assertThrows(NullPointerException.class, () -> os.write(null, 0, 1)); } } private void writeAnyValue(WriteMethod writeMethod, OutputStream os) throws IOException { switch (writeMethod) { - case ByteArray: + case BYTE_ARRAY: os.write(new byte[] {anyValue}); break; - case Int: + case INT: os.write(anyValue); break; - case ByteArrayWithParameter: + case BYTE_ARRAY_WITH_PARAMETER: os.write(new byte[] {anyValue, 0, 1}); break; default: @@ -1028,7 +1030,7 @@ public void run() { } } }); - writeAnyValue(WriteMethod.Int, os); + writeAnyValue(WriteMethod.INT, os); is.read(); // act @@ -1129,15 +1131,14 @@ public void run() { @DisplayName("blockDataAvailable(): stream already closed — return") @Test - public void blockDataAvailable_streamAlreadyClosed_return() throws IOException, InterruptedException { + public void blockDataAvailable_streamAlreadyClosed_return() throws IOException { // arrange final StreamBuffer sb = new StreamBuffer(); - - // act sb.close(); - sb.blockDataAvailable(); - // assert — no exception thrown + // act + // assert — returns immediately without throwing + assertDoesNotThrow(() -> sb.blockDataAvailable()); } @DisplayName("blockDataAvailable(): data already available — only one wakeup") @@ -1195,10 +1196,8 @@ public void blockDataAvailable_multipleWritesBeforeCall_doesNotBlock() throws Ex os.write(anyValue); // act - // Should not block since data is already written - sb.blockDataAvailable(); - - // assert — does not block + // assert — does not block since data is already written + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> sb.blockDataAvailable()); } @DisplayName("blockDataAvailable(): after bytes consumed — blocks again") @@ -1289,16 +1288,15 @@ public void trim_preservesAllBytesInCorrectOrder() throws Exception { @DisplayName("trim(): empty buffer — no exception thrown") @Test - public void trim_emptyBuffer_noExceptionThrown() throws IOException { + public void trim_emptyBuffer_noExceptionThrown() { // arrange StreamBuffer sb = new StreamBuffer(); sb.setMaxBufferElements(1); + OutputStream os = sb.getOutputStream(); // act - // nothing written yet, but trim should not fail - sb.getOutputStream().write(new byte[0]); - - // assert — no exception thrown + // assert — empty write triggers trim path without throwing + assertDoesNotThrow(() -> os.write(new byte[0])); } } @@ -1307,15 +1305,14 @@ public void trim_emptyBuffer_noExceptionThrown() throws IOException { class CloseTests { @DisplayName("close(): multiple calls — no exception thrown") @Test - public void close_multipleCalls_noExceptionThrown() throws IOException { + public void close_multipleCalls_noExceptionThrown() { // arrange StreamBuffer sb = new StreamBuffer(); // act - sb.close(); - sb.close(); // Should not throw - - // assert — no exception thrown + // assert — second close must be idempotent + assertDoesNotThrow(() -> sb.close()); + assertDoesNotThrow(() -> sb.close()); } } @@ -2023,7 +2020,9 @@ public void concurrentTrimAndWrite_noCrashOrCorruption() throws Exception { trimmer.join(); reader.join(); - // assert — no crash or data corruption + // assert — reader drained the full stream without crash or corruption + assertThat(is.available(), is(0)); + assertThat(sb.isClosed(), is(true)); } } diff --git a/src/test/java/net/ladenthin/streambuffer/WriteMethod.java b/src/test/java/net/ladenthin/streambuffer/WriteMethod.java index 7e2a9e2..e27862f 100644 --- a/src/test/java/net/ladenthin/streambuffer/WriteMethod.java +++ b/src/test/java/net/ladenthin/streambuffer/WriteMethod.java @@ -4,7 +4,7 @@ package net.ladenthin.streambuffer; public enum WriteMethod { - ByteArray, - Int, - ByteArrayWithParameter; + BYTE_ARRAY, + INT, + BYTE_ARRAY_WITH_PARAMETER; } diff --git a/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java b/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java index cc09eba..6d57ba5 100644 --- a/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java +++ b/src/test/java/net/ladenthin/streambuffer/jcstress/ConcurrentWriteRace.java @@ -36,6 +36,8 @@ public void writerA() { try { os.write(A); } catch (IOException ignored) { + // Writes racing with a concurrent close() may legitimately throw; + // the arbiter only checks the final buffer state, not write success. } } @@ -44,6 +46,7 @@ public void writerB() { try { os.write(B); } catch (IOException ignored) { + // See writerA(): IOException here is an acceptable race outcome. } }