Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions src/Helper/Text.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}";
}
}
14 changes: 12 additions & 2 deletions tests/Helper/TextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.',
);
}
}
106 changes: 106 additions & 0 deletions tests/Provider/UrlPathProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Tests\Provider;

/**
* Characterization cases for captured URL display without changing the original diagnostic value.
*/
final class UrlPathProvider
{
/**
* @return iterable<string, array{string, string}>
*/
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/<b>?q="a"&x=1#<i>',
'/<b>?q="a"&x=1#<i>',
];
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',
];
}
}
Loading