diff --git a/src/Command/PopulateKicadCommand.php b/src/Command/PopulateKicadCommand.php new file mode 100644 index 00000000..0766892e --- /dev/null +++ b/src/Command/PopulateKicadCommand.php @@ -0,0 +1,495 @@ +setHelp('This command populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities based on their names.'); + + $this + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without applying them') + ->addOption('footprints', null, InputOption::VALUE_NONE, 'Only update footprint entities') + ->addOption('categories', null, InputOption::VALUE_NONE, 'Only update category entities') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing values (by default, only empty values are updated)') + ->addOption('list', null, InputOption::VALUE_NONE, 'List all footprints and categories with their current KiCad values') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = $input->getOption('dry-run'); + $footprintsOnly = $input->getOption('footprints'); + $categoriesOnly = $input->getOption('categories'); + $force = $input->getOption('force'); + $list = $input->getOption('list'); + + // If neither specified, do both + $doFootprints = !$categoriesOnly || $footprintsOnly; + $doCategories = !$footprintsOnly || $categoriesOnly; + + if ($list) { + $this->listCurrentValues($io); + return Command::SUCCESS; + } + + if ($dryRun) { + $io->note('DRY RUN MODE - No changes will be made'); + } + + $totalUpdated = 0; + + if ($doFootprints) { + $updated = $this->updateFootprints($io, $dryRun, $force); + $totalUpdated += $updated; + } + + if ($doCategories) { + $updated = $this->updateCategories($io, $dryRun, $force); + $totalUpdated += $updated; + } + + if (!$dryRun && $totalUpdated > 0) { + $this->entityManager->flush(); + $io->success(sprintf('Updated %d entities. Run "php bin/console cache:clear" to clear the cache.', $totalUpdated)); + } elseif ($dryRun && $totalUpdated > 0) { + $io->info(sprintf('DRY RUN: Would update %d entities. Run without --dry-run to apply changes.', $totalUpdated)); + } else { + $io->info('No entities needed updating.'); + } + + return Command::SUCCESS; + } + + private function listCurrentValues(SymfonyStyle $io): void + { + $io->section('Current Footprint KiCad Values'); + + $footprintRepo = $this->entityManager->getRepository(Footprint::class); + $footprints = $footprintRepo->findAll(); + + $rows = []; + foreach ($footprints as $footprint) { + $kicadValue = $footprint->getEdaInfo()->getKicadFootprint(); + $rows[] = [ + $footprint->getId(), + $footprint->getName(), + $kicadValue ?? '(empty)', + ]; + } + + $io->table(['ID', 'Name', 'KiCad Footprint'], $rows); + + $io->section('Current Category KiCad Values'); + + $categoryRepo = $this->entityManager->getRepository(Category::class); + $categories = $categoryRepo->findAll(); + + $rows = []; + foreach ($categories as $category) { + $kicadValue = $category->getEdaInfo()->getKicadSymbol(); + $rows[] = [ + $category->getId(), + $category->getName(), + $kicadValue ?? '(empty)', + ]; + } + + $io->table(['ID', 'Name', 'KiCad Symbol'], $rows); + } + + private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force): int + { + $io->section('Updating Footprint Entities'); + + $mappings = $this->getFootprintMappings(); + + $footprintRepo = $this->entityManager->getRepository(Footprint::class); + $footprints = $footprintRepo->findAll(); + + $updated = 0; + $skipped = []; + + foreach ($footprints as $footprint) { + $name = $footprint->getName(); + $currentValue = $footprint->getEdaInfo()->getKicadFootprint(); + + // Skip if already has value and not forcing + if (!$force && $currentValue !== null && $currentValue !== '') { + continue; + } + + // Check for exact match first + if (isset($mappings[$name])) { + $newValue = $mappings[$name]; + $io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $newValue)); + + if (!$dryRun) { + $footprint->getEdaInfo()->setKicadFootprint($newValue); + } + $updated++; + } else { + // No mapping found + $skipped[] = $name; + } + } + + $io->newLine(); + $io->text(sprintf('Updated: %d footprints', $updated)); + + if (count($skipped) > 0) { + $io->warning(sprintf('No mapping found for %d footprints:', count($skipped))); + foreach ($skipped as $name) { + $io->text(' - ' . $name); + } + } + + return $updated; + } + + private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force): int + { + $io->section('Updating Category Entities'); + + $mappings = $this->getCategoryMappings(); + + $categoryRepo = $this->entityManager->getRepository(Category::class); + $categories = $categoryRepo->findAll(); + + $updated = 0; + $skipped = []; + + foreach ($categories as $category) { + $name = $category->getName(); + $currentValue = $category->getEdaInfo()->getKicadSymbol(); + + // Skip if already has value and not forcing + if (!$force && $currentValue !== null && $currentValue !== '') { + continue; + } + + // Check for matches using the pattern-based mappings + $matched = false; + foreach ($mappings as $pattern => $kicadSymbol) { + if ($this->matchesPattern($name, $pattern)) { + $io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $kicadSymbol)); + + if (!$dryRun) { + $category->getEdaInfo()->setKicadSymbol($kicadSymbol); + } + $updated++; + $matched = true; + break; + } + } + + if (!$matched) { + $skipped[] = $name; + } + } + + $io->newLine(); + $io->text(sprintf('Updated: %d categories', $updated)); + + if (count($skipped) > 0) { + $io->note(sprintf('No mapping found for %d categories (this is often expected):', count($skipped))); + foreach ($skipped as $name) { + $io->text(' - ' . $name); + } + } + + return $updated; + } + + private function matchesPattern(string $name, string $pattern): bool + { + // Check for exact match + if ($pattern === $name) { + return true; + } + + // Check for case-insensitive contains + if (stripos($name, $pattern) !== false) { + return true; + } + + return false; + } + + /** + * Returns footprint name to KiCad footprint path mappings. + * These are based on KiCad 9.x standard library paths. + * + * @return array + */ + private function getFootprintMappings(): array + { + return [ + // === SOT packages === + 'SOT-23' => 'Package_TO_SOT_SMD:SOT-23', + 'SOT-23-3' => 'Package_TO_SOT_SMD:SOT-23', + 'SOT-23-5' => 'Package_TO_SOT_SMD:SOT-23-5', + 'SOT-23-6' => 'Package_TO_SOT_SMD:SOT-23-6', + 'SOT-223' => 'Package_TO_SOT_SMD:SOT-223-3_TabPin2', + 'SOT-223-3' => 'Package_TO_SOT_SMD:SOT-223-3_TabPin2', + 'SOT-89' => 'Package_TO_SOT_SMD:SOT-89-3', + 'SOT-89-3' => 'Package_TO_SOT_SMD:SOT-89-3', + 'SOT-323' => 'Package_TO_SOT_SMD:SOT-323_SC-70', + 'SOT-363' => 'Package_TO_SOT_SMD:SOT-363_SC-70-6', + 'TSOT-25' => 'Package_TO_SOT_SMD:SOT-23-5', + + // === SC-70 === + 'SC-70-5' => 'Package_TO_SOT_SMD:SOT-353_SC-70-5', + 'SC-70-6' => 'Package_TO_SOT_SMD:SOT-363_SC-70-6', + + // === TO packages (through-hole) === + 'TO-220' => 'Package_TO_SOT_THT:TO-220-3_Vertical', + 'TO-220AB' => 'Package_TO_SOT_THT:TO-220-3_Vertical', + 'TO-220AB-3' => 'Package_TO_SOT_THT:TO-220-3_Vertical', + 'TO-220FP' => 'Package_TO_SOT_THT:TO-220F-3_Vertical', + 'TO-247-3' => 'Package_TO_SOT_THT:TO-247-3_Vertical', + 'TO-92' => 'Package_TO_SOT_THT:TO-92_Inline', + 'TO-92-3' => 'Package_TO_SOT_THT:TO-92_Inline', + + // === TO packages (SMD) === + 'TO-252' => 'Package_TO_SOT_SMD:TO-252-2', + 'TO-252-2L' => 'Package_TO_SOT_SMD:TO-252-2', + 'TO-252-3L' => 'Package_TO_SOT_SMD:TO-252-3', + 'TO-263' => 'Package_TO_SOT_SMD:TO-263-2', + 'TO-263-2' => 'Package_TO_SOT_SMD:TO-263-2', + 'D2PAK' => 'Package_TO_SOT_SMD:TO-252-2', + 'DPAK' => 'Package_TO_SOT_SMD:TO-252-2', + + // === SOIC === + 'SOIC-8' => 'Package_SO:SOIC-8_3.9x4.9mm_P1.27mm', + 'ESOP-8' => 'Package_SO:SOIC-8_3.9x4.9mm_P1.27mm', + 'SOIC-14' => 'Package_SO:SOIC-14_3.9x8.7mm_P1.27mm', + 'SOIC-16' => 'Package_SO:SOIC-16_3.9x9.9mm_P1.27mm', + + // === TSSOP / MSOP === + 'TSSOP-8' => 'Package_SO:TSSOP-8_3x3mm_P0.65mm', + 'TSSOP-14' => 'Package_SO:TSSOP-14_4.4x5mm_P0.65mm', + 'TSSOP-16' => 'Package_SO:TSSOP-16_4.4x5mm_P0.65mm', + 'TSSOP-16L' => 'Package_SO:TSSOP-16_4.4x5mm_P0.65mm', + 'TSSOP-20' => 'Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm', + 'MSOP-8' => 'Package_SO:MSOP-8_3x3mm_P0.65mm', + 'MSOP-10' => 'Package_SO:MSOP-10_3x3mm_P0.5mm', + 'MSOP-16' => 'Package_SO:MSOP-16_3x4mm_P0.5mm', + + // === SOT-5 / SO-5 === + 'SO-5' => 'Package_TO_SOT_SMD:SOT-23-5', + + // === DIP === + 'DIP-4' => 'Package_DIP:DIP-4_W7.62mm', + 'DIP-6' => 'Package_DIP:DIP-6_W7.62mm', + 'DIP-8' => 'Package_DIP:DIP-8_W7.62mm', + 'DIP-14' => 'Package_DIP:DIP-14_W7.62mm', + 'DIP-16' => 'Package_DIP:DIP-16_W7.62mm', + 'DIP-18' => 'Package_DIP:DIP-18_W7.62mm', + 'DIP-20' => 'Package_DIP:DIP-20_W7.62mm', + 'DIP-24' => 'Package_DIP:DIP-24_W7.62mm', + 'DIP-28' => 'Package_DIP:DIP-28_W7.62mm', + 'DIP-40' => 'Package_DIP:DIP-40_W15.24mm', + + // === QFN === + 'QFN-8' => 'Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm', + 'QFN-12(3x3)' => 'Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm', + 'QFN-16' => 'Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm', + 'QFN-20' => 'Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm', + 'QFN-24' => 'Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm', + 'QFN-32' => 'Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm', + 'QFN-48' => 'Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm', + + // === TQFP / LQFP === + 'TQFP-32' => 'Package_QFP:TQFP-32_7x7mm_P0.8mm', + 'TQFP-44' => 'Package_QFP:TQFP-44_10x10mm_P0.8mm', + 'TQFP-48' => 'Package_QFP:TQFP-48_7x7mm_P0.5mm', + 'TQFP-48(7x7)' => 'Package_QFP:TQFP-48_7x7mm_P0.5mm', + 'TQFP-64' => 'Package_QFP:TQFP-64_10x10mm_P0.5mm', + 'TQFP-100' => 'Package_QFP:TQFP-100_14x14mm_P0.5mm', + 'LQFP-32' => 'Package_QFP:LQFP-32_7x7mm_P0.8mm', + 'LQFP-48' => 'Package_QFP:LQFP-48_7x7mm_P0.5mm', + 'LQFP-64' => 'Package_QFP:LQFP-64_10x10mm_P0.5mm', + 'LQFP-100' => 'Package_QFP:LQFP-100_14x14mm_P0.5mm', + + // === Diode packages === + 'SOD-123' => 'Diode_SMD:D_SOD-123', + 'SOD-123F' => 'Diode_SMD:D_SOD-123F', + 'SOD-123FL' => 'Diode_SMD:D_SOD-123F', + 'SOD-323' => 'Diode_SMD:D_SOD-323', + 'SOD-523' => 'Diode_SMD:D_SOD-523', + 'SOD-882' => 'Diode_SMD:D_SOD-882', + 'SOD-882D' => 'Diode_SMD:D_SOD-882', + 'SMA(DO-214AC)' => 'Diode_SMD:D_SMA', + 'SMA' => 'Diode_SMD:D_SMA', + 'SMB' => 'Diode_SMD:D_SMB', + 'SMC' => 'Diode_SMD:D_SMC', + 'DO-35' => 'Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal', + 'DO-35(DO-204AH)' => 'Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal', + 'DO-41' => 'Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal', + 'DO-201' => 'Diode_THT:D_DO-201_P15.24mm_Horizontal', + + // === DFN === + 'DFN-2(0.6x1)' => 'Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm', + 'DFN1006-2' => 'Package_DFN_QFN:DFN-2_1.0x0.6mm', + 'DFN-6' => 'Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm', + 'DFN-8' => 'Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm', + + // === Passive component packages (SMD chip sizes) === + // Using Resistor_SMD as default - capacitors/inductors can override at part level + '0201' => 'Resistor_SMD:R_0201_0603Metric', + '0402' => 'Resistor_SMD:R_0402_1005Metric', + '0603' => 'Resistor_SMD:R_0603_1608Metric', + '0805' => 'Resistor_SMD:R_0805_2012Metric', + '1206' => 'Resistor_SMD:R_1206_3216Metric', + '1210' => 'Resistor_SMD:R_1210_3225Metric', + '1812' => 'Resistor_SMD:R_1812_4532Metric', + '2010' => 'Resistor_SMD:R_2010_5025Metric', + '2512' => 'Resistor_SMD:R_2512_6332Metric', + '2917' => 'Resistor_SMD:R_2917_7343Metric', + '2920' => 'Resistor_SMD:R_2920_7350Metric', + + // === Tantalum / electrolytic capacitor packages === + 'CASE-A-3216-18(mm)' => 'Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A', + 'CASE-B-3528-21(mm)' => 'Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B', + 'CASE-C-6032-28(mm)' => 'Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C', + 'CASE-D-7343-31(mm)' => 'Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D', + 'CASE-E-7343-43(mm)' => 'Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E', + + // === Electrolytic capacitor (SMD) === + 'SMD,D4xL5.4mm' => 'Capacitor_SMD:CP_Elec_4x5.4', + 'SMD,D5xL5.4mm' => 'Capacitor_SMD:CP_Elec_5x5.4', + 'SMD,D6.3xL5.4mm' => 'Capacitor_SMD:CP_Elec_6.3x5.4', + 'SMD,D6.3xL7.7mm' => 'Capacitor_SMD:CP_Elec_6.3x7.7', + 'SMD,D8xL6.5mm' => 'Capacitor_SMD:CP_Elec_8x6.5', + 'SMD,D8xL10mm' => 'Capacitor_SMD:CP_Elec_8x10', + 'SMD,D10xL10mm' => 'Capacitor_SMD:CP_Elec_10x10', + 'SMD,D10xL10.5mm' => 'Capacitor_SMD:CP_Elec_10x10.5', + + // === Through-hole electrolytic capacitors (radial) === + 'Through Hole,D5xL11mm' => 'Capacitor_THT:CP_Radial_D5.0mm_P2.00mm', + 'Through Hole,D6.3xL11mm' => 'Capacitor_THT:CP_Radial_D6.3mm_P2.50mm', + 'Through Hole,D8xL11mm' => 'Capacitor_THT:CP_Radial_D8.0mm_P3.50mm', + 'Through Hole,D10xL16mm' => 'Capacitor_THT:CP_Radial_D10.0mm_P5.00mm', + 'Through Hole,D10xL20mm' => 'Capacitor_THT:CP_Radial_D10.0mm_P5.00mm', + 'Through Hole,D12.5xL20mm' => 'Capacitor_THT:CP_Radial_D12.5mm_P5.00mm', + + // === LED packages === + 'LED 3mm' => 'LED_THT:LED_D3.0mm', + 'LED 5mm' => 'LED_THT:LED_D5.0mm', + 'LED 0603' => 'LED_SMD:LED_0603_1608Metric', + 'LED 0805' => 'LED_SMD:LED_0805_2012Metric', + 'SMD5050-4P' => 'LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm', + 'SMD5050-6P' => 'LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm', + + // === Crystal packages === + 'HC-49' => 'Crystal:Crystal_HC49-4H_Vertical', + 'HC-49/U' => 'Crystal:Crystal_HC49-4H_Vertical', + 'HC-49/S' => 'Crystal:Crystal_HC49-U_Vertical', + 'HC-49/US' => 'Crystal:Crystal_HC49-U_Vertical', + + // === USB connectors === + 'USB-A' => 'Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal', + 'USB-B' => 'Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal', + 'USB-Mini-B' => 'Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal', + 'USB-Micro-B' => 'Connector_USB:USB_Micro-B_Molex-105017-0001', + 'USB-C' => 'Connector_USB:USB_C_Receptacle_GCT_USB4085', + + // === Pin headers === + '1x2 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical', + '1x3 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical', + '1x4 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical', + '1x5 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical', + '1x6 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical', + '1x8 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical', + '1x10 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical', + '2x2 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical', + '2x3 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical', + '2x4 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical', + '2x5 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical', + '2x10 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical', + '2x20 P2.54mm' => 'Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical', + + // === SIP packages === + 'SIP-3-2.54mm' => 'Package_SIP:SIP-3_P2.54mm', + 'SIP-4-2.54mm' => 'Package_SIP:SIP-4_P2.54mm', + 'SIP-5-2.54mm' => 'Package_SIP:SIP-5_P2.54mm', + ]; + } + + /** + * Returns category name patterns to KiCad symbol path mappings. + * Uses pattern matching - order matters (first match wins). + * + * @return array + */ + private function getCategoryMappings(): array + { + return [ + // More specific matches first + 'Electrolytic' => 'Device:C_Polarized', + 'Polarized' => 'Device:C_Polarized', + 'Tantalum' => 'Device:C_Polarized', + 'Zener' => 'Device:D_Zener', + 'Schottky' => 'Device:D_Schottky', + 'TVS' => 'Device:D_TVS', + 'LED' => 'Device:LED', + 'NPN' => 'Device:Q_NPN_BCE', + 'PNP' => 'Device:Q_PNP_BCE', + 'N-MOSFET' => 'Device:Q_NMOS_GDS', + 'NMOS' => 'Device:Q_NMOS_GDS', + 'N-MOS' => 'Device:Q_NMOS_GDS', + 'P-MOSFET' => 'Device:Q_PMOS_GDS', + 'PMOS' => 'Device:Q_PMOS_GDS', + 'P-MOS' => 'Device:Q_PMOS_GDS', + 'MOSFET' => 'Device:Q_NMOS_GDS', // Default to N-channel + 'JFET' => 'Device:Q_NJFET_DSG', + 'Ferrite' => 'Device:Ferrite_Bead', + 'Crystal' => 'Device:Crystal', + 'Oscillator' => 'Oscillator:Oscillator_Crystal', + 'Fuse' => 'Device:Fuse', + 'Transformer' => 'Device:Transformer_1P_1S', + + // Generic matches (less specific) + 'Resistor' => 'Device:R', + 'Capacitor' => 'Device:C', + 'Inductor' => 'Device:L', + 'Diode' => 'Device:D', + 'Transistor' => 'Device:Q_NPN_BCE', + 'Voltage Regulator' => 'Regulator_Linear:LM317_TO-220', + 'LDO' => 'Regulator_Linear:AMS1117-3.3', + 'Op-Amp' => 'Amplifier_Operational:LM358', + 'Comparator' => 'Comparator:LM393', + 'Optocoupler' => 'Isolator:PC817', + 'Relay' => 'Relay:Relay_DPDT', + 'Connector' => 'Connector:Conn_01x02', + 'Switch' => 'Switch:SW_Push', + 'Button' => 'Switch:SW_Push', + 'Potentiometer' => 'Device:R_POT', + 'Trimpot' => 'Device:R_POT', + 'Thermistor' => 'Device:Thermistor', + 'Varistor' => 'Device:Varistor', + 'Photo' => 'Device:LED', // Photodiode/phototransistor + ]; + } +} diff --git a/tests/Command/PopulateKicadCommandTest.php b/tests/Command/PopulateKicadCommandTest.php new file mode 100644 index 00000000..bbbfa607 --- /dev/null +++ b/tests/Command/PopulateKicadCommandTest.php @@ -0,0 +1,276 @@ +find('partdb:kicad:populate'); + $this->commandTester = new CommandTester($command); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testListOption(): void + { + $this->commandTester->execute(['--list' => true]); + + $output = $this->commandTester->getDisplay(); + + // Should show footprints and categories tables + $this->assertStringContainsString('Current Footprint KiCad Values', $output); + $this->assertStringContainsString('Current Category KiCad Values', $output); + $this->assertStringContainsString('ID', $output); + $this->assertStringContainsString('Name', $output); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testDryRunDoesNotModifyDatabase(): void + { + // Create a test footprint without KiCad value + $footprint = new Footprint(); + $footprint->setName('SOT-23'); + $this->entityManager->persist($footprint); + $this->entityManager->flush(); + + $footprintId = $footprint->getId(); + + // Run in dry-run mode + $this->commandTester->execute(['--dry-run' => true, '--footprints' => true]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('DRY RUN MODE', $output); + $this->assertStringContainsString('SOT-23', $output); + + // Clear entity manager to force reload from DB + $this->entityManager->clear(); + + // Verify footprint was NOT updated in the database + $reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId); + $this->assertNull($reloadedFootprint->getEdaInfo()->getKicadFootprint()); + + // Cleanup + $this->entityManager->remove($reloadedFootprint); + $this->entityManager->flush(); + } + + public function testFootprintMappingUpdatesCorrectly(): void + { + // Create test footprints + $footprint1 = new Footprint(); + $footprint1->setName('SOT-23'); + + $footprint2 = new Footprint(); + $footprint2->setName('0805'); + + $footprint3 = new Footprint(); + $footprint3->setName('DIP-8'); + + $this->entityManager->persist($footprint1); + $this->entityManager->persist($footprint2); + $this->entityManager->persist($footprint3); + $this->entityManager->flush(); + + $ids = [$footprint1->getId(), $footprint2->getId(), $footprint3->getId()]; + + // Run the command + $this->commandTester->execute(['--footprints' => true]); + + $output = $this->commandTester->getDisplay(); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + + // Clear and reload + $this->entityManager->clear(); + + // Verify mappings were applied + $reloaded1 = $this->entityManager->find(Footprint::class, $ids[0]); + $this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded1->getEdaInfo()->getKicadFootprint()); + + $reloaded2 = $this->entityManager->find(Footprint::class, $ids[1]); + $this->assertEquals('Resistor_SMD:R_0805_2012Metric', $reloaded2->getEdaInfo()->getKicadFootprint()); + + $reloaded3 = $this->entityManager->find(Footprint::class, $ids[2]); + $this->assertEquals('Package_DIP:DIP-8_W7.62mm', $reloaded3->getEdaInfo()->getKicadFootprint()); + + // Cleanup + $this->entityManager->remove($reloaded1); + $this->entityManager->remove($reloaded2); + $this->entityManager->remove($reloaded3); + $this->entityManager->flush(); + } + + public function testSkipsExistingValuesWithoutForce(): void + { + // Create footprint with existing value + $footprint = new Footprint(); + $footprint->setName('SOT-23'); + $footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint'); + $this->entityManager->persist($footprint); + $this->entityManager->flush(); + + $footprintId = $footprint->getId(); + + // Run without --force + $this->commandTester->execute(['--footprints' => true]); + + $this->entityManager->clear(); + + // Should keep original value + $reloaded = $this->entityManager->find(Footprint::class, $footprintId); + $this->assertEquals('Custom:MyFootprint', $reloaded->getEdaInfo()->getKicadFootprint()); + + // Cleanup + $this->entityManager->remove($reloaded); + $this->entityManager->flush(); + } + + public function testForceOptionOverwritesExistingValues(): void + { + // Create footprint with existing value + $footprint = new Footprint(); + $footprint->setName('SOT-23'); + $footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint'); + $this->entityManager->persist($footprint); + $this->entityManager->flush(); + + $footprintId = $footprint->getId(); + + // Run with --force + $this->commandTester->execute(['--footprints' => true, '--force' => true]); + + $this->entityManager->clear(); + + // Should overwrite with mapped value + $reloaded = $this->entityManager->find(Footprint::class, $footprintId); + $this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded->getEdaInfo()->getKicadFootprint()); + + // Cleanup + $this->entityManager->remove($reloaded); + $this->entityManager->flush(); + } + + public function testCategoryMappingUpdatesCorrectly(): void + { + // Create test categories + $category1 = new Category(); + $category1->setName('Resistors'); + + $category2 = new Category(); + $category2->setName('LED Indicators'); + + $category3 = new Category(); + $category3->setName('Zener Diodes'); + + $this->entityManager->persist($category1); + $this->entityManager->persist($category2); + $this->entityManager->persist($category3); + $this->entityManager->flush(); + + $ids = [$category1->getId(), $category2->getId(), $category3->getId()]; + + // Run the command + $this->commandTester->execute(['--categories' => true]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + + // Clear and reload + $this->entityManager->clear(); + + // Verify mappings were applied (using pattern matching) + $reloaded1 = $this->entityManager->find(Category::class, $ids[0]); + $this->assertEquals('Device:R', $reloaded1->getEdaInfo()->getKicadSymbol()); + + $reloaded2 = $this->entityManager->find(Category::class, $ids[1]); + $this->assertEquals('Device:LED', $reloaded2->getEdaInfo()->getKicadSymbol()); + + $reloaded3 = $this->entityManager->find(Category::class, $ids[2]); + $this->assertEquals('Device:D_Zener', $reloaded3->getEdaInfo()->getKicadSymbol()); + + // Cleanup + $this->entityManager->remove($reloaded1); + $this->entityManager->remove($reloaded2); + $this->entityManager->remove($reloaded3); + $this->entityManager->flush(); + } + + public function testUnmappedFootprintsAreListed(): void + { + // Create footprint with no mapping + $footprint = new Footprint(); + $footprint->setName('CustomPackage-XYZ'); + $this->entityManager->persist($footprint); + $this->entityManager->flush(); + + $footprintId = $footprint->getId(); + + // Run the command + $this->commandTester->execute(['--footprints' => true]); + + $output = $this->commandTester->getDisplay(); + + // Should list the unmapped footprint + $this->assertStringContainsString('No mapping found', $output); + $this->assertStringContainsString('CustomPackage-XYZ', $output); + + // Cleanup + $this->entityManager->clear(); + $reloaded = $this->entityManager->find(Footprint::class, $footprintId); + $this->entityManager->remove($reloaded); + $this->entityManager->flush(); + } + + public function testBothFootprintsAndCategoriesUpdatedByDefault(): void + { + // Create one of each + $footprint = new Footprint(); + $footprint->setName('TO-220'); + $this->entityManager->persist($footprint); + + $category = new Category(); + $category->setName('Capacitors'); + $this->entityManager->persist($category); + + $this->entityManager->flush(); + + $footprintId = $footprint->getId(); + $categoryId = $category->getId(); + + // Run without specific options (should do both) + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Updating Footprint Entities', $output); + $this->assertStringContainsString('Updating Category Entities', $output); + + $this->entityManager->clear(); + + // Both should be updated + $reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId); + $this->assertEquals('Package_TO_SOT_THT:TO-220-3_Vertical', $reloadedFootprint->getEdaInfo()->getKicadFootprint()); + + $reloadedCategory = $this->entityManager->find(Category::class, $categoryId); + $this->assertEquals('Device:C', $reloadedCategory->getEdaInfo()->getKicadSymbol()); + + // Cleanup + $this->entityManager->remove($reloadedFootprint); + $this->entityManager->remove($reloadedCategory); + $this->entityManager->flush(); + } +}