diff --git a/CHANGELOG.md b/CHANGELOG.md index 801f550..9dc2e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,3 +40,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat(api): centralize typed structural payload differences and add immutable fluent toolbar item and panel construction while preserving diagnostic values, constructors, and serialized payloads. - feat(api): centralize request-summary metric calculations and formatting in `SummaryMetricComparison`, preserving history labels, order, units, rounding, percentages, trends, and panel links. - feat(api): add framework-neutral `PanelComparison` for ordered panel IDs, failure precedence, capture states, and combined structural/state counts without changing adapter output or diagnostic values. +- refactor: share captured URL-to-path display conversion through `Text::urlToPath()` while retaining original diagnostic URLs and adapter-owned presentation models. diff --git a/src/Helper/Text.php b/src/Helper/Text.php index d65072e..a6fbce3 100644 --- a/src/Helper/Text.php +++ b/src/Helper/Text.php @@ -4,7 +4,9 @@ namespace PHPForge\Debug\Helper; +use function is_string; use function mb_strtolower; +use function parse_url; use function preg_replace; use function str_replace; use function trim; @@ -27,4 +29,27 @@ public static function camel2id(string $name): string return mb_strtolower(trim(str_replace('_', '-', $replaced), '-'), 'UTF-8'); } + + /** + * Strips the authority from a captured URL for display, retaining its path, non-empty query, and fragment. + * + * Unparsable URLs pass through unchanged. This is a display conversion, not URL validation or HTML escaping; + * callers must retain the original diagnostic URL and escape the result at the rendering boundary. + */ + public static function urlToPath(string $url): string + { + $parsed = parse_url($url); + + if ($parsed === false) { + return $url; + } + + $path = is_string($parsed['path'] ?? null) ? $parsed['path'] : '/'; + $query = is_string($parsed['query'] ?? null) && $parsed['query'] !== '' ? '?' . $parsed['query'] : ''; + $fragment = is_string($parsed['fragment'] ?? null) && $parsed['fragment'] !== '' + ? '#' . $parsed['fragment'] + : ''; + + return "{$path}{$query}{$fragment}"; + } } diff --git a/tests/Helper/TextTest.php b/tests/Helper/TextTest.php index dfbf691..8afb840 100644 --- a/tests/Helper/TextTest.php +++ b/tests/Helper/TextTest.php @@ -5,11 +5,12 @@ namespace PHPForge\Debug\Tests\Helper; use PHPForge\Debug\Helper\Text; -use PHPUnit\Framework\Attributes\Group; +use PHPForge\Debug\Tests\Provider\UrlPathProvider; +use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see Text} covering identifier separators and Unicode case conversion. + * Unit tests for {@see Text} covering identifiers, Unicode case conversion, and captured URL display. */ #[Group('helpers')] final class TextTest extends TestCase @@ -39,4 +40,13 @@ public function testCamel2idPreservesEmptyInput(): void 'Empty input must remain empty.', ); } + #[DataProviderExternal(UrlPathProvider::class, 'paths')] + public function testUrlToPathPreservesCapturedDisplay(string $url, string $expected): void + { + self::assertSame( + $expected, + Text::urlToPath($url), + 'Captured URL display must match both adapters exactly.', + ); + } } diff --git a/tests/Provider/UrlPathProvider.php b/tests/Provider/UrlPathProvider.php new file mode 100644 index 0000000..6b21df5 --- /dev/null +++ b/tests/Provider/UrlPathProvider.php @@ -0,0 +1,106 @@ + + */ + public static function paths(): iterable + { + yield 'absolute URL' => [ + 'https://example.test:8443/orders?sort=id#row', + '/orders?sort=id#row', + ]; + yield 'authority only' => [ + 'https://example.test', + '/', + ]; + yield 'console invocation' => [ + 'php yii migrate/up', + 'php yii migrate/up', + ]; + yield 'credentials and IPv6' => [ + 'https://user:pass@[::1]:8080/a', + '/a', + ]; + yield 'empty fragment' => [ + 'https://example.test/a#', + '/a', + ]; + yield 'empty input' => [ + '', + '', + ]; + yield 'empty query and fragment' => [ + 'https://example.test?#', + '/', + ]; + yield 'empty query' => [ + 'https://example.test/a?', + '/a', + ]; + yield 'encoded components' => [ + 'https://example.test/a%2Fb?q=%23%26#x%20y', + '/a%2Fb?q=%23%26#x%20y', + ]; + yield 'fragment without path' => [ + '#details', + '/#details', + ]; + yield 'invalid network path' => [ + '///broken?x=1#b', + '///broken?x=1#b', + ]; + yield 'invalid port' => [ + 'http://example.test:99999/a?q=1#b', + 'http://example.test:99999/a?q=1#b', + ]; + yield 'markup remains diagnostic text' => [ + 'https://example.test/?q="a"&x=1#', + '/?q="a"&x=1#', + ]; + yield 'missing host' => [ + 'http://', + 'http://', + ]; + yield 'network path' => [ + '//example.test/a?x=1#b', + '/a?x=1#b', + ]; + yield 'nonhierarchical scheme' => [ + 'mailto:user@example.test?subject=Hi#part', + 'user@example.test?subject=Hi#part', + ]; + yield 'query without path' => [ + '?x=1', + '/?x=1', + ]; + yield 'relative path' => [ + 'orders/42?x=1#details', + 'orders/42?x=1#details', + ]; + yield 'root path' => [ + '/', + '/', + ]; + yield 'zero fragment' => [ + 'https://example.test#0', + '/#0', + ]; + yield 'zero path' => [ + '0', + '0', + ]; + yield 'zero query' => [ + 'https://example.test?0', + '/?0', + ]; + } +}