diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c5bda..a51887a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Combined initials no longer split digits, punctuation, or caseless characters + out of a token, so `Smith, A1 John` keeps `A1` as the first name. + - Keep confidence tokenization, delimiter handling, and comma credential tails linear and bounded for hostile input while preserving parser/config parity. diff --git a/src/Mapper/InitialMapper.php b/src/Mapper/InitialMapper.php index fbe70b2..451a1fa 100644 --- a/src/Mapper/InitialMapper.php +++ b/src/Mapper/InitialMapper.php @@ -100,6 +100,18 @@ public function map(array $parts): array && $length <= $this->combinedMax && $stripped !== mb_strtolower($stripped, 'UTF-8') ) { + $initials = Text::graphemes($stripped); + + // A case signal alone does not make digits, punctuation, or + // caseless characters initials. Keep mixed tokens intact. + foreach ($initials as $initial) { + if (! $this->isInitial($initial)) { + $mapped[] = $part; + + continue 2; + } + } + $expandedParts += $length; if ($expandedParts > self::MAX_COMBINED_EXPANSION_PARTS) { throw new \LengthException( @@ -109,8 +121,8 @@ public function map(array $parts): array ); } - foreach (Text::graphemes($stripped) as $initial) { - $mapped[] = $this->isInitial($initial) ? new Initial($initial) : $initial; + foreach ($initials as $initial) { + $mapped[] = new Initial($initial); } continue; diff --git a/tests/CombinedInitialTokenTest.php b/tests/CombinedInitialTokenTest.php new file mode 100644 index 0000000..8bc362e --- /dev/null +++ b/tests/CombinedInitialTokenTest.php @@ -0,0 +1,64 @@ + + */ + public static function nonInitialTokens(): iterable + { + yield 'digit after letter' => ['A1', 2]; + yield 'digit before letter' => ['1A', 2]; + yield 'trailing hyphen' => ['A-', 2]; + yield 'caseless character' => ['A李', 2]; + yield 'hyphenated token' => ['J-P', 3]; + yield 'apostrophe token' => ["O'B", 3]; + yield 'mixed dotted token' => ['A.1.', 2]; + } + + #[DataProvider('nonInitialTokens')] + public function testCombinedMappingPreservesWholeNonInitialTokens(string $token, int $limit): void + { + $parts = ['John', $token, 'Smith']; + + $this->assertSame($parts, (new InitialMapper($limit))->map($parts)); + } + + public function testWesternNameDoesNotSplitAlphanumericMiddleName(): void + { + $name = (new Parser())->parse('John A1 Smith'); + + $this->assertSame('John', $name->getFirstname()); + $this->assertSame('A1', $name->getMiddlename()); + $this->assertSame('', $name->getInitials()); + $this->assertSame('John A1 Smith', $name->getFullName()); + } + + public function testCommaNameDoesNotPromoteDigitToFirstname(): void + { + $name = (new Parser())->parse('Smith, A1 John'); + + $this->assertSame('A1', $name->getFirstname()); + $this->assertSame('John', $name->getMiddlename()); + $this->assertSame('', $name->getInitials()); + $this->assertSame('A1 John Smith', $name->getFullName()); + } + + public function testRealCombinedInitialsStillSplit(): void + { + foreach (['JM', 'J.M.', "JE\u{0301}"] as $token) { + $name = (new Parser())->parse('John ' . $token . ' Smith'); + $expected = $token === "JE\u{0301}" ? "J E\u{0301}" : 'J M'; + + $this->assertSame($expected, $name->getInitials()); + $this->assertSame('', $name->getMiddlename()); + } + } +}