Add property-based tests and fix the bugs discovered.

Change-Id: I8deb1a7d75078c037714541d8f6f656052c2476c
diff --git a/.gitignore b/.gitignore
index b8b3920..944aa9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
 *~
 .envrc
 .flattened-pom.xml
+.jqwik-database
 /.idea
 /jgvariant-*/target
 /target
diff --git a/jgvariant-core/pom.xml b/jgvariant-core/pom.xml
index b37b65c..da34551 100644
--- a/jgvariant-core/pom.xml
+++ b/jgvariant-core/pom.xml
@@ -61,6 +61,11 @@
       <artifactId>junit-jupiter-api</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>net.jqwik</groupId>
+      <artifactId>jqwik</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
 </project>
diff --git a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java
index 9362487..fcbb639 100644
--- a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java
+++ b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java
@@ -342,14 +342,22 @@
   }
 
   private static int getIntN(ByteBuffer byteSlice) {
-    var intBytes = new byte[4];
-    byteSlice.get(intBytes, 0, Math.min(4, byteSlice.limit()));
-    return ByteBuffer.wrap(intBytes).order(LITTLE_ENDIAN).getInt();
+    return switch (byteSlice.limit()) {
+      case 0 -> 0;
+      case 1 ->
+        Byte.toUnsignedInt(byteSlice.order(LITTLE_ENDIAN).get());
+      case 2 ->
+        Short.toUnsignedInt(byteSlice.order(LITTLE_ENDIAN).getShort());
+      case 4 ->
+        byteSlice.order(LITTLE_ENDIAN).getInt();
+      default ->
+        throw new IllegalArgumentException("invalid byte count: %d".formatted(byteSlice.limit()));
+    };
   }
 
   @SuppressWarnings("java:S3358")
   private static int byteCount(int n) {
-    return n < (1 << 8) ? 1 : n < (1 << 16) ? 2 : 4;
+    return n == 0 ? 0 : n < (1 << 8) ? 1 : n < (1 << 16) ? 2 : 4;
   }
 
   private static int computeFramingOffsetSize(int elementsRelativeEnd, List<Integer> framingOffsets) {
@@ -404,7 +412,7 @@
         elements = List.of();
       } else {
         // An array with aligned elements and a vector of framing offsets in the end.
-        int framingOffsetSize = byteCount(byteSlice.limit());
+        int framingOffsetSize = max(1, byteCount(byteSlice.limit()));
         int lastFramingOffset =
             getIntN(byteSlice.slice(byteSlice.limit() - framingOffsetSize, framingOffsetSize));
         int elementCount = (byteSlice.limit() - lastFramingOffset) / framingOffsetSize;
diff --git a/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderPropertyTest.java b/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderPropertyTest.java
new file mode 100644
index 0000000..5e07ea0
--- /dev/null
+++ b/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderPropertyTest.java
@@ -0,0 +1,89 @@
+package eu.mulk.jgvariant.core;
+
+import java.text.ParseException;
+import java.util.Optional;
+import net.jqwik.api.*;
+
+@SuppressWarnings("java:S2187")
+class DecoderPropertyTest {
+
+  @Group
+  class VariantRoundtripLaw implements RoundtripLaw<Variant> {
+
+    @Override
+    public Decoder<Variant> decoder() {
+      return Decoder.ofVariant();
+    }
+
+    @Override
+    public Arbitrary<Variant> anyT() {
+      return anyVariant();
+    }
+  }
+
+  interface RoundtripLaw<T> {
+
+    @Property
+    default boolean roundtripsWell(@ForAll(value = "anyT") T entityLeft) {
+      var decoder = decoder();
+      var bytes = decoder.encode(entityLeft);
+      var entityRight = decoder.decode(bytes);
+      return entityLeft.equals(entityRight);
+    }
+
+    Decoder<T> decoder();
+
+    @Provide
+    Arbitrary<T> anyT();
+  }
+
+  @Provide
+  Arbitrary<Variant> anyVariant() {
+    var anyString = Arbitraries.strings().map(s -> new Variant(parseSignature("s"), s));
+    var anyInt = Arbitraries.integers().map(i -> new Variant(parseSignature("i"), i));
+    var anyLong = Arbitraries.longs().map(l -> new Variant(parseSignature("x"), l));
+    var anyDouble = Arbitraries.doubles().map(d -> new Variant(parseSignature("d"), d));
+    var anyBoolean =
+        Arbitraries.of(Boolean.TRUE, Boolean.FALSE).map(b -> new Variant(parseSignature("b"), b));
+    var anyByte = Arbitraries.bytes().map(b -> new Variant(parseSignature("y"), b));
+    var anyShort = Arbitraries.shorts().map(s -> new Variant(parseSignature("n"), s));
+    var anyByteArray = Arbitraries.bytes().list().map(b -> new Variant(parseSignature("ay"), b));
+    var anySome =
+        Arbitraries.lazyOf(
+            () ->
+                anyVariant()
+                    .map(
+                        x ->
+                            new Variant(
+                                parseSignature("m" + x.signature().toString()),
+                                Optional.of(x.value()))));
+    var anyNone =
+        Arbitraries.lazyOf(
+            () ->
+                anyVariant()
+                    .map(
+                        x ->
+                            new Variant(
+                                parseSignature("m" + x.signature().toString()), Optional.empty())));
+    // FIXME missing: list, tuple, dictionary, variant
+    return Arbitraries.oneOf(
+        anyString,
+        anyInt,
+        anyLong,
+        anyDouble,
+        anyBoolean,
+        anyByte,
+        anyShort,
+        anyByteArray,
+        anySome,
+        anyNone);
+  }
+
+  private Signature parseSignature(String s) {
+    try {
+      return Signature.parse(s);
+    } catch (ParseException e) {
+      throw new AssertionError(e);
+    }
+  }
+}
diff --git a/jgvariant-ostree/pom.xml b/jgvariant-ostree/pom.xml
index 79f5f48..e5aebc3 100644
--- a/jgvariant-ostree/pom.xml
+++ b/jgvariant-ostree/pom.xml
@@ -79,6 +79,11 @@
       <artifactId>inject-resources-junit-jupiter</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>net.jqwik</groupId>
+      <artifactId>jqwik</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
 </project>
diff --git a/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderPropertyTest.java b/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderPropertyTest.java
new file mode 100644
index 0000000..acd11c4
--- /dev/null
+++ b/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderPropertyTest.java
@@ -0,0 +1,66 @@
+package eu.mulk.jgvariant.ostree;
+
+import eu.mulk.jgvariant.core.Decoder;
+import java.util.Map;
+import net.jqwik.api.*;
+
+@SuppressWarnings("java:S2187")
+class OstreeDecoderPropertyTest {
+
+  @Group
+  class SummaryRoundtripLaw implements RoundtripLaw<Summary> {
+
+    @Override
+    public Decoder<Summary> decoder() {
+      return Summary.decoder();
+    }
+
+    @Override
+    public Arbitrary<Summary> anyT() {
+      return anySummary();
+    }
+  }
+
+  interface RoundtripLaw<T> {
+
+    @Property
+    default boolean roundtripsWell(@ForAll(value = "anyT") T entityLeft) {
+      var decoder = decoder();
+      var bytes = decoder.encode(entityLeft);
+      var entityRight = decoder.decode(bytes);
+      return entityLeft.equals(entityRight);
+    }
+
+    Decoder<T> decoder();
+
+    @Provide
+    Arbitrary<T> anyT();
+  }
+
+  @Provide
+  Arbitrary<Summary> anySummary() {
+    return Combinators.combine(anySummaryEntry().list(), anyMetadata()).as(Summary::new);
+  }
+
+  @Provide
+  Arbitrary<Metadata> anyMetadata() {
+    return Arbitraries.of(new Metadata(Map.of()));
+  }
+
+  @Provide
+  Arbitrary<Summary.Entry> anySummaryEntry() {
+    return Combinators.combine(Arbitraries.strings(), anySummaryEntryValue())
+        .as(Summary.Entry::new);
+  }
+
+  @Provide
+  Arbitrary<Summary.Entry.Value> anySummaryEntryValue() {
+    return Combinators.combine(Arbitraries.integers(), anyChecksum(), anyMetadata())
+        .as(Summary.Entry.Value::new);
+  }
+
+  @Provide
+  Arbitrary<Checksum> anyChecksum() {
+    return Arbitraries.of(new Checksum(new ByteString(new byte[32])));
+  }
+}
diff --git a/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderTest.java b/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderTest.java
index 4465d02..793c5a5 100644
--- a/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderTest.java
+++ b/jgvariant-ostree/src/test/java/eu/mulk/jgvariant/ostree/OstreeDecoderTest.java
@@ -110,10 +110,9 @@
                 summary.metadata().fields()));
 
     var encoded = decoder.encode(summary);
+
     input.rewind();
     assertEquals(input, encoded);
-
-    System.out.println(summary);
   }
 
   @Test
@@ -123,10 +122,9 @@
     var commit = decoder.decode(input);
 
     var encoded = decoder.encode(commit);
+
     input.rewind();
     assertEquals(input, encoded);
-
-    System.out.println(commit);
   }
 
   @Test
@@ -136,10 +134,9 @@
     var dirTree = decoder.decode(input);
 
     var encoded = decoder.encode(dirTree);
+
     input.rewind();
     assertEquals(input, encoded);
-
-    System.out.println(dirTree);
   }
 
   @Test
@@ -149,10 +146,9 @@
     var dirMeta = decoder.decode(ByteBuffer.wrap(dirMetaBytes));
 
     var encoded = decoder.encode(dirMeta);
+
     input.rewind();
     assertEquals(input, encoded);
-
-    System.out.println(dirMeta);
   }
 
   @Test
@@ -160,9 +156,9 @@
     var decoder = DeltaSuperblock.decoder();
     var input = ByteBuffer.wrap(deltaSuperblockBytes);
     var deltaSuperblock = decoder.decode(input);
-    System.out.println(deltaSuperblock);
 
     var encoded = decoder.encode(deltaSuperblock);
+
     input.rewind();
     assertEquals(input, encoded);
   }
diff --git a/jgvariant-parent/pom.xml b/jgvariant-parent/pom.xml
index 5444b7d..283c62a 100644
--- a/jgvariant-parent/pom.xml
+++ b/jgvariant-parent/pom.xml
@@ -76,6 +76,7 @@
     <guava.version>32.1.3-jre</guava.version>
     <inject-resources.version>0.3.3</inject-resources.version>
     <jetbrains-annotations.version>24.1.0</jetbrains-annotations.version>
+    <jqwik.version>1.8.2</jqwik.version>
     <junit-jupiter.version>5.10.1</junit-jupiter.version>
     <nullaway.version>0.10.18</nullaway.version>
     <picocli.version>4.7.5</picocli.version>
@@ -156,6 +157,12 @@
         <version>${inject-resources.version}</version>
         <scope>test</scope>
       </dependency>
+      <dependency>
+        <groupId>net.jqwik</groupId>
+        <artifactId>jqwik</artifactId>
+        <version>${jqwik.version}</version>
+        <scope>test</scope>
+      </dependency>
     </dependencies>
   </dependencyManagement>