mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-04 07:31:40 +00:00
Adds a "Value calculator" tool (Tools menu) that decodes/encodes the value of common components and generates a clean SVG picture you can attach to a part — handy for assortments imported with blank thumbnails and sparse data. Calculator tabs (bidirectional decode <-> encode), each drawing the part to the selected package with a collapsible appearance panel and an "attach to part" action (optionally as the master picture): - Resistor: 4/5/6-band colour code - Capacitor: value <-> 3-digit code <-> tolerance - SMD resistor: value <-> 3-digit / 4-digit / EIA-96 code - Inductor: colour-band code - SMD inductor: value <-> uH code Bulk "Generate component images" parts-table action: - Classifies each selected part (resistor / capacitor / inductor / diode / LED, THT or SMD) with ComponentValueGuesser and detects value, voltage, tolerance, power, ppm, pitch, diameter, colour, SMD package and marking from the name, description and parameters. - Renders a preview per part, lets you tweak appearance, and attaches the images in one go; can also write matching KiCad symbol/footprint/reference-prefix EDA fields. Backend: ComponentValueGuesser (classification + detection + EDA suggestion), GeneratedImageAttachmentHelper (stores the SVG through the existing attachment pipeline, so it is sanitised on save), two POST endpoints on PartController (generate_image, set_eda) guarded by `edit` + CSRF, a new `@tools.value_calculator` permission, a Tools-tree entry and docs. All drawing is client-side in a Stimulus controller; the un-sanitised live preview escapes part-derived text and validates colours as defence-in-depth. Tests: unit tests for ComponentValueGuesser and functional tests for the endpoints, including permission and CSRF enforcement and the persisted side effects.
462 lines
18 KiB
PHP
462 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server).
|
|
*
|
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
namespace App\Tests\Services\Tools;
|
|
|
|
use App\Entity\Parameters\PartParameter;
|
|
use App\Entity\Parts\Part;
|
|
use App\Services\Tools\ComponentValueGuesser;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Unit tests for the classification/parsing logic behind the value calculator and bulk image
|
|
* generator. The guesser has no dependencies, so the tests construct plain Part entities in memory.
|
|
*/
|
|
class ComponentValueGuesserTest extends TestCase
|
|
{
|
|
private ComponentValueGuesser $guesser;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->guesser = new ComponentValueGuesser();
|
|
}
|
|
|
|
private function part(string $name, ?string $description = null): Part
|
|
{
|
|
$part = new Part();
|
|
$part->setName($name);
|
|
if ($description !== null) {
|
|
$part->setDescription($description);
|
|
}
|
|
|
|
return $part;
|
|
}
|
|
|
|
private function partWithParameter(string $paramName, float $value, string $unit): Part
|
|
{
|
|
$part = new Part();
|
|
$part->setName('Some part');
|
|
$param = new PartParameter();
|
|
$param->setName($paramName);
|
|
$param->setValueTypical($value);
|
|
$param->setUnit($unit);
|
|
$part->addParameter($param);
|
|
|
|
return $part;
|
|
}
|
|
|
|
/**
|
|
* @dataProvider resistorValueProvider
|
|
*/
|
|
public function testResistorValueFromName(string $name, float $expectedOhms): void
|
|
{
|
|
[$ohms, $farads] = $this->guesser->extractValue($this->part($name));
|
|
self::assertNull($farads, "Expected no capacitance for '$name'");
|
|
self::assertNotNull($ohms, "Expected a resistance for '$name'");
|
|
self::assertEqualsWithDelta($expectedOhms, $ohms, $expectedOhms * 1e-9 + 1e-9);
|
|
}
|
|
|
|
public static function resistorValueProvider(): \Generator
|
|
{
|
|
yield 'plain ohm with space' => ['100 Ω', 100.0];
|
|
yield 'plain ohm no space' => ['470Ω', 470.0];
|
|
yield 'R notation' => ['470R', 470.0];
|
|
yield 'kilo with space (regression: Ω is a PCRE word char under /u)' => ['1 kΩ', 1000.0];
|
|
yield 'kilo no space' => ['10kΩ', 10000.0];
|
|
yield 'mega with space' => ['1 MΩ', 1_000_000.0];
|
|
yield 'decimal kilo' => ['4.7 kΩ', 4700.0];
|
|
yield 'RKM kilo' => ['4k7', 4700.0];
|
|
yield 'RKM mega' => ['2M2', 2_200_000.0];
|
|
yield 'bare magnitude letter' => ['10k', 10000.0];
|
|
yield 'realistic imported name' => ['Resistor 10 kΩ 0.25W 1% Metal Film', 10000.0];
|
|
yield 'realistic mega name' => ['Resistor 1 MΩ 0.25W 1% Metal Film', 1_000_000.0];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider capacitorValueProvider
|
|
*/
|
|
public function testCapacitorValueFromName(string $name, float $expectedFarads): void
|
|
{
|
|
[$ohms, $farads] = $this->guesser->extractValue($this->part($name));
|
|
self::assertNull($ohms, "Expected no resistance for '$name'");
|
|
self::assertNotNull($farads, "Expected a capacitance for '$name'");
|
|
self::assertEqualsWithDelta($expectedFarads, $farads, $expectedFarads * 1e-6);
|
|
}
|
|
|
|
public static function capacitorValueProvider(): \Generator
|
|
{
|
|
yield 'nanofarad' => ['10nF', 10e-9];
|
|
yield 'microfarad greek mu' => ['0.1µF', 0.1e-6];
|
|
yield 'picofarad' => ['100pF', 100e-12];
|
|
yield 'RKM nano' => ['4n7', 4.7e-9];
|
|
yield 'RKM pico' => ['2p2', 2.2e-12];
|
|
yield 'named ceramic cap' => ['Ceramic capacitor 100nF', 100e-9];
|
|
}
|
|
|
|
public function testValueFromResistanceParameter(): void
|
|
{
|
|
$part = $this->partWithParameter('Resistance', 4.7, 'kΩ');
|
|
[$ohms, $farads] = $this->guesser->extractValue($part);
|
|
self::assertNull($farads);
|
|
self::assertEqualsWithDelta(4700.0, $ohms, 1e-6);
|
|
}
|
|
|
|
public function testValueFromCapacitanceParameter(): void
|
|
{
|
|
$part = $this->partWithParameter('Capacitance', 100.0, 'nF');
|
|
[$ohms, $farads] = $this->guesser->extractValue($part);
|
|
self::assertNull($ohms);
|
|
self::assertEqualsWithDelta(100e-9, $farads, 1e-15);
|
|
}
|
|
|
|
public function testClassifiesThroughHoleResistor(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Resistor 10 kΩ 0.25W 1% blue body'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('resistor', $guess['type']);
|
|
self::assertEqualsWithDelta(10000.0, $guess['value'], 1e-6);
|
|
self::assertSame(0.25, $guess['power']);
|
|
self::assertSame('±1%', $guess['tolerance']);
|
|
self::assertSame('#2f6db0', $guess['color']);
|
|
}
|
|
|
|
public function testClassifiesSmdResistorFromPackage(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Resistor 4.7 kΩ 0805 1% SMD'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('smd_resistor', $guess['type']);
|
|
self::assertSame('0805', $guess['package']);
|
|
}
|
|
|
|
public function testClassifiesCapacitor(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Ceramic capacitor 100nF 50V'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('capacitor', $guess['type']);
|
|
self::assertSame(50, $guess['voltage']);
|
|
}
|
|
|
|
public function testClassifiesSmdCapacitorFromPackage(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('MLCC capacitor 100nF 0805 X7R'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('smd_capacitor', $guess['type']);
|
|
self::assertSame('0805', $guess['package']);
|
|
self::assertEqualsWithDelta(100e-9, $guess['value'], 1e-15);
|
|
|
|
$eda = $this->guesser->edaSuggestion($guess);
|
|
self::assertSame('Device:C', $eda['symbol']);
|
|
self::assertSame('Capacitor_SMD:C_0805_2012Metric', $eda['footprint']);
|
|
}
|
|
|
|
public function testUnclassifiableReturnsNull(): void
|
|
{
|
|
self::assertNull($this->guesser->guess($this->part('Arduino Uno R3 development board')));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider powerProvider
|
|
*/
|
|
public function testDetectPower(string $name, float $expected): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part($name));
|
|
self::assertNotNull($guess);
|
|
self::assertSame($expected, $guess['power']);
|
|
}
|
|
|
|
public static function powerProvider(): \Generator
|
|
{
|
|
yield 'decimal watt' => ['Resistor 1k 0.25W', 0.25];
|
|
yield 'fractional watt' => ['Resistor 1k 1/4W', 0.25];
|
|
yield 'half watt spaced' => ['Resistor 1k 0.5 W', 0.5];
|
|
yield 'one watt' => ['Resistor 1k 1W', 1.0];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider ppmProvider
|
|
*/
|
|
public function testDetectPpm(string $name, ?int $expected): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part($name));
|
|
self::assertNotNull($guess);
|
|
self::assertSame($expected, $guess['ppm']);
|
|
}
|
|
|
|
public static function ppmProvider(): \Generator
|
|
{
|
|
yield 'plain ppm' => ['Resistor 1k 50ppm', 50];
|
|
yield 'ppm per celsius' => ['Resistor 1k 100 ppm/°C', 100];
|
|
yield 'no ppm' => ['Resistor 1k', null];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider toleranceProvider
|
|
*/
|
|
public function testDetectTolerance(string $name, ?string $expected): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part($name));
|
|
self::assertNotNull($guess);
|
|
self::assertSame($expected, $guess['tolerance']);
|
|
}
|
|
|
|
public static function toleranceProvider(): \Generator
|
|
{
|
|
yield 'plus-minus percent' => ['Resistor 1k ±5%', '±5%'];
|
|
yield 'bare percent' => ['Resistor 1k 1%', '±1%'];
|
|
yield 'sub-percent' => ['Resistor 1k 0.1%', '±0.1%'];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider colorProvider
|
|
*/
|
|
public function testDetectBodyColor(string $description, ?string $expected): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Resistor 1k', $description));
|
|
self::assertNotNull($guess);
|
|
self::assertSame($expected, $guess['color']);
|
|
}
|
|
|
|
public static function colorProvider(): \Generator
|
|
{
|
|
yield 'blue body' => ['blue body metal film', '#2f6db0'];
|
|
yield 'green' => ['green body', '#2e7d4f'];
|
|
yield 'no colour word' => ['axial resistor', null];
|
|
yield 'colour word inside another word is ignored' => ['tantalum resistor', null];
|
|
}
|
|
|
|
public function testEdaSuggestionForThroughHoleResistor(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'resistor', 'package' => null, 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('Device:R', $eda['symbol']);
|
|
self::assertSame('R', $eda['reference']);
|
|
self::assertStringContainsString('Resistor_THT:R_Axial', (string) $eda['footprint']);
|
|
}
|
|
|
|
public function testEdaSuggestionForSmdResistor(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'smd_resistor', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('Device:R', $eda['symbol']);
|
|
self::assertSame('Resistor_SMD:R_0805_2012Metric', $eda['footprint']);
|
|
}
|
|
|
|
public function testEdaSuggestionForCapacitorUsesPitch(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'capacitor', 'package' => null, 'pitch' => 5.08, 'diameter' => 5.0]);
|
|
self::assertSame('Device:C', $eda['symbol']);
|
|
self::assertSame('C', $eda['reference']);
|
|
self::assertStringContainsString('P5.00mm', (string) $eda['footprint']);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider inductorValueProvider
|
|
*/
|
|
public function testClassifiesInductor(string $name, float $expectedHenries): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part($name));
|
|
self::assertNotNull($guess, "Expected '$name' to classify");
|
|
self::assertSame('inductor', $guess['type']);
|
|
self::assertEqualsWithDelta($expectedHenries, $guess['value'], $expectedHenries * 1e-6);
|
|
}
|
|
|
|
public static function inductorValueProvider(): \Generator
|
|
{
|
|
yield 'microhenry µ' => ['Inductor 100µH', 100e-6];
|
|
yield 'microhenry u' => ['Choke 4.7uH', 4.7e-6];
|
|
yield 'millihenry' => ['Coil 10mH', 10e-3];
|
|
yield 'nanohenry' => ['100nH inductor', 100e-9];
|
|
yield 'henry' => ['1H filter choke', 1.0];
|
|
}
|
|
|
|
public function testInductanceFromParameter(): void
|
|
{
|
|
$part = $this->partWithParameter('Inductance', 100.0, 'µH');
|
|
$guess = $this->guesser->guess($part);
|
|
self::assertNotNull($guess);
|
|
self::assertSame('inductor', $guess['type']);
|
|
self::assertEqualsWithDelta(100e-6, $guess['value'], 1e-12);
|
|
}
|
|
|
|
public function testMegahertzIsNotMistakenForInductance(): void
|
|
{
|
|
//"100MHz" must not parse as 100 mH — the (?![a-zA-Z0-9]) guard prevents it.
|
|
self::assertNull($this->guesser->guess($this->part('Crystal oscillator 100MHz')));
|
|
}
|
|
|
|
public function testEdaSuggestionForInductor(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'inductor', 'package' => null, 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('Device:L', $eda['symbol']);
|
|
self::assertSame('L', $eda['reference']);
|
|
self::assertNull($eda['footprint']);
|
|
}
|
|
|
|
public function testClassifiesSmdInductorFromPackage(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Inductor 10µH 0805 SMD'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('smd_inductor', $guess['type']);
|
|
self::assertSame('0805', $guess['package']);
|
|
self::assertEqualsWithDelta(10e-6, $guess['value'], 1e-12);
|
|
}
|
|
|
|
public function testEdaSuggestionForSmdInductor(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'smd_inductor', 'package' => '1210', 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('Device:L', $eda['symbol']);
|
|
self::assertSame('L', $eda['reference']);
|
|
self::assertSame('Inductor_SMD:L_1210_3225Metric', $eda['footprint']);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider diodeProvider
|
|
*/
|
|
public function testClassifiesDiode(string $name, string $expectedSubtype): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part($name));
|
|
self::assertNotNull($guess, "Expected '$name' to classify");
|
|
self::assertSame('diode', $guess['type']);
|
|
self::assertSame($expectedSubtype, $guess['subtype']);
|
|
}
|
|
|
|
public static function diodeProvider(): \Generator
|
|
{
|
|
yield 'led word' => ['LED red 5mm 20mA', 'led'];
|
|
yield 'light emitting' => ['Light-emitting diode green', 'led'];
|
|
yield 'zener word' => ['Zener diode 5.1V', 'zener'];
|
|
yield 'zener BZX family' => ['BZX55C5V1', 'zener'];
|
|
yield 'zener 1N47xx' => ['1N4733A', 'zener'];
|
|
yield 'schottky word' => ['Schottky barrier diode', 'schottky'];
|
|
yield 'schottky BAT family' => ['BAT54', 'schottky'];
|
|
yield 'schottky 1N58xx' => ['1N5819', 'schottky'];
|
|
yield 'tvs word' => ['TVS diode array', 'tvs'];
|
|
yield 'tvs SMBJ family' => ['SMBJ15A', 'tvs'];
|
|
yield 'generic rectifier' => ['Rectifier diode', 'diode'];
|
|
yield '1N4148 small signal' => ['1N4148 switching', 'diode'];
|
|
yield '1N4007 rectifier' => ['1N4007', 'diode'];
|
|
yield 'BAV family' => ['BAV99 dual', 'diode'];
|
|
}
|
|
|
|
public function testLedUsesEmissionColor(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('LED blue 5mm'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('diode', $guess['type']);
|
|
self::assertSame('led', $guess['subtype']);
|
|
self::assertSame('#2f6db0', $guess['color']);
|
|
}
|
|
|
|
public function testZenerCarriesVoltage(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Zener diode 5.1V 0.5W'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('zener', $guess['subtype']);
|
|
self::assertSame(5, $guess['voltage']);
|
|
}
|
|
|
|
public function testResistorForLedStaysResistor(): void
|
|
{
|
|
//"220R" yields a resistance, which is classified before the diode fallback ever runs.
|
|
$guess = $this->guesser->guess($this->part('220R resistor for LED indicator'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('resistor', $guess['type']);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider diodeEdaProvider
|
|
*/
|
|
public function testEdaSuggestionForDiode(string $subtype, string $expectedSymbol): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => $subtype, 'package' => null, 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame($expectedSymbol, $eda['symbol']);
|
|
self::assertSame('D', $eda['reference']);
|
|
self::assertNull($eda['footprint']);
|
|
}
|
|
|
|
public static function diodeEdaProvider(): \Generator
|
|
{
|
|
yield 'generic' => ['diode', 'Device:D'];
|
|
yield 'led' => ['led', 'Device:LED'];
|
|
yield 'zener' => ['zener', 'Device:D_Zener'];
|
|
yield 'schottky' => ['schottky', 'Device:D_Schottky'];
|
|
yield 'tvs' => ['tvs', 'Device:D_TVS'];
|
|
}
|
|
|
|
public function testEdaSuggestionForSmdDiodeFootprint(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'diode', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('Diode_SMD:D_0805_2012Metric', $eda['footprint']);
|
|
}
|
|
|
|
public function testEdaSuggestionForSmdLedFootprint(): void
|
|
{
|
|
$eda = $this->guesser->edaSuggestion(['type' => 'diode', 'subtype' => 'led', 'package' => '0805', 'pitch' => null, 'diameter' => null]);
|
|
self::assertSame('LED_SMD:LED_0805_2012Metric', $eda['footprint']);
|
|
}
|
|
|
|
public function testDetectsThtDiodePackageAndMarking(): void
|
|
{
|
|
//Real-world case: importing a 1N400x rectifier kit, whose names spell out the THT package.
|
|
$guess = $this->guesser->guess($this->part('1N4001 Rectifier Diode 1A 50V DO-41'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('diode', $guess['type']);
|
|
self::assertSame('diode', $guess['subtype']);
|
|
self::assertSame('DO-41', $guess['package']);
|
|
self::assertSame('1N4001', $guess['marking']);
|
|
|
|
$eda = $this->guesser->edaSuggestion($guess);
|
|
self::assertSame('Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal', $eda['footprint']);
|
|
}
|
|
|
|
public function testDetectsSotSchottkyPackage(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('BAT54 Schottky diode SOT-23'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('schottky', $guess['subtype']);
|
|
self::assertSame('SOT-23', $guess['package']);
|
|
self::assertSame('BAT54', $guess['marking']);
|
|
|
|
$eda = $this->guesser->edaSuggestion($guess);
|
|
self::assertSame('Diode_SMD:D_SOT-23', $eda['footprint']);
|
|
}
|
|
|
|
public function testDetectsLedDomeSizeFootprint(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('LED red 5mm diffused'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('led', $guess['subtype']);
|
|
self::assertSame('5MM', $guess['package']);
|
|
//LEDs aren't normally printed with a part number.
|
|
self::assertNull($guess['marking']);
|
|
|
|
$eda = $this->guesser->edaSuggestion($guess);
|
|
self::assertSame('LED_THT:LED_D5.0mm', $eda['footprint']);
|
|
}
|
|
|
|
public function testMarkingNullWhenNoRecognisablePartNumber(): void
|
|
{
|
|
$guess = $this->guesser->guess($this->part('Generic rectifier diode'));
|
|
self::assertNotNull($guess);
|
|
self::assertSame('diode', $guess['subtype']);
|
|
self::assertNull($guess['marking']);
|
|
}
|
|
}
|