diff --git a/src/Exception/Message.php b/src/Exception/Message.php index d562384..7bc89fc 100644 --- a/src/Exception/Message.php +++ b/src/Exception/Message.php @@ -202,6 +202,28 @@ enum Message: string */ case PANEL_TITLE_EMPTY = 'Debug panel title must not be empty.'; + /** + * Indicates that a registration entry declares a non-boolean `enabled` option. + * + * Format: "Debug registration '%s' option 'enabled' must be a boolean." + */ + case REGISTRATION_ENABLED_INVALID = 'Debug registration \'%s\' option \'enabled\' must be a boolean.'; + + /** + * Indicates that a registration entry is neither a class string nor an array declaring a `class` string. + * + * Format: "Debug registration '%s' must be a class string or an array declaring a 'class' string." + */ + case REGISTRATION_ENTRY_INVALID + = 'Debug registration \'%s\' must be a class string or an array declaring a \'class\' string.'; + + /** + * Indicates that a registration key differs from the ID the registered collector or panel declares. + * + * Format: "Debug %s registered as '%s' must match its ID '%s'." + */ + case REGISTRATION_ID_MISMATCH = 'Debug %s registered as \'%s\' must match its ID \'%s\'.'; + /** * Indicates that a route definition field does not satisfy its expected shape. * diff --git a/src/Registration/EntryParser.php b/src/Registration/EntryParser.php new file mode 100644 index 0000000..9074a5b --- /dev/null +++ b/src/Registration/EntryParser.php @@ -0,0 +1,99 @@ +getMessage($kind, $key, $id), + ); + } + } + + /** + * Returns the effective `enabled` flag of an entry. + * + * A `null` value counts as absent, as it does for {@see PanelOverride::fromArray()}. + * + * @param array $options Entry options, or a whole configuration array declaring `enabled`. + * @param string $id Configuration ID naming the entry in a failure. + * + * @throws InvalidArgumentException when `enabled` is neither a `bool` nor `null`. + * + * @return bool Declared flag, or `true` when the entry omits it. + */ + public static function enabled(array $options, string $id): bool + { + $enabled = $options['enabled'] ?? true; + + if (is_bool($enabled) === false) { + throw new InvalidArgumentException( + Message::REGISTRATION_ENABLED_INVALID->getMessage($id), + ); + } + + return $enabled; + } + + /** + * Splits one entry into the class to resolve and the options declared beside it. + * + * The class is not checked for existence, so a disabled entry naming an uninstalled optional package never + * reaches the autoloader. + * + * @param mixed $entry Class string, or an array declaring a `class` string plus options. + * @param string $id Configuration ID naming the entry in a failure. + * + * @throws InvalidArgumentException when the entry declares no class string. + * + * @return ParsedEntry Class and remaining options, `enabled` included. + */ + public static function parse(mixed $entry, string $id): ParsedEntry + { + if (is_string($entry)) { + return new ParsedEntry($entry); + } + + if (is_array($entry) === false || is_string($entry['class'] ?? null) === false) { + throw new InvalidArgumentException( + Message::REGISTRATION_ENTRY_INVALID->getMessage($id), + ); + } + + /** @var string $class */ + $class = $entry['class']; + + unset($entry['class']); + + return new ParsedEntry($class, $entry); + } +} diff --git a/src/Registration/ParsedEntry.php b/src/Registration/ParsedEntry.php new file mode 100644 index 0000000..9da40d6 --- /dev/null +++ b/src/Registration/ParsedEntry.php @@ -0,0 +1,21 @@ + $options Keys the entry declares beside `class`, in declaration order. + */ + public function __construct(public string $class, public array $options = []) {} +} diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php index e4d0e60..e45facb 100644 --- a/src/Storage/ExceptionSnapshot.php +++ b/src/Storage/ExceptionSnapshot.php @@ -12,6 +12,7 @@ use Throwable; use function array_map; +use function array_replace; use function is_int; use function is_string; @@ -256,7 +257,7 @@ public function getPrevious(): self|null public function getTrace(): array { return array_map( - static fn(array $frame): array => [...$frame, 'args' => $frame['args']->values()], + static fn(array $frame): array => array_replace($frame, ['args' => $frame['args']->values()]), $this->trace, ); } @@ -275,7 +276,7 @@ public function jsonSerialize(): array 'file' => $this->file, 'line' => $this->line, 'trace' => array_map( - static fn(array $frame): array => [...$frame, 'args' => $frame['args']->jsonSerialize()], + static fn(array $frame): array => array_replace($frame, ['args' => $frame['args']->jsonSerialize()]), $this->trace, ), 'toString' => $this->toString, diff --git a/src/View/Grid/FilterInput.php b/src/View/Grid/FilterInput.php new file mode 100644 index 0000000..a32ccc9 --- /dev/null +++ b/src/View/Grid/FilterInput.php @@ -0,0 +1,76 @@ + $filters Active filters of the group, keyed by attribute. + * @param array $options Selectable values mapped to the label shown for each of them. + * @param string $class Class list applied to the control. + * + * @return Select Rendered dropdown. + */ + public static function select( + string $prefix, + string $attribute, + string $label, + array $filters, + array $options, + string $class = 'yii-debug-select', + ): Select { + $select = Select::tag() + ->class($class) + ->addAriaAttribute('label', "Filter by {$label}") + ->name("{$prefix}[{$attribute}]") + ->value($filters[$attribute] ?? '') + ->option(Option::tag()->value('')->content('')); + + foreach ($options as $value => $optionLabel) { + $select = $select->option( + Option::tag() + ->value((string) $value) + ->content($optionLabel), + ); + } + + return $select; + } + + /** + * Returns the free-text filter box carrying the value the attribute is currently filtered by. + * + * @param string $prefix Filter group the attribute belongs to. + * @param string $attribute Attribute the control filters on. + * @param string $label Attribute name announced to assistive technology. + * @param array $filters Active filters of the group, keyed by attribute. + * @param string $class Class list applied to the control. + * + * @return InputText Rendered text box. + */ + public static function text( + string $prefix, + string $attribute, + string $label, + array $filters, + string $class = 'yii-debug-input', + ): InputText { + return InputText::tag() + ->class($class) + ->addAriaAttribute('label', "Filter by {$label}") + ->name("{$prefix}[{$attribute}]") + ->value($filters[$attribute] ?? ''); + } +} diff --git a/src/View/Grid/PanelHeading.php b/src/View/Grid/PanelHeading.php new file mode 100644 index 0000000..1ce7e55 --- /dev/null +++ b/src/View/Grid/PanelHeading.php @@ -0,0 +1,29 @@ +class('yii-debug-sr-only') + ->content($title) + ->render(); + } +} diff --git a/src/View/Grid/SortHeader.php b/src/View/Grid/SortHeader.php new file mode 100644 index 0000000..f91cb57 --- /dev/null +++ b/src/View/Grid/SortHeader.php @@ -0,0 +1,30 @@ + $attributes Header cell attributes announcing the active order, empty while the + * rows are ordered by another attribute. + */ + public function __construct(public string $link, public array $attributes = []) {} + + /** + * Returns the rendered sort link of the header cell. + * + * @return string Rendered link. + */ + public function __toString(): string + { + return $this->link; + } +} diff --git a/src/View/Grid/SortState.php b/src/View/Grid/SortState.php new file mode 100644 index 0000000..5d1417e --- /dev/null +++ b/src/View/Grid/SortState.php @@ -0,0 +1,160 @@ + $rows Rows to order. + * @param Closure(TRow, TRow): int $compare Ascending comparator for the active attribute. + * @param (Closure(TRow, TRow): int)|null $tieBreak Direction-independent comparator applied to rows the main + * comparator considers equal, or `null` to keep their relative input order. + * + * @return list Ordered rows. + */ + public function apply(array $rows, Closure $compare, Closure|null $tieBreak = null): array + { + $descending = $this->direction === 'desc'; + + usort( + $rows, + /** + * @param TRow $left + * @param TRow $right + */ + static function (mixed $left, mixed $right) use ($compare, $descending, $tieBreak): int { + $result = $compare($left, $right); + + if ($result !== 0) { + return $descending ? -$result : $result; + } + + return $tieBreak === null ? 0 : $tieBreak($left, $right); + }, + ); + + return $rows; + } + + /** + * Parses the query value, keeping it only when the attribute is sortable; otherwise applies the default. + * + * @param string|null $sort Raw `sort` query value, optionally prefixed with `-` to request a descending order. + * @param list $attributes Sortable attribute names. + * @param string $defaultAttribute Attribute used when the query value names no sortable attribute. + * @param 'asc'|'desc' $defaultDirection Direction paired with the default attribute. + * + * @return self Sort state for the visible page. + */ + public static function fromQuery( + string|null $sort, + array $attributes, + string $defaultAttribute, + string $defaultDirection = 'asc', + ): self { + $sort ??= ''; + + $direction = str_starts_with($sort, '-') ? 'desc' : 'asc'; + + $attribute = $direction === 'desc' ? substr($sort, 1) : $sort; + + return in_array($attribute, $attributes, true) + ? new self($attribute, $direction) + : new self($defaultAttribute, $defaultDirection); + } + + /** + * Builds the header cell of a sortable column, linking to the order the next click on it requests. + * + * @param string $attribute Attribute backing the header cell. + * @param string $label Visible column label. + * @param Closure(string): string $url Builds the panel URL requesting a `sort` query value. + * @param bool $descendingFirst Whether the first click on an inactive attribute requests a descending order. + * + * @return SortHeader Header cell announcing the active order. + */ + public function header(string $attribute, string $label, Closure $url, bool $descendingFirst = false): SortHeader + { + $link = A::tag() + ->href($url($this->next($attribute, $descendingFirst))) + ->content($label); + + if ($this->isActive($attribute) === false) { + return new SortHeader($link->render()); + } + + return new SortHeader( + $link->class($this->direction)->render(), + ['aria-sort' => $this->direction === 'asc' ? 'ascending' : 'descending'], + ); + } + + /** + * Builds the header-link factory of a panel grid, carrying the query parameters of the visible page. + * + * @param PanelRenderContext $context State of the debugger request being rendered. + * @param array $queryParams Query parameters the header links are built from. + * + * @return Closure(string): string Builds the panel URL requesting a `sort` query value. + */ + public static function panelUrl(PanelRenderContext $context, array $queryParams): Closure + { + return static fn(string $sort): string => $context->panelUrl( + queryParams: array_replace($queryParams, ['sort' => $sort]), + ); + } + + /** + * Determines whether the rows are currently ordered by the attribute. + * + * @param string $attribute Attribute backing a header cell. + * + * @return bool `true` when the rows are ordered by that attribute; `false` otherwise. + */ + private function isActive(string $attribute): bool + { + return $this->attribute === $attribute; + } + + /** + * Returns the `sort` query value that a header link for the attribute must produce. + * + * @param string $attribute Attribute backing the header link. + * @param bool $descendingFirst Whether the first click on an inactive attribute requests a descending order. + * + * @return string Value for the `sort` query parameter. + */ + private function next(string $attribute, bool $descendingFirst): string + { + if ($this->isActive($attribute)) { + return $this->direction === 'asc' ? "-{$attribute}" : $attribute; + } + + return $descendingFirst ? "-{$attribute}" : $attribute; + } +} diff --git a/src/View/Grid/SummaryChip.php b/src/View/Grid/SummaryChip.php new file mode 100644 index 0000000..c333d7d --- /dev/null +++ b/src/View/Grid/SummaryChip.php @@ -0,0 +1,40 @@ +html(Strong::tag() + ->content($value), $label); + } + + /** + * Renders the separator between summary chips. + * + * @return Span Rendered separator. + */ + public static function separator(): Span + { + return Span::tag() + ->class('yii-debug-grid-summary-sep') + ->content('·'); + } +} diff --git a/src/View/Sidebar/SidebarNavigation.php b/src/View/Sidebar/SidebarNavigation.php new file mode 100644 index 0000000..bdad794 --- /dev/null +++ b/src/View/Sidebar/SidebarNavigation.php @@ -0,0 +1,59 @@ +`). Empty string falls back to the newest captured request. + */ + public string $cursorInitTag = '', + /** + * Newest request link target (top of list); empty string renders an empty `href`. + */ + public string $newestUrl = '', + /** + * Oldest request link target (bottom of list); empty string renders an empty `href`. + */ + public string $oldestUrl = '', + /** + * Newer request link target; empty string when the snapshot is already on the newest row. + */ + public string $newerUrl = '', + /** + * Older request link target; empty string when the snapshot is already on the oldest row. + */ + public string $olderUrl = '', + /** + * `true` when the snapshot is the newest captured request; disables the Newest button. + */ + public bool $isNewest = true, + /** + * `true` when the snapshot is the oldest captured request; disables the Oldest button. + */ + public bool $isOldest = true, + /** + * `true` when there is a newer request available; controls the Newer button. + */ + public bool $hasNewer = false, + /** + * `true` when there is an older request available; controls the Older button. + */ + public bool $hasOlder = false, + ) {} +} diff --git a/src/View/Sidebar/SidebarRenderer.php b/src/View/Sidebar/SidebarRenderer.php index b56af9b..a92bac6 100644 --- a/src/View/Sidebar/SidebarRenderer.php +++ b/src/View/Sidebar/SidebarRenderer.php @@ -73,7 +73,7 @@ private static function renderHistoryCard(SidebarSnapshot $snapshot): Div ->content($snapshot->path), ), self::renderMetaStrip($snapshot), - self::renderNavRow($snapshot), + self::renderNavRow($snapshot->navigation), ); } @@ -140,27 +140,27 @@ private static function renderNavGroup(string $label, array $items): Section * directly. An entry links to its target capture, except in cursor mode and when the target does not exist, where * it renders as a button the history script drives. * - * @param SidebarSnapshot $snapshot Capture the navigator moves away from. + * @param SidebarNavigation $navigation Navigator row of the capture the card describes. * * @return string Rendered navigator row. */ - private static function renderNavRow(SidebarSnapshot $snapshot): string + private static function renderNavRow(SidebarNavigation $navigation): string { $entries = [ [ - 'newest', $snapshot->isNewest, $snapshot->newestUrl, ViewMessage::NEWEST_REQUEST->value, + 'newest', $navigation->isNewest, $navigation->newestUrl, ViewMessage::NEWEST_REQUEST->value, ViewMessage::NEWEST_CAPTURED_REQUEST->value, 'chevrons-up', ], [ - 'newer', $snapshot->hasNewer === false, $snapshot->newerUrl, 'Newer request', + 'newer', $navigation->hasNewer === false, $navigation->newerUrl, 'Newer request', 'Newer captured request', 'chevron-up', ], [ - 'older', $snapshot->hasOlder === false, $snapshot->olderUrl, 'Older request', + 'older', $navigation->hasOlder === false, $navigation->olderUrl, 'Older request', 'Older captured request', 'chevron-down', ], [ - 'oldest', $snapshot->isOldest, $snapshot->oldestUrl, 'Oldest request', + 'oldest', $navigation->isOldest, $navigation->oldestUrl, 'Oldest request', 'Oldest captured request', 'chevrons-down', ], ]; @@ -168,7 +168,7 @@ private static function renderNavRow(SidebarSnapshot $snapshot): string $items = []; foreach ($entries as [$target, $isDisabled, $url, $title, $ariaLabel, $icon]) { - $asButton = $snapshot->isCursor || $isDisabled; + $asButton = $navigation->isCursor || $isDisabled; $attributes = $asButton ? ['type' => 'button', 'title' => $title] : ['title' => $title]; @@ -178,7 +178,7 @@ private static function renderNavRow(SidebarSnapshot $snapshot): string $attributes['aria-label'] = $ariaLabel; - if ($asButton && $snapshot->isCursor) { + if ($asButton && $navigation->isCursor) { $attributes['data-yii-debug-cursor'] = $target; } @@ -254,11 +254,11 @@ private static function renderSnapshotSection(SidebarSnapshot $snapshot): Sectio ->class('yii-debug-side-section yii-debug-request-nav') ->addAriaAttribute('label', $snapshot->ariaLabel); - if ($snapshot->isCursor) { + if ($snapshot->navigation->isCursor) { $section = $section->addDataAttribute('yii-debug-history-cursor', true); - if ($snapshot->cursorInitTag !== '') { - $section = $section->addDataAttribute('yii-debug-cursor-init', $snapshot->cursorInitTag); + if ($snapshot->navigation->cursorInitTag !== '') { + $section = $section->addDataAttribute('yii-debug-cursor-init', $snapshot->navigation->cursorInitTag); } } diff --git a/src/View/Sidebar/SidebarSnapshot.php b/src/View/Sidebar/SidebarSnapshot.php index 87affc5..ad96a8a 100644 --- a/src/View/Sidebar/SidebarSnapshot.php +++ b/src/View/Sidebar/SidebarSnapshot.php @@ -4,9 +4,17 @@ namespace PHPForge\Debug\View\Sidebar; +use PHPForge\Debug\Helper\{Text, Vocabulary}; +use PHPForge\Debug\Storage\RequestSummary; + +use function date; + /** * Typed view-model for the snapshot card surfaced at the top of the debugger sidebar ('CURRENT REQUEST' / * 'NEWEST REQUEST'). + * + * The request identity is derived from the capture summary, so every host renders the method, URL, status, and time + * of a capture the same way; the navigator row comes from the host through {@see SidebarNavigation}. */ final readonly class SidebarSnapshot { @@ -36,7 +44,7 @@ private function __construct( */ public int $statusCode, /** - * Status-pill CSS modifier ('success' / 'muted' / 'warning' / 'danger') derived from `$statusCode`. + * Status-pill CSS modifier ('2xx' / '3xx' / '4xx' / '5xx' / 'none') derived from `$statusCode`. */ public string $statusVariant, /** @@ -48,222 +56,40 @@ private function __construct( */ public bool $isAjax, /** - * `true` when the sidebar is rendered for the index page and the navigator buttons act as a grid cursor. - */ - public bool $isCursor, - /** - * Optional tag the cursor JS should land on when the sidebar arrives from a panel view's History link - * (`?cursor=`). Empty string falls back to the newest captured request. - */ - public string $cursorInitTag, - /** - * Newest request link target (top of list); empty string renders an empty `href`. - */ - public string $newestUrl, - /** - * Oldest request link target (bottom of list); empty string renders an empty `href`. - */ - public string $oldestUrl, - /** - * Newer request link target; empty string when the snapshot is already on the newest row. - */ - public string $newerUrl, - /** - * Older request link target; empty string when the snapshot is already on the oldest row. - */ - public string $olderUrl, - /** - * `true` when the snapshot is the newest captured request; disables the Newest button. + * Navigator row moving away from this capture. */ - public bool $isNewest, - /** - * `true` when the snapshot is the oldest captured request; disables the Oldest button. - */ - public bool $isOldest, - /** - * `true` when there is a newer request available; controls the Newer button. - */ - public bool $hasNewer, - /** - * `true` when there is an older request available; controls the Older button. - */ - public bool $hasOlder, + public SidebarNavigation $navigation, ) {} /** - * Creates an empty sidebar snapshot ready for immutable enrichment. - */ - public static function create(string $title, string|null $ariaLabel = null): self - { - return new self( - title: $title, - ariaLabel: $ariaLabel ?? $title, - method: '', - path: '', - fullUrl: '', - statusCode: 0, - statusVariant: 'muted', - time: '', - isAjax: false, - isCursor: false, - cursorInitTag: '', - newestUrl: '', - oldestUrl: '', - newerUrl: '', - olderUrl: '', - isNewest: true, - isOldest: true, - hasNewer: false, - hasOlder: false, - ); - } - - /** - * Returns a copy with history-cursor behavior. - */ - public function withCursor(bool $isCursor = true, string $cursorInitTag = ''): self - { - return new self( - title: $this->title, - ariaLabel: $this->ariaLabel, - method: $this->method, - path: $this->path, - fullUrl: $this->fullUrl, - statusCode: $this->statusCode, - statusVariant: $this->statusVariant, - time: $this->time, - isAjax: $this->isAjax, - isCursor: $isCursor, - cursorInitTag: $cursorInitTag, - newestUrl: $this->newestUrl, - oldestUrl: $this->oldestUrl, - newerUrl: $this->newerUrl, - olderUrl: $this->olderUrl, - isNewest: $this->isNewest, - isOldest: $this->isOldest, - hasNewer: $this->hasNewer, - hasOlder: $this->hasOlder, - ); - } - - /** - * Returns a copy with navigator availability state. + * Creates the snapshot card of one capture. + * + * @param RequestSummary $summary Summary of the capture the card describes. + * @param SidebarNavigation $navigation Navigator row the host computed for that capture. + * @param string $title Section heading shown above the card. + * @param string|null $ariaLabel Accessible name of the section, or `null` to reuse the heading. + * + * @return self Snapshot card view-model. */ - public function withNavigationState(bool $isNewest, bool $isOldest, bool $hasNewer, bool $hasOlder): self - { - return new self( - title: $this->title, - ariaLabel: $this->ariaLabel, - method: $this->method, - path: $this->path, - fullUrl: $this->fullUrl, - statusCode: $this->statusCode, - statusVariant: $this->statusVariant, - time: $this->time, - isAjax: $this->isAjax, - isCursor: $this->isCursor, - cursorInitTag: $this->cursorInitTag, - newestUrl: $this->newestUrl, - oldestUrl: $this->oldestUrl, - newerUrl: $this->newerUrl, - olderUrl: $this->olderUrl, - isNewest: $isNewest, - isOldest: $isOldest, - hasNewer: $hasNewer, - hasOlder: $hasOlder, - ); - } - - /** - * Returns a copy with navigator URLs. - */ - public function withNavigationUrls( - string $newestUrl, - string $oldestUrl, - string $newerUrl, - string $olderUrl, + public static function fromSummary( + RequestSummary $summary, + SidebarNavigation $navigation, + string $title, + string|null $ariaLabel = null, ): self { - return new self( - title: $this->title, - ariaLabel: $this->ariaLabel, - method: $this->method, - path: $this->path, - fullUrl: $this->fullUrl, - statusCode: $this->statusCode, - statusVariant: $this->statusVariant, - time: $this->time, - isAjax: $this->isAjax, - isCursor: $this->isCursor, - cursorInitTag: $this->cursorInitTag, - newestUrl: $newestUrl, - oldestUrl: $oldestUrl, - newerUrl: $newerUrl, - olderUrl: $olderUrl, - isNewest: $this->isNewest, - isOldest: $this->isOldest, - hasNewer: $this->hasNewer, - hasOlder: $this->hasOlder, - ); - } + $unix = (int) $summary->time; - /** - * Returns a copy with request identity and URL data. - */ - public function withRequest( - string $method, - string $path, - string $fullUrl, - string $time = '', - bool $isAjax = false, - ): self { return new self( - title: $this->title, - ariaLabel: $this->ariaLabel, - method: $method, - path: $path, - fullUrl: $fullUrl, - statusCode: $this->statusCode, - statusVariant: $this->statusVariant, - time: $time, - isAjax: $isAjax, - isCursor: $this->isCursor, - cursorInitTag: $this->cursorInitTag, - newestUrl: $this->newestUrl, - oldestUrl: $this->oldestUrl, - newerUrl: $this->newerUrl, - olderUrl: $this->olderUrl, - isNewest: $this->isNewest, - isOldest: $this->isOldest, - hasNewer: $this->hasNewer, - hasOlder: $this->hasOlder, - ); - } - - /** - * Returns a copy with response metadata. - */ - public function withResponse(int $statusCode, string $statusVariant): self - { - return new self( - title: $this->title, - ariaLabel: $this->ariaLabel, - method: $this->method, - path: $this->path, - fullUrl: $this->fullUrl, - statusCode: $statusCode, - statusVariant: $statusVariant, - time: $this->time, - isAjax: $this->isAjax, - isCursor: $this->isCursor, - cursorInitTag: $this->cursorInitTag, - newestUrl: $this->newestUrl, - oldestUrl: $this->oldestUrl, - newerUrl: $this->newerUrl, - olderUrl: $this->olderUrl, - isNewest: $this->isNewest, - isOldest: $this->isOldest, - hasNewer: $this->hasNewer, - hasOlder: $this->hasOlder, + title: $title, + ariaLabel: $ariaLabel ?? $title, + method: $summary->method, + path: Text::urlToPath($summary->url), + fullUrl: $summary->url, + statusCode: $summary->statusCode, + statusVariant: Vocabulary::statusClass($summary->statusCode), + time: $unix > 0 ? date('H:i:s', $unix) : '', + isAjax: $summary->ajax, + navigation: $navigation, ); } } diff --git a/tests/Panel/Event/EventInspectionTest.php b/tests/Panel/Event/EventInspectionTest.php index 72d7c2c..286eb7d 100644 --- a/tests/Panel/Event/EventInspectionTest.php +++ b/tests/Panel/Event/EventInspectionTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\{DataProviderExternal, Group}; use PHPUnit\Framework\TestCase; +use function array_replace; use function strlen; /** @@ -242,7 +243,7 @@ public function testFluentConfigurationPreservesEarlierCopiesAndSerializedFields 'Later options must not change the context-only copy.', ); self::assertSame( - [...$expected, 'pairId' => null, 'phase' => '', 'depth' => 0, 'clock' => null], + array_replace($expected, ['pairId' => null, 'phase' => '', 'depth' => 0, 'clock' => null]), $withTrace->jsonSerialize(), 'Lifecycle options must not change earlier copies.', ); diff --git a/tests/Registration/EntryParserTest.php b/tests/Registration/EntryParserTest.php new file mode 100644 index 0000000..43d5a93 --- /dev/null +++ b/tests/Registration/EntryParserTest.php @@ -0,0 +1,135 @@ +expectNotToPerformAssertions(); + } + + public function testAssertKeyMatchesIdAcceptsTheDeclaredId(): void + { + EntryParser::assertKeyMatchesId('cache', 'cache', 'panel'); + + $this->expectNotToPerformAssertions(); + } + + public function testEnabledDefaultsToTrueWhenTheOptionIsAbsent(): void + { + self::assertTrue( + EntryParser::enabled(['class' => 'Acme\\CacheCollector'], 'cache'), + 'An absent flag must enable the entry.', + ); + } + + public function testEnabledReturnsTheDeclaredFlag(): void + { + self::assertFalse( + EntryParser::enabled(['enabled' => false], 'cache'), + "Declared 'false' must be returned.", + ); + self::assertTrue( + EntryParser::enabled(['enabled' => true], 'cache'), + "Declared 'true' must be returned.", + ); + } + + public function testEnabledTreatsNullAsAbsent(): void + { + self::assertTrue( + EntryParser::enabled(['enabled' => null], 'cache'), + "'null' must count as an absent flag.", + ); + } + + public function testParseKeepsEveryOptionBesideTheClass(): void + { + $entry = EntryParser::parse( + ['class' => 'Acme\\CachePanel', 'enabled' => false, 'title' => 'Cache'], + 'cache', + ); + + self::assertSame( + 'Acme\\CachePanel', + $entry->class, + "Class must be read from the 'class' key.", + ); + self::assertSame( + ['enabled' => false, 'title' => 'Cache'], + $entry->options, + "Options must exclude 'class' and keep 'enabled'.", + ); + } + + public function testParseReadsAClassStringWithoutOptions(): void + { + $entry = EntryParser::parse('Acme\\CacheCollector', 'cache'); + + self::assertSame( + 'Acme\\CacheCollector', + $entry->class, + 'Class string must be carried verbatim.', + ); + self::assertSame( + [], + $entry->options, + 'A class string declares no option.', + ); + } + + public function testThrowInvalidArgumentExceptionForArrayEntryWithoutAClassString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::REGISTRATION_ENTRY_INVALID->getMessage('cache'), + ); + + EntryParser::parse(['class' => 42, 'enabled' => true], 'cache'); + } + + public function testThrowInvalidArgumentExceptionForEntryThatIsNeitherStringNorArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::REGISTRATION_ENTRY_INVALID->getMessage('cache'), + ); + + EntryParser::parse(42, 'cache'); + } + + public function testThrowInvalidArgumentExceptionForKeyNotMatchingTheDeclaredId(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::REGISTRATION_ID_MISMATCH->getMessage('panel', 'wrong', 'vite'), + ); + + EntryParser::assertKeyMatchesId('wrong', 'vite', 'panel'); + } + + public function testThrowInvalidArgumentExceptionForNonBooleanEnabledFlag(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + Message::REGISTRATION_ENABLED_INVALID->getMessage('cache'), + ); + + EntryParser::enabled(['enabled' => 'yes'], 'cache'); + } +} diff --git a/tests/View/Grid/FilterInputTest.php b/tests/View/Grid/FilterInputTest.php new file mode 100644 index 0000000..81c7cfd --- /dev/null +++ b/tests/View/Grid/FilterInputTest.php @@ -0,0 +1,53 @@ + + + + + + HTML, + FilterInput::select('Log', 'level', 'Level', ['level' => 'error'], ['error' => 'Error', 3 => 'Info']) + ->render(), + 'Dropdown must carry the group name, the label, and the active value.', + ); + } + + public function testTextCarriesTheActiveValueAndTheRequestedClass(): void + { + self::assertSame( + '', + FilterInput::text('Log', 'category', 'Category', ['category' => 'app'])->render(), + 'Text input must default to the shared class and keep the value.', + ); + self::assertSame( + '', + FilterInput::text('Log', 'category', 'Category', [], 'yii-debug-input-sm')->render(), + 'An inactive filter must render no value.', + ); + } +} diff --git a/tests/View/Grid/PanelHeadingTest.php b/tests/View/Grid/PanelHeadingTest.php new file mode 100644 index 0000000..f4a25e9 --- /dev/null +++ b/tests/View/Grid/PanelHeadingTest.php @@ -0,0 +1,31 @@ + + Log Messages + + HTML, + PanelHeading::render(PanelTitle::LOG_MESSAGES), + "Heading must be an 'h1' with the screen-reader-only class.", + ); + } +} diff --git a/tests/View/Grid/SortStateTest.php b/tests/View/Grid/SortStateTest.php new file mode 100644 index 0000000..bcf9d06 --- /dev/null +++ b/tests/View/Grid/SortStateTest.php @@ -0,0 +1,184 @@ + 1, 'id' => 'b'], ['n' => 2, 'id' => 'c'], ['n' => 1, 'id' => 'a']]; + + self::assertSame( + [['n' => 2, 'id' => 'c'], ['n' => 1, 'id' => 'a'], ['n' => 1, 'id' => 'b']], + SortState::fromQuery('-n', ['n'], 'n')->apply( + $rows, + static fn(array $left, array $right): int => $left['n'] <=> $right['n'], + static fn(array $left, array $right): int => $left['id'] <=> $right['id'], + ), + 'Descending order must not invert the tie-break.', + ); + } + + public function testApplyKeepsTheInputOrderOfEqualRowsWithoutTieBreak(): void + { + // Seventeen rows exceed the insertion-sort threshold of `usort()`, where a non-zero tie result reorders them. + $rows = []; + + foreach (range(0, 16) as $id) { + $rows[] = ['n' => 1, 'id' => $id]; + } + + self::assertSame( + $rows, + SortState::fromQuery('-n', ['n'], 'n')->apply( + $rows, + static fn(array $left, array $right): int => $left['n'] <=> $right['n'], + ), + 'Equal rows must keep their relative order.', + ); + } + + public function testFromQueryFallsBackToTheDefaultForAnUnknownAttribute(): void + { + $state = SortState::fromQuery('-color', ['time', 'level'], 'time', 'desc'); + + self::assertSame( + 'time', + $state->attribute, + 'Unknown attribute must yield the default attribute.', + ); + self::assertSame( + 'desc', + $state->direction, + 'Default direction must be paired with the default attribute.', + ); + } + + public function testFromQueryFallsBackToTheDefaultWhenTheValueIsMissing(): void + { + $state = SortState::fromQuery(null, ['time'], 'time'); + + self::assertSame( + 'time', + $state->attribute, + 'Missing value must yield the default attribute.', + ); + self::assertSame( + 'asc', + $state->direction, + 'Omitted default direction must be ascending.', + ); + } + + public function testFromQueryReadsTheDescendingPrefix(): void + { + $state = SortState::fromQuery('-level', ['time', 'level'], 'time'); + + self::assertSame( + 'level', + $state->attribute, + 'Prefix must be stripped from the attribute.', + ); + self::assertSame( + 'desc', + $state->direction, + 'A `-` prefix must request a descending order.', + ); + } + + public function testHeaderAnnouncesTheActiveOrderAndLinksToTheReverseOne(): void + { + $url = static fn(string $sort): string => "/panel?sort={$sort}"; + + $descending = SortState::fromQuery('-time', ['time'], 'time')->header('time', 'Time', $url); + $ascending = SortState::fromQuery('time', ['time'], 'time')->header('time', 'Time', $url); + + self::assertSame( + 'Time', + (string) $descending, + 'Active descending column must link to the ascending order.', + ); + self::assertSame( + ['aria-sort' => 'descending'], + $descending->attributes, + "Active descending column must announce 'descending'.", + ); + self::assertSame( + 'Time', + $ascending->link, + 'Active ascending column must link to the descending order.', + ); + self::assertSame( + ['aria-sort' => 'ascending'], + $ascending->attributes, + "Active ascending column must announce 'ascending'.", + ); + } + + public function testHeaderOfAnInactiveColumnRequestsItsFirstOrderWithoutAnnouncingOne(): void + { + $state = SortState::fromQuery('time', ['time', 'level'], 'time'); + + $url = static fn(string $sort): string => "/panel?sort={$sort}"; + + $ascendingFirst = $state->header('level', 'Level', $url); + $descendingFirst = $state->header('level', 'Level', $url, true); + + self::assertSame( + 'Level', + (string) $ascendingFirst, + 'First click must request an ascending order by default.', + ); + self::assertSame( + [], + $ascendingFirst->attributes, + 'Inactive column must announce no order.', + ); + self::assertSame( + 'Level', + (string) $descendingFirst, + 'Descending-first column must request a descending order.', + ); + } + + public function testPanelUrlKeepsIntegerQueryKeys(): void + { + $context = new PanelRenderContext('request-1', 'log', [], 'light', new DebugUrlGeneratorFixture()); + + $url = SortState::panelUrl($context, [5 => 'x', 'page' => 2]); + + self::assertSame( + '/panel/request-1/log?5=x&page=2&sort=time', + $url('time'), + 'Integer key must not be renumbered.', + ); + } + + public function testPanelUrlMergesTheSortValueIntoTheVisibleQuery(): void + { + $context = new PanelRenderContext('request-1', 'log', ['page' => 3], 'light', new DebugUrlGeneratorFixture()); + + $url = SortState::panelUrl($context, ['Log' => ['level' => 'error'], 'sort' => 'time']); + + self::assertSame( + '/panel/request-1/log?Log%5Blevel%5D=error&sort=-time', + $url('-time'), + 'Sort value must replace the one of the visible page.', + ); + } +} diff --git a/tests/View/Grid/SummaryChipTest.php b/tests/View/Grid/SummaryChipTest.php new file mode 100644 index 0000000..4522d1b --- /dev/null +++ b/tests/View/Grid/SummaryChipTest.php @@ -0,0 +1,35 @@ +3 queries', + SummaryChip::render('3', ' queries')->render(), + 'Value must be strong and followed by the label.', + ); + } + + public function testSeparatorUsesTheSharedClass(): void + { + self::assertSame( + '·', + SummaryChip::separator()->render(), + 'Separator must be a middle dot with the shared class.', + ); + } +} diff --git a/tests/View/Sidebar/SidebarRendererTest.php b/tests/View/Sidebar/SidebarRendererTest.php index dcb889e..d85679a 100644 --- a/tests/View/Sidebar/SidebarRendererTest.php +++ b/tests/View/Sidebar/SidebarRendererTest.php @@ -4,10 +4,12 @@ namespace PHPForge\Debug\Tests\View\Sidebar; -use PHPForge\Debug\View\Sidebar\{SidebarNavItem, SidebarRenderer, SidebarSnapshot, SidebarView}; +use PHPForge\Debug\Tests\Support\RequestSummaryFixture; +use PHPForge\Debug\View\Sidebar\{SidebarNavItem, SidebarNavigation, SidebarRenderer, SidebarSnapshot, SidebarView}; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use function mktime; use function substr_count; /** @@ -252,7 +254,7 @@ public function testRenderHidesAjaxTagWhenNotAjax(): void public function testRenderHidesTimeChipWhenTimeEmpty(): void { - $view = new SidebarView(snapshot: $this->snapshot(time: ''), navItems: []); + $view = new SidebarView(snapshot: $this->snapshot(hasTime: false), navItems: []); self::assertMatchesRegularExpression( '/yii-debug-snapshot-time[^>]*hidden/', @@ -275,7 +277,7 @@ public function testRenderShowsDashWhenStatusCodeIsZero(): void
GET/index.php
- –12:34:56AJAX + –12:34:56AJAX