1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:00:23 +01:00

feat(production): Quantity VO mit Catch-Weight, YieldPercentage und BatchNumber

Shared Value Objects für den Production BC implementiert (#25):
- Quantity mit Dual-Quantity/Catch-Weight Support und Arithmetik
- UnitOfMeasure Enum (kg, g, L, mL, pc, m)
- YieldPercentage (1-200) mit calculateRequiredInput()
- BatchNumber mit Format P-YYYY-MM-DD-XXX
- QuantityError sealed interface für funktionales Error Handling
- 60 Unit Tests für alle VOs
This commit is contained in:
Sebastian Frick 2026-02-19 09:48:11 +01:00
parent c474388f32
commit a1161cfbad
9 changed files with 968 additions and 0 deletions

View file

@ -0,0 +1,141 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("BatchNumber Value Object")
class BatchNumberTest {
@Nested
@DisplayName("of() factory")
class OfFactory {
@Test
@DisplayName("should create BatchNumber with valid format")
void should_Create_When_ValidFormat() {
var result = BatchNumber.of("P-2026-02-19-001");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().value()).isEqualTo("P-2026-02-19-001");
}
@ParameterizedTest
@ValueSource(strings = {
"P-2026-01-15-999",
"P-2025-12-31-042",
"P-2024-06-01-001"
})
@DisplayName("should accept various valid batch numbers")
void should_Accept_When_ValidFormats(String batchNumber) {
var result = BatchNumber.of(batchNumber);
assertThat(result.isSuccess()).isTrue();
}
@ParameterizedTest
@ValueSource(strings = {
"2026-02-19-001",
"P-2026-02-19-01",
"P-2026-02-19-1000",
"X-2026-02-19-001",
"P-26-02-19-001",
"P2026-02-19-001",
"",
"random-string"
})
@DisplayName("should fail for invalid formats")
void should_Fail_When_InvalidFormat(String batchNumber) {
var result = BatchNumber.of(batchNumber);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidBatchNumber.class);
}
@Test
@DisplayName("should fail for null value")
void should_Fail_When_Null() {
var result = BatchNumber.of(null);
assertThat(result.isFailure()).isTrue();
}
}
@Nested
@DisplayName("generate()")
class Generate {
@Test
@DisplayName("should generate batch number for date and sequence")
void should_Generate_When_ValidDateAndSequence() {
var bn = BatchNumber.generate(LocalDate.of(2026, 2, 19), 1);
assertThat(bn.value()).isEqualTo("P-2026-02-19-001");
}
@Test
@DisplayName("should generate with 3-digit sequence")
void should_PadSequence_When_SmallNumber() {
var bn = BatchNumber.generate(LocalDate.of(2026, 2, 19), 42);
assertThat(bn.value()).isEqualTo("P-2026-02-19-042");
}
@Test
@DisplayName("should throw when sequence out of range")
void should_Throw_When_SequenceOutOfRange() {
assertThatThrownBy(() -> BatchNumber.generate(LocalDate.of(2026, 1, 1), 0))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> BatchNumber.generate(LocalDate.of(2026, 1, 1), 1000))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("Extraction methods")
class Extraction {
@Test
@DisplayName("should extract date from batch number")
void should_ExtractDate() {
var bn = new BatchNumber("P-2026-02-19-001");
assertThat(bn.date()).isEqualTo(LocalDate.of(2026, 2, 19));
}
@Test
@DisplayName("should extract sequence number from batch number")
void should_ExtractSequence() {
var bn = new BatchNumber("P-2026-02-19-042");
assertThat(bn.sequenceNumber()).isEqualTo(42);
}
}
@Nested
@DisplayName("Equality")
class Equality {
@Test
@DisplayName("should be equal when same value")
void should_BeEqual_When_SameValue() {
var bn1 = new BatchNumber("P-2026-02-19-001");
var bn2 = new BatchNumber("P-2026-02-19-001");
assertThat(bn1).isEqualTo(bn2);
assertThat(bn1.hashCode()).isEqualTo(bn2.hashCode());
}
@Test
@DisplayName("should not be equal when different value")
void should_NotBeEqual_When_DifferentValue() {
var bn1 = new BatchNumber("P-2026-02-19-001");
var bn2 = new BatchNumber("P-2026-02-19-002");
assertThat(bn1).isNotEqualTo(bn2);
}
}
}

View file

@ -0,0 +1,283 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static de.effigenix.domain.production.UnitOfMeasure.*;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Quantity Value Object")
class QuantityTest {
@Nested
@DisplayName("of() factory")
class OfFactory {
@Test
@DisplayName("should create Quantity with valid amount and UoM")
void should_CreateQuantity_When_ValidAmountAndUom() {
var result = Quantity.of(new BigDecimal("10"), KILOGRAM);
assertThat(result.isSuccess()).isTrue();
var qty = result.unsafeGetValue();
assertThat(qty.amount()).isEqualByComparingTo(new BigDecimal("10"));
assertThat(qty.uom()).isEqualTo(KILOGRAM);
assertThat(qty.hasDualQuantity()).isFalse();
assertThat(qty.secondaryAmount()).isNull();
assertThat(qty.secondaryUom()).isNull();
}
@Test
@DisplayName("should fail when amount is zero")
void should_Fail_When_AmountIsZero() {
var result = Quantity.of(BigDecimal.ZERO, KILOGRAM);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
}
@Test
@DisplayName("should fail when amount is negative")
void should_Fail_When_AmountIsNegative() {
var result = Quantity.of(new BigDecimal("-5"), KILOGRAM);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
}
@Test
@DisplayName("should fail when amount is null")
void should_Fail_When_AmountIsNull() {
var result = Quantity.of(null, KILOGRAM);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
}
}
@Nested
@DisplayName("dual() factory")
class DualFactory {
@Test
@DisplayName("should create dual Quantity with primary and secondary")
void should_CreateDualQuantity_When_ValidInputs() {
var result = Quantity.dual(
new BigDecimal("10"), PIECE,
new BigDecimal("23"), KILOGRAM);
assertThat(result.isSuccess()).isTrue();
var qty = result.unsafeGetValue();
assertThat(qty.hasDualQuantity()).isTrue();
assertThat(qty.amount()).isEqualByComparingTo(new BigDecimal("10"));
assertThat(qty.uom()).isEqualTo(PIECE);
assertThat(qty.secondaryAmount()).isEqualByComparingTo(new BigDecimal("23"));
assertThat(qty.secondaryUom()).isEqualTo(KILOGRAM);
}
@Test
@DisplayName("should fail when secondary amount is zero")
void should_Fail_When_SecondaryAmountIsZero() {
var result = Quantity.dual(
new BigDecimal("10"), PIECE,
BigDecimal.ZERO, KILOGRAM);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.SecondaryAmountMustBePositive.class);
}
@Test
@DisplayName("should fail when secondary UoM is null")
void should_Fail_When_SecondaryUomIsNull() {
var result = Quantity.dual(
new BigDecimal("10"), PIECE,
new BigDecimal("23"), null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.SecondaryUomRequired.class);
}
@Test
@DisplayName("should fail when primary amount is invalid")
void should_Fail_When_PrimaryAmountInvalid() {
var result = Quantity.dual(
BigDecimal.ZERO, PIECE,
new BigDecimal("23"), KILOGRAM);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
}
}
@Nested
@DisplayName("Arithmetic operations")
class Arithmetic {
@Test
@DisplayName("should add quantities with same UoM")
void should_Add_When_SameUom() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
var result = q1.add(q2);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("15"));
assertThat(result.unsafeGetValue().uom()).isEqualTo(KILOGRAM);
}
@Test
@DisplayName("should fail to add quantities with different UoM")
void should_FailAdd_When_DifferentUom() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("5"), LITER).unsafeGetValue();
var result = q1.add(q2);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.UnitOfMeasureMismatch.class);
}
@Test
@DisplayName("should subtract quantities with same UoM")
void should_Subtract_When_SameUom() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("3"), KILOGRAM).unsafeGetValue();
var result = q1.subtract(q2);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("7"));
}
@Test
@DisplayName("should fail to subtract when result would not be positive")
void should_FailSubtract_When_ResultNotPositive() {
var q1 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
var result = q1.subtract(q2);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
}
@Test
@DisplayName("should fail to subtract with different UoM")
void should_FailSubtract_When_DifferentUom() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("3"), GRAM).unsafeGetValue();
var result = q1.subtract(q2);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.UnitOfMeasureMismatch.class);
}
@Test
@DisplayName("should multiply quantity by factor")
void should_Multiply_When_ValidFactor() {
var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var result = q.multiply(new BigDecimal("2.5"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("25"));
assertThat(result.unsafeGetValue().uom()).isEqualTo(KILOGRAM);
}
@Test
@DisplayName("should fail to multiply by zero")
void should_FailMultiply_When_FactorIsZero() {
var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var result = q.multiply(BigDecimal.ZERO);
assertThat(result.isFailure()).isTrue();
}
@Test
@DisplayName("should add dual quantities preserving secondary")
void should_AddDual_When_BothHaveSecondary() {
var q1 = Quantity.dual(new BigDecimal("10"), PIECE, new BigDecimal("20"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.dual(new BigDecimal("5"), PIECE, new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var result = q1.add(q2);
assertThat(result.isSuccess()).isTrue();
var sum = result.unsafeGetValue();
assertThat(sum.amount()).isEqualByComparingTo(new BigDecimal("15"));
assertThat(sum.secondaryAmount()).isEqualByComparingTo(new BigDecimal("30"));
assertThat(sum.secondaryUom()).isEqualTo(KILOGRAM);
}
}
@Nested
@DisplayName("Query methods")
class QueryMethods {
@Test
@DisplayName("isPositive returns true for positive quantity")
void should_BePositive_When_AmountIsPositive() {
var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
assertThat(q.isPositive()).isTrue();
assertThat(q.isZero()).isFalse();
}
}
@Nested
@DisplayName("Equality")
class Equality {
@Test
@DisplayName("should be equal when same amount and UoM")
void should_BeEqual_When_SameValues() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("10.000000"), KILOGRAM).unsafeGetValue();
assertThat(q1).isEqualTo(q2);
assertThat(q1.hashCode()).isEqualTo(q2.hashCode());
}
@Test
@DisplayName("should not be equal when different amount")
void should_NotBeEqual_When_DifferentAmount() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("20"), KILOGRAM).unsafeGetValue();
assertThat(q1).isNotEqualTo(q2);
}
@Test
@DisplayName("should not be equal when different UoM")
void should_NotBeEqual_When_DifferentUom() {
var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
var q2 = Quantity.of(new BigDecimal("10"), GRAM).unsafeGetValue();
assertThat(q1).isNotEqualTo(q2);
}
}
@Nested
@DisplayName("toString()")
class ToStringTest {
@Test
@DisplayName("should format simple quantity")
void should_FormatSimple() {
var q = Quantity.of(new BigDecimal("10.5"), KILOGRAM).unsafeGetValue();
assertThat(q.toString()).contains("10.5").contains("kg");
}
@Test
@DisplayName("should format dual quantity")
void should_FormatDual() {
var q = Quantity.dual(new BigDecimal("10"), PIECE, new BigDecimal("23"), KILOGRAM).unsafeGetValue();
assertThat(q.toString()).contains("10").contains("pc").contains("23").contains("kg");
}
}
}

View file

@ -0,0 +1,27 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("UnitOfMeasure Enum")
class UnitOfMeasureTest {
@Test
@DisplayName("should have 6 values")
void should_HaveSixValues() {
assertThat(UnitOfMeasure.values()).hasSize(6);
}
@Test
@DisplayName("should have correct symbols")
void should_HaveCorrectSymbols() {
assertThat(UnitOfMeasure.KILOGRAM.symbol()).isEqualTo("kg");
assertThat(UnitOfMeasure.GRAM.symbol()).isEqualTo("g");
assertThat(UnitOfMeasure.LITER.symbol()).isEqualTo("L");
assertThat(UnitOfMeasure.MILLILITER.symbol()).isEqualTo("mL");
assertThat(UnitOfMeasure.PIECE.symbol()).isEqualTo("pc");
assertThat(UnitOfMeasure.METER.symbol()).isEqualTo("m");
}
}

View file

@ -0,0 +1,138 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.math.BigDecimal;
import static de.effigenix.domain.production.UnitOfMeasure.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("YieldPercentage Value Object")
class YieldPercentageTest {
@Nested
@DisplayName("Creation")
class Creation {
@Test
@DisplayName("should create YieldPercentage with valid value")
void should_Create_When_ValidValue() {
var result = YieldPercentage.of(80);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().value()).isEqualTo(80);
}
@ParameterizedTest
@ValueSource(ints = {1, 100, 200})
@DisplayName("should accept boundary values")
void should_Accept_When_BoundaryValues(int value) {
var result = YieldPercentage.of(value);
assertThat(result.isSuccess()).isTrue();
}
@Test
@DisplayName("should fail when value is 0")
void should_Fail_When_ValueIsZero() {
var result = YieldPercentage.of(0);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidYieldPercentage.class);
}
@Test
@DisplayName("should fail when value is 201")
void should_Fail_When_ValueIs201() {
var result = YieldPercentage.of(201);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidYieldPercentage.class);
}
@ParameterizedTest
@ValueSource(ints = {-1, 0, 201, 300})
@DisplayName("should fail for out-of-range values")
void should_Fail_When_OutOfRange(int value) {
var result = YieldPercentage.of(value);
assertThat(result.isFailure()).isTrue();
}
@Test
@DisplayName("should throw from constructor for invalid value")
void should_ThrowFromConstructor_When_InvalidValue() {
assertThatThrownBy(() -> new YieldPercentage(0))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("calculateRequiredInput()")
class CalculateRequiredInput {
@Test
@DisplayName("80% yield: 100kg output requires 125kg input")
void should_Calculate125kg_When_80PercentYieldAnd100kg() {
var yield80 = new YieldPercentage(80);
var output = Quantity.of(new BigDecimal("100"), KILOGRAM).unsafeGetValue();
var input = yield80.calculateRequiredInput(output);
assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("125"));
assertThat(input.uom()).isEqualTo(KILOGRAM);
}
@Test
@DisplayName("100% yield: input equals output")
void should_ReturnSameAmount_When_100PercentYield() {
var yield100 = new YieldPercentage(100);
var output = Quantity.of(new BigDecimal("50"), LITER).unsafeGetValue();
var input = yield100.calculateRequiredInput(output);
assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("50"));
assertThat(input.uom()).isEqualTo(LITER);
}
@Test
@DisplayName("50% yield: 100kg output requires 200kg input")
void should_Calculate200kg_When_50PercentYieldAnd100kg() {
var yield50 = new YieldPercentage(50);
var output = Quantity.of(new BigDecimal("100"), KILOGRAM).unsafeGetValue();
var input = yield50.calculateRequiredInput(output);
assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("200"));
}
@Test
@DisplayName("should handle dual quantities")
void should_HandleDualQuantity() {
var yield80 = new YieldPercentage(80);
var output = Quantity.dual(
new BigDecimal("10"), PIECE,
new BigDecimal("20"), KILOGRAM).unsafeGetValue();
var input = yield80.calculateRequiredInput(output);
assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("12.5"));
assertThat(input.secondaryAmount()).isEqualByComparingTo(new BigDecimal("25"));
}
}
@Nested
@DisplayName("asFactor()")
class AsFactor {
@Test
@DisplayName("should return decimal factor")
void should_ReturnDecimalFactor() {
var yield80 = new YieldPercentage(80);
assertThat(yield80.asFactor()).isEqualByComparingTo(new BigDecimal("0.8"));
}
}
}