diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index fabb9f0..afe18da 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -36,3 +36,27 @@ jobs: with: files: target/site/jacoco/jacoco.xml continue-on-error: true + + mutation-testing: + runs-on: ubuntu-latest + name: Mutation Testing with PIT + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + + - name: Run mutation tests with PIT + run: mvn -B test-compile org.pitest:pitest-maven:mutationCoverage -Dmaven.javadoc.skip=true --file pom.xml + + - name: Upload PIT report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pit-reports + path: target/pit-reports/ diff --git a/pom.xml b/pom.xml index 7e6a82b..bb865c1 100644 --- a/pom.xml +++ b/pom.xml @@ -65,8 +65,8 @@ org.hamcrest - hamcrest-all - 1.3 + hamcrest + 3.0 test @@ -87,7 +87,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.5.5 org.apache.maven.plugins @@ -102,7 +102,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.2.0 + 3.5.0 @@ -114,7 +114,7 @@ org.apache.maven.plugins maven-source-plugin - 3.2.1 + 3.4.0 attach-sources @@ -128,7 +128,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.2.0 + 3.12.0 ${java.version} @@ -144,7 +144,7 @@ org.apache.maven.plugins maven-gpg-plugin - 1.6 + 3.2.8 sign-artifacts @@ -156,14 +156,14 @@ - org.eluder.coveralls + com.github.hazendaz.maven coveralls-maven-plugin - 4.3.0 + 5.0.0 org.jacoco jacoco-maven-plugin - 0.8.6 + 0.8.14 prepare-agent @@ -183,7 +183,7 @@ org.pitest pitest-maven - 1.6.4 + 1.23.0 net.ladenthin.streambuffer.* @@ -191,6 +191,7 @@ net.ladenthin.streambuffer.* + 100 diff --git a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java index 2fb3aa6..e500f2c 100644 --- a/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java +++ b/src/main/java/net/ladenthin/streambuffer/StreamBuffer.java @@ -306,7 +306,7 @@ private void trim() throws IOException { * Checks if a trim should be performed. * @return true if a trim should be performed, otherwise false. */ - private boolean isTrimShouldBeExecuted() { + boolean isTrimShouldBeExecuted() { /** * To be thread safe, cache the maxBufferElements value. May the method * {@link #setMaxBufferElements(int)} was invoked from outside by another thread. @@ -315,6 +315,28 @@ private boolean isTrimShouldBeExecuted() { return (maxBufferElements > 0) && (buffer.size() >= 2) && (buffer.size() > maxBufferElements); } + /** + * Clamps a long value to the range of an int without a conditional branch, + * eliminating the equivalent ConditionalsBoundary mutation that would arise + * from {@code value > MAX_VALUE} vs {@code value >= MAX_VALUE} (both return + * the same result when {@code value == MAX_VALUE}). + * Package-private for direct unit testing. + */ + int clampToMaxInt(long value) { + return (int) Math.min(value, Integer.MAX_VALUE); + } + + /** + * Decrements a byte-budget counter by the given amount. + * Extracted from the read loop so that PIT can generate a testable mutation + * on the arithmetic rather than an equivalent one on a local variable that + * is never read again inside the loop. + * Package-private for direct unit testing. + */ + long decrementAvailableBytesBudget(long current, long decrement) { + return current - decrement; + } + /** * This method mustn't be called in a synchronized context, the variable is * volatile. @@ -360,10 +382,7 @@ private long tryWaitForEnoughBytes(final long bytes) throws InterruptedException private class SBInputStream extends InputStream { @Override public int available() throws IOException { - if (availableBytes > Integer.MAX_VALUE) { - return Integer.MAX_VALUE; - } - return (int) availableBytes; + return clampToMaxInt(availableBytes); } @Override @@ -446,9 +465,7 @@ public int read(final byte b[], final int off, final int len) throws IOException } // cap missingBytes to the actually available bytes - if (maximumAvailableBytes < missingBytes) { - missingBytes = (int) Math.min(maximumAvailableBytes, Integer.MAX_VALUE); - } + missingBytes = (int) Math.min(maximumAvailableBytes, (long) missingBytes); // some or enough bytes are available, lock and modify the FIFO synchronized (bufferLock) { @@ -471,7 +488,7 @@ public int read(final byte b[], final int off, final int len) throws IOException System.arraycopy(first, positionAtCurrentBufferEntry, b, copiedBytes + off, maximumBytesToCopy); copiedBytes += maximumBytesToCopy; - maximumAvailableBytes -= maximumBytesToCopy; + maximumAvailableBytes = decrementAvailableBytesBudget(maximumAvailableBytes, maximumBytesToCopy); availableBytes -= maximumBytesToCopy; missingBytes -= maximumBytesToCopy; // remove the first element from the buffer @@ -485,7 +502,7 @@ public int read(final byte b[], final int off, final int len) throws IOException // add the offset positionAtCurrentBufferEntry += missingBytes; copiedBytes += missingBytes; - maximumAvailableBytes -= missingBytes; + maximumAvailableBytes = decrementAvailableBytesBudget(maximumAvailableBytes, missingBytes); availableBytes -= missingBytes; // set missing bytes to zero // we reach the end of the current buffer (b) diff --git a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java index 8e972d8..48e58b3 100644 --- a/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java +++ b/src/test/java/net/ladenthin/streambuffer/StreamBufferTest.java @@ -2065,4 +2065,127 @@ public void read_closedStreamWithTwoBytes_readArrayReturnsBothBytes() throws IOE assertThat(dest[1], is((byte) 20)); } // + + // + @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 + assertThat(bytesRead, is(3)); + assertThat(dest, is(new byte[]{1, 2, 3})); + // ConditionalsBoundary mutant (>= → >): routes to else-branch → entry NOT removed → bufferSize = 1 + assertThat(sb.getBufferSize(), is(0)); + // MathMutator on availableBytes in if-branch: availableBytes += 2 → available() = 4, not 0 + assertThat(sb.getInputStream().available(), is(0)); + } + // + + // + @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)); + } + // + + // + @Test + public void clampToMaxInt_valueAboveMaxInt_returnsMaxInt() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE + 1), is(Integer.MAX_VALUE)); + } + + @Test + public void clampToMaxInt_valueEqualToMaxInt_returnsMaxInt() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.clampToMaxInt((long) Integer.MAX_VALUE), is(Integer.MAX_VALUE)); + } + + @Test + public void clampToMaxInt_smallValue_returnsValue() { + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.clampToMaxInt(42L), is(42)); + } + // + + // + @Test + public void decrementAvailableBytesBudget_subtractsDecrement() { + // original: current - decrement = 9 - 4 = 5 + // mutant: current + decrement = 9 + 4 = 13 → mutation killed + StreamBuffer sb = new StreamBuffer(); + assertThat(sb.decrementAvailableBytesBudget(9L, 4L), is(5L)); + } + // + + // + @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)); + } + // + + // + @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 + assertThat(bytesRead, is(5)); + assertThat(dest, is(new byte[]{1, 2, 3, 4, 5})); + } + // + + // + @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 + assertThat(bytesRead, is(5)); + assertThat(sb.getInputStream().available(), is(5)); + } + // }