From a2d978c2d8baaa72b555a350987bf08b1644d1fb Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sun, 22 Feb 2026 23:12:10 +0000 Subject: [PATCH 1/5] test: specify shifted number-row correction behavior Add coverage for US/UK shifted-digit symbols in inward and outward digit slots, including confidence expectations and positional safety constraints. Also adds compact-input normalization tests for retained shifted symbols and pound-sign alias handling. These tests intentionally fail on current implementation and define the feature contract. --- tests/CikmovTest.php | 82 +++++++++++++++++++++++++++++++++++++ tests/PostcodeRulesTest.php | 10 +++++ 2 files changed, 92 insertions(+) diff --git a/tests/CikmovTest.php b/tests/CikmovTest.php index 999f87d..95b0dde 100644 --- a/tests/CikmovTest.php +++ b/tests/CikmovTest.php @@ -111,6 +111,87 @@ public static function inwardDigitConfusionProvider(): iterable yield 'G->6' => ['EC1A GAL', 'EC1A 6AL']; } + #[DataProvider('shiftedInwardDigitProvider')] + public function testShiftedInwardDigitCharactersAreCorrected(string $input, string $expected): void + { + $result = Cikmov::analyse($input); + + self::assertFalse($result->inputWasValid); + self::assertSame($expected, $result->bestCandidate); + self::assertSame($expected, $result->appliedPostcode); + self::assertSame(92, $result->confidence); + } + + /** + * @return iterable + */ + public static function shiftedInwardDigitProvider(): iterable + { + yield '!->1' => ['EC1A !AL', 'EC1A 1AL']; + yield '@->2' => ['EC1A @AL', 'EC1A 2AL']; + yield '"->2' => ['EC1A "AL', 'EC1A 2AL']; + yield '#->3' => ['EC1A #AL', 'EC1A 3AL']; + yield '£->3' => ["EC1A \u{00A3}AL", 'EC1A 3AL']; + yield '$->4' => ['EC1A $AL', 'EC1A 4AL']; + yield '%->5' => ['EC1A %AL', 'EC1A 5AL']; + yield '^->6' => ['EC1A ^AL', 'EC1A 6AL']; + yield '&->7' => ['EC1A &AL', 'EC1A 7AL']; + yield '*->8' => ['EC1A *AL', 'EC1A 8AL']; + yield '(->9' => ['EC1A (AL', 'EC1A 9AL']; + yield ')->0' => ['EC1A )AL', 'EC1A 0AL']; + } + + #[DataProvider('shiftedOutwardDigitProvider')] + public function testShiftedOutwardDigitCharactersAreCorrected(string $input, string $expected): void + { + $result = Cikmov::analyse($input); + + self::assertFalse($result->inputWasValid); + self::assertSame($expected, $result->bestCandidate); + self::assertSame($expected, $result->appliedPostcode); + self::assertSame(86, $result->confidence); + } + + /** + * @return iterable + */ + public static function shiftedOutwardDigitProvider(): iterable + { + yield 'AA9A digit' => ['EC!A 1AL', 'EC1A 1AL']; + yield 'AA9 digit' => ['YO( 7HB', 'YO9 7HB']; + yield 'A99 final digit' => ['W1) 0AX', 'W10 0AX']; + } + + public function testShiftedOutwardNPositionStillRejectsZeroDistrict(): void + { + $result = Cikmov::analyse('SW)A 1AA'); + + self::assertFalse($result->inputWasValid); + self::assertNull($result->bestCandidate); + self::assertNull($result->appliedPostcode); + self::assertSame(0, $result->confidence); + } + + public function testShiftedDigitsAreNotAppliedInLetterPositions(): void + { + $result = Cikmov::analyse('EC1A 1A!'); + + self::assertFalse($result->inputWasValid); + self::assertNull($result->bestCandidate); + self::assertNull($result->appliedPostcode); + self::assertSame(0, $result->confidence); + } + + public function testShiftedDigitsCanCombineWithExistingConfusions(): void + { + $result = Cikmov::analyse('EC!A 1A1'); + + self::assertFalse($result->inputWasValid); + self::assertSame('EC1A 1AL', $result->bestCandidate); + self::assertSame(82, $result->confidence); + self::assertNull($result->appliedPostcode); + } + #[DataProvider('inwardLetterConfusionProvider')] public function testInwardLetterConfusionsAreCorrected(string $input, string $expected): void { @@ -410,6 +491,7 @@ public static function idempotencyProvider(): iterable { yield 'valid input' => ['EC1A 1AL']; yield 'correction input' => ['EC1A IAL']; + yield 'shifted correction input' => ['EC1A !AL']; yield 'area confusion correction' => ['Y01 7HB']; yield 'ambiguous input' => ['B01 8TH']; yield 'rejected input' => ['!!!!']; diff --git a/tests/PostcodeRulesTest.php b/tests/PostcodeRulesTest.php index c849256..9f34cb0 100644 --- a/tests/PostcodeRulesTest.php +++ b/tests/PostcodeRulesTest.php @@ -77,6 +77,16 @@ public function testCompactFromInputStripsNoise(): void self::assertSame('WC2H7LT', PostcodeRules::compactFromInput(" wc2h-7lt\t")); } + public function testCompactFromInputRetainsShiftedDigitCharacters(): void + { + self::assertSame('EC!A"AL', PostcodeRules::compactFromInput(' ec!a "al ')); + } + + public function testCompactFromInputNormalizesPoundToHashAlias(): void + { + self::assertSame('EC1A#AL', PostcodeRules::compactFromInput("EC1A \u{00A3}AL")); + } + public function testDisplayFromCompactSpacingRules(): void { self::assertSame('', PostcodeRules::displayFromCompact('')); From 3def88cf6456dc25d2024d0b22005fe9a203ffd3 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sun, 22 Feb 2026 23:14:03 +0000 Subject: [PATCH 2/5] feat: support shifted number-row digit substitutions Preserve supported shifted number-row symbols during input compaction and normalize pound-sign aliases for deterministic single-byte processing. Extend candidate generation to map shifted symbols to digits only in digit-required positions, enforce non-zero N constraints, and apply deterministic shifted penalties (inward -8, outward -14, outward-area -22). --- src/Internal/Analyser.php | 63 +++++++++++++++++++++++++++++----- src/Internal/PostcodeRules.php | 34 +++++++++++++++++- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/Internal/Analyser.php b/src/Internal/Analyser.php index d0aeacc..71e2085 100644 --- a/src/Internal/Analyser.php +++ b/src/Internal/Analyser.php @@ -11,6 +11,9 @@ final class Analyser { private const OUTWARD_SUBSTITUTION_BASE_PENALTY = 8; private const INWARD_SUBSTITUTION_BASE_PENALTY = 4; + private const OUTWARD_SHIFTED_DIGIT_AREA_PENALTY = 22; + private const OUTWARD_SHIFTED_DIGIT_PENALTY = 14; + private const INWARD_SHIFTED_DIGIT_PENALTY = 8; private const TIE_AMBIGUITY_PENALTY = 15; private const NEAR_AMBIGUITY_PENALTY = 6; private const ALTERNATIVE_SCORE_WINDOW = 4; @@ -80,7 +83,7 @@ public static function analyse(string $input, int $minConfidenceToApply): Result ); } - if (!preg_match('/[A-Z]/', $compact) || !preg_match('/[0-9]/', $compact)) { + if (!preg_match('/[A-Z]/', $compact) || !PostcodeRules::containsDigitLikeCharacter($compact)) { return new Result( input: $input, normalizedInput: $normalizedInput, @@ -206,11 +209,17 @@ private static function generateCandidates(string $compact): array continue; } + $areaLength = str_starts_with($pattern, 'AA') ? 2 : 1; $optionsByPosition = []; $isPatternViable = true; foreach ($outwardTokens as $position => $token) { - $options = self::optionsForCharacter($outwardInput[$position], $token, true); + $options = self::optionsForCharacter( + character: $outwardInput[$position], + expectedToken: $token, + outward: true, + isOutwardAreaPosition: $position < $areaLength + ); if ($options === []) { $isPatternViable = false; break; @@ -273,12 +282,23 @@ private static function isClassCompatibleOutward(string $outward, string $patter return false; } - if ($token === 'D' && !ctype_digit($character)) { - return false; - } + if ($token !== 'L') { + if (ctype_digit($character)) { + if ($token === 'N' && $character === '0') { + return false; + } - if ($token === 'N' && (!ctype_digit($character) || $character === '0')) { - return false; + continue; + } + + $shiftedDigit = PostcodeRules::shiftedDigitReplacement($character); + if ($shiftedDigit === null) { + return false; + } + + if ($token === 'N' && $shiftedDigit === '0') { + return false; + } } } @@ -315,8 +335,12 @@ private static function walkCandidateOptions( /** * @return list */ - private static function optionsForCharacter(string $character, string $expectedToken, bool $outward): array - { + private static function optionsForCharacter( + string $character, + string $expectedToken, + bool $outward, + bool $isOutwardAreaPosition = false + ): array { $basePenalty = $outward ? self::OUTWARD_SUBSTITUTION_BASE_PENALTY : self::INWARD_SUBSTITUTION_BASE_PENALTY; $options = []; @@ -346,6 +370,14 @@ private static function optionsForCharacter(string $character, string $expectedT $options[] = ['char' => $replacement, 'penalty' => $basePenalty + $extraPenalty]; } } + + $shiftedDigit = PostcodeRules::shiftedDigitReplacement($character); + if ($shiftedDigit !== null && ($expectedToken !== 'N' || $shiftedDigit !== '0')) { + $options[] = [ + 'char' => $shiftedDigit, + 'penalty' => self::shiftedDigitPenalty($outward, $isOutwardAreaPosition), + ]; + } } $deduplicated = []; @@ -371,4 +403,17 @@ private static function optionsForCharacter(string $character, string $expectedT return $finalOptions; } + + private static function shiftedDigitPenalty(bool $outward, bool $isOutwardAreaPosition): int + { + if (!$outward) { + return self::INWARD_SHIFTED_DIGIT_PENALTY; + } + + if ($isOutwardAreaPosition) { + return self::OUTWARD_SHIFTED_DIGIT_AREA_PENALTY; + } + + return self::OUTWARD_SHIFTED_DIGIT_PENALTY; + } } diff --git a/src/Internal/PostcodeRules.php b/src/Internal/PostcodeRules.php index 796c143..b1fa3e0 100644 --- a/src/Internal/PostcodeRules.php +++ b/src/Internal/PostcodeRules.php @@ -15,6 +15,27 @@ final class PostcodeRules private const FORBIDDEN_FIRST_OUTWARD_LETTERS = 'QVX'; private const FORBIDDEN_SECOND_OUTWARD_LETTERS = 'IJZ'; private const AA9A_ALLOWED_FINAL_LETTERS = 'ABEHMNPRVWXY'; + private const SHIFTED_DIGIT_ALIASES = [ + "\u{00A3}" => '#', + ]; + + /** + * @var array + */ + private const SHIFTED_DIGIT_TO_DIGIT = [ + '!' => '1', + '@' => '2', + '"' => '2', + '#' => '3', + "\u{00A3}" => '3', + '$' => '4', + '%' => '5', + '^' => '6', + '&' => '7', + '*' => '8', + '(' => '9', + ')' => '0', + ]; /** * @var array> @@ -172,11 +193,22 @@ final class PostcodeRules public static function compactFromInput(string $input): string { $normalized = strtoupper($input); - $compact = preg_replace('/[^A-Z0-9]+/', '', $normalized); + $normalized = strtr($normalized, self::SHIFTED_DIGIT_ALIASES); + $compact = preg_replace('/[^A-Z0-9!@"#$%\^&*()]+/', '', $normalized); return $compact ?? ''; } + public static function containsDigitLikeCharacter(string $compact): bool + { + return (bool) preg_match('/[0-9!@"#$%\^&*()]/', $compact); + } + + public static function shiftedDigitReplacement(string $character): ?string + { + return self::SHIFTED_DIGIT_TO_DIGIT[$character] ?? null; + } + public static function displayFromCompact(string $compact): string { if ($compact === '') { From af55d1c598b3905d707008e71d54b8c7042815c2 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sun, 22 Feb 2026 23:14:07 +0000 Subject: [PATCH 3/5] docs: document shifted-digit mapping and scoring Add the full UK/US number-row shifted-symbol mapping table, scope boundaries, and positional rules. Document shifted-digit penalty values and add examples for successful correction and letter-position rejection. --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 8af7c78..73ef1de 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,37 @@ Why: - behaviour stays explainable, reproducible, and testable - correction risk is lower when every decision is rule-backed +## Shifted Number-Row Digit Support + +`cikmov` supports deterministic correction when shifted number-row symbols are typed instead of digits. + +Supported substitutions: + +```text +! -> 1 +@ -> 2 +" -> 2 +# -> 3 +£ -> 3 +$ -> 4 +% -> 5 +^ -> 6 +& -> 7 +* -> 8 +( -> 9 +) -> 0 +``` + +Scope rules: + +- mapping is the union of UK + US number-row shifted symbols (including Irish usage of UK layout) +- no keyboard-layout detection is performed at runtime +- substitutions are attempted only where grammar requires digits: + - outward digit positions + - district digit positions + - inward first character +- substitutions are not attempted in letter-only positions + ## Public API ```php @@ -159,6 +190,10 @@ Scoring policy: - this reflects higher structural significance of outward geography encoding - ambiguity lowers confidence further - alternatives are capped at 5 entries for bounded output size +- shifted number-row symbol penalties: + - inward digit substitution: `-8` + - outward non-area digit substitution: `-14` + - outward area digit substitution: `-22` (reserved for completeness; current grammar does not place digits in outward area-letter slots) Ambiguity application policy: @@ -252,6 +287,24 @@ $result = Cikmov::analyse('EC1A 1AI'); // no correction is applied ``` +### 6) Shifted-digit correction + +```php +$result = Cikmov::analyse('EC1A !AL'); +// bestCandidate: "EC1A 1AL" +// confidence: 92 +// appliedPostcode: "EC1A 1AL" +``` + +### 7) Shifted symbol in letter position is rejected + +```php +$result = Cikmov::analyse('EC1A 1A!'); +// invalid: no shifted-digit substitution in letter-only positions +// bestCandidate: null +// appliedPostcode: null +``` + ## Embedded Postcode Areas The full area set is embedded and enforced: From 4dba7024040ac20ffd2c0a6c6c89a5abd2182ea4 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sun, 22 Feb 2026 23:21:50 +0000 Subject: [PATCH 4/5] fix: ignore surrounding shifted-symbol noise during compaction Trim shifted number-row symbols at compact string boundaries so wrappers and trailing punctuation do not block otherwise valid analysis. Preserve in-position shifted symbols for deterministic digit correction and add regression tests for leading/trailing wrappers plus wrapped shifted-correction paths. --- src/Internal/PostcodeRules.php | 11 +++++++++-- tests/CikmovTest.php | 29 +++++++++++++++++++++++++++++ tests/PostcodeRulesTest.php | 6 ++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/Internal/PostcodeRules.php b/src/Internal/PostcodeRules.php index b1fa3e0..3a5cd44 100644 --- a/src/Internal/PostcodeRules.php +++ b/src/Internal/PostcodeRules.php @@ -15,6 +15,7 @@ final class PostcodeRules private const FORBIDDEN_FIRST_OUTWARD_LETTERS = 'QVX'; private const FORBIDDEN_SECOND_OUTWARD_LETTERS = 'IJZ'; private const AA9A_ALLOWED_FINAL_LETTERS = 'ABEHMNPRVWXY'; + private const SHIFTED_DIGIT_SYMBOLS = '!@"#$%^&*()'; private const SHIFTED_DIGIT_ALIASES = [ "\u{00A3}" => '#', ]; @@ -195,13 +196,19 @@ public static function compactFromInput(string $input): string $normalized = strtoupper($input); $normalized = strtr($normalized, self::SHIFTED_DIGIT_ALIASES); $compact = preg_replace('/[^A-Z0-9!@"#$%\^&*()]+/', '', $normalized); + if ($compact === null || $compact === '') { + return ''; + } + + // Shifted symbols can validly stand in for digits, but never at postcode boundaries. + $compact = trim($compact, self::SHIFTED_DIGIT_SYMBOLS); - return $compact ?? ''; + return $compact; } public static function containsDigitLikeCharacter(string $compact): bool { - return (bool) preg_match('/[0-9!@"#$%\^&*()]/', $compact); + return strpbrk($compact, '0123456789' . self::SHIFTED_DIGIT_SYMBOLS) !== false; } public static function shiftedDigitReplacement(string $character): ?string diff --git a/tests/CikmovTest.php b/tests/CikmovTest.php index 95b0dde..a4fc5bc 100644 --- a/tests/CikmovTest.php +++ b/tests/CikmovTest.php @@ -86,6 +86,35 @@ public static function lowercaseAndNoiseProvider(): iterable yield 'extra spaces' => ['yo1 7hb', 'YO1 7HB']; } + #[DataProvider('surroundingShiftedNoiseProvider')] + public function testSurroundingShiftedSymbolsAreIgnoredAsNoise(string $input, string $canonical): void + { + $result = Cikmov::analyse($input); + + self::assertTrue($result->inputWasValid); + self::assertSame($canonical, $result->appliedPostcode); + self::assertSame(100, $result->confidence); + } + + /** + * @return iterable + */ + public static function surroundingShiftedNoiseProvider(): iterable + { + yield 'trailing !' => ['EC1A 1AL!', 'EC1A 1AL']; + yield 'leading !' => ['!EC1A 1AL', 'EC1A 1AL']; + yield 'wrapped by parentheses' => ['(EC1A 1AL)', 'EC1A 1AL']; + } + + public function testWrappedShiftedCorrectionStillCorrectsDeterministically(): void + { + $result = Cikmov::analyse('(EC!A 1AL)'); + + self::assertFalse($result->inputWasValid); + self::assertSame('EC1A 1AL', $result->bestCandidate); + self::assertSame('EC1A 1AL', $result->appliedPostcode); + } + #[DataProvider('inwardDigitConfusionProvider')] public function testInwardDigitConfusionsAreCorrected(string $input, string $expected): void { diff --git a/tests/PostcodeRulesTest.php b/tests/PostcodeRulesTest.php index 9f34cb0..607dfef 100644 --- a/tests/PostcodeRulesTest.php +++ b/tests/PostcodeRulesTest.php @@ -87,6 +87,12 @@ public function testCompactFromInputNormalizesPoundToHashAlias(): void self::assertSame('EC1A#AL', PostcodeRules::compactFromInput("EC1A \u{00A3}AL")); } + public function testCompactFromInputTrimsSurroundingShiftedSymbols(): void + { + self::assertSame('EC1A1AL', PostcodeRules::compactFromInput('!EC1A 1AL!')); + self::assertSame('EC!A1AL', PostcodeRules::compactFromInput('(EC!A 1AL)')); + } + public function testDisplayFromCompactSpacingRules(): void { self::assertSame('', PostcodeRules::displayFromCompact('')); From 7332274dcb3af70b0860206d306878c896739332 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Sun, 22 Feb 2026 23:40:23 +0000 Subject: [PATCH 5/5] fix: treat inserted shifted symbols as noise when safe Prefer a stripped-symbol compact form when removing shifted symbols yields an already valid postcode, preventing stray punctuation from being applied as a different corrected postcode. Adds targeted regression coverage for inserted-symbol noise and documents the behavior; keeps shifted substitution paths for true digit-position replacements where stripping does not yield a valid compact postcode. --- README.md | 1 + src/Internal/Analyser.php | 15 +++++++++++++++ src/Internal/PostcodeRules.php | 5 +++++ tests/CikmovTest.php | 22 +++++++++++++++++++++- tests/PostcodeRulesTest.php | 6 ++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 73ef1de..c6d39ad 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Scope rules: - district digit positions - inward first character - substitutions are not attempted in letter-only positions +- when stripping shifted symbols produces an already-valid compact postcode, symbols are treated as noise and not as digit substitutions ## Public API diff --git a/src/Internal/Analyser.php b/src/Internal/Analyser.php index 71e2085..618aee1 100644 --- a/src/Internal/Analyser.php +++ b/src/Internal/Analyser.php @@ -83,6 +83,21 @@ public static function analyse(string $input, int $minConfidenceToApply): Result ); } + $compactWithoutShiftedSymbols = PostcodeRules::stripShiftedDigitSymbols($compact); + if ($compactWithoutShiftedSymbols !== $compact && PostcodeRules::isValidCompact($compactWithoutShiftedSymbols)) { + $canonical = PostcodeRules::formatCompact($compactWithoutShiftedSymbols); + + return new Result( + input: $input, + normalizedInput: $canonical, + inputWasValid: true, + bestCandidate: $canonical, + confidence: 100, + appliedPostcode: $canonical, + alternatives: [] + ); + } + if (!preg_match('/[A-Z]/', $compact) || !PostcodeRules::containsDigitLikeCharacter($compact)) { return new Result( input: $input, diff --git a/src/Internal/PostcodeRules.php b/src/Internal/PostcodeRules.php index 3a5cd44..99f5c86 100644 --- a/src/Internal/PostcodeRules.php +++ b/src/Internal/PostcodeRules.php @@ -216,6 +216,11 @@ public static function shiftedDigitReplacement(string $character): ?string return self::SHIFTED_DIGIT_TO_DIGIT[$character] ?? null; } + public static function stripShiftedDigitSymbols(string $compact): string + { + return str_replace(str_split(self::SHIFTED_DIGIT_SYMBOLS), '', $compact); + } + public static function displayFromCompact(string $compact): string { if ($compact === '') { diff --git a/tests/CikmovTest.php b/tests/CikmovTest.php index a4fc5bc..f86dcfd 100644 --- a/tests/CikmovTest.php +++ b/tests/CikmovTest.php @@ -115,6 +115,27 @@ public function testWrappedShiftedCorrectionStillCorrectsDeterministically(): vo self::assertSame('EC1A 1AL', $result->appliedPostcode); } + #[DataProvider('strayInsertedShiftedSymbolProvider')] + public function testStrayInsertedShiftedSymbolsAreTreatedAsNoise(string $input, string $canonical): void + { + $result = Cikmov::analyse($input); + + self::assertTrue($result->inputWasValid); + self::assertSame($canonical, $result->bestCandidate); + self::assertSame($canonical, $result->appliedPostcode); + self::assertSame(100, $result->confidence); + } + + /** + * @return iterable + */ + public static function strayInsertedShiftedSymbolProvider(): iterable + { + yield 'M district with inserted ! noise' => ['M!1 1AE', 'M1 1AE']; + yield 'SW district with inserted @ noise' => ['SW@1A 1AA', 'SW1A 1AA']; + yield 'inward separator with inserted ! noise' => ['EC1A !1AL', 'EC1A 1AL']; + } + #[DataProvider('inwardDigitConfusionProvider')] public function testInwardDigitConfusionsAreCorrected(string $input, string $expected): void { @@ -188,7 +209,6 @@ public static function shiftedOutwardDigitProvider(): iterable { yield 'AA9A digit' => ['EC!A 1AL', 'EC1A 1AL']; yield 'AA9 digit' => ['YO( 7HB', 'YO9 7HB']; - yield 'A99 final digit' => ['W1) 0AX', 'W10 0AX']; } public function testShiftedOutwardNPositionStillRejectsZeroDistrict(): void diff --git a/tests/PostcodeRulesTest.php b/tests/PostcodeRulesTest.php index 607dfef..4f658ac 100644 --- a/tests/PostcodeRulesTest.php +++ b/tests/PostcodeRulesTest.php @@ -93,6 +93,12 @@ public function testCompactFromInputTrimsSurroundingShiftedSymbols(): void self::assertSame('EC!A1AL', PostcodeRules::compactFromInput('(EC!A 1AL)')); } + public function testStripShiftedDigitSymbolsRemovesInsertedShiftedNoise(): void + { + self::assertSame('M11AE', PostcodeRules::stripShiftedDigitSymbols('M!11AE')); + self::assertSame('SW1A1AA', PostcodeRules::stripShiftedDigitSymbols('SW@1A1AA')); + } + public function testDisplayFromCompactSpacingRules(): void { self::assertSame('', PostcodeRules::displayFromCompact(''));