From 9eaefbbfb717be0641a8bb10c46064f3d95c06a7 Mon Sep 17 00:00:00 2001 From: Lisa Fischer Date: Thu, 7 May 2026 11:50:17 +0200 Subject: [PATCH 1/6] feat: allow float concentrations in AbsoluteQuantificationSample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../AbsoluteQuantificationSample.php | 18 ++++++++++++++---- .../AbsoluteQuantificationSampleTest.php | 11 +++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php index eefb394c..ac70696d 100644 --- a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php +++ b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php @@ -19,7 +19,7 @@ class AbsoluteQuantificationSample /** Key used to determine replication grouping - samples with the same key will replicate to the first occurrence */ public string $replicationOfKey; - public ?int $concentration; + public ?float $concentration; public function __construct( string $sampleName, @@ -27,7 +27,7 @@ public function __construct( string $hexColor, string $sampleType, string $replicationOfKey, - ?int $concentration + ?float $concentration ) { $this->sampleName = $sampleName; $this->filterCombination = $filterCombination; @@ -37,18 +37,28 @@ public function __construct( $this->concentration = $concentration; } - public static function formatConcentration(?int $concentration): ?string + public static function formatConcentration(?float $concentration): ?string { if ($concentration === null) { return null; } - if ($concentration === 0) { + if (! is_finite($concentration)) { + throw new \InvalidArgumentException('Concentration must be finite, got: ' . var_export($concentration, true)); + } + + if ($concentration === 0.0) { return '0.00E0'; } $exponent = SafeCast::toInt(floor(log10(abs($concentration)))); $mantissa = $concentration / (10 ** $exponent); + $mantissa = round($mantissa, 2); + + if (abs($mantissa) >= 10) { + $mantissa /= 10; + ++$exponent; + } return number_format($mantissa, 2) . 'E' . $exponent; } diff --git a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php index c2e43943..5a9ebc27 100644 --- a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php +++ b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php @@ -10,18 +10,19 @@ final class AbsoluteQuantificationSampleTest extends TestCase { /** @dataProvider concentrationFormattingProvider */ #[DataProvider('concentrationFormattingProvider')] - public function testFormatConcentration(?int $input, ?string $expected): void + public function testFormatConcentration(?float $input, ?string $expected): void { $result = AbsoluteQuantificationSample::formatConcentration($input); self::assertSame($expected, $result); } - /** @return iterable */ + /** @return iterable */ public static function concentrationFormattingProvider(): iterable { yield 'null concentration returns null' => [null, null]; yield 'zero concentration' => [0, '0.00E0']; + yield 'zero float concentration' => [0.0, '0.00E0']; yield 'small positive number' => [1, '1.00E0']; yield 'ten' => [10, '1.00E1']; yield 'hundred' => [100, '1.00E2']; @@ -30,5 +31,11 @@ public static function concentrationFormattingProvider(): iterable yield 'ten thousand' => [10000, '1.00E4']; yield 'million' => [1000000, '1.00E6']; yield 'large number' => [12345678, '1.23E7']; + yield 'float twenty' => [20.0, '2.00E1']; + yield 'float two' => [2.0, '2.00E0']; + yield 'sub-one float' => [0.2, '2.00E-1']; + yield 'small float' => [0.02, '2.00E-2']; + yield 'very small float' => [0.002, '2.00E-3']; + yield 'tiny float' => [0.0002, '2.00E-4']; } } From ee2719df338deb39cdd56a2dbb7877238c6db664 Mon Sep 17 00:00:00 2001 From: Lisa Fischer Date: Fri, 8 May 2026 11:49:28 +0200 Subject: [PATCH 2/6] feat: filter LightcyclerXmlParser by "Abs Quant/2nd Der" shortname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also applies review fixes: error message formatting, immutability rename, and non-finite guard test coverage. 🤖 Generated with Claude Code --- .../LightcyclerXmlParser.php | 17 +++++++++ .../AbsoluteQuantificationSample.php | 7 ++-- .../QpcrXmlParserTest.php | 35 +++++++++++++++++++ .../AbsoluteQuantificationSampleTest.php | 16 +++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index 08a75acc..05cff7c4 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -28,12 +28,18 @@ public function parse(string $xmlContent): Collection return $this->extractAnalysisSamples($analyses); } + private const ANALYSIS_SHORTNAME = 'Abs Quant/2nd Der'; + /** @return Collection */ private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection { $samples = []; foreach ($analyses->analysis as $analysis) { + if (! $this->isAbsoluteQuantificationAnalysis($analysis)) { + continue; + } + if (property_exists($analysis, 'AnalysisSamples') && $analysis->AnalysisSamples !== null ) { @@ -46,6 +52,17 @@ private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection return $this->validateUniqueCoordinates(new Collection($samples)); } + private function isAbsoluteQuantificationAnalysis(\SimpleXMLElement $analysis): bool + { + foreach ($analysis->prop as $prop) { + if ((string) $prop['name'] === 'shortname') { + return (string) $prop === self::ANALYSIS_SHORTNAME; + } + } + + return false; + } + private function createSampleFromXml(\SimpleXMLElement $xmlSample): LightcyclerSample { $sampleProperties = $this->extractPropertiesFromXml($xmlSample); diff --git a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php index ac70696d..e6c28a70 100644 --- a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php +++ b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php @@ -44,7 +44,8 @@ public static function formatConcentration(?float $concentration): ?string } if (! is_finite($concentration)) { - throw new \InvalidArgumentException('Concentration must be finite, got: ' . var_export($concentration, true)); + $exported = var_export($concentration, true); + throw new \InvalidArgumentException("Concentration must be finite, got: {$exported}."); } if ($concentration === 0.0) { @@ -52,8 +53,8 @@ public static function formatConcentration(?float $concentration): ?string } $exponent = SafeCast::toInt(floor(log10(abs($concentration)))); - $mantissa = $concentration / (10 ** $exponent); - $mantissa = round($mantissa, 2); + $rawMantissa = $concentration / (10 ** $exponent); + $mantissa = round($rawMantissa, 2); if (abs($mantissa) >= 10) { $mantissa /= 10; diff --git a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php index 42e45c79..b35f0028 100644 --- a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php +++ b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php @@ -16,6 +16,7 @@ public function testParseXmlHandlesMissingRequiredProperties(): void + Abs Quant/2nd Der A1 @@ -34,6 +35,33 @@ public function testParseXmlHandlesMissingRequiredProperties(): void $parser->parse($xmlWithMissingName); } + public function testParseXmlIgnoresAnalysesWithDifferentShortname(): void + { + $xml = /* @lang XML */ << + + + + Tm Calling + + + ShouldBeIgnored + A1 + 100.0 + 25.0 + + + + + + XML; + + $parser = new LightcyclerXmlParser(); + $result = $parser->parse($xml); + + self::assertTrue($result->isEmpty()); + } + public function testParseXmlReturnsEmptyCollectionForInvalidXml(): void { $invalidXml = /* @lang XML */ 'xml'; @@ -54,6 +82,7 @@ public function testSampleTypeDetection(): void + Abs Quant/2nd Der XX-XXXXXX @@ -77,6 +106,7 @@ public function testSampleTypeDetection(): void + Abs Quant/2nd Der STANDARD-400 @@ -102,6 +132,7 @@ public function testSampleTypeDetection(): void + Abs Quant/2nd Der CONTROL @@ -131,6 +162,7 @@ public function testParseXmlValidatesConcentrationAndCrossingPointConsistency(): + Abs Quant/2nd Der P123 @@ -157,6 +189,7 @@ public function testParseXmlHandlesEmptyAndMissingValues(): void + Abs Quant/2nd Der Control-Empty @@ -181,6 +214,7 @@ public function testParseXmlHandlesEmptyAndMissingValues(): void + Abs Quant/2nd Der NTC-Control @@ -206,6 +240,7 @@ public function testParseXmlHandlesInvalidFloatValues(): void + Abs Quant/2nd Der Test diff --git a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php index 5a9ebc27..65d8fcc0 100644 --- a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php +++ b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php @@ -17,6 +17,22 @@ public function testFormatConcentration(?float $input, ?string $expected): void self::assertSame($expected, $result); } + /** @return iterable */ + public static function nonFiniteConcentrationProvider(): iterable + { + yield 'INF' => [INF]; + yield '-INF' => [-INF]; + yield 'NAN' => [NAN]; + } + + #[DataProvider('nonFiniteConcentrationProvider')] + public function testFormatConcentrationThrowsForNonFiniteValues(float $input): void + { + $this->expectException(\InvalidArgumentException::class); + + AbsoluteQuantificationSample::formatConcentration($input); + } + /** @return iterable */ public static function concentrationFormattingProvider(): iterable { From 4d4c2c3df5870417981634cf772b35a9061ca96d Mon Sep 17 00:00:00 2001 From: Lisa Fischer Date: Fri, 8 May 2026 13:33:44 +0200 Subject: [PATCH 3/6] fix: apply self-review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use protected visibility for new method in package class - Simplify non-finite error message (drop var_export) - Improve data provider labels to describe scenarios - Assert exception message in non-finite test - Narrow docblock type to match method signature - Add test case for mantissa normalization branch 🤖 Generated with Claude Code --- .../LightcyclerXmlParser.php | 2 +- .../AbsoluteQuantificationSample.php | 3 +-- .../AbsoluteQuantificationSampleTest.php | 14 ++++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index 05cff7c4..3eb82e5a 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -52,7 +52,7 @@ private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection return $this->validateUniqueCoordinates(new Collection($samples)); } - private function isAbsoluteQuantificationAnalysis(\SimpleXMLElement $analysis): bool + protected function isAbsoluteQuantificationAnalysis(\SimpleXMLElement $analysis): bool { foreach ($analysis->prop as $prop) { if ((string) $prop['name'] === 'shortname') { diff --git a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php index e6c28a70..d8144fde 100644 --- a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php +++ b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php @@ -44,8 +44,7 @@ public static function formatConcentration(?float $concentration): ?string } if (! is_finite($concentration)) { - $exported = var_export($concentration, true); - throw new \InvalidArgumentException("Concentration must be finite, got: {$exported}."); + throw new \InvalidArgumentException("Concentration must be finite, got: {$concentration}."); } if ($concentration === 0.0) { diff --git a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php index 65d8fcc0..4d64fa90 100644 --- a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php +++ b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php @@ -20,20 +20,21 @@ public function testFormatConcentration(?float $input, ?string $expected): void /** @return iterable */ public static function nonFiniteConcentrationProvider(): iterable { - yield 'INF' => [INF]; - yield '-INF' => [-INF]; - yield 'NAN' => [NAN]; + yield 'positive infinity is rejected' => [INF]; + yield 'negative infinity is rejected' => [-INF]; + yield 'NaN is rejected' => [NAN]; } #[DataProvider('nonFiniteConcentrationProvider')] public function testFormatConcentrationThrowsForNonFiniteValues(float $input): void { $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Concentration must be finite, got: {$input}."); AbsoluteQuantificationSample::formatConcentration($input); } - /** @return iterable */ + /** @return iterable */ public static function concentrationFormattingProvider(): iterable { yield 'null concentration returns null' => [null, null]; @@ -47,11 +48,12 @@ public static function concentrationFormattingProvider(): iterable yield 'ten thousand' => [10000, '1.00E4']; yield 'million' => [1000000, '1.00E6']; yield 'large number' => [12345678, '1.23E7']; - yield 'float twenty' => [20.0, '2.00E1']; - yield 'float two' => [2.0, '2.00E0']; + yield 'two-digit whole-number float' => [20.0, '2.00E1']; + yield 'single-digit whole-number float' => [2.0, '2.00E0']; yield 'sub-one float' => [0.2, '2.00E-1']; yield 'small float' => [0.02, '2.00E-2']; yield 'very small float' => [0.002, '2.00E-3']; yield 'tiny float' => [0.0002, '2.00E-4']; + yield 'triggers mantissa normalization' => [9995.0, '1.00E4']; } } From b83667abf6bcb05cef4779352d2065b0e580165f Mon Sep 17 00:00:00 2001 From: Lisa Date: Mon, 18 May 2026 12:02:27 +0200 Subject: [PATCH 4/6] Update src/LightcyclerExportSheet/LightcyclerXmlParser.php Co-authored-by: Simon Bigelmayr --- src/LightcyclerExportSheet/LightcyclerXmlParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index 3eb82e5a..cadba747 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -28,7 +28,7 @@ public function parse(string $xmlContent): Collection return $this->extractAnalysisSamples($analyses); } - private const ANALYSIS_SHORTNAME = 'Abs Quant/2nd Der'; + private const QUANTIFICATION_SHORTNAME = 'Abs Quant/2nd Der'; /** @return Collection */ private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection From 06941ea182f9bf28cdcebc290fd437c3b5edfeda Mon Sep 17 00:00:00 2001 From: Lisa Fischer Date: Mon, 18 May 2026 12:05:31 +0200 Subject: [PATCH 5/6] const --- src/LightcyclerExportSheet/LightcyclerXmlParser.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index cadba747..d5666b1f 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -15,6 +15,8 @@ class LightcyclerXmlParser public const FLOAT_ZERO = 0.0; + private const QUANTIFICATION_SHORTNAME = 'Abs Quant/2nd Der'; + /** @return Collection */ public function parse(string $xmlContent): Collection { @@ -28,8 +30,6 @@ public function parse(string $xmlContent): Collection return $this->extractAnalysisSamples($analyses); } - private const QUANTIFICATION_SHORTNAME = 'Abs Quant/2nd Der'; - /** @return Collection */ private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection { @@ -56,7 +56,7 @@ protected function isAbsoluteQuantificationAnalysis(\SimpleXMLElement $analysis) { foreach ($analysis->prop as $prop) { if ((string) $prop['name'] === 'shortname') { - return (string) $prop === self::ANALYSIS_SHORTNAME; + return (string) $prop === self::QUANTIFICATION_SHORTNAME; } } From 4465c8996b0f519312e1fc7b23a291bda008d915 Mon Sep 17 00:00:00 2001 From: Lisa Fischer Date: Mon, 18 May 2026 13:24:58 +0200 Subject: [PATCH 6/6] fix: add missing @dataProvider docblock for older PHPUnit compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php index 4d64fa90..4e81a81e 100644 --- a/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php +++ b/tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php @@ -25,6 +25,7 @@ public static function nonFiniteConcentrationProvider(): iterable yield 'NaN is rejected' => [NAN]; } + /** @dataProvider nonFiniteConcentrationProvider */ #[DataProvider('nonFiniteConcentrationProvider')] public function testFormatConcentrationThrowsForNonFiniteValues(float $input): void {