Skip to content

Commit b457cd6

Browse files
dfa1claude
andcommitted
test: add pitest mutation testing and kill survivors
Add an opt-in `pitest` profile (mirrors vortex-java): common PIT setup in the parent pom's pluginManagement, the zstd module names its targetClasses. PIT minions get --enable-native-access so they can drive the native library. Run: ./mvnw -pl zstd -P pitest verify Raise mutation test strength 76% -> 84% with targeted unit tests: - zero-copy heap-segment rejection for the dictionary compress/decompress overloads and the previously-uncovered dst/src sides - fluent return-self contract for reset / loadDictionary / refDictionary - cached-level semantics of reset across the legacy dictionary path - ZstdFrame: sane blockSizeMax mask, truncated-header rejection, dictId and header through the segment overloads, garbage/standard frame negatives - pin Zstd.defaultCompressionLevel() to 3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7112494 commit b457cd6

7 files changed

Lines changed: 345 additions & 0 deletions

File tree

pom.xml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
<maven-release-plugin.version>3.3.1</maven-release-plugin.version>
5959
<maven-jar-plugin.version>3.4.2</maven-jar-plugin.version>
6060
<jacoco-maven-plugin.version>0.8.15</jacoco-maven-plugin.version>
61+
<pitest-maven.version>1.25.5</pitest-maven.version>
62+
<pitest-junit5-plugin.version>1.2.3</pitest-junit5-plugin.version>
6163

6264
<!-- SonarCloud analysis (.github/workflows/sonar.yml). -->
6365
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
@@ -408,5 +410,49 @@
408410
</plugins>
409411
</build>
410412
</profile>
413+
<!-- Mutation testing (opt-in: -P pitest). Common PIT setup lives here once; the
414+
zstd module's own `pitest` profile only adds the plugin to <plugins> and names
415+
its <targetClasses>. PIT forks minion JVMs that exercise the native library, so
416+
they get the same enable-native-access flag as surefire. -->
417+
<profile>
418+
<id>pitest</id>
419+
<build>
420+
<pluginManagement>
421+
<plugins>
422+
<plugin>
423+
<groupId>org.pitest</groupId>
424+
<artifactId>pitest-maven</artifactId>
425+
<version>${pitest-maven.version}</version>
426+
<dependencies>
427+
<dependency>
428+
<groupId>org.pitest</groupId>
429+
<artifactId>pitest-junit5-plugin</artifactId>
430+
<version>${pitest-junit5-plugin.version}</version>
431+
</dependency>
432+
</dependencies>
433+
<executions>
434+
<execution>
435+
<id>pit-report</id>
436+
<phase>verify</phase>
437+
<goals>
438+
<goal>mutationCoverage</goal>
439+
</goals>
440+
</execution>
441+
</executions>
442+
<configuration>
443+
<jvmArgs>
444+
<param>--enable-native-access=ALL-UNNAMED</param>
445+
</jvmArgs>
446+
<outputFormats>
447+
<param>HTML</param>
448+
<param>XML</param>
449+
</outputFormats>
450+
<timestampedReports>false</timestampedReports>
451+
</configuration>
452+
</plugin>
453+
</plugins>
454+
</pluginManagement>
455+
</build>
456+
</profile>
411457
</profiles>
412458
</project>

zstd/pom.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,24 @@
116116
</dependency>
117117
</dependencies>
118118
</profile>
119+
<!-- Mutation testing (opt-in): ./mvnw -pl zstd -P pitest verify
120+
Common PIT setup is inherited from the parent `pitest` profile; this only
121+
names the scope. Report: zstd/target/pit-reports/. -->
122+
<profile>
123+
<id>pitest</id>
124+
<build>
125+
<plugins>
126+
<plugin>
127+
<groupId>org.pitest</groupId>
128+
<artifactId>pitest-maven</artifactId>
129+
<configuration>
130+
<targetClasses>
131+
<param>io.github.dfa1.zstd.*</param>
132+
</targetClasses>
133+
</configuration>
134+
</plugin>
135+
</plugins>
136+
</build>
137+
</profile>
119138
</profiles>
120139
</project>

zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,38 @@ void nullNativeSegmentClearsTheLoadedDictionary() {
482482
assertThat(cleared).isEqualTo(noDict);
483483
}
484484

485+
@Test
486+
void loadAndRefReturnTheSameCompressContext() {
487+
// Given a compress context and dictionaries to load and reference
488+
try (Arena arena = Arena.ofConfined();
489+
ZstdCompressCtx cctx = new ZstdCompressCtx();
490+
ZstdCompressDict cdict = new ZstdCompressDict(sut, 19)) {
491+
492+
// Then every sticky-dictionary call returns the same context, for chaining
493+
assertThat(cctx.loadDictionary(sut)).isSameAs(cctx);
494+
assertThat(cctx.loadDictionary(nativeDict(arena, sut.toByteArray()))).isSameAs(cctx);
495+
assertThat(cctx.loadDictionary((ZstdDictionary) null)).isSameAs(cctx);
496+
assertThat(cctx.refDictionary(cdict)).isSameAs(cctx);
497+
assertThat(cctx.refDictionary(null)).isSameAs(cctx);
498+
}
499+
}
500+
501+
@Test
502+
void loadAndRefReturnTheSameDecompressContext() {
503+
// Given a decompress context and dictionaries to load and reference
504+
try (Arena arena = Arena.ofConfined();
505+
ZstdDecompressCtx dctx = new ZstdDecompressCtx();
506+
ZstdDecompressDict ddict = new ZstdDecompressDict(sut)) {
507+
508+
// Then every sticky-dictionary call returns the same context, for chaining
509+
assertThat(dctx.loadDictionary(sut)).isSameAs(dctx);
510+
assertThat(dctx.loadDictionary(nativeDict(arena, sut.toByteArray()))).isSameAs(dctx);
511+
assertThat(dctx.loadDictionary((ZstdDictionary) null)).isSameAs(dctx);
512+
assertThat(dctx.refDictionary(ddict)).isSameAs(dctx);
513+
assertThat(dctx.refDictionary(null)).isSameAs(dctx);
514+
}
515+
}
516+
485517
private MemorySegment nativeDict(Arena arena, byte[] raw) {
486518
MemorySegment seg = arena.allocate(raw.length);
487519
MemorySegment.copy(raw, 0, seg, ValueLayout.JAVA_BYTE, 0, raw.length);

zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.lang.foreign.MemorySegment;
1010
import java.nio.charset.StandardCharsets;
1111
import java.util.ArrayList;
12+
import java.util.Arrays;
1213
import java.util.List;
1314

1415
import static org.assertj.core.api.Assertions.assertThat;
@@ -117,6 +118,28 @@ void reflectsChecksumFlag() {
117118
}
118119
assertThat(ZstdFrame.header(frame).hasChecksum()).isTrue();
119120
}
121+
122+
@Test
123+
void reportsASaneBlockSizeMax() {
124+
// Given a default frame
125+
ZstdFrameHeader header = ZstdFrame.header(Zstd.compress(PAYLOAD));
126+
127+
// Then blockSizeMax is the masked 32-bit field — a real block size, never
128+
// the all-ones value a sign-extension or wrong mask would produce
129+
assertThat(header.blockSizeMax()).isPositive().isLessThanOrEqualTo(128 * 1024L);
130+
}
131+
132+
@Test
133+
void rejectsATruncatedHeader() {
134+
// Given the first two bytes of a frame — too short to hold a header
135+
byte[] truncated = Arrays.copyOf(Zstd.compress(PAYLOAD), 2);
136+
137+
// When its header is parsed
138+
ThrowingCallable result = () -> ZstdFrame.header(truncated);
139+
140+
// Then it fails instead of returning a bogus header
141+
assertThatThrownBy(result).isInstanceOf(ZstdException.class);
142+
}
120143
}
121144

122145
@Nested
@@ -196,6 +219,22 @@ void matchesTheDictionaryUsed() {
196219
assertThat(ZstdFrame.dictId(frame)).isEqualTo(dict.id());
197220
}
198221

222+
@Test
223+
void matchesThroughTheSegmentOverload() {
224+
// Given a dictionary frame in a native segment
225+
ZstdDictionary dict = trainDict();
226+
byte[] frame;
227+
try (ZstdCompressCtx ctx = new ZstdCompressCtx()) {
228+
frame = ctx.compress(PAYLOAD, dict);
229+
}
230+
try (Arena arena = Arena.ofConfined()) {
231+
MemorySegment seg = Zstd.copyIn(arena, frame);
232+
233+
// Then the segment overload reports the same non-zero id
234+
assertThat(ZstdFrame.dictId(seg)).isNotZero().isEqualTo(dict.id());
235+
}
236+
}
237+
199238
private ZstdDictionary trainDict() {
200239
List<byte[]> samples = new ArrayList<>();
201240
for (int i = 0; i < 3000; i++) {
@@ -235,5 +274,39 @@ void recognisesASkippableNativeFrame() {
235274
assertThat(ZstdFrame.isSkippableFrame(seg)).isTrue();
236275
}
237276
}
277+
278+
@Test
279+
void headerThroughTheSegmentOverloadMatchesTheByteArrayForm() {
280+
// Given a frame in a native segment
281+
byte[] frame = Zstd.compress(PAYLOAD);
282+
try (Arena arena = Arena.ofConfined()) {
283+
MemorySegment seg = Zstd.copyIn(arena, frame);
284+
285+
// Then the parsed header equals the one from the byte[] overload
286+
assertThat(ZstdFrame.header(seg)).isEqualTo(ZstdFrame.header(frame));
287+
}
288+
}
289+
290+
@Test
291+
void garbageNativeSegmentIsNotAFrame() {
292+
// Given non-frame bytes in a native segment
293+
try (Arena arena = Arena.ofConfined()) {
294+
MemorySegment seg = Zstd.copyIn(arena, "not a frame".getBytes(StandardCharsets.UTF_8));
295+
296+
// Then the segment overload rejects it
297+
assertThat(ZstdFrame.isZstdFrame(seg)).isFalse();
298+
}
299+
}
300+
301+
@Test
302+
void standardNativeFrameIsNotSkippable() {
303+
// Given a standard frame in a native segment
304+
try (Arena arena = Arena.ofConfined()) {
305+
MemorySegment seg = Zstd.copyIn(arena, Zstd.compress(PAYLOAD));
306+
307+
// Then the segment overload reports it is not skippable
308+
assertThat(ZstdFrame.isSkippableFrame(seg)).isFalse();
309+
}
310+
}
238311
}
239312
}

zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,59 @@ void decompressContextStillDecodesAfterReset() {
216216
}
217217
}
218218

219+
@Test
220+
void resetReturnsTheSameContext() {
221+
// Given both contexts
222+
try (ZstdCompressCtx cctx = new ZstdCompressCtx();
223+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
224+
// Then reset returns the same instance, for chaining
225+
assertThat(cctx.reset(ZstdResetDirective.SESSION_ONLY)).isSameAs(cctx);
226+
assertThat(dctx.reset(ZstdResetDirective.SESSION_ONLY)).isSameAs(dctx);
227+
}
228+
}
229+
230+
@Test
231+
void sessionOnlyKeepsTheCachedLevelForTheLegacyDictionaryPath() {
232+
// Given a level-19 context reset for the session only, then compressing
233+
// against a dictionary — the legacy path that reads the cached level field
234+
ZstdDictionary dict =
235+
ZstdDictionary.of("dictionary sample payload ".repeat(64).getBytes(StandardCharsets.UTF_8));
236+
byte[] afterSessionReset;
237+
try (ZstdCompressCtx sut = new ZstdCompressCtx().level(19)) {
238+
sut.compress(PAYLOAD, dict);
239+
sut.reset(ZstdResetDirective.SESSION_ONLY);
240+
afterSessionReset = sut.compress(PAYLOAD, dict);
241+
}
242+
byte[] freshLevel19;
243+
try (ZstdCompressCtx ctx = new ZstdCompressCtx().level(19)) {
244+
freshLevel19 = ctx.compress(PAYLOAD, dict);
245+
}
246+
247+
// Then the cached level survives the session-only reset
248+
assertThat(afterSessionReset).isEqualTo(freshLevel19);
249+
}
250+
251+
@Test
252+
void parameterResetClearsTheCachedLevelForTheLegacyDictionaryPath() {
253+
// Given a level-19 context with parameters reset, then compressing against
254+
// a dictionary via the legacy path that reads the cached level field
255+
ZstdDictionary dict =
256+
ZstdDictionary.of("dictionary sample payload ".repeat(64).getBytes(StandardCharsets.UTF_8));
257+
byte[] afterParameterReset;
258+
try (ZstdCompressCtx sut = new ZstdCompressCtx().level(19)) {
259+
sut.compress(PAYLOAD, dict);
260+
sut.reset(ZstdResetDirective.PARAMETERS);
261+
afterParameterReset = sut.compress(PAYLOAD, dict);
262+
}
263+
byte[] freshDefaultLevel;
264+
try (ZstdCompressCtx ctx = new ZstdCompressCtx()) {
265+
freshDefaultLevel = ctx.compress(PAYLOAD, dict);
266+
}
267+
268+
// Then the cached level fell back to the default
269+
assertThat(afterParameterReset).isEqualTo(freshDefaultLevel);
270+
}
271+
219272
@Test
220273
void rejectsNullDirective() {
221274
// Given a compression context

0 commit comments

Comments
 (0)