From 112e962239969487842e2940c1f0432e94dcae55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 11 May 2026 23:13:46 +0200 Subject: [PATCH] Test some more edge cases in tests --- .../LogSystem/EventCommentHelperTest.php | 28 +++ .../Parts/PartLotWithdrawAddHelperTest.php | 218 ++++++++++++++++++ .../Parts/PartsTableActionHandlerTest.php | 38 ++- .../Services/Parts/PricedetailHelperTest.php | 179 ++++++++++++++ .../ProjectSystem/ProjectBuildHelperTest.php | 126 ++++++++++ .../UniqueObjectCollectionValidatorTest.php | 27 +++ .../Constraints/ValidGTINValidatorTest.php | 78 ++++--- .../Constraints/ValidPartLotValidatorTest.php | 37 +++ 8 files changed, 690 insertions(+), 41 deletions(-) diff --git a/tests/Services/LogSystem/EventCommentHelperTest.php b/tests/Services/LogSystem/EventCommentHelperTest.php index 616c1ddf..f56214a1 100644 --- a/tests/Services/LogSystem/EventCommentHelperTest.php +++ b/tests/Services/LogSystem/EventCommentHelperTest.php @@ -87,4 +87,32 @@ final class EventCommentHelperTest extends WebTestCase $this->service->clearMessage(); $this->assertFalse($this->service->isMessageSet()); } + + public function testEmptyStringTreatedAsNotSet(): void + { + // Empty string is falsy in PHP, so setMessage('') stores null internally + $this->service->setMessage(''); + $this->assertFalse($this->service->isMessageSet()); + $this->assertNull($this->service->getMessage()); + } + + public function testSetMessageNullClearsMessage(): void + { + $this->service->setMessage('Hello'); + $this->service->setMessage(null); + $this->assertFalse($this->service->isMessageSet()); + $this->assertNull($this->service->getMessage()); + } + + public function testLongMessageIsTruncated(): void + { + // MAX_MESSAGE_LENGTH is 255; a longer string should be truncated with '...' suffix + $long = str_repeat('a', 300); + $this->service->setMessage($long); + + $stored = $this->service->getMessage(); + $this->assertNotNull($stored); + $this->assertLessThanOrEqual(255, mb_strlen($stored)); + $this->assertStringEndsWith('...', $stored); + } } diff --git a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php index de684094..a142c956 100644 --- a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php +++ b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Services\Parts; +use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; @@ -167,6 +168,223 @@ final class PartLotWithdrawAddHelperTest extends WebTestCase $this->service->stocktake($this->partLot2, 0, "Test"); $this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); $this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared + } + // --- withdraw() error paths --- + + public function testWithdrawZeroAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->withdraw($this->partLot1, 0, "Test"); + } + + public function testWithdrawNegativeAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->withdraw($this->partLot1, -5, "Test"); + } + + public function testWithdrawMoreThanStockThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->withdraw($this->partLot1, 999, "Test"); + } + + public function testWithdrawFromUnknownInstockLotThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->withdraw($this->lotWithUnknownInstock, 1, "Test"); + } + + // --- add() error paths --- + + public function testAddZeroAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->add($this->partLot1, 0, "Test"); + } + + public function testAddNegativeAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->add($this->partLot1, -3, "Test"); + } + + public function testAddToFullLotThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->add($this->fullLot, 1, "Test"); + } + + public function testAddToUnknownInstockLotThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->add($this->lotWithUnknownInstock, 1, "Test"); + } + + // --- move() error paths --- + + public function testMoveZeroAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->move($this->partLot1, $this->partLot2, 0, "Test"); + } + + public function testMoveBetweenDifferentPartsThrows(): void + { + $otherPart = new Part(); + $otherLot = new TestPartLot(); + $otherLot->setPart($otherPart); + $otherLot->setAmount(5); + + $this->expectException(\RuntimeException::class); + $this->service->move($this->partLot1, $otherLot, 5, "Test"); + } + + public function testMoveMoreThanOriginStockThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->move($this->partLot1, $this->partLot2, 999, "Test"); + } + + public function testMoveFromUnwithdrawableLotThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->move($this->lotWithUnknownInstock, $this->partLot2, 1, "Test"); + } + + public function testMoveToUnavailableLotThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->service->move($this->partLot1, $this->fullLot, 1, "Test"); + } + + // --- stocktake() error paths --- + + public function testStocktakeNegativeAmountThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->stocktake($this->partLot1, -1, "Test"); + } + + // --- integer-rounding (useFloatAmount() = false, no unit set) --- + + public function testWithdrawRoundsAmountForIntegerPart(): void + { + // No unit → useFloatAmount() = false → fractional amounts are rounded + $this->assertFalse($this->part->useFloatAmount()); + + $this->service->withdraw($this->partLot1, 1.7, "Test"); // rounds to 2 + $this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON); + } + + public function testAddRoundsAmountForIntegerPart(): void + { + $this->assertFalse($this->part->useFloatAmount()); + + $this->service->add($this->partLot3, 1.7, "Test"); // rounds to 2 + $this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON); + } + + public function testStocktakeRoundsAmountForIntegerPart(): void + { + $this->assertFalse($this->part->useFloatAmount()); + + $this->service->stocktake($this->partLot1, 7.6, "Test"); // rounds to 8 + $this->assertEqualsWithDelta(8.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON); + } + + // --- float amounts are preserved when the unit allows floats --- + + public function testAddPreservesFloatAmountForFloatUnit(): void + { + $unit = new MeasurementUnit(); + $unit->setIsInteger(false); + + $floatPart = new Part(); + $floatPart->setPartUnit($unit); + $this->assertTrue($floatPart->useFloatAmount()); + + $lot = new TestPartLot(); + $lot->setPart($floatPart); + $lot->setAmount(1.0); + + $this->service->add($lot, 1.3, "Test"); + $this->assertEqualsWithDelta(2.3, $lot->getAmount(), PHP_FLOAT_EPSILON); + } + + public function testWithdrawPreservesFloatAmountForFloatUnit(): void + { + $unit = new MeasurementUnit(); + $unit->setIsInteger(false); + + $floatPart = new Part(); + $floatPart->setPartUnit($unit); + + $lot = new TestPartLot(); + $lot->setPart($floatPart); + $lot->setAmount(5.0); + + $this->service->withdraw($lot, 1.3, "Test"); + $this->assertEqualsWithDelta(3.7, $lot->getAmount(), PHP_FLOAT_EPSILON); + } + + // --- delete_lot_if_empty --- + + /** + * Creates a PartLot that looks like a managed, persisted entity to Doctrine: + * - has a non-null ID (required by AbstractLogEntry when creating stock-change log entries) + * - is registered in the UnitOfWork as managed (required so EntityManager::remove() accepts it) + */ + private function makeManagedLot(float $amount, int $fakeId = 42): PartLot + { + $lot = new PartLot(); + $lot->setPart($this->part); + $lot->setAmount($amount); + + $ref = new \ReflectionProperty($lot, 'id'); + $ref->setValue($lot, $fakeId); + + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $em->getUnitOfWork()->registerManaged($lot, ['id' => $fakeId], []); + + return $lot; + } + + public function testWithdrawDeletesLotWhenEmptyAndFlagSet(): void + { + $lot = $this->makeManagedLot(10); + + $this->service->withdraw($lot, 10, "Test", null, true); + $this->assertEqualsWithDelta(0.0, $lot->getAmount(), PHP_FLOAT_EPSILON); + + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions(); + $this->assertContains($lot, $scheduled); + } + + public function testWithdrawDoesNotDeleteLotWhenNotEmptyAndFlagSet(): void + { + $lot = $this->makeManagedLot(10); + + $this->service->withdraw($lot, 5, "Test", null, true); + $this->assertEqualsWithDelta(5.0, $lot->getAmount(), PHP_FLOAT_EPSILON); + + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions(); + $this->assertNotContains($lot, $scheduled); + } + + public function testMoveDeletesOriginLotWhenEmptyAndFlagSet(): void + { + $origin = $this->makeManagedLot(10, 43); + $target = $this->makeManagedLot(0, 44); + + $this->service->move($origin, $target, 10, "Test", null, true); + $this->assertEqualsWithDelta(0.0, $origin->getAmount(), PHP_FLOAT_EPSILON); + + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $scheduled = $em->getUnitOfWork()->getScheduledEntityDeletions(); + $this->assertContains($origin, $scheduled); } } diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php index 1772195e..ec1973d4 100644 --- a/tests/Services/Parts/PartsTableActionHandlerTest.php +++ b/tests/Services/Parts/PartsTableActionHandlerTest.php @@ -43,20 +43,52 @@ final class PartsTableActionHandlerTest extends WebTestCase $part = $this->createMock(Part::class); $part->method('getId')->willReturn(1); $part->method('getName')->willReturn('Test Part'); - + $selected_parts = [$part]; // Test each export format, focusing on our new xlsx format $formats = ['json', 'csv', 'xml', 'yaml', 'xlsx']; - + foreach ($formats as $format) { $action = "export_{$format}"; $result = $this->service->handleAction($action, $selected_parts, 1, '/test'); - + $this->assertInstanceOf(RedirectResponse::class, $result); $this->assertStringContainsString('parts/export', $result->getTargetUrl()); $this->assertStringContainsString("format={$format}", $result->getTargetUrl()); } } + public function testExportUrlContainsPartIds(): void + { + $part1 = $this->createMock(Part::class); + $part1->method('getId')->willReturn(42); + + $part2 = $this->createMock(Part::class); + $part2->method('getId')->willReturn(99); + + $result = $this->service->handleAction('export_csv', [$part1, $part2], 1, '/test'); + + $this->assertInstanceOf(RedirectResponse::class, $result); + // Commas in query-string values are not percent-encoded by Symfony's UrlGenerator + $this->assertStringContainsString('ids=42,99', $result->getTargetUrl()); + } + + public function testExportWithNoPartsProducesEmptyIds(): void + { + $result = $this->service->handleAction('export_json', [], 1, '/test'); + + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertStringContainsString('parts/export', $result->getTargetUrl()); + // ids parameter present but empty + $this->assertStringContainsString('ids=', $result->getTargetUrl()); + } + + public function testUnknownActionWithEmptyPartsReturnsNull(): void + { + // The unknown-action switch only runs inside the foreach loop, so an + // empty parts list means the loop body never executes and no exception is thrown. + $result = $this->service->handleAction('unknown_action_xyz', [], null, '/test'); + $this->assertNull($result); + } } \ No newline at end of file diff --git a/tests/Services/Parts/PricedetailHelperTest.php b/tests/Services/Parts/PricedetailHelperTest.php index 08a5d6dd..46276e63 100644 --- a/tests/Services/Parts/PricedetailHelperTest.php +++ b/tests/Services/Parts/PricedetailHelperTest.php @@ -24,10 +24,12 @@ namespace App\Tests\Services\Parts; use PHPUnit\Framework\Attributes\DataProvider; use App\Entity\Parts\Part; +use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; use App\Services\Formatters\AmountFormatter; use App\Services\Parts\PricedetailHelper; +use Brick\Math\BigDecimal; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; final class PricedetailHelperTest extends WebTestCase @@ -87,4 +89,181 @@ final class PricedetailHelperTest extends WebTestCase { $this->assertSame($expected_result, $this->service->getMaxDiscountAmount($part), $message); } + + // --- getMinOrderAmount --- + + public static function minOrderAmountDataProvider(): \Generator + { + $part = new Part(); + yield [$part, null, 'No orderdetails']; + + $part = new Part(); + $part->addOrderdetail(new Orderdetail()); // orderdetail with no pricedetails + yield [$part, null, 'Empty orderdetail']; + + $part = new Part(); + $od = new Orderdetail(); + $od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5)); + $part->addOrderdetail($od); + yield [$part, 5.0, 'Single pricedetail']; + + // The service reads $pricedetails[0] assuming the collection is sorted ascending + // (which Doctrine does automatically for persistent collections). For in-memory + // collections we must insert in ascending order ourselves. + $part = new Part(); + $od = new Orderdetail(); + $od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)); + $od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(3)); + $od->addPricedetail((new Pricedetail())->setMinDiscountQuantity(10)); + $part->addOrderdetail($od); + yield [$part, 1.0, 'Multiple pricedetails — picks minimum (first in ascending order)']; + + $part = new Part(); + $od1 = new Orderdetail(); + $od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(5)); + $od2 = new Orderdetail(); + $od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(2)); + $part->addOrderdetail($od1); + $part->addOrderdetail($od2); + yield [$part, 2.0, 'Multiple orderdetails — picks global minimum']; + } + + #[DataProvider('minOrderAmountDataProvider')] + public function testGetMinOrderAmount(Part $part, ?float $expected, string $message): void + { + $this->assertSame($expected, $this->service->getMinOrderAmount($part), $message); + } + + // --- calculateAvgPrice --- + + private static function makePartWithPrice(float $pricePerUnit, float $minQty = 1.0): Part + { + $part = new Part(); + $od = new Orderdetail(); + $pd = (new Pricedetail()) + ->setMinDiscountQuantity($minQty) + ->setPrice(BigDecimal::of((string) $pricePerUnit)); + $od->addPricedetail($pd); + $part->addOrderdetail($od); + return $part; + } + + public function testCalculateAvgPriceNoOrderdetailsReturnsNull(): void + { + $this->assertNull($this->service->calculateAvgPrice(new Part())); + } + + public function testCalculateAvgPriceExplicitAmount(): void + { + $part = self::makePartWithPrice(2.00); + $result = $this->service->calculateAvgPrice($part, 1.0); + $this->assertNotNull($result); + $this->assertTrue(BigDecimal::of('2.00000')->isEqualTo($result)); + } + + public function testCalculateAvgPriceUsesMinOrderAmountWhenAmountIsNull(): void + { + // Min order amount is 5; the price applies for qty >= 5 + $part = self::makePartWithPrice(3.00, 5.0); + $result = $this->service->calculateAvgPrice($part, null); + $this->assertNotNull($result); + $this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result)); + } + + public function testCalculateAvgPriceAveragesMultipleSuppliers(): void + { + $part = new Part(); + + $od1 = new Orderdetail(); + $od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('2.00'))); + $part->addOrderdetail($od1); + + $od2 = new Orderdetail(); + $od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('4.00'))); + $part->addOrderdetail($od2); + + // Average of 2.00 and 4.00 = 3.00 + $result = $this->service->calculateAvgPrice($part, 1.0); + $this->assertNotNull($result); + $this->assertTrue(BigDecimal::of('3.00000')->isEqualTo($result)); + } + + public function testCalculateAvgPriceSkipsSupplierWithNoCoverageForAmount(): void + { + // Only one supplier covers qty=1, the other requires qty >= 100 + $part = new Part(); + $od1 = new Orderdetail(); + $od1->addPricedetail((new Pricedetail())->setMinDiscountQuantity(1)->setPrice(BigDecimal::of('5.00'))); + $part->addOrderdetail($od1); + + $od2 = new Orderdetail(); + $od2->addPricedetail((new Pricedetail())->setMinDiscountQuantity(100)->setPrice(BigDecimal::of('1.00'))); + $part->addOrderdetail($od2); + + $result = $this->service->calculateAvgPrice($part, 1.0); + $this->assertNotNull($result); + $this->assertTrue(BigDecimal::of('5.00000')->isEqualTo($result)); + } + + // --- convertMoneyToCurrency --- + + public function testConvertMoneyToCurrencyIdentityBothNull(): void + { + // Both currencies null = base currency; same currency, no conversion + $value = BigDecimal::of('10.00'); + $result = $this->service->convertMoneyToCurrency($value, null, null); + $this->assertNotNull($result); + $this->assertTrue($value->isEqualTo($result)); + } + + public function testConvertMoneyToCurrencyFromForeignToBase(): void + { + // EUR → base (null): exchange rate = 1.2 means 1 foreign = 1.2 base + $currency = new Currency(); + $currency->setExchangeRate(BigDecimal::of('1.2')); + + $result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null); + $this->assertNotNull($result); + // 10 * 1.2 = 12 + $this->assertTrue(BigDecimal::of('12.00000')->isEqualTo($result)); + } + + public function testConvertMoneyToCurrencyNullExchangeRateReturnsNull(): void + { + $currency = new Currency(); + // exchange rate not set → null + + $result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null); + $this->assertNull($result); + } + + public function testConvertMoneyToCurrencyZeroExchangeRateReturnsNull(): void + { + $currency = new Currency(); + $currency->setExchangeRate(BigDecimal::zero()); + + $result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), $currency, null); + $this->assertNull($result); + } + + public function testConvertMoneyToCurrencyTargetNullExchangeRateReturnsNull(): void + { + $target = new Currency(); + // exchange rate not set → getInverseExchangeRate() returns null + + $result = $this->service->convertMoneyToCurrency(BigDecimal::of('10.00'), null, $target); + $this->assertNull($result); + } + + public function testConvertMoneyToCurrencySameCurrencyInstanceIsIdentity(): void + { + $currency = new Currency(); + $currency->setExchangeRate(BigDecimal::of('2.0')); + + $value = BigDecimal::of('5.00'); + // origin === target → no conversion at all + $result = $this->service->convertMoneyToCurrency($value, $currency, $currency); + $this->assertNotNull($result); + $this->assertTrue($value->isEqualTo($result)); + } } diff --git a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php index b80adb2f..a30380db 100644 --- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php +++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php @@ -240,6 +240,132 @@ final class ProjectBuildHelperTest extends WebTestCase $this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result)); } + // --- unknown-instock lots are excluded from buildable count --- + + public function testGetMaximumBuildableCountForBOMEntryExcludesUnknownInstockLots(): void + { + $part = new Part(); + $lot = new PartLot(); + $lot->setAmount(100); + $lot->setInstockUnknown(true); // this lot should be ignored + $part->addPartLot($lot); + + $entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10); + + // All stock is in an unknown-instock lot → effective amount = 0 → 0 builds + $this->assertSame(0, $this->service->getMaximumBuildableCountForBOMEntry($entry)); + } + + public function testGetMaximumBuildableCountMixedKnownAndUnknownLots(): void + { + $part = new Part(); + + $knownLot = new PartLot(); + $knownLot->setAmount(30); + + $unknownLot = new PartLot(); + $unknownLot->setAmount(999); + $unknownLot->setInstockUnknown(true); + + $part->addPartLot($knownLot); + $part->addPartLot($unknownLot); + + $entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10); + + // Only the 30 known parts count → floor(30/10) = 3 + $this->assertSame(3, $this->service->getMaximumBuildableCountForBOMEntry($entry)); + } + + // --- project with only non-part BOM entries --- + + public function testGetMaximumBuildableCountOnlyNonPartEntriesReturnsIntMax(): void + { + $project = new Project(); + $project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1)); + $project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(2)); + + // No part entries → nothing constrains the count → PHP_INT_MAX + $this->assertSame(PHP_INT_MAX, $this->service->getMaximumBuildableCount($project)); + } + + public function testGetMaximumBuildableCountAsStringOnlyNonPartEntries(): void + { + $project = new Project(); + $project->addBomEntry((new ProjectBOMEntry())->setName('Solder')->setQuantity(1)); + + $this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project)); + } + + // --- isProjectBuildable --- + + public function testIsProjectBuildable(): void + { + $project = new Project(); + $part = new Part(); + $lot = new PartLot(); + $lot->setAmount(15); + $part->addPartLot($lot); + $project->addBomEntry((new ProjectBOMEntry())->setPart($part)->setQuantity(5)); + + $this->assertTrue($this->service->isProjectBuildable($project, 3)); // 15/5 = 3 ✓ + $this->assertFalse($this->service->isProjectBuildable($project, 4)); // 4 > 3 ✗ + } + + // --- isBOMEntryBuildable --- + + public function testIsBOMEntryBuildable(): void + { + $part = new Part(); + $lot = new PartLot(); + $lot->setAmount(20); + $part->addPartLot($lot); + + $entry = (new ProjectBOMEntry())->setPart($part)->setQuantity(10); + + $this->assertTrue($this->service->isBOMEntryBuildable($entry, 2)); // 20/10 = 2 ✓ + $this->assertFalse($this->service->isBOMEntryBuildable($entry, 3)); // 3 > 2 ✗ + } + + // --- getNonBuildableProjectBomEntries --- + + public function testGetNonBuildableProjectBomEntriesReturnsShortEntries(): void + { + $project = new Project(); + + $abundantPart = new Part(); + $lot1 = new PartLot(); + $lot1->setAmount(100); + $abundantPart->addPartLot($lot1); + $project->addBomEntry((new ProjectBOMEntry())->setPart($abundantPart)->setQuantity(5)); + + $scarcePart = new Part(); + $lot2 = new PartLot(); + $lot2->setAmount(3); + $scarcePart->addPartLot($lot2); + $scarceEntry = (new ProjectBOMEntry())->setPart($scarcePart)->setQuantity(10); + $project->addBomEntry($scarceEntry); + + // For 1 build: abundantPart OK (100 >= 5), scarcePart not (3 < 10) + $nonBuildable = $this->service->getNonBuildableProjectBomEntries($project, 1); + $this->assertCount(1, $nonBuildable); + $this->assertSame($scarceEntry, $nonBuildable[0]); + } + + public function testGetNonBuildableProjectBomEntriesSkipsNonPartEntries(): void + { + $project = new Project(); + $project->addBomEntry((new ProjectBOMEntry())->setName('Wire')->setQuantity(5)); + + // Non-part entries are ignored → no non-buildable entries + $this->assertCount(0, $this->service->getNonBuildableProjectBomEntries($project, 1)); + } + + public function testGetNonBuildableProjectBomEntriesThrowsOnZeroBuilds(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->getNonBuildableProjectBomEntries(new Project(), 0); + } + public function testCalculateTotalBuildPriceMixedEntries(): void { $project = new Project(); diff --git a/tests/Validator/Constraints/UniqueObjectCollectionValidatorTest.php b/tests/Validator/Constraints/UniqueObjectCollectionValidatorTest.php index 3863d604..67b3f8a6 100644 --- a/tests/Validator/Constraints/UniqueObjectCollectionValidatorTest.php +++ b/tests/Validator/Constraints/UniqueObjectCollectionValidatorTest.php @@ -154,6 +154,33 @@ final class UniqueObjectCollectionValidatorTest extends ConstraintValidatorTestC ->assertRaised(); } + public function testThirdElementDuplicatePointsToIndexTwo(): void + { + // First two elements are unique; only the third duplicates the first. + $this->validator->validate(new ArrayCollection([ + new DummyUniqueValidatableObject(['a' => 1]), + new DummyUniqueValidatableObject(['a' => 2]), + new DummyUniqueValidatableObject(['a' => 1]), // duplicate of index 0 + ]), + new UniqueObjectCollection(fields: ['a'])); + $this + ->buildViolation('This value is already used.') + ->setCode(UniqueObjectCollection::IS_NOT_UNIQUE) + ->setParameter('{{ object }}', 'objectString') + ->atPath('property.path[2].a') + ->assertRaised(); + } + public function testAllNullsWithAllowNullProducesNoViolation(): void + { + $this->validator->validate(new ArrayCollection([ + new DummyUniqueValidatableObject(['a' => null]), + new DummyUniqueValidatableObject(['a' => null]), + new DummyUniqueValidatableObject(['a' => null]), + ]), + new UniqueObjectCollection(fields: ['a'], allowNull: true)); + + $this->assertNoViolation(); + } } diff --git a/tests/Validator/Constraints/ValidGTINValidatorTest.php b/tests/Validator/Constraints/ValidGTINValidatorTest.php index 6b01519b..7a4017d5 100644 --- a/tests/Validator/Constraints/ValidGTINValidatorTest.php +++ b/tests/Validator/Constraints/ValidGTINValidatorTest.php @@ -24,52 +24,54 @@ namespace App\Tests\Validator\Constraints; use App\Validator\Constraints\ValidGTIN; use App\Validator\Constraints\ValidGTINValidator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; final class ValidGTINValidatorTest extends ConstraintValidatorTestCase { - - public function testAllowNull(): void - { - $this->validator->validate(null, new ValidGTIN()); - $this->assertNoViolation(); - } - - public function testValidGTIN8(): void - { - $this->validator->validate('12345670', new ValidGTIN()); - $this->assertNoViolation(); - } - - public function testValidGTIN12(): void - { - $this->validator->validate('123456789012', new ValidGTIN()); - $this->assertNoViolation(); - } - - public function testValidGTIN13(): void - { - $this->validator->validate('1234567890128', new ValidGTIN()); - $this->assertNoViolation(); - } - - public function testValidGTIN14(): void - { - $this->validator->validate('12345678901231', new ValidGTIN()); - $this->assertNoViolation(); - } - - public function testInvalidGTIN(): void - { - $this->validator->validate('1234567890123', new ValidGTIN()); - $this->buildViolation('validator.invalid_gtin') - ->assertRaised(); - } - protected function createValidator(): ConstraintValidatorInterface { return new ValidGTINValidator(); } + + // --- values that must produce no violation --- + + public static function validValuesProvider(): \Generator + { + yield 'null is skipped' => [null]; + yield 'empty string is skipped' => ['']; + yield 'valid GTIN-8' => ['12345670']; + yield 'valid GTIN-12' => ['123456789012']; + yield 'valid GTIN-13' => ['1234567890128']; + yield 'valid GTIN-14' => ['12345678901231']; + } + + #[DataProvider('validValuesProvider')] + public function testValidValue(mixed $value): void + { + $this->validator->validate($value, new ValidGTIN()); + $this->assertNoViolation(); + } + + // --- values that must produce a violation --- + + public static function invalidValuesProvider(): \Generator + { + yield 'wrong check digit (GTIN-13)' => ['1234567890123']; + yield 'non-numeric string' => ['ABCDEFGHIJKLM']; + yield 'wrong length — 9 digits' => ['123456789']; + yield 'wrong length — 11 digits' => ['12345678901']; + yield 'leading whitespace' => [' 1234567890128']; + yield 'trailing whitespace' => ['1234567890128 ']; + } + + #[DataProvider('invalidValuesProvider')] + public function testInvalidValue(string $value): void + { + $this->validator->validate($value, new ValidGTIN()); + $this->buildViolation('validator.invalid_gtin') + ->assertRaised(); + } } diff --git a/tests/Validator/Constraints/ValidPartLotValidatorTest.php b/tests/Validator/Constraints/ValidPartLotValidatorTest.php index 394ee66c..b66a0bf4 100644 --- a/tests/Validator/Constraints/ValidPartLotValidatorTest.php +++ b/tests/Validator/Constraints/ValidPartLotValidatorTest.php @@ -83,4 +83,41 @@ final class ValidPartLotValidatorTest extends WebTestCase $this->expectException(\Symfony\Component\Form\Exception\UnexpectedTypeException::class); self::$validator->validate('not a part lot', new ValidPartLot()); } + + public function testPartLotWithFullLocationRaisesNamedViolation(): void + { + $lot = new PartLot(); + $lot->setPart(new Part()); + + $location = new StorageLocation(); + $location->setIsFull(true); + $lot->setStorageLocation($location); + + $violations = self::$validator->validate($lot, new ValidPartLot()); + // Expect exactly one violation on the storage_location path + $this->assertCount(1, $violations); + $this->assertSame('storage_location', $violations[0]->getPropertyPath()); + $this->assertStringContainsString('location_full', $violations[0]->getMessageTemplate()); + } + + public function testLimitToExistingPartsWithNewLotRaisesViolation(): void + { + $lot = new PartLot(); + $lot->setPart(new Part()); + + $location = new StorageLocation(); + $location->setLimitToExistingParts(true); + $lot->setStorageLocation($location); + + // New lot (no ID) → parts collection is empty → part is not in the list → violation + $violations = self::$validator->validate($lot, new ValidPartLot()); + $this->assertCount(1, $violations); + $this->assertSame('storage_location', $violations[0]->getPropertyPath()); + $this->assertSame('validator.part_lot.only_existing', $violations[0]->getMessageTemplate()); + } + + // NOTE: The 'location_full.no_increase' violation (raised when a lot's amount + // is increased while its storage location is marked full) requires the entity to + // carry a real Doctrine originalEntityData snapshot, which is only set after an + // actual persist+flush. Testing that path belongs in a database integration test. }