diff --git a/makefile b/makefile index 6b5ac61f..9041ba0f 100644 --- a/makefile +++ b/makefile @@ -73,6 +73,10 @@ test-run: @echo "🧪 Running tests..." php bin/phpunit +test-typecheck: + @echo "🧪 Running type checks..." + COMPOSER_MEMORY_LIMIT=-1 composer phpstan + # Quick test reset (clean + migrate + fixtures, skip DB creation) test-reset: test-cache-clear test-db-migrate test-fixtures @echo "✅ Test environment reset complete!" diff --git a/tests/Services/ImportExportSystem/BOMImporterTest.php b/tests/Services/ImportExportSystem/BOMImporterTest.php index b9aba1d4..52c633d0 100644 --- a/tests/Services/ImportExportSystem/BOMImporterTest.php +++ b/tests/Services/ImportExportSystem/BOMImporterTest.php @@ -22,9 +22,12 @@ declare(strict_types=1); */ namespace App\Tests\Services\ImportExportSystem; +use App\Entity\Parts\Part; +use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Services\ImportExportSystem\BOMImporter; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\File\File; @@ -36,11 +39,17 @@ class BOMImporterTest extends WebTestCase */ protected $service; + /** + * @var EntityManagerInterface + */ + protected $entityManager; + protected function setUp(): void { //Get a service instance. self::bootKernel(); $this->service = self::getContainer()->get(BOMImporter::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); } public function testImportFileIntoProject(): void @@ -119,4 +128,489 @@ class BOMImporterTest extends WebTestCase $this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']); } + + public function testDetectFields(): void + { + $input = <<service->detectFields($input); + + $this->assertIsArray($fields); + $this->assertCount(8, $fields); + $this->assertContains('Reference', $fields); + $this->assertContains('Value', $fields); + $this->assertContains('Footprint', $fields); + $this->assertContains('Quantity', $fields); + $this->assertContains('MPN', $fields); + $this->assertContains('Manufacturer', $fields); + $this->assertContains('LCSC SPN', $fields); + $this->assertContains('Mouser SPN', $fields); + } + + public function testDetectFieldsWithQuotes(): void + { + $input = <<service->detectFields($input); + + $this->assertIsArray($fields); + $this->assertCount(8, $fields); + $this->assertEquals('Reference', $fields[0]); + $this->assertEquals('Value', $fields[1]); + } + + public function testDetectFieldsWithSemicolon(): void + { + $input = <<service->detectFields($input, ';'); + + $this->assertIsArray($fields); + $this->assertCount(8, $fields); + $this->assertEquals('Reference', $fields[0]); + $this->assertEquals('Value', $fields[1]); + } + + public function testGetAvailableFieldTargets(): void + { + $targets = $this->service->getAvailableFieldTargets(); + + $this->assertIsArray($targets); + $this->assertArrayHasKey('Designator', $targets); + $this->assertArrayHasKey('Quantity', $targets); + $this->assertArrayHasKey('Value', $targets); + $this->assertArrayHasKey('Package', $targets); + $this->assertArrayHasKey('MPN', $targets); + $this->assertArrayHasKey('Manufacturer', $targets); + $this->assertArrayHasKey('Part-DB ID', $targets); + $this->assertArrayHasKey('Comment', $targets); + + // Check structure of a target + $this->assertArrayHasKey('label', $targets['Designator']); + $this->assertArrayHasKey('description', $targets['Designator']); + $this->assertArrayHasKey('required', $targets['Designator']); + $this->assertArrayHasKey('multiple', $targets['Designator']); + + $this->assertTrue($targets['Designator']['required']); + $this->assertTrue($targets['Quantity']['required']); + $this->assertFalse($targets['Value']['required']); + } + + public function testGetAvailableFieldTargetsWithSuppliers(): void + { + // Create test suppliers + $supplier1 = new Supplier(); + $supplier1->setName('LCSC'); + $supplier2 = new Supplier(); + $supplier2->setName('Mouser'); + + $this->entityManager->persist($supplier1); + $this->entityManager->persist($supplier2); + $this->entityManager->flush(); + + $targets = $this->service->getAvailableFieldTargets(); + + $this->assertArrayHasKey('LCSC SPN', $targets); + $this->assertArrayHasKey('Mouser SPN', $targets); + + $this->assertEquals('LCSC SPN', $targets['LCSC SPN']['label']); + $this->assertEquals('Mouser SPN', $targets['Mouser SPN']['label']); + $this->assertFalse($targets['LCSC SPN']['required']); + $this->assertTrue($targets['LCSC SPN']['multiple']); + + // Clean up + $this->entityManager->remove($supplier1); + $this->entityManager->remove($supplier2); + $this->entityManager->flush(); + } + + public function testGetSuggestedFieldMapping(): void + { + $detected_fields = [ + 'Reference', + 'Value', + 'Footprint', + 'Quantity', + 'MPN', + 'Manufacturer', + 'LCSC', + 'Mouser', + 'Part-DB ID', + 'Comment' + ]; + + $suggestions = $this->service->getSuggestedFieldMapping($detected_fields); + + $this->assertIsArray($suggestions); + $this->assertEquals('Designator', $suggestions['Reference']); + $this->assertEquals('Value', $suggestions['Value']); + $this->assertEquals('Package', $suggestions['Footprint']); + $this->assertEquals('Quantity', $suggestions['Quantity']); + $this->assertEquals('MPN', $suggestions['MPN']); + $this->assertEquals('Manufacturer', $suggestions['Manufacturer']); + $this->assertEquals('Part-DB ID', $suggestions['Part-DB ID']); + $this->assertEquals('Comment', $suggestions['Comment']); + } + + public function testGetSuggestedFieldMappingWithSuppliers(): void + { + // Create test suppliers + $supplier1 = new Supplier(); + $supplier1->setName('LCSC'); + $supplier2 = new Supplier(); + $supplier2->setName('Mouser'); + + $this->entityManager->persist($supplier1); + $this->entityManager->persist($supplier2); + $this->entityManager->flush(); + + $detected_fields = [ + 'Reference', + 'LCSC', + 'Mouser', + 'lcsc_part', + 'mouser_spn' + ]; + + $suggestions = $this->service->getSuggestedFieldMapping($detected_fields); + + $this->assertIsArray($suggestions); + $this->assertEquals('Designator', $suggestions['Reference']); + // Note: The exact mapping depends on the pattern matching logic + // We just check that supplier fields are mapped to something + $this->assertArrayHasKey('LCSC', $suggestions); + $this->assertArrayHasKey('Mouser', $suggestions); + $this->assertArrayHasKey('lcsc_part', $suggestions); + $this->assertArrayHasKey('mouser_spn', $suggestions); + + // Clean up + $this->entityManager->remove($supplier1); + $this->entityManager->remove($supplier2); + $this->entityManager->flush(); + } + + public function testValidateFieldMappingValid(): void + { + $field_mapping = [ + 'Reference' => 'Designator', + 'Quantity' => 'Quantity', + 'Value' => 'Value' + ]; + + $detected_fields = ['Reference', 'Quantity', 'Value', 'MPN']; + + $result = $this->service->validateFieldMapping($field_mapping, $detected_fields); + + $this->assertIsArray($result); + $this->assertArrayHasKey('errors', $result); + $this->assertArrayHasKey('warnings', $result); + $this->assertArrayHasKey('is_valid', $result); + + $this->assertTrue($result['is_valid']); + $this->assertEmpty($result['errors']); + $this->assertNotEmpty($result['warnings']); // Should warn about unmapped MPN + } + + public function testValidateFieldMappingMissingRequired(): void + { + $field_mapping = [ + 'Value' => 'Value', + 'MPN' => 'MPN' + ]; + + $detected_fields = ['Value', 'MPN']; + + $result = $this->service->validateFieldMapping($field_mapping, $detected_fields); + + $this->assertFalse($result['is_valid']); + $this->assertNotEmpty($result['errors']); + $this->assertContains("Required field 'Designator' is not mapped from any CSV column.", $result['errors']); + $this->assertContains("Required field 'Quantity' is not mapped from any CSV column.", $result['errors']); + } + + public function testValidateFieldMappingInvalidTarget(): void + { + $field_mapping = [ + 'Reference' => 'Designator', + 'Quantity' => 'Quantity', + 'Value' => 'InvalidTarget' + ]; + + $detected_fields = ['Reference', 'Quantity', 'Value']; + + $result = $this->service->validateFieldMapping($field_mapping, $detected_fields); + + $this->assertFalse($result['is_valid']); + $this->assertNotEmpty($result['errors']); + $this->assertContains("Invalid target field 'InvalidTarget' for CSV field 'Value'.", $result['errors']); + } + + public function testStringToBOMEntriesKiCADSchematic(): void + { + $input = << 'Designator', + 'Value' => 'Value', + 'Footprint' => 'Package', + 'Quantity' => 'Quantity', + 'MPN' => 'MPN', + 'Manufacturer' => 'Manufacturer', + 'LCSC SPN' => 'LCSC SPN', + 'Mouser SPN' => 'Mouser SPN' + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(2, $bom_entries); + + // Check first entry + $this->assertEquals('R1,R2', $bom_entries[0]->getMountnames()); + $this->assertEquals(2.0, $bom_entries[0]->getQuantity()); + $this->assertEquals('CRCW080510K0FKEA (R_0805_2012Metric)', $bom_entries[0]->getName()); + $this->assertStringContainsString('Value: 10k', $bom_entries[0]->getComment()); + $this->assertStringContainsString('MPN: CRCW080510K0FKEA', $bom_entries[0]->getComment()); + $this->assertStringContainsString('Manf: Vishay', $bom_entries[0]->getComment()); + + // Check second entry + $this->assertEquals('C1', $bom_entries[1]->getMountnames()); + $this->assertEquals(1.0, $bom_entries[1]->getQuantity()); + } + + public function testStringToBOMEntriesKiCADSchematicWithPriority(): void + { + $input = << 'Designator', + 'Value' => 'Value', + 'MPN1' => 'MPN', + 'MPN2' => 'MPN', + 'Quantity' => 'Quantity' + ]; + + $field_priorities = [ + 'MPN1' => 1, + 'MPN2' => 2 + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'field_priorities' => $field_priorities, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(2, $bom_entries); + + // First entry should use MPN1 (higher priority) + $this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName()); + + // Second entry should use MPN2 (MPN1 is empty) + $this->assertEquals('CL21A104KOCLRNC', $bom_entries[1]->getName()); + } + + public function testStringToBOMEntriesKiCADSchematicWithPartDBID(): void + { + // Create a test part with required fields + $part = new Part(); + $part->setName('Test Part'); + $part->setCategory($this->getDefaultCategory($this->entityManager)); + $this->entityManager->persist($part); + $this->entityManager->flush(); + + $input = <<getID()}","2" + CSV; + + $field_mapping = [ + 'Reference' => 'Designator', + 'Value' => 'Value', + 'Part-DB ID' => 'Part-DB ID', + 'Quantity' => 'Quantity' + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(1, $bom_entries); + + $this->assertEquals('Test Part', $bom_entries[0]->getName()); + $this->assertSame($part, $bom_entries[0]->getPart()); + $this->assertStringContainsString("Part-DB ID: {$part->getID()}", $bom_entries[0]->getComment()); + + // Clean up + $this->entityManager->remove($part); + $this->entityManager->flush(); + } + + public function testStringToBOMEntriesKiCADSchematicWithInvalidPartDBID(): void + { + $input = << 'Designator', + 'Value' => 'Value', + 'Part-DB ID' => 'Part-DB ID', + 'Quantity' => 'Quantity' + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(1, $bom_entries); + + $this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name + $this->assertNull($bom_entries[0]->getPart()); // Should not link to part + $this->assertStringContainsString("Part-DB ID: 99999 (NOT FOUND)", $bom_entries[0]->getComment()); + } + + public function testStringToBOMEntriesKiCADSchematicMergeDuplicates(): void + { + $input = << 'Designator', + 'Value' => 'Value', + 'MPN' => 'MPN', + 'Quantity' => 'Quantity' + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(1, $bom_entries); // Should merge into one entry + + $this->assertEquals('R1,R2', $bom_entries[0]->getMountnames()); + $this->assertEquals(2.0, $bom_entries[0]->getQuantity()); + $this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName()); + } + + public function testStringToBOMEntriesKiCADSchematicMissingRequired(): void + { + $input = << 'Value', + 'MPN' => 'MPN' + ]; + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Required field "Designator" is missing or empty'); + + $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + } + + public function testStringToBOMEntriesKiCADSchematicQuantityMismatch(): void + { + $input = << 'Designator', + 'Value' => 'Value', + 'Quantity' => 'Quantity' + ]; + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Mismatch between quantity and component references'); + + $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + } + + public function testStringToBOMEntriesKiCADSchematicWithBOM(): void + { + // Test with BOM (Byte Order Mark) + $input = "\xEF\xBB\xBF" . << 'Designator', + 'Value' => 'Value', + 'Quantity' => 'Quantity' + ]; + + $bom_entries = $this->service->stringToBOMEntries($input, [ + 'type' => 'kicad_schematic', + 'field_mapping' => $field_mapping, + 'delimiter' => ',' + ]); + + $this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries); + $this->assertCount(1, $bom_entries); + $this->assertEquals('R1,R2', $bom_entries[0]->getMountnames()); + } + + private function getDefaultCategory(EntityManagerInterface $entityManager) + { + // Get the first available category or create a default one + $categoryRepo = $entityManager->getRepository(\App\Entity\Parts\Category::class); + $categories = $categoryRepo->findAll(); + + if (empty($categories)) { + // Create a default category if none exists + $category = new \App\Entity\Parts\Category(); + $category->setName('Default Category'); + $entityManager->persist($category); + $entityManager->flush(); + return $category; + } + + return $categories[0]; + } } diff --git a/tests/Services/ImportExportSystem/BOMValidationServiceTest.php b/tests/Services/ImportExportSystem/BOMValidationServiceTest.php new file mode 100644 index 00000000..055db8b4 --- /dev/null +++ b/tests/Services/ImportExportSystem/BOMValidationServiceTest.php @@ -0,0 +1,349 @@ +. + */ +namespace App\Tests\Services\ImportExportSystem; + +use App\Entity\Parts\Part; +use App\Services\ImportExportSystem\BOMValidationService; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @see \App\Services\ImportExportSystem\BOMValidationService + */ +class BOMValidationServiceTest extends WebTestCase +{ + private BOMValidationService $validationService; + private EntityManagerInterface $entityManager; + private TranslatorInterface $translator; + + protected function setUp(): void + { + self::bootKernel(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->translator = self::getContainer()->get(TranslatorInterface::class); + $this->validationService = new BOMValidationService($this->entityManager, $this->translator); + } + + public function testValidateBOMEntryWithValidData(): void + { + $entry = [ + 'Designator' => 'R1,C2,R3', + 'Quantity' => '3', + 'MPN' => 'RES-10K', + 'Package' => '0603', + 'Value' => '10k', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); + $this->assertEmpty($result['errors']); + $this->assertEquals(1, $result['line_number']); + } + + public function testValidateBOMEntryWithMissingRequiredFields(): void + { + $entry = [ + 'MPN' => 'RES-10K', + 'Package' => '0603', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertCount(2, $result['errors']); + $this->assertStringContainsString('Designator', (string) $result['errors'][0]); + $this->assertStringContainsString('Quantity', (string) $result['errors'][1]); + } + + public function testValidateBOMEntryWithQuantityMismatch(): void + { + $entry = [ + 'Designator' => 'R1,C2,R3,C4', + 'Quantity' => '3', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertCount(1, $result['errors']); + $this->assertStringContainsString('Mismatch between quantity and component references', (string) $result['errors'][0]); + } + + public function testValidateBOMEntryWithInvalidQuantity(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => 'abc', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertGreaterThanOrEqual(1, count($result['errors'])); + $this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors']))); + } + + public function testValidateBOMEntryWithZeroQuantity(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '0', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertGreaterThanOrEqual(1, count($result['errors'])); + $this->assertStringContainsString('must be greater than 0', implode(' ', array_map('strval', $result['errors']))); + } + + public function testValidateBOMEntryWithDuplicateDesignators(): void + { + $entry = [ + 'Designator' => 'R1,R1,C2', + 'Quantity' => '3', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertCount(1, $result['errors']); + $this->assertStringContainsString('Duplicate component references', (string) $result['errors'][0]); + } + + public function testValidateBOMEntryWithInvalidDesignatorFormat(): void + { + $entry = [ + 'Designator' => 'R1,invalid,C2', + 'Quantity' => '3', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); // Warnings don't make it invalid + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('unusual format', (string) $result['warnings'][0]); + } + + public function testValidateBOMEntryWithEmptyDesignator(): void + { + $entry = [ + 'Designator' => '', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertGreaterThanOrEqual(1, count($result['errors'])); + $this->assertStringContainsString('Required field "Designator" is missing or empty', implode(' ', array_map('strval', $result['errors']))); + } + + public function testValidateBOMEntryWithInvalidPartDBID(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + 'Part-DB ID' => 'abc', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertFalse($result['is_valid']); + $this->assertGreaterThanOrEqual(1, count($result['errors'])); + $this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors']))); + } + + public function testValidateBOMEntryWithNonExistentPartDBID(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + 'Part-DB ID' => '999999', // Use very high ID that doesn't exist + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); // Warnings don't make it invalid + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('not found in database', (string) $result['warnings'][0]); + } + + public function testValidateBOMEntryWithNoComponentName(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'Package' => '0603', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); // Warnings don't make it invalid + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('No component name/designation', (string) $result['warnings'][0]); + } + + public function testValidateBOMEntryWithLongPackageName(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + 'Package' => str_repeat('A', 150), // Very long package name + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); // Warnings don't make it invalid + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('unusually long', (string) $result['warnings'][0]); + } + + public function testValidateBOMEntryWithLibraryPrefix(): void + { + $entry = [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + 'Package' => 'Resistor_SMD:R_0603_1608Metric', + ]; + + $result = $this->validationService->validateBOMEntry($entry, 1); + + $this->assertTrue($result['is_valid']); + $this->assertCount(1, $result['info']); + $this->assertStringContainsString('library prefix', $result['info'][0]); + } + + public function testValidateBOMEntriesWithMultipleEntries(): void + { + $entries = [ + [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + ], + [ + 'Designator' => 'C1,C2', + 'Quantity' => '2', + 'MPN' => 'CAP-100nF', + ], + ]; + + $result = $this->validationService->validateBOMEntries($entries); + + $this->assertTrue($result['is_valid']); + $this->assertEquals(2, $result['total_entries']); + $this->assertEquals(2, $result['valid_entries']); + $this->assertEquals(0, $result['invalid_entries']); + $this->assertCount(2, $result['line_results']); + } + + public function testValidateBOMEntriesWithMixedResults(): void + { + $entries = [ + [ + 'Designator' => 'R1', + 'Quantity' => '1', + 'MPN' => 'RES-10K', + ], + [ + 'Designator' => 'C1,C2', + 'Quantity' => '1', // Mismatch + 'MPN' => 'CAP-100nF', + ], + ]; + + $result = $this->validationService->validateBOMEntries($entries); + + $this->assertFalse($result['is_valid']); + $this->assertEquals(2, $result['total_entries']); + $this->assertEquals(1, $result['valid_entries']); + $this->assertEquals(1, $result['invalid_entries']); + $this->assertCount(1, $result['errors']); + } + + public function testGetValidationStats(): void + { + $validation_result = [ + 'total_entries' => 10, + 'valid_entries' => 8, + 'invalid_entries' => 2, + 'errors' => ['Error 1', 'Error 2'], + 'warnings' => ['Warning 1'], + 'info' => ['Info 1', 'Info 2'], + ]; + + $stats = $this->validationService->getValidationStats($validation_result); + + $this->assertEquals(10, $stats['total_entries']); + $this->assertEquals(8, $stats['valid_entries']); + $this->assertEquals(2, $stats['invalid_entries']); + $this->assertEquals(2, $stats['error_count']); + $this->assertEquals(1, $stats['warning_count']); + $this->assertEquals(2, $stats['info_count']); + $this->assertEquals(80.0, $stats['success_rate']); + } + + public function testGetErrorMessage(): void + { + $validation_result = [ + 'is_valid' => false, + 'errors' => ['Error 1', 'Error 2'], + 'warnings' => ['Warning 1'], + ]; + + $message = $this->validationService->getErrorMessage($validation_result); + + $this->assertStringContainsString('Errors:', $message); + $this->assertStringContainsString('• Error 1', $message); + $this->assertStringContainsString('• Error 2', $message); + $this->assertStringContainsString('Warnings:', $message); + $this->assertStringContainsString('• Warning 1', $message); + } + + public function testGetErrorMessageWithValidResult(): void + { + $validation_result = [ + 'is_valid' => true, + 'errors' => [], + 'warnings' => [], + ]; + + $message = $this->validationService->getErrorMessage($validation_result); + + $this->assertEquals('', $message); + } +} \ No newline at end of file