Skip to content

Commit ff115cd

Browse files
dfa1claude
andcommitted
test: strengthen tests against PIT mutants (85% -> 90% strength)
Mutation analysis (./mvnw -pl zstd -P pitest verify) surfaced surviving mutants in the I/O streams, context dictionary/prefix paths, size guards, and native-library lookup. Add behavior-asserting tests that kill them: - ZstdStreamTest: flush reaches the sink, close idempotency, unsigned read() value, EOF stays -1 across both read overloads, refill boundaries - RefPrefixTest: fluent setters return the same context (chaining contract) - NativeLibraryTest: missing-symbol lookup throws - ZstdParameterTest: level() actually applies to the native path - ZstdSegmentTest / ZstdErrorTest: decompress dict path, native error name Killed 327 -> 354 of 406 mutations; test strength 85% -> 90%; no-coverage 21 -> 12. Remaining survivors are mostly equivalent (native-call wrapper lambda returns). Tests only; no production changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9c08c4 commit ff115cd

6 files changed

Lines changed: 424 additions & 0 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
import org.junit.jupiter.params.ParameterizedTest;
77
import org.junit.jupiter.params.provider.CsvSource;
88

9+
import java.lang.foreign.FunctionDescriptor;
10+
import java.lang.invoke.MethodHandle;
11+
12+
import static java.lang.foreign.ValueLayout.JAVA_INT;
913
import static org.assertj.core.api.Assertions.assertThat;
1014
import static org.assertj.core.api.Assertions.assertThatThrownBy;
1115

@@ -48,6 +52,37 @@ void rejectsUnsupportedArchitecture() {
4852
}
4953
}
5054

55+
@Nested
56+
class Lookup {
57+
58+
@Test
59+
void bindsAnExistingSymbolToAnInvokableHandle() throws Throwable {
60+
// Given the descriptor for unsigned ZSTD_versionNumber(void)
61+
FunctionDescriptor fd = FunctionDescriptor.of(JAVA_INT);
62+
63+
// When the symbol is looked up
64+
MethodHandle handle = NativeLibrary.lookup("ZSTD_versionNumber", fd);
65+
66+
// Then a real, callable handle comes back (not null) and reports a version
67+
assertThat(handle).isNotNull();
68+
assertThat((int) handle.invokeExact()).isPositive();
69+
}
70+
71+
@Test
72+
void rejectsAMissingSymbol() {
73+
// Given a symbol the library does not export
74+
FunctionDescriptor fd = FunctionDescriptor.of(JAVA_INT);
75+
76+
// When it is looked up
77+
ThrowingCallable result = () -> NativeLibrary.lookup("ZSTD_no_such_symbol_xyz", fd);
78+
79+
// Then it fails fast naming the missing symbol, rather than returning null
80+
assertThatThrownBy(result)
81+
.isInstanceOf(UnsatisfiedLinkError.class)
82+
.hasMessageContaining("Symbol not found");
83+
}
84+
}
85+
5186
@Nested
5287
class LibraryExtension {
5388

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,86 @@ void decompressRefPrefixRejectsAHeapSegment() {
179179
.hasMessageContaining("prefix");
180180
}
181181

182+
@Test
183+
void compressLoadDictionaryRejectsAHeapSegment() {
184+
// Given a heap-backed segment
185+
MemorySegment heap = MemorySegment.ofArray("dictionary".getBytes());
186+
187+
// When loaded as a zero-copy dictionary
188+
ThrowingCallable result = () -> {
189+
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
190+
cctx.loadDictionary(heap);
191+
}
192+
};
193+
194+
// Then it is rejected naming the offending argument
195+
assertThatThrownBy(result)
196+
.isInstanceOf(IllegalArgumentException.class)
197+
.hasMessageContaining("dict");
198+
}
199+
200+
@Test
201+
void decompressLoadDictionaryRejectsAHeapSegment() {
202+
// Given a heap-backed segment
203+
MemorySegment heap = MemorySegment.ofArray("dictionary".getBytes());
204+
205+
// When loaded as a zero-copy dictionary
206+
ThrowingCallable result = () -> {
207+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
208+
dctx.loadDictionary(heap);
209+
}
210+
};
211+
212+
// Then it is rejected naming the offending argument
213+
assertThatThrownBy(result)
214+
.isInstanceOf(IllegalArgumentException.class)
215+
.hasMessageContaining("dict");
216+
}
217+
218+
@Test
219+
void refPrefixReturnsTheSameContextForChaining() {
220+
// Given both contexts and a native prefix
221+
try (Arena arena = Arena.ofConfined();
222+
ZstdCompressCtx cctx = new ZstdCompressCtx();
223+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
224+
MemorySegment prefix = copy(arena, "a prior version".getBytes());
225+
226+
// When setting and then clearing a prefix on both contexts
227+
ZstdCompressCtx cSet = cctx.refPrefix(prefix);
228+
ZstdCompressCtx cCleared = cctx.refPrefix((MemorySegment) null);
229+
ZstdDecompressCtx dSet = dctx.refPrefix(prefix);
230+
ZstdDecompressCtx dCleared = dctx.refPrefix((MemorySegment) null);
231+
232+
// Then every call returns the same instance, for chaining
233+
assertThat(cSet).isSameAs(cctx);
234+
assertThat(cCleared).isSameAs(cctx);
235+
assertThat(dSet).isSameAs(dctx);
236+
assertThat(dCleared).isSameAs(dctx);
237+
}
238+
}
239+
240+
@Test
241+
void loadDictionaryFromSegmentReturnsTheSameContextForChaining() {
242+
// Given both contexts and a native dictionary segment
243+
try (Arena arena = Arena.ofConfined();
244+
ZstdCompressCtx cctx = new ZstdCompressCtx();
245+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
246+
MemorySegment dict = copy(arena, "dictionary sample payload ".repeat(64).getBytes());
247+
248+
// When loading and then clearing a segment dictionary on both contexts
249+
ZstdCompressCtx cSet = cctx.loadDictionary(dict);
250+
ZstdCompressCtx cCleared = cctx.loadDictionary(MemorySegment.NULL);
251+
ZstdDecompressCtx dSet = dctx.loadDictionary(dict);
252+
ZstdDecompressCtx dCleared = dctx.loadDictionary(MemorySegment.NULL);
253+
254+
// Then every call returns the same instance, for chaining
255+
assertThat(cSet).isSameAs(cctx);
256+
assertThat(cCleared).isSameAs(cctx);
257+
assertThat(dSet).isSameAs(dctx);
258+
assertThat(dCleared).isSameAs(dctx);
259+
}
260+
}
261+
182262
private static MemorySegment copy(Arena arena, byte[] src) {
183263
MemorySegment seg = arena.allocate(src.length);
184264
MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length);

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ void reportsDstSizeTooSmall() {
2525
assertThat(ex.code()).isEqualTo(ZstdErrorCode.DST_SIZE_TOO_SMALL);
2626
}
2727

28+
@Test
29+
void carriesTheNativeErrorNameAsTheMessage() {
30+
// Given a frame decompressed into too small a buffer
31+
byte[] frame = Zstd.compress(PAYLOAD);
32+
33+
// When it fails
34+
ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, 1));
35+
36+
// Then the message is the descriptive native error name, not an empty string
37+
assertThat(ex).hasMessageContaining("too small");
38+
}
39+
2840
@Test
2941
void reportsParameterOutOfBound() {
3042
try (ZstdCompressCtx ctx = new ZstdCompressCtx()) {

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.junit.jupiter.params.provider.EnumSource;
88

99
import java.nio.charset.StandardCharsets;
10+
import java.util.Random;
1011

1112
import static org.assertj.core.api.Assertions.assertThat;
1213
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -311,5 +312,34 @@ void rejectsOutOfRangeValue() {
311312
assertThatThrownBy(result).isInstanceOf(ZstdException.class);
312313
}
313314
}
315+
316+
@Test
317+
void levelActuallyAppliesToTheNativeCompressionPath() {
318+
// Given a level-sensitive payload (enough entropy that the level changes
319+
// the ratio, so a no-op level() would betray itself)
320+
byte[] data = levelSensitivePayload();
321+
byte[] atMin;
322+
byte[] atMax;
323+
324+
// When compressing via level() at the minimum and the maximum level
325+
try (ZstdCompressCtx low = new ZstdCompressCtx().level(Zstd.minCompressionLevel());
326+
ZstdCompressCtx high = new ZstdCompressCtx().level(Zstd.maxCompressionLevel())) {
327+
atMin = low.compress(data);
328+
atMax = high.compress(data);
329+
}
330+
331+
// Then the higher level produces a strictly smaller frame — proving level()
332+
// sets the native parameter rather than silently leaving the default
333+
assertThat(atMax.length).isLessThan(atMin.length);
334+
}
335+
336+
private static byte[] levelSensitivePayload() {
337+
byte[] b = new byte[64 * 1024];
338+
Random r = new Random(0xA11CE);
339+
for (int i = 0; i < b.length; i++) {
340+
b[i] = (byte) ((i % 17 == 0) ? r.nextInt(256) : 'a' + (i % 8));
341+
}
342+
return b;
343+
}
314344
}
315345
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,31 @@ void roundTripsWithDigestedDictionary() {
9797
assertThat(bytesOf(out, written)).isEqualTo(record);
9898
}
9999
}
100+
101+
@Test
102+
void arenaAllocatingDecompressSizesOutputFromTheDigestedDictionaryFrame() {
103+
// Given a digested-dictionary frame that stores its decompressed size
104+
ZstdDictionary dict = trainSmallDictionary();
105+
byte[] record = "{\"id\":99,\"user\":\"u\",\"active\":false}".getBytes(StandardCharsets.UTF_8);
106+
107+
try (Arena arena = Arena.ofConfined();
108+
ZstdCompressCtx cctx = new ZstdCompressCtx();
109+
ZstdDecompressCtx dctx = new ZstdDecompressCtx();
110+
ZstdCompressDict cdict = new ZstdCompressDict(dict);
111+
ZstdDecompressDict ddict = new ZstdDecompressDict(dict)) {
112+
113+
MemorySegment src = segmentOf(arena, record);
114+
MemorySegment frame = cctx.compress(arena, src, cdict);
115+
116+
// When decoded through the arena-allocating ddict overload
117+
MemorySegment out = dctx.decompress(arena, frame, ddict);
118+
119+
// Then it allocates the exact size and returns the record (a non-null segment)
120+
assertThat(out).isNotNull();
121+
assertThat(out.byteSize()).isEqualTo(record.length);
122+
assertThat(bytesOf(out, out.byteSize())).isEqualTo(record);
123+
}
124+
}
100125
}
101126

102127
@Nested

0 commit comments

Comments
 (0)