diff --git a/.gitignore b/.gitignore
index 5621a8b..b78592b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,6 @@ hs_err_*.log
*.synctex.gz
*.toc
*.xdv
+
+# jqwik
+.jqwik-database
diff --git a/pom.xml b/pom.xml
index 3133ecf..f3e5792 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,6 +58,13 @@ SPDX-FileCopyrightText: 2023 swt23w23
runtime
+
+ net.jqwik
+ jqwik
+ 1.8.2
+ test
+
+
diff --git a/src/test/java/catering/inventory/InventoryMutateFormUnitTests.java b/src/test/java/catering/inventory/InventoryMutateFormUnitTests.java
new file mode 100644
index 0000000..b45bd93
--- /dev/null
+++ b/src/test/java/catering/inventory/InventoryMutateFormUnitTests.java
@@ -0,0 +1,266 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// SPDX-FileCopyrightText: 2024 swt23w23
+package catering.inventory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.salespointframework.core.Currencies.EURO;
+
+import java.util.Optional;
+import java.util.Set;
+
+import javax.money.MonetaryAmount;
+import javax.money.NumberValue;
+
+import org.javamoney.moneta.Money;
+import org.junit.jupiter.api.Test;
+import org.salespointframework.catalog.Product;
+import org.salespointframework.inventory.UniqueInventoryItem;
+import org.salespointframework.quantity.Metric;
+import org.salespointframework.quantity.Quantity;
+
+import catering.catalog.Consumable;
+import catering.catalog.Rentable;
+import catering.order.OrderType;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Assume;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.constraints.NotBlank;
+
+public class InventoryMutateFormUnitTests {
+ /*
+ * Providers for jqwik
+ */
+
+ @Provide
+ Arbitrary euroMonetaryAmount() {
+ return Arbitraries.doubles()
+ .filter(amount -> amount >= 0)
+ .map(amount -> Money.of(amount, EURO));
+ }
+
+ @Provide
+ Arbitrary> optionalEuroMonetaryAmount() {
+ return euroMonetaryAmount().optional();
+ }
+
+ @Provide
+ Arbitrary positiveOrZero() {
+ return Arbitraries.longs().filter(x -> x >= 0);
+ }
+
+ @Provide
+ Arbitrary consumable() {
+ return Combinators
+ .combine(Arbitraries.strings().filter(s -> !s.isBlank()), euroMonetaryAmount(), euroMonetaryAmount(),
+ optionalEuroMonetaryAmount(), Arbitraries.subsetOf(OrderType.values()),
+ Arbitraries.of(Metric.class))
+ .as((name, retailPrice, wholesalePrice, promotionPrice, orderTypes, metric) -> new Consumable(name,
+ retailPrice, wholesalePrice, promotionPrice, orderTypes, metric));
+ }
+
+ @Provide
+ Arbitrary rentable() {
+ return Combinators
+ .combine(Arbitraries.strings().filter(s -> !s.isBlank()), euroMonetaryAmount(), euroMonetaryAmount(),
+ Arbitraries.subsetOf(OrderType.values()),
+ Arbitraries.of(Metric.class))
+ .as((name, retailPrice, wholesalePrice, orderTypes, metric) -> new Rentable(name, retailPrice,
+ wholesalePrice, orderTypes, metric));
+ }
+
+ Arbitrary item(Arbitrary extends Product> product) {
+ return Combinators.combine(product, positiveOrZero())
+ .as((prod, qty) -> new UniqueInventoryItem(prod, prod.createQuantity(qty)));
+ }
+
+ @Provide
+ Arbitrary consumableItem() {
+ return item(consumable());
+ }
+
+ @Provide
+ Arbitrary rentableItem() {
+ return item(rentable());
+ }
+
+ /*
+ * Property-based tests with jqwik
+ */
+
+ @Property
+ void property_formToConsumableToFormIsIdentity(@ForAll @NotBlank String name,
+ @ForAll("positiveOrZero") long qty,
+ @ForAll Metric metric,
+ @ForAll("euroMonetaryAmount") MonetaryAmount retailPrice,
+ @ForAll("euroMonetaryAmount") MonetaryAmount wholesalePrice,
+ @ForAll("optionalEuroMonetaryAmount") Optional promotionPrice,
+ @ForAll Set orderTypes) {
+ ConsumableMutateForm form = new ConsumableMutateForm();
+ Assume.that(form.supportedMetrics().contains(metric));
+ form.setName(name);
+ form.setQuantity(qty);
+ form.setMetric(metric);
+ form.setRetailPrice(retailPrice.getNumber().doubleValueExact());
+ form.setWholesalePrice(retailPrice.getNumber().doubleValueExact());
+ form.setPromotionPrice(promotionPrice.map(MonetaryAmount::getNumber).map(NumberValue::doubleValueExact));
+ form.setOrderTypes(Set.of(OrderType.PARTY_SERVICE));
+
+ Product product = form.toProduct();
+ UniqueInventoryItem item = new UniqueInventoryItem(product, product.createQuantity(qty));
+ assertThat(ConsumableMutateForm.of(item))
+ .usingRecursiveComparison()
+ .isEqualTo(form);
+ }
+
+ @Property
+ void property_formToRentableToFormIsIdentity(@ForAll @NotBlank String name,
+ @ForAll("positiveOrZero") long qty,
+ @ForAll Metric metric,
+ @ForAll("euroMonetaryAmount") MonetaryAmount retailPrice,
+ @ForAll("euroMonetaryAmount") MonetaryAmount wholesalePrice,
+ @ForAll Set orderTypes) {
+ RentableMutateForm form = new RentableMutateForm();
+ Assume.that(form.supportedMetrics().contains(metric));
+ form.setName(name);
+ form.setQuantity(qty);
+ form.setMetric(metric);
+ form.setRetailPrice(retailPrice.getNumber().doubleValueExact());
+ form.setWholesalePrice(retailPrice.getNumber().doubleValueExact());
+ form.setOrderTypes(Set.of(OrderType.PARTY_SERVICE));
+
+ Product product = form.toProduct();
+ UniqueInventoryItem item = new UniqueInventoryItem(product, product.createQuantity(qty));
+ assertThat(RentableMutateForm.of(item))
+ .usingRecursiveComparison()
+ .isEqualTo(form);
+ }
+
+ @Property
+ void property_consumableToFormToConsumableIsIdentity(@ForAll("consumableItem") UniqueInventoryItem item) {
+ InventoryMutateForm form = InventoryMutateForm.of(item);
+
+ assertThat(form).isInstanceOf(ConsumableMutateForm.class);
+ assertThat(form.toProduct())
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .isEqualTo(item.getProduct());
+ assertThat(form.getQuantity()).isEqualTo(item.getQuantity().getAmount().longValueExact());
+ }
+
+ @Property
+ void property_rentableToFormToRentableIsIdentity(@ForAll("rentableItem") UniqueInventoryItem item) {
+ InventoryMutateForm form = InventoryMutateForm.of(item);
+
+ assertThat(form).isInstanceOf(RentableMutateForm.class);
+ assertThat(form.toProduct())
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .isEqualTo(item.getProduct());
+ assertThat(form.getQuantity()).isEqualTo(item.getQuantity().getAmount().longValueExact());
+ }
+
+ @Property
+ void property_consumableFormModifiesExistingConsumable(@ForAll("consumableItem") UniqueInventoryItem item,
+ @ForAll("consumable") Consumable product) {
+ InventoryMutateForm.of(item).modifyProduct(product);
+
+ assertThat(product)
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .ignoringFields("metric") // metric can’t be modified (salespoint limitation)
+ .isEqualTo(item.getProduct());
+ }
+
+ @Property
+ void property_rentableFormModifiesExistingConsumable(@ForAll("rentableItem") UniqueInventoryItem item,
+ @ForAll("rentable") Rentable product) {
+ InventoryMutateForm.of(item).modifyProduct(product);
+
+ assertThat(product)
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .ignoringFields("metric") // metric can’t be modified (salespoint limitation)
+ .isEqualTo(item.getProduct());
+ }
+
+ /*
+ * Edge cases with jqwik
+ */
+
+ @Property
+ void property_ofIllegal(@ForAll("consumableItem") UniqueInventoryItem consumableItem,
+ @ForAll("rentableItem") UniqueInventoryItem rentableItem) {
+ assertThatThrownBy(() -> InventoryMutateForm
+ .of(new UniqueInventoryItem(new Product("no subclass", Money.of(0.0, EURO)), Quantity.of(1))))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> ConsumableMutateForm.of(rentableItem))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> RentableMutateForm.of(consumableItem))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Property
+ void property_modifyIllegalProduct(@ForAll("consumableItem") UniqueInventoryItem consumableItem,
+ @ForAll("rentableItem") UniqueInventoryItem rentableItem,
+ @ForAll("consumable") Consumable consumable,
+ @ForAll("rentable") Rentable rentable) {
+ assertThatThrownBy(() -> InventoryMutateForm.of(rentableItem).modifyProduct(consumable))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> InventoryMutateForm.of(consumableItem).modifyProduct(rentable))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ /*
+ * JUnit Unit tests
+ */
+ @Test
+ void toConsumable() {
+ ConsumableMutateForm form = new ConsumableMutateForm();
+ form.setName("Weißwurst (vegan)");
+ form.setQuantity(10L);
+ form.setMetric(Metric.UNIT);
+ form.setRetailPrice(3.46);
+ form.setWholesalePrice(1.50);
+ form.setPromotionPrice(Optional.of(3.33));
+ form.setOrderTypes(Set.of(OrderType.PARTY_SERVICE));
+ assertThat(form.getQuantity()).isEqualTo(10L);
+ assertThat(form.toProduct())
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .isEqualTo(new Consumable("Weißwurst (vegan)", Money.of(3.46, EURO), Money.of(1.50, EURO),
+ Optional.of(Money.of(3.33, EURO)), Set.of(OrderType.PARTY_SERVICE), Metric.UNIT));
+ }
+
+ @Test
+ void toRentable() {
+ RentableMutateForm form = new RentableMutateForm();
+ form.setName("Kebab-Drehspieß „Der Gerät“ Alkadur");
+ form.setQuantity(3L);
+ form.setMetric(Metric.UNIT);
+ form.setRetailPrice(20.0);
+ form.setWholesalePrice(10000.0);
+ form.setOrderTypes(Set.of(OrderType.EVENT_CATERING));
+ assertThat(form.getQuantity()).isEqualTo(3L);
+ assertThat(form.toProduct())
+ .usingRecursiveComparison()
+ .ignoringFields("id")
+ .isEqualTo(new Rentable("Kebab-Drehspieß „Der Gerät“ Alkadur", Money.of(20.0, EURO),
+ Money.of(10000.0, EURO), Set.of(OrderType.EVENT_CATERING), Metric.UNIT));
+ }
+
+ @Test
+ void forProductType() {
+ assertThat(InventoryMutateForm.forProductType(Consumable.class)).isInstanceOf(ConsumableMutateForm.class);
+ assertThat(InventoryMutateForm.forProductType(Rentable.class)).isInstanceOf(RentableMutateForm.class);
+
+ assertThatThrownBy(() -> InventoryMutateForm.forProductType(Product.class)).isInstanceOf(IllegalArgumentException.class);
+ }
+}