From 788d7bc306b7e8f0cc1a84c586982e136e9a4215 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 18 Aug 2026 20:33:26 -0400 Subject: [PATCH 1/2] feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation. --- CHANGELOG.md | 1 + README.md | 8 + src/Data/FilterEngine.php | 172 ++++++++++++++++ src/Data/FilterPrefix.php | 33 +++ src/Data/PageSize.php | 104 ++++++++++ src/Data/QueryInput.php | 75 +++++++ src/Panel/Asset/AssetSectionRenderer.php | 82 ++++++++ src/Panel/PanelRenderContext.php | 64 ++++++ src/Routing/DebugUrlGeneratorInterface.php | 36 ++++ tests/Data/FilterEngineTest.php | 194 ++++++++++++++++++ tests/Data/FilterPrefixTest.php | 50 +++++ tests/Data/PageSizeTest.php | 117 +++++++++++ tests/Data/QueryInputTest.php | 76 +++++++ .../Panel/Asset/AssetSectionRendererTest.php | 107 ++++++++++ tests/Panel/PanelRenderContextTest.php | 102 +++++++++ 15 files changed, 1221 insertions(+) create mode 100644 src/Data/FilterEngine.php create mode 100644 src/Data/FilterPrefix.php create mode 100644 src/Data/PageSize.php create mode 100644 src/Data/QueryInput.php create mode 100644 src/Panel/Asset/AssetSectionRenderer.php create mode 100644 src/Panel/PanelRenderContext.php create mode 100644 src/Routing/DebugUrlGeneratorInterface.php create mode 100644 tests/Data/FilterEngineTest.php create mode 100644 tests/Data/FilterPrefixTest.php create mode 100644 tests/Data/PageSizeTest.php create mode 100644 tests/Data/QueryInputTest.php create mode 100644 tests/Panel/Asset/AssetSectionRendererTest.php create mode 100644 tests/Panel/PanelRenderContextTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3a266..3eef982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat(router): implement Router panel with Current Route and Rules sections. - fix(toolbar): follow debug tags through adapter query URLs and enforce complete JavaScript mutation coverage. - feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape. +- feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation. diff --git a/README.md b/README.md index eebf6d1..9e13ffe 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,20 @@ the toolbar data contract, and framework-neutral PHP templates composed with the does not register assets, render responses, inject toolbar markup, or depend on Yii2, Yii3, an application container, a view implementation, or a framework request lifecycle. +Shared adapter UI contracts include `PHPForge\Debug\Data\FilterEngine`, `FilterPrefix`, `PageSize`, and `QueryInput`, +plus `PHPForge\Debug\Panel\PanelRenderContext`. Adapters provide a +`PHPForge\Debug\Routing\DebugUrlGeneratorInterface` implementation so portable panel renderers can build history, +panel, and action links without importing a framework URL manager. + Adapters collect framework data, convert it into immutable snapshots, expose toolbar data endpoints, define and publish assets through their framework, and render the shared templates with their framework view component. They also own toolbar response injection. Routes, controllers or actions, URL generation, panel metadata, and framework-specific panel views remain in each adapter. Yii adapters resolve the packaged frontend at `@vendor/php-forge/debug-core/resources/assets` and configure their own alias for `resources/views`. +The visual and behavioral synchronization contract for the Yii adapters is documented in the +[Yii Debug UI parity baseline](docs/ui-parity-baseline.md). + Current adapters: - `yii2-extensions/debug` diff --git a/src/Data/FilterEngine.php b/src/Data/FilterEngine.php new file mode 100644 index 0000000..5101b58 --- /dev/null +++ b/src/Data/FilterEngine.php @@ -0,0 +1,172 @@ +'|'<'|'>=', value: float} + * |array{attribute: string, operator: 'contains'|'same', value: string} + * > + */ + private array $conditions = []; + + /** + * Registers an exact, partial, or leading numeric comparison condition. + * + * Empty and non-scalar raw values register nothing, so unfiltered attributes can be passed through unconditionally. + * + * @param string $attribute Row attribute (public property or array key) the condition applies to. + * @param mixed $rawValue Raw filter value as read from the request; scalars are compared as strings. + * @param bool $partial Whether a non-numeric value matches as a case-insensitive substring instead of whole-value. + */ + public function addCondition(string $attribute, mixed $rawValue, bool $partial = false): void + { + $value = is_scalar($rawValue) ? (string) $rawValue : ''; + + if ($value === '') { + return; + } + + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + $this->conditions[] = [ + 'attribute' => $attribute, + 'operator' => $matches[1], + 'value' => (float) $matches[2], + ]; + + return; + } + + $this->conditions[] = [ + 'attribute' => $attribute, + 'operator' => $partial ? 'contains' : 'same', + 'value' => $value, + ]; + } + + /** + * Registers a numeric greater-than-or-equal condition. + * + * @param string $attribute Row attribute (public property or array key) the condition applies to. + * @param float $value Inclusive lower bound the attribute value must reach. + */ + public function addMinimumCondition(string $attribute, float $value): void + { + $this->conditions[] = ['attribute' => $attribute, 'operator' => '>=', 'value' => $value]; + } + + /** + * Applies all registered conditions and resets the engine for the next run. + * + * @template TRow of array|object + * + * @param array $rows Rows to filter; typed row objects or string-keyed arrays. + * + * @return list Rows matching every registered condition, reindexed. + */ + public function filter(array $rows): array + { + $filtered = array_values(array_filter($rows, $this->matches(...))); + + $this->conditions = []; + + return $filtered; + } + + /** + * @param array|object $row Typed row object or string-keyed array. + */ + private function matches(array|object $row): bool + { + foreach ($this->conditions as $condition) { + $attribute = $condition['attribute']; + + $values = is_object($row) ? get_object_vars($row) : $row; + + if (!array_key_exists($attribute, $values)) { + return false; + } + + $candidate = $values[$attribute]; + $operator = $condition['operator']; + + if ($operator === '>' || $operator === '<' || $operator === '>=') { + $expected = $condition['value']; + + if ( + !is_float($expected) + || !is_int($candidate) && !is_float($candidate) && !is_string($candidate) + || !is_numeric($candidate) + ) { + return false; + } + + $candidate = (float) $candidate; + + $matched = match ($operator) { + '>' => $candidate > $expected, + '<' => $candidate < $expected, + default => $candidate >= $expected, + }; + + if (!$matched) { + return false; + } + + continue; + } + + $candidate = is_scalar($candidate) + ? (string) $candidate + : Dump::export($candidate); + + $expected = $condition['value']; + + if (!is_string($expected)) { + return false; + } + + if ($operator === 'contains') { + if (mb_stripos($candidate, $expected, 0, self::CHARSET) === false) { + return false; + } + + continue; + } + + if ( + mb_strtolower($candidate, self::CHARSET) + !== mb_strtolower($expected, self::CHARSET) + ) { + return false; + } + } + + return true; + } +} diff --git a/src/Data/FilterPrefix.php b/src/Data/FilterPrefix.php new file mode 100644 index 0000000..8e10080 --- /dev/null +++ b/src/Data/FilterPrefix.php @@ -0,0 +1,33 @@ +addDataAttribute('yii-debug-pagesize', true) + ->class('yii-debug-grid-pagesize-select') + ->name('per-page'); + + foreach (self::OPTIONS as $row) { + $select = $select->option( + Option::tag() + ->value($row) + ->content($row === 'all' ? 'All' : $row) + ->selected($row === $current), + ); + } + + return Label::tag() + ->class('yii-debug-grid-pagesize') + ->html( + Span::tag() + ->class('yii-debug-grid-pagesize-label') + ->content('Rows'), + $select, + ) + ->render(); + } +} diff --git a/src/Data/QueryInput.php b/src/Data/QueryInput.php new file mode 100644 index 0000000..44eb007 --- /dev/null +++ b/src/Data/QueryInput.php @@ -0,0 +1,75 @@ + $query Parsed query parameters. + * @param string $prefix Filter-group prefix (for example, `Debug` matches `Debug[statusCode]`). + * + * @return array Attribute-to-value map with empty and non-scalar entries removed. + */ + public static function group(array $query, string $prefix): array + { + $group = $query[$prefix] ?? null; + + if (!is_array($group)) { + return []; + } + + $filters = []; + + foreach ($group as $attribute => $value) { + if (!is_string($attribute)) { + continue; + } + + $normalized = self::stringValue($value); + + if ($normalized === null || $normalized === '') { + continue; + } + + $filters[$attribute] = $normalized; + } + + return $filters; + } + + /** + * Returns a top-level query parameter as a string, or `null` when absent or non-scalar. + * + * @param array $query Parsed query parameters. + * @param string $name Parameter name to read. + */ + public static function scalar(array $query, string $name): string|null + { + return self::stringValue($query[$name] ?? null); + } + + private static function stringValue(mixed $value): string|null + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + return null; + } +} diff --git a/src/Panel/Asset/AssetSectionRenderer.php b/src/Panel/Asset/AssetSectionRenderer.php new file mode 100644 index 0000000..e6736f3 --- /dev/null +++ b/src/Panel/Asset/AssetSectionRenderer.php @@ -0,0 +1,82 @@ +totalBundles, 'bundle' . ($summary->totalBundles === 1 ? '' : 's')], + ['css', 'brand-css3', $summary->totalCss, 'css'], + ['js', 'brand-javascript', $summary->totalJs, 'js'], + ['deps', 'link', $summary->totalDeps, 'link' . ($summary->totalDeps === 1 ? '' : 's')], + ]; + $blocks = []; + + foreach ($stats as [$kind, $icon, $value, $label]) { + $blocks[] = Div::tag() + ->addDataAttribute('kind', $kind) + ->class('yii-debug-asset-stat') + ->html( + Span::tag() + ->addAriaAttribute('hidden', 'true') + ->class('yii-debug-asset-stat-icon') + ->html(Icon::render($icon)), + Strong::tag() + ->class('yii-debug-asset-stat-value') + ->content((string) $value), + Span::tag() + ->class('yii-debug-asset-stat-label') + ->content($label), + ); + } + + return H1::tag() + ->class('yii-debug-sr-only') + ->content('Asset Bundles') + ->render() + . Header::tag() + ->class('yii-debug-asset-stats') + ->html(...$blocks) + ->render(); + } + + /** + * Renders the normalized bundle inventory as an ordered list of shared bundle cards. + */ + public static function renderInventory(AssetSummary $summary): string + { + if ($summary->isEmpty()) { + return ''; + } + + $items = []; + + foreach ($summary->bundles as $bundle) { + $items[] = Li::tag() + ->class('yii-debug-asset-list-item') + ->html(AssetCardRenderer::renderCard($bundle, $summary)); + } + + return Ol::tag() + ->class('yii-debug-asset-list') + ->html(...$items) + ->render(); + } +} diff --git a/src/Panel/PanelRenderContext.php b/src/Panel/PanelRenderContext.php new file mode 100644 index 0000000..db73a82 --- /dev/null +++ b/src/Panel/PanelRenderContext.php @@ -0,0 +1,64 @@ + $queryParams Parsed query parameters of the current debugger request. + * @param string $theme Resolved debugger theme. + * @param DebugUrlGeneratorInterface $urls Adapter-owned URL generator. + */ + public function __construct( + public string $tag, + public string $panel, + public array $queryParams, + public string $theme, + private DebugUrlGeneratorInterface $urls, + ) {} + + /** + * Builds an adapter action URL for this captured request. + * + * @param string $action Adapter-defined action identifier. + * @param array|null $queryParams Additional parameters, or `null` to reuse the current query. + */ + public function actionUrl(string $action, array|null $queryParams = null): string + { + return $this->urls->action($action, $this->tag, $queryParams ?? $this->queryParams); + } + + /** + * Builds the request-history URL. + * + * @param array|null $queryParams History parameters, or `null` to reuse the current query. + */ + public function historyUrl(array|null $queryParams = null): string + { + return $this->urls->history($queryParams ?? $this->queryParams); + } + + /** + * Builds a panel URL for this captured request. + * + * @param string|null $panel Target panel, or `null` to keep the current panel. + * @param array|null $queryParams Panel parameters, or `null` to reuse the current query. + */ + public function panelUrl(string|null $panel = null, array|null $queryParams = null): string + { + return $this->urls->panel( + $this->tag, + $panel ?? $this->panel, + $queryParams ?? $this->queryParams, + ); + } +} diff --git a/src/Routing/DebugUrlGeneratorInterface.php b/src/Routing/DebugUrlGeneratorInterface.php new file mode 100644 index 0000000..cc8e0ae --- /dev/null +++ b/src/Routing/DebugUrlGeneratorInterface.php @@ -0,0 +1,36 @@ + $queryParams Additional query parameters. + */ + public function action(string $action, string $tag, array $queryParams = []): string; + + /** + * Builds the request-history URL. + * + * @param array $queryParams History filter, sort, or cursor parameters. + */ + public function history(array $queryParams = []): string; + + /** + * Builds a captured-request panel URL. + * + * @param string $tag Captured request tag. + * @param string $panel Stable panel identifier. + * @param array $queryParams Panel filter, sort, or pagination parameters. + */ + public function panel(string $tag, string $panel, array $queryParams = []): string; +} diff --git a/tests/Data/FilterEngineTest.php b/tests/Data/FilterEngineTest.php new file mode 100644 index 0000000..5b6a272 --- /dev/null +++ b/tests/Data/FilterEngineTest.php @@ -0,0 +1,194 @@ +addCondition('level', ''); + $engine->addCondition('level', null); + $engine->addCondition('level', ['error']); + + $rows = [['level' => 'info'], ['level' => 'error']]; + + self::assertSame( + $engine->filter($rows), + $rows, + 'Empty and non-scalar values must register no condition.', + ); + } + + public function testAddMinimumConditionKeepsRowsAtOrAboveTheBound(): void + { + $engine = new FilterEngine(); + + $engine->addMinimumCondition('duration', 0.5); + + self::assertSame( + [['duration' => 0.5], ['duration' => 2]], + $engine->filter([['duration' => 0.1], ['duration' => 0.5], ['duration' => 2]]), + 'Inclusive lower bound must keep the boundary row.', + ); + } + + public function testFilterComparesNonScalarCandidatesThroughDumpExport(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('payload', 'alpha', partial: true); + + self::assertCount( + 1, + $engine->filter([['payload' => ['alpha' => 1]], ['payload' => ['beta' => 2]]]), + 'Array candidates must match through their exported representation.', + ); + } + + public function testFilterDropsRowsMissingTheAttribute(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('level', 'error'); + + self::assertSame( + [], + $engine->filter([['category' => 'app']]), + 'Rows without the filtered attribute must be dropped.', + ); + } + + public function testFilterMatchesCaseInsensitiveSubstringWhenPartial(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('message', 'SESSION', partial: true); + + self::assertSame( + [['message' => 'Session started']], + $engine->filter([['message' => 'Session started'], ['message' => 'Connection opened']]), + 'Partial conditions must match case-insensitive substrings.', + ); + } + + public function testFilterMatchesCaseInsensitiveWholeValueByDefault(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('level', 'ERROR'); + + self::assertSame( + [['level' => 'error']], + $engine->filter([['level' => 'error'], ['level' => 'error-handler']]), + 'Default conditions must match the whole value case-insensitively.', + ); + } + + public function testFilterParsesLeadingComparisonOperators(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('sqlCount', '> 5'); + + self::assertSame( + [['sqlCount' => 9]], + $engine->filter([['sqlCount' => 3], ['sqlCount' => 9]]), + 'A leading `>` must compare numerically.', + ); + + $engine->addCondition('duration', '<0.5'); + + self::assertSame( + [['duration' => '0.25']], + $engine->filter([['duration' => '0.25'], ['duration' => '0.75']]), + 'A leading `<` must compare numeric strings numerically.', + ); + } + + public function testFilterReadsPublicPropertiesFromObjectRows(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('method', 'get'); + + $match = new class { + public string $method = 'GET'; + }; + $miss = new class { + public string $method = 'POST'; + }; + + self::assertSame( + [$match], + $engine->filter([$match, $miss]), + 'Object rows must be matched through their public properties.', + ); + } + + public function testFilterRejectsMalformedInternalConditions(): void + { + $engine = new FilterEngine(); + + $property = new ReflectionProperty($engine, 'conditions'); + + $property->setValue($engine, [['attribute' => 'value', 'operator' => '>', 'value' => '5']]); + + self::assertSame( + [], + $engine->filter([['value' => 6]]), + 'Numeric conditions with a non-float boundary must reject the row.', + ); + + $property->setValue($engine, [['attribute' => 'value', 'operator' => 'same', 'value' => 5.0]]); + + self::assertSame( + [], + $engine->filter([['value' => '5']]), + 'Text conditions with a non-string boundary must reject the row.', + ); + } + + public function testFilterRejectsNonNumericCandidatesForNumericOperators(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('sqlCount', '>5'); + + self::assertSame( + [], + $engine->filter([['sqlCount' => 'many'], ['sqlCount' => null]]), + 'Numeric operators must drop rows whose candidate is not numeric.', + ); + } + + public function testFilterResetsConditionsAfterEachRun(): void + { + $engine = new FilterEngine(); + + $engine->addCondition('level', 'error'); + $engine->filter([['level' => 'info']]); + + $rows = [['level' => 'info']]; + + self::assertSame( + $rows, + $engine->filter($rows), + 'Conditions must reset after each filter run.', + ); + } +} diff --git a/tests/Data/FilterPrefixTest.php b/tests/Data/FilterPrefixTest.php new file mode 100644 index 0000000..eff44eb --- /dev/null +++ b/tests/Data/FilterPrefixTest.php @@ -0,0 +1,50 @@ +', + $html, + 'The current value must render selected.', + ); + self::assertMatchesRegularExpression( + '/