diff --git a/CHANGELOG.md b/CHANGELOG.md index 850854b..acec6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,3 +25,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix(ui): add keyboard-resizable drawers with Escape handling and focus restoration. - fix: harden packaging, privacy, lifecycle, snapshot recovery, dump and toolbar security, and accelerate value hydration. - refactor: simplify strict value hydration, collector cleanup reporting, sensitive-key lookup, and toolbar message validation without changing public contracts. +- test: enforce complete PHP line, method, and mutation coverage with exact HTML rendering assertions. diff --git a/src/Capture/CapturePolicy.php b/src/Capture/CapturePolicy.php index 8d51a01..06f76b2 100644 --- a/src/Capture/CapturePolicy.php +++ b/src/Capture/CapturePolicy.php @@ -60,7 +60,10 @@ public function isSensitiveKey(string $key): bool */ public function redact(#[SensitiveParameter] array $value): array { - return SensitiveDataRedactor::redact($value, $this->sensitiveKeys); + return SensitiveDataRedactor::redact( + $value, + $this->sensitiveKeys, + ); } /** diff --git a/src/Collector/CollectorCoordinator.php b/src/Collector/CollectorCoordinator.php index d68be60..9d430ff 100644 --- a/src/Collector/CollectorCoordinator.php +++ b/src/Collector/CollectorCoordinator.php @@ -51,12 +51,6 @@ public function __construct(iterable $collectors) /** * Captures every collector into one versioned request snapshot. * - * Usage example: - * - * ```php - * $snapshot = $coordinator->capture($summary); - * ``` - * * @param RequestSummary $summary Captured request metadata. * * @return DebugSnapshot Captured request envelope. @@ -78,18 +72,16 @@ public function capture(RequestSummary $summary): DebugSnapshot } } - return new DebugSnapshot($summary, $panels, $failures); + return new DebugSnapshot( + $summary, + $panels, + $failures, + ); } /** * Returns the collector registered under the given stable ID. * - * Usage example: - * - * ```php - * $collector = $coordinator->collector('app.orders'); - * ``` - * * @param string $id Collector ID. * * @return CollectorInterface|null Registered collector, or `null` when the ID is unknown. @@ -102,12 +94,6 @@ public function collector(string $id): CollectorInterface|null /** * Returns whether a collector is registered under the given stable ID. * - * Usage example: - * - * ```php - * $registered = $coordinator->hasCollector('app.orders'); - * ``` - * * @param string $id Collector ID. * * @return bool Whether the collector is registered. @@ -157,20 +143,10 @@ public function run(callable $operation, callable|null $cleanupFailureHandler = /** * Stops every collector once and propagates the first shutdown error after cleanup completes. * - * Usage example: - * - * ```php - * $coordinator->shutdown(); - * ``` - * * @throws Throwable When a collector cannot shut down. */ public function shutdown(): void { - if (!$this->started && $this->startedCollectors === []) { - return; - } - $this->started = false; $failure = null; @@ -193,12 +169,6 @@ public function shutdown(): void /** * Starts every registered collector once and rolls back affected collectors when startup fails. * - * Usage example: - * - * ```php - * $coordinator->startup(); - * ``` - * * @throws Throwable When a collector cannot start. */ public function startup(): void diff --git a/src/Collector/CollectorInterface.php b/src/Collector/CollectorInterface.php index 868d2c3..5d66e9e 100644 --- a/src/Collector/CollectorInterface.php +++ b/src/Collector/CollectorInterface.php @@ -14,12 +14,6 @@ interface CollectorInterface /** * Captures the current request data as a typed snapshot. * - * Usage example: - * - * ```php - * $snapshot = $collector->capture(); - * ``` - * * @return PanelSnapshot|null Captured payload or `null` when the collector has no data. */ public function capture(): PanelSnapshot|null; @@ -27,35 +21,17 @@ public function capture(): PanelSnapshot|null; /** * Returns the stable ID used as the persisted panel key. * - * Usage example: - * - * ```php - * $id = $collector->id(); - * ``` - * * @return string Stable collector ID. */ public function id(): string; /** * Stops collection and clears request-scoped state idempotently. - * - * Usage example: - * - * ```php - * $collector->shutdown(); - * ``` */ public function shutdown(): void; /** * Starts collection for the current request. - * - * Usage example: - * - * ```php - * $collector->startup(); - * ``` */ public function startup(): void; } diff --git a/src/Data/FilterEngine.php b/src/Data/FilterEngine.php index 5101b58..fda28ea 100644 --- a/src/Data/FilterEngine.php +++ b/src/Data/FilterEngine.php @@ -52,7 +52,7 @@ public function addCondition(string $attribute, mixed $rawValue, bool $partial = return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1], @@ -126,8 +126,6 @@ private function matches(array|object $row): bool return false; } - $candidate = (float) $candidate; - $matched = match ($operator) { '>' => $candidate > $expected, '<' => $candidate < $expected, diff --git a/src/Data/PageSize.php b/src/Data/PageSize.php index ae5565c..952fd69 100644 --- a/src/Data/PageSize.php +++ b/src/Data/PageSize.php @@ -29,7 +29,13 @@ final class PageSize /** * Selector options in display order; the literal `all` disables pagination. */ - public const array OPTIONS = ['10', '25', '50', '100', 'all']; + public const array OPTIONS = [ + '10', + '25', + '50', + '100', + 'all', + ]; /** * Returns the `per-page` selector state, canonicalizing `all` and falling back to the default. @@ -72,11 +78,6 @@ public static function resolve(string|null $raw, int $default = self::DEFAULT): /** * Renders the inline page-size selector shown in the grid summary header. * - * Usage example: - * ```php - * $html = \PHPForge\Debug\Data\PageSize::selectorHtml('50'); - * ``` - * * @param string $current Currently selected raw value (one of {@see OPTIONS} for a highlighted option). */ public static function selectorHtml(string $current): string diff --git a/src/Helper/Avatar.php b/src/Helper/Avatar.php index 45592f9..5f19ec6 100644 --- a/src/Helper/Avatar.php +++ b/src/Helper/Avatar.php @@ -10,10 +10,6 @@ /** * Derives stable, deterministic avatar colours from arbitrary identifying strings. - * - * Two debug-panel renderers (mail and queue) display a colored circle next to each item; both used to compute the hue - * with the same `abs(crc32(strtolower(...))) % 360` formula. This helper centralises that derivation so the colour - * stays consistent across renderers. */ final class Avatar { @@ -25,11 +21,6 @@ final class Avatar /** * Returns a stable hue (`0..359`) for the given seed, or {@see self::DEFAULT_HUE} when the seed is empty. * - * Usage example: - * ```php - * $hue = \PHPForge\Debug\Helper\Avatar::hueFor('Alice'); - * ``` - * * @param string $seed Identifying value used to derive the hue. * * @return int Hue in the `0..359` range. diff --git a/src/Helper/CellMore.php b/src/Helper/CellMore.php index 9a37e24..a21835a 100644 --- a/src/Helper/CellMore.php +++ b/src/Helper/CellMore.php @@ -12,10 +12,6 @@ /** * Wraps long grid-cell content in a collapsible clamp with an expand/collapse pill toggle. - * - * The body collapses to a few lines through a CSS max-height clamp — the markup is never truncated server side, so - * any cell payload (plain text, highlighted SQL, trace lists) stays intact. The `debug.min.js` `cell-more` delegation - * flips the `is-open` state and swaps the toggle label. */ final class CellMore { @@ -37,11 +33,6 @@ final class CellMore * The decision reads the raw source rather than the rendered markup, so highlighting or trace lists never tip a * short value over the threshold. * - * Usage example: - * ```php - * \PHPForge\Debug\Helper\CellMore::clamp($highlightedSql, $row->query); - * ``` - * * @param string $content Rendered cell HTML. * @param string $source Raw payload the content was rendered from. * @@ -55,11 +46,6 @@ public static function clamp(string $content, string $source): string /** * Wraps the rendered cell content in the collapsible clamp container. * - * Usage example: - * ```php - * \PHPForge\Debug\Helper\CellMore::wrap($renderedCellHtml); - * ``` - * * @param string $content Rendered cell HTML to clamp; emitted verbatim inside the body container. * * @return string Collapsible cell markup. diff --git a/src/Helper/Coerce.php b/src/Helper/Coerce.php index e00d284..5561cea 100644 --- a/src/Helper/Coerce.php +++ b/src/Helper/Coerce.php @@ -13,21 +13,12 @@ /** * Narrows arbitrary mixed payloads into the typed scalars debug-panel renderers expect. - * - * Framework callbacks, logger entries, request parameters, and renderer rows remain mixed even though persisted - * snapshots use strict JSON DTOs. This helper is limited to those external runtime boundaries; snapshot hydration is - * handled by {@see \PHPForge\Debug\Storage\Payload} without scalar coercion. */ final class Coerce { /** * Returns a numeric value as a float or the supplied default. * - * Usage example: - * ```php - * $duration = \PHPForge\Debug\Helper\Coerce::float($payload['duration'] ?? null, 0.0); - * ``` - * * @param mixed $value Value to narrow. * @param float $default Value returned for non-numeric input. * @@ -41,11 +32,6 @@ public static function float(mixed $value, float $default = 0.0): float /** * Returns the value as a float when it is numeric, `null` otherwise. * - * Usage example: - * ```php - * $duration = \PHPForge\Debug\Helper\Coerce::floatOrNull($payload['duration'] ?? null); - * ``` - * * @param mixed $value Value to narrow. * * @return float|null Numeric value or `null`. @@ -58,11 +44,6 @@ public static function floatOrNull(mixed $value): float|null /** * Returns a numeric value as an int or the supplied default. * - * Usage example: - * ```php - * $count = \PHPForge\Debug\Helper\Coerce::int($payload['count'] ?? null); - * ``` - * * @param mixed $value Value to narrow. * @param int $default Value returned for non-numeric input. * @@ -76,11 +57,6 @@ public static function int(mixed $value, int $default = 0): int /** * Returns the value as an int when it is an integer or numeric, `null` otherwise. * - * Usage example: - * ```php - * $statusCode = \PHPForge\Debug\Helper\Coerce::intOrNull($payload['statusCode'] ?? null); - * ``` - * * @param mixed $value Value to narrow. * * @return int|null Numeric value or `null`. @@ -93,11 +69,6 @@ public static function intOrNull(mixed $value): int|null /** * Returns a string value or the supplied default. * - * Usage example: - * ```php - * $category = \PHPForge\Debug\Helper\Coerce::string($payload['category'] ?? null, 'application'); - * ``` - * * @param mixed $value Value to narrow. * @param string $default Value returned for non-string input. * @@ -114,11 +85,6 @@ public static function string(mixed $value, string $default = ''): string * Narrows a mixed/`array` snapshot down to the `array` shape downstream view-model * normalizers expect. * - * Usage example: - * ```php - * $data = \PHPForge\Debug\Helper\Coerce::stringKeyedArray(['name' => 'debug', 0 => 'ignored']); - * ``` - * * @param array $data Source array with arbitrary keys. * * @return array Entries whose key was already a string, in original order. @@ -139,11 +105,6 @@ public static function stringKeyedArray(array $data): array /** * Returns only the string entries of a raw list, preserving order. * - * Usage example: - * ```php - * $categories = \PHPForge\Debug\Helper\Coerce::stringList(['application', 42, 'database']); - * ``` - * * @param mixed $values Raw list, typically a user-configured category list. * * @return list String entries in original order, possibly empty. @@ -168,11 +129,6 @@ public static function stringList(mixed $values): array /** * Returns the value as a string when it is scalar or {@see Stringable}, `null` otherwise. * - * Usage example: - * ```php - * $label = \PHPForge\Debug\Helper\Coerce::stringOrNull($payload['label'] ?? null); - * ``` - * * @param mixed $value Value to narrow. * * @return string|null String representation or `null`. @@ -190,13 +146,6 @@ public static function stringOrNull(mixed $value): string|null * Narrows a raw trace value (as captured by Yii's logger) into the `list>` shape every panel * renderer consumes. * - * Each frame keeps only its string-keyed entries; non-array frames are dropped. - * - * Usage example: - * ```php - * $frames = \PHPForge\Debug\Helper\Coerce::traceFrames($payload['trace'] ?? null); - * ``` - * * @param mixed $value Raw trace payload. * * @return list> Trace frames normalized to string-keyed maps. diff --git a/src/Helper/Disclosure.php b/src/Helper/Disclosure.php index b43105f..eac20d1 100644 --- a/src/Helper/Disclosure.php +++ b/src/Helper/Disclosure.php @@ -10,10 +10,6 @@ /** * Renders the shared collapsible section: a titled `` with an expand/collapse hint over a `
` body. - * - * Both wordings ship in the markup and CSS reveals the one matching the `open` state, so the affordance never invites - * a click it cannot honour and no script is involved. The `` element already announces the state through - * `aria-expanded`, so the hint stays out of the accessibility tree. */ final class Disclosure { @@ -23,11 +19,6 @@ final class Disclosure * Exposed on its own so sections that build their own `` (the phpinfo Overview blocks) still share one * wording and one behaviour. * - * Usage example: - * ```php - * $hint = \PHPForge\Debug\Helper\Disclosure::hint(); - * ``` - * * @return Span Expand/collapse hint element. */ public static function hint(): Span @@ -48,11 +39,6 @@ public static function hint(): Span /** * Renders a titled collapsible section. * - * Usage example: - * ```php - * $section = \PHPForge\Debug\Helper\Disclosure::render('Raw payload', $preBlock); - * ``` - * * @param string $title Section heading shown in the summary. * @param string $body Rendered HTML revealed when the section expands. * diff --git a/src/Helper/Dump.php b/src/Helper/Dump.php index 30454a8..1f4d5d7 100644 --- a/src/Helper/Dump.php +++ b/src/Helper/Dump.php @@ -24,12 +24,6 @@ final class Dump * Renders a value as a display string: quoted strings, bare scalars, and 4-space-indented arrays without * trailing commas. * - * Usage example: - * - * ```php - * $text = \PHPForge\Debug\Helper\Dump::asString(['a' => 1]); - * ``` - * * @param mixed $value JSON-safe value to render. * @param int $depth Maximum nesting level rendered before collapsing to `[...]`. * @@ -44,12 +38,6 @@ public static function asString(mixed $value, int $depth = 10): string * Renders a value as a parsable PHP expression: `var_export()` scalars and short-syntax arrays with trailing * commas, omitting sequential integer keys. * - * Usage example: - * - * ```php - * $code = \PHPForge\Debug\Helper\Dump::export(['a', 'b']); - * ``` - * * @param mixed $value JSON-safe value to render. * * @return string Parsable PHP expression. diff --git a/src/Helper/EmptyState.php b/src/Helper/EmptyState.php index 54f8fc5..c83d033 100644 --- a/src/Helper/EmptyState.php +++ b/src/Helper/EmptyState.php @@ -10,9 +10,6 @@ /** * Renders the contextual empty-state card shown when a panel captured no data for the request. - * - * Every panel shares the same `Div.yii-debug-empty-state` container and `

` headline; the caller supplies the - * explanatory body elements (paragraphs, code snippets), keeping the copy local to each view. */ final class EmptyState { @@ -21,16 +18,6 @@ final class EmptyState * * Body values are trusted markup assembled by debug adapters; callers must encode untrusted data before passing it. * - * Usage example: - * ```php - * use UIAwesome\Html\Flow\P; - * - * \PHPForge\Debug\Helper\EmptyState::card( - * 'No variables dumped in this request', - * P::tag()->content('To populate this view, dump values with Yii::debug().'), - * ); - * ``` - * * @param string $headline Card headline describing the empty capture. * @param string|Stringable ...$body Trusted explanatory body markup rendered after the headline. * @@ -40,7 +27,10 @@ public static function card(string $headline, string|Stringable ...$body): strin { return Div::tag() ->class('yii-debug-empty-state') - ->html(H2::tag()->content($headline), ...$body) + ->html( + H2::tag()->content($headline), + ...$body, + ) ->render(); } } diff --git a/src/Helper/Format.php b/src/Helper/Format.php index 124444d..e9da7e8 100644 --- a/src/Helper/Format.php +++ b/src/Helper/Format.php @@ -17,11 +17,6 @@ final class Format /** * Returns a `N.NN MB` string for the given byte count, rounded to the requested precision. * - * Usage example: - * ```php - * $memory = \PHPForge\Debug\Helper\Format::bytesToMb(2_097_152); - * ``` - * * @param float|int $bytes Byte count to format. * @param int $precision Number of decimal places. * @@ -35,11 +30,6 @@ public static function bytesToMb(float|int $bytes, int $precision = 2): string /** * Returns a CSS percentage (`42%`, `33.333%`) with at most three decimals and trailing zeros trimmed. * - * Usage example: - * ```php - * $width = \PHPForge\Debug\Helper\Format::cssPercent(33.3333); - * ``` - * * @param float $value Percentage value to format. * * @return string CSS percentage. diff --git a/src/Helper/Fqcn.php b/src/Helper/Fqcn.php index a24c0f5..3799bcb 100644 --- a/src/Helper/Fqcn.php +++ b/src/Helper/Fqcn.php @@ -11,9 +11,6 @@ /** * Splits a fully-qualified class name into its short name and namespace prefix. - * - * Multiple renderers (asset, event, log, profile, queue) display the short class name next to a muted namespace prefix; - * this helper keeps every view aligned on the same splitting rules and on the shared two-tone label markup. */ final class Fqcn { @@ -21,11 +18,6 @@ final class Fqcn * Returns the namespace prefix (everything before the last `\`, without trailing separator), or `''` when none is * present. * - * Usage example: - * ```php - * $namespace = \PHPForge\Debug\Helper\Fqcn::namespacePart('App\\Service\\Mailer'); - * ``` - * * @param string $fqcn Fully-qualified class name. * * @return string Namespace prefix or `''`. @@ -47,14 +39,6 @@ public static function namespacePart(string $fqcn): string * values without a namespace render the bold segment only, and `''` collapses to an em dash. A `` between * the two segments marks the namespace boundary as the preferred line-break opportunity. * - * Usage example: - * ```php - * \PHPForge\Debug\Helper\Fqcn::renderLabel('yii\db\Command::query'); - * // - * // yii\db\Command::query - * // - * ``` - * * @param string $value Fully-qualified class name, `FQCN::method` pair, or plain category string. * * @return string Two-tone label markup or an em dash for an empty value. @@ -84,11 +68,6 @@ public static function renderLabel(string $value): string /** * Returns the segment after the last `\` separator, or the full `$fqcn` when no separator is present. * - * Usage example: - * ```php - * $name = \PHPForge\Debug\Helper\Fqcn::shortName('App\\Service\\Mailer'); - * ``` - * * @param string $fqcn Fully-qualified class name. * * @return string Short class name. diff --git a/src/Helper/Gauge.php b/src/Helper/Gauge.php index 7bf0e99..a6268f4 100644 --- a/src/Helper/Gauge.php +++ b/src/Helper/Gauge.php @@ -11,22 +11,12 @@ /** * Renders the inline micro-gauge rail behind numeric readouts (History Duration/Memory, Profiling Duration). - * - * The rail length is a percentage of the capture maximum, published as the `--yii-debug-gauge` custom property and - * drawn entirely in CSS — no JavaScript involved. */ final class Gauge { /** * Wraps a formatted readout in a micro-gauge scaled to `current / max`, clamped to `0%`..`100%`. * - * Returns the readout untouched when `max` is not positive (no scale to draw against). - * - * Usage example: - * ```php - * $gauge = \PHPForge\Debug\Helper\Gauge::render('125 ms', 0.5, 1.0); - * ``` - * * @param string $value Pre-formatted readout text (for example `125 ms`). * @param float $current Row value in the same unit as `$max`. * @param float $max Capture maximum the rail is scaled against. @@ -45,8 +35,12 @@ public static function render(string $value, float $current, float $max): string ->class('yii-debug-gauge') ->style(['--yii-debug-gauge' => Format::cssPercent($percent)]) ->html( - Span::tag()->class('yii-debug-gauge-value')->content($value), - Span::tag()->class('yii-debug-gauge-bar')->addAttribute('aria-hidden', 'true'), + Span::tag() + ->class('yii-debug-gauge-value') + ->content($value), + Span::tag() + ->class('yii-debug-gauge-bar') + ->addAttribute('aria-hidden', 'true'), ) ->render(); } diff --git a/src/Helper/Icon.php b/src/Helper/Icon.php index 2f896d9..03fa777 100644 --- a/src/Helper/Icon.php +++ b/src/Helper/Icon.php @@ -12,9 +12,6 @@ /** * Renders SVG icons bundled with the debug extension via {@see Svg::tag()}. - * - * Icons live in the framework-neutral core asset library and are looked up by name (without extension). Results are - * cached in-memory for the request, so repeated lookups do not re-read the file or re-run sanitization. */ final class Icon { @@ -26,11 +23,6 @@ final class Icon /** * Returns the rendered SVG markup for the given icon name, or an empty string when the file does not exist. * - * Usage example: - * ```php - * $icon = \PHPForge\Debug\Helper\Icon::render('request'); - * ``` - * * @param string $name Icon basename without the `.svg` extension (for example, `chevron-down`). * * @return string Sanitized SVG markup, or `''` when the source file is missing. @@ -51,6 +43,8 @@ public static function render(string $name): string return self::$cache[$name] = ''; } - return self::$cache[$name] = Svg::tag()->filePath($path)->render(); + return self::$cache[$name] = Svg::tag() + ->filePath($path) + ->render(); } } diff --git a/src/Helper/LogLevel.php b/src/Helper/LogLevel.php index aced0dd..bed7ca8 100644 --- a/src/Helper/LogLevel.php +++ b/src/Helper/LogLevel.php @@ -41,12 +41,6 @@ final class LogLevel /** * Returns the lowercase display name of a level, matching the Yii logger naming. * - * Usage example: - * - * ```php - * $name = \PHPForge\Debug\Helper\LogLevel::name(\PHPForge\Debug\Helper\LogLevel::ERROR); - * ``` - * * @param int $level Log-level wire value. * * @return string Display name; `unknown` for unrecognized values. diff --git a/src/Helper/Text.php b/src/Helper/Text.php index c7a533a..d65072e 100644 --- a/src/Helper/Text.php +++ b/src/Helper/Text.php @@ -17,22 +17,12 @@ final class Text /** * Converts a CamelCase name into a lowercase id with `-` word separators, matching the Yii inflector semantics. * - * Usage example: - * - * ```php - * $id = \PHPForge\Debug\Helper\Text::camel2id('AppAssetBundle'); - * ``` - * * @param string $name CamelCase name to convert. * * @return string Lowercase kebab-case id. */ public static function camel2id(string $name): string { - if ($name === '') { - return ''; - } - $replaced = preg_replace('/(?class('yii-debug-asset-section') ->html( - H3::tag()->class('yii-debug-asset-section-title')->content('Files'), + H3::tag() + ->class('yii-debug-asset-section-title') + ->content('Files'), ...$fileLists, ); } diff --git a/src/Panel/Config/ApplicationConfig.php b/src/Panel/Config/ApplicationConfig.php index ac1f421..ffd5669 100644 --- a/src/Panel/Config/ApplicationConfig.php +++ b/src/Panel/Config/ApplicationConfig.php @@ -6,9 +6,6 @@ /** * Typed view-model for the application section of the Configuration panel. - * - * Mirrors the `application` slice of the configuration snapshot payload after every value has been narrowed - * to its declared scalar type; the consuming view reads properties without further type checks. */ final readonly class ApplicationConfig { diff --git a/src/Panel/Config/ConfigCardRenderer.php b/src/Panel/Config/ConfigCardRenderer.php index 466e5d7..3baf264 100644 --- a/src/Panel/Config/ConfigCardRenderer.php +++ b/src/Panel/Config/ConfigCardRenderer.php @@ -19,10 +19,6 @@ /** * Renders the typed sections of the Configuration panel detail view. - * - * Stateless static helpers: every method takes the data it needs as arguments and returns the rendered HTML tree. - * Concentrates the render logic (readout cards, extension pills, package list, php-info CTA) in one testable place, - * keeping the detail view focused on page-level scaffolding. */ final class ConfigCardRenderer { @@ -230,10 +226,8 @@ private static function renderDlRow(string $term, string $value): Div return Div::tag() ->class('yii-debug-dl-row') ->html( - Dt::tag() - ->content($term), - Dd::tag() - ->content($value), + Dt::tag()->content($term), + Dd::tag()->content($value), ); } @@ -303,7 +297,8 @@ private static function renderReadoutCard(string $label, string $value, Span|str $metaWrap, ]; - return Article::tag()->class('yii-debug-readout-card') + return Article::tag() + ->class('yii-debug-readout-card') ->html(...$children); } } diff --git a/src/Panel/Db/DbExplainRenderer.php b/src/Panel/Db/DbExplainRenderer.php index 5559ed6..5bf18a1 100644 --- a/src/Panel/Db/DbExplainRenderer.php +++ b/src/Panel/Db/DbExplainRenderer.php @@ -71,7 +71,9 @@ private static function renderPlan(string $query, array $results, string|null $e $headerCells = []; foreach ($columns as $column) { - $headerCells[] = Th::tag()->scope('col')->content((string) $column); + $headerCells[] = Th::tag() + ->scope('col') + ->content((string) $column); } $bodyRows = []; @@ -82,8 +84,10 @@ private static function renderPlan(string $query, array $results, string|null $e foreach ($columns as $column) { $value = $row[$column] ?? null; $cells[] = $value === null - ? Td::tag()->html(Em::tag()->content('NULL')) - : Td::tag()->content(is_scalar($value) ? (string) $value : Dump::export($value)); + ? Td::tag() + ->html(Em::tag()->content('NULL')) + : Td::tag() + ->content(is_scalar($value) ? (string) $value : Dump::export($value)); } $bodyRows[] = Tr::tag()->html(...$cells); diff --git a/src/Panel/Db/DbQueryRenderer.php b/src/Panel/Db/DbQueryRenderer.php index 7288190..ef2dd4c 100644 --- a/src/Panel/Db/DbQueryRenderer.php +++ b/src/Panel/Db/DbQueryRenderer.php @@ -30,12 +30,6 @@ final class DbQueryRenderer * accepted; metadata, session-control, and transaction-control statements either error or return noise, so they * are filtered out. * - * Usage example: - * - * ```php - * $supported = \PHPForge\Debug\Panel\Db\DbQueryRenderer::canBeExplained('SELECT'); - * ``` - * * @param string $type SQL command verb (case-insensitive). */ public static function canBeExplained(string $type): bool @@ -108,8 +102,7 @@ public static function renderQueryCell( ->content('Explain'), ) ->role('button'), - Div::tag() - ->class('yii-debug-db-explain-text'), + Div::tag()->class('yii-debug-db-explain-text'), ); } diff --git a/src/Panel/Db/QueryRow.php b/src/Panel/Db/QueryRow.php index 67bc587..854ce43 100644 --- a/src/Panel/Db/QueryRow.php +++ b/src/Panel/Db/QueryRow.php @@ -11,9 +11,6 @@ /** * Typed view-model for a single database query row consumed by the queries grid. - * - * Resolved once at capture time from the logger timings, then persisted in that form: the SQL verb, the statement, - * its duration, the backtrace and its hash, the duplicate count, and the reported row count. */ final readonly class QueryRow implements PanelRow { diff --git a/src/Panel/Dump/DumpCardRenderer.php b/src/Panel/Dump/DumpCardRenderer.php index e38256e..3616388 100644 --- a/src/Panel/Dump/DumpCardRenderer.php +++ b/src/Panel/Dump/DumpCardRenderer.php @@ -25,16 +25,17 @@ use function ltrim; use function preg_match; use function preg_replace; +use function preg_split; use function sprintf; use function str_replace; use function strip_tags; -use function strpos; use function strtolower; -use function substr; use const ENT_HTML5; use const ENT_NOQUOTES; use const ENT_SUBSTITUTE; +use const PREG_SPLIT_DELIM_CAPTURE; +use const PREG_SPLIT_NO_EMPTY; /** * Renders the typed dump cells of the dumps grid for the Dump debug panel. @@ -197,25 +198,13 @@ private static function sanitizeMessage(string $message): string { $escaped = htmlspecialchars($message, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); - $parts = []; - $offset = 0; - - while (($start = strpos($escaped, '<', $offset)) !== false) { - $end = strpos($escaped, '>', $start + 4); - - if ($end === false) { - break; - } - - if ($start > $offset) { - $parts[] = substr($escaped, $offset, $start - $offset); - } - - $parts[] = substr($escaped, $start, $end + 4 - $start); - $offset = $end + 4; - } - - $parts[] = substr($escaped, $offset); + $parts = preg_split( + '/(<.*?>)/s', + $escaped, + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, + ); + $parts = $parts === false ? [] : $parts; /** @var list $openTags */ $openTags = []; @@ -223,7 +212,7 @@ private static function sanitizeMessage(string $message): string foreach ($parts as $index => $part) { $opening = match (true) { $part === '<pre>' => ['tag' => 'pre', 'html' => '
'],
-                preg_match('/^<(code|span) style="color: (#[0-9A-Fa-f]{6})">$/', $part, $match) === 1 => [
+                preg_match('/^<(code|span) style="color: (#[0-9A-Fa-f]{6})">/', $part, $match) === 1 => [
                     'tag' => $match[1],
                     'html' => '<' . $match[1] . ' style="color: ' . $match[2] . '">',
                 ],
@@ -236,7 +225,7 @@ private static function sanitizeMessage(string $message): string
                 continue;
             }
 
-            if (preg_match('/^<\/(pre|code|span)>$/', $part, $match) !== 1 || $openTags === []) {
+            if (preg_match('/^<\/(pre|code|span)>/', $part, $match) !== 1 || $openTags === []) {
                 continue;
             }
 
diff --git a/src/Panel/Dump/DumpRow.php b/src/Panel/Dump/DumpRow.php
index bb013a0..1d47024 100644
--- a/src/Panel/Dump/DumpRow.php
+++ b/src/Panel/Dump/DumpRow.php
@@ -4,14 +4,12 @@
 
 namespace PHPForge\Debug\Panel\Dump;
 
-use PHPForge\Debug\Helper\Coerce;
 use PHPForge\Debug\Storage\{PanelRow, Payload};
 
 /**
  * Typed dump row narrowed once from the Yii logger tuple and persisted in that form.
  *
- * The payload is already rendered by the Dump collector `varDump()` pipeline at capture time, so the detail view
- * renders it without re-serializing.
+ * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot
  */
 final readonly class DumpRow implements PanelRow
 {
@@ -61,18 +59,18 @@ public static function fromArray(mixed $data, string $path): self
     }
 
     /**
-     * Narrows one raw Yii logger tuple into a typed row.
+     * Converts one canonical logger tuple into a typed row.
      *
-     * @param array $message Logger tuple `[message, level, category, timestamp, traces]`.
+     * @param LogMessage $message Logger tuple `[message, level, category, timestamp, traces]`.
      */
     public static function fromLoggerTuple(array $message): self
     {
         return new self(
-            message: Coerce::stringOrNull($message[0] ?? null) ?? '',
-            level: Coerce::intOrNull($message[1] ?? null) ?? 0,
-            category: Coerce::stringOrNull($message[2] ?? null) ?? '',
-            time: (Coerce::floatOrNull($message[3] ?? null) ?? 0.0) * 1000,
-            trace: Coerce::traceFrames($message[4] ?? []),
+            message: $message[0],
+            level: $message[1],
+            category: $message[2],
+            time: $message[3] * 1000,
+            trace: $message[4],
         );
     }
 
diff --git a/src/Panel/Dump/DumpSnapshot.php b/src/Panel/Dump/DumpSnapshot.php
index 0dabffc..6aacd54 100644
--- a/src/Panel/Dump/DumpSnapshot.php
+++ b/src/Panel/Dump/DumpSnapshot.php
@@ -7,10 +7,11 @@
 use PHPForge\Debug\Storage\{PanelSnapshot, Payload};
 
 use function array_map;
-use function is_array;
 
 /**
  * Canonical Dump panel snapshot holding the captured rows in their typed form.
+ *
+ * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot
  */
 final readonly class DumpSnapshot implements PanelSnapshot
 {
@@ -20,18 +21,16 @@
     public function __construct(private array $entries) {}
 
     /**
-     * Narrows the raw logger tuples into typed rows.
+     * Converts canonical logger tuples into typed rows.
      *
-     * @param array $messages Logger tuples in capture order; non-array entries are dropped.
+     * @param list $messages Logger tuples in capture order.
      */
     public static function capture(array $messages): self
     {
         $entries = [];
 
         foreach ($messages as $message) {
-            if (is_array($message)) {
-                $entries[] = DumpRow::fromLoggerTuple($message);
-            }
+            $entries[] = DumpRow::fromLoggerTuple($message);
         }
 
         return new self($entries);
diff --git a/src/Panel/Log/LogCellRenderer.php b/src/Panel/Log/LogCellRenderer.php
index 8e8fc67..547d5f4 100644
--- a/src/Panel/Log/LogCellRenderer.php
+++ b/src/Panel/Log/LogCellRenderer.php
@@ -135,9 +135,11 @@ public static function renderTimeCell(LogRow $row): string
     public static function renderTimeSincePreviousCell(LogRow $row): string
     {
         $diffMsTotal = (int) ($row->time - $row->timeOfPrevious);
+
         $diffSecondsTotal = intdiv($diffMsTotal, 1000);
         $diffMinutesTotal = intdiv($diffSecondsTotal, 60);
         $diffHours = intdiv($diffMinutesTotal, 60);
+
         $diffMs = $diffMsTotal % 1000;
         $diffSeconds = $diffSecondsTotal % 60;
         $diffMinutes = $diffMinutesTotal % 60;
@@ -145,18 +147,18 @@ public static function renderTimeSincePreviousCell(LogRow $row): string
         $parts = [];
 
         if ($diffHours > 0) {
-            $parts[] = $diffHours . 'h';
+            $parts[] = "{$diffHours}h";
         }
 
         if ($diffMinutes > 0) {
-            $parts[] = $diffMinutes . 'm';
+            $parts[] = "{$diffMinutes}m";
         }
 
         if ($diffSeconds > 0) {
-            $parts[] = $diffSeconds . 's';
+            $parts[] = "{$diffSeconds}s";
         }
 
-        $parts[] = $diffMs . 'ms';
+        $parts[] = "{$diffMs}ms";
 
         return Div::tag()
             ->class('yii-debug-since-previous')
diff --git a/src/Panel/Log/LogRow.php b/src/Panel/Log/LogRow.php
index 7e7c149..d8ae98b 100644
--- a/src/Panel/Log/LogRow.php
+++ b/src/Panel/Log/LogRow.php
@@ -4,13 +4,12 @@
 
 namespace PHPForge\Debug\Panel\Log;
 
-use PHPForge\Debug\Helper\{Coerce, Dump};
 use PHPForge\Debug\Storage\{PanelRow, Payload};
 
-use function is_string;
-
 /**
  * Typed log row narrowed once from the Yii logger tuple and persisted in that form.
+ *
+ * @phpstan-import-type LogMessage from LogSnapshot
  */
 final readonly class LogRow implements PanelRow
 {
@@ -20,7 +19,7 @@ public function __construct(
          */
         public int $id,
         /**
-         * Display string for the log payload, exported via {@see Dump::export()} when the source was not a string.
+         * Display string for the log payload.
          */
         public string $message,
         /**
@@ -96,9 +95,9 @@ public static function fromArray(mixed $data, string $path): self
     }
 
     /**
-     * Narrows one raw Yii logger tuple into a typed row.
+     * Converts one canonical logger tuple into a typed row.
      *
-     * @param array $message Logger tuple `[message, level, category, timestamp, traces, memory]`.
+     * @param LogMessage $message Logger tuple `[message, level, category, timestamp, traces, memory]`.
      * @param int $id One-based row id assigned in capture order.
      * @param float $timeOfPrevious Timestamp of the previous row in seconds; this row's own timestamp for the first.
      * @param int|null $idOfPrevious Row id preceding this one, or `null` for the first row.
@@ -111,22 +110,20 @@ public static function fromLoggerTuple(
         int|null $idOfPrevious,
         int|null $idOfNext,
     ): self {
-        $payload = $message[0] ?? null;
-
-        $timestamp = Coerce::floatOrNull($message[3] ?? null) ?? 0.0;
+        $timestamp = $message[3];
 
         return new self(
             id: $id,
-            message: is_string($payload) ? $payload : Dump::export($payload),
-            level: Coerce::intOrNull($message[1] ?? null) ?? 0,
-            category: Coerce::stringOrNull($message[2] ?? null) ?? '',
+            message: $message[0],
+            level: $message[1],
+            category: $message[2],
             time: $timestamp * 1000,
             timeOfPrevious: $timeOfPrevious * 1000,
             timeSincePrevious: $timestamp - $timeOfPrevious,
             idOfPrevious: $idOfPrevious,
             idOfNext: $idOfNext,
-            memory: Coerce::intOrNull($message[5] ?? null) ?? 0,
-            trace: Coerce::traceFrames($message[4] ?? []),
+            memory: $message[5] ?? 0,
+            trace: $message[4],
         );
     }
 
diff --git a/src/Panel/Log/LogSnapshot.php b/src/Panel/Log/LogSnapshot.php
index 502c420..d2aa77b 100644
--- a/src/Panel/Log/LogSnapshot.php
+++ b/src/Panel/Log/LogSnapshot.php
@@ -4,15 +4,23 @@
 
 namespace PHPForge\Debug\Panel\Log;
 
-use PHPForge\Debug\Helper\Coerce;
 use PHPForge\Debug\Storage\{PanelSnapshot, Payload};
 
 use function array_map;
 use function count;
-use function is_array;
 
 /**
  * Canonical Log panel snapshot holding the captured rows in their typed form.
+ *
+ * @phpstan-type TraceFrame array
+ * @phpstan-type LogMessage array{
+ *   0: string,
+ *   1: int,
+ *   2: string,
+ *   3: float,
+ *   4: list,
+ *   5?: int
+ * }
  */
 final readonly class LogSnapshot implements PanelSnapshot
 {
@@ -22,31 +30,23 @@
     public function __construct(private array $entries) {}
 
     /**
-     * Narrows the raw logger tuples into typed rows, deriving the previous/next links and the inter-row deltas.
+     * Converts canonical logger tuples into typed rows, deriving the previous/next links and the inter-row deltas.
      *
-     * @param array $messages Logger tuples in capture order; non-array entries are dropped.
+     * @param list $messages Logger tuples in capture order.
      */
     public static function capture(array $messages): self
     {
-        $tuples = [];
-
-        foreach ($messages as $message) {
-            if (is_array($message)) {
-                $tuples[] = $message;
-            }
-        }
-
         $entries = [];
 
-        $count = count($tuples);
+        $count = count($messages);
 
         $previousId = null;
         $previousTime = null;
 
-        foreach ($tuples as $index => $message) {
+        foreach ($messages as $index => $message) {
             $id = $index + 1;
 
-            $timestamp = Coerce::floatOrNull($message[3] ?? null) ?? 0.0;
+            $timestamp = $message[3];
 
             $previousTime ??= $timestamp;
 
diff --git a/src/Panel/Mail/MailSnapshot.php b/src/Panel/Mail/MailSnapshot.php
index 99aef7d..f09dc64 100644
--- a/src/Panel/Mail/MailSnapshot.php
+++ b/src/Panel/Mail/MailSnapshot.php
@@ -56,7 +56,9 @@ public static function fromArray(mixed $data, string $path): self
             $entries[] = MailMessage::fromArray($entry, "{$path}.entries[{$index}]");
         }
 
-        return new self($entries);
+        return new self(
+            $entries,
+        );
     }
 
     /**
diff --git a/src/Panel/Profile/ProfileRow.php b/src/Panel/Profile/ProfileRow.php
index 067a5ae..71b9394 100644
--- a/src/Panel/Profile/ProfileRow.php
+++ b/src/Panel/Profile/ProfileRow.php
@@ -4,14 +4,14 @@
 
 namespace PHPForge\Debug\Panel\Profile;
 
-use PHPForge\Debug\Helper\Coerce;
 use PHPForge\Debug\Storage\{PanelRow, Payload};
 
-use function is_array;
 use function max;
 
 /**
  * Typed profile block derived once from the Yii logger timings and persisted in that form.
+ *
+ * @phpstan-import-type ProfileTiming from ProfileTimings
  */
 final readonly class ProfileRow implements PanelRow
 {
@@ -85,38 +85,23 @@ public static function fromArray(mixed $data, string $path): self
     }
 
     /**
-     * Narrows one timing returned by {@see ProfileTimings::calculate()} into a typed row.
+     * Converts one timing returned by {@see ProfileTimings::calculate()} into a typed row.
      *
-     * @param mixed $timing Raw timing entry.
+     * @param ProfileTiming $timing Profile timing entry.
      * @param int $seq Zero-based sequence index to assign.
-     *
-     * @return self|null Typed row, or `null` when the timing carries no usable timestamp or duration.
      */
-    public static function fromTiming(mixed $timing, int $seq): self|null
+    public static function fromTiming(array $timing, int $seq): self
     {
-        if (!is_array($timing)) {
-            return null;
-        }
-
-        $timestamp = Coerce::floatOrNull($timing['timestamp'] ?? null);
-        $duration = Coerce::floatOrNull($timing['duration'] ?? null);
-
-        if ($timestamp === null || $duration === null) {
-            return null;
-        }
-
-        $level = Coerce::intOrNull($timing['level'] ?? null);
-
         return new self(
-            timestamp: $timestamp * 1000,
-            duration: $duration * 1000,
-            category: Coerce::stringOrNull($timing['category'] ?? null) ?? '',
-            info: Coerce::stringOrNull($timing['info'] ?? null) ?? '',
-            level: $level === null ? 0 : max(0, $level),
+            timestamp: $timing['timestamp'] * 1000,
+            duration: $timing['duration'] * 1000,
+            category: $timing['category'],
+            info: $timing['info'],
+            level: $timing['level'],
             seq: $seq,
-            memory: Coerce::intOrNull($timing['memory'] ?? null) ?? 0,
-            memoryDiff: Coerce::intOrNull($timing['memoryDiff'] ?? null) ?? 0,
-            trace: Coerce::traceFrames($timing['trace'] ?? []),
+            memory: $timing['memory'],
+            memoryDiff: $timing['memoryDiff'],
+            trace: $timing['trace'],
         );
     }
 
diff --git a/src/Panel/Profile/ProfileTimings.php b/src/Panel/Profile/ProfileTimings.php
index bfdaf40..f337a95 100644
--- a/src/Panel/Profile/ProfileTimings.php
+++ b/src/Panel/Profile/ProfileTimings.php
@@ -4,16 +4,26 @@
 
 namespace PHPForge\Debug\Panel\Profile;
 
-use PHPForge\Debug\Helper\{Coerce, LogLevel};
+use PHPForge\Debug\Helper\LogLevel;
 
 use function array_pop;
 use function array_values;
-use function json_encode;
 use function ksort;
-use function md5;
 
 /**
  * Pairs profile begin/end log tuples into per-block timings.
+ *
+ * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot
+ * @phpstan-type ProfileTiming array{
+ *   info: string,
+ *   category: string,
+ *   timestamp: float,
+ *   trace: list>,
+ *   level: int,
+ *   duration: float,
+ *   memory: int,
+ *   memoryDiff: int
+ * }
  */
 final class ProfileTimings
 {
@@ -23,68 +33,56 @@ final class ProfileTimings
      * Each tuple is `[token, level, category, timestamp, traces, memory]`; a begin marker is matched with the next end
      * marker carrying the same token, producing one timing entry ordered by the begin position.
      *
-     * Usage example:
+     * @param list $messages Profile log tuples in capture order.
      *
-     * ```php
-     * $timings = \PHPForge\Debug\Panel\Profile\ProfileTimings::calculate($tuples);
-     * ```
-     *
-     * @param array> $messages Profile log tuples in capture order.
-     *
-     * @return list Timings ordered by their begin marker.
+     * @return list Timings ordered by their begin marker.
      */
     public static function calculate(array $messages): array
     {
         $timings = [];
+        /** @var array> $stack */
         $stack = [];
         $nestedLevel = 0;
 
         foreach ($messages as $index => $log) {
-            $level = Coerce::intOrNull($log[1] ?? null);
-
-            $hash = md5(Coerce::string(json_encode($log[0] ?? null)));
+            $level = $log[1];
+            $tokenKey = $log[0];
 
             if ($level === LogLevel::PROFILE_BEGIN) {
-                $log['index'] = $index;
-                $log['level'] = $nestedLevel++;
-                $stack[$hash][] = $log;
+                $stack[$tokenKey][] = [
+                    'message' => $log,
+                    'index' => $index,
+                    'level' => $nestedLevel++,
+                ];
 
                 continue;
             }
 
-            if ($level !== LogLevel::PROFILE_END || ($stack[$hash] ?? []) === []) {
+            if ($level !== LogLevel::PROFILE_END || ($stack[$tokenKey] ?? []) === []) {
                 continue;
             }
 
-            $begin = array_pop($stack[$hash]);
+            $begin = array_pop($stack[$tokenKey]);
             --$nestedLevel;
 
-            if ($stack[$hash] === []) {
-                unset($stack[$hash]);
+            if ($stack[$tokenKey] === []) {
+                unset($stack[$tokenKey]);
             }
 
-            $beginIndex = Coerce::intOrNull($begin['index']) ?? 0;
-            $beginTimestamp = Coerce::floatOrNull($begin[3] ?? null) ?? 0.0;
-            $memory = Coerce::intOrNull($log[5] ?? null) ?? 0;
+            $beginMessage = $begin['message'];
+            $beginIndex = $begin['index'];
+            $beginTimestamp = $beginMessage[3];
+            $memory = $log[5] ?? 0;
 
             $timings[$beginIndex] = [
-                'info' => $begin[0] ?? null,
-                'category' => $begin[2] ?? null,
+                'info' => $beginMessage[0],
+                'category' => $beginMessage[2],
                 'timestamp' => $beginTimestamp,
-                'trace' => $begin[4] ?? [],
-                'level' => Coerce::intOrNull($begin['level']) ?? 0,
-                'duration' => (Coerce::floatOrNull($log[3] ?? null) ?? 0.0) - $beginTimestamp,
+                'trace' => $beginMessage[4],
+                'level' => $begin['level'],
+                'duration' => $log[3] - $beginTimestamp,
                 'memory' => $memory,
-                'memoryDiff' => $memory - (Coerce::intOrNull($begin[5] ?? null) ?? 0),
+                'memoryDiff' => $memory - ($beginMessage[5] ?? 0),
             ];
         }
 
diff --git a/src/Panel/Profile/ProfilingSnapshot.php b/src/Panel/Profile/ProfilingSnapshot.php
index a8e99ac..bdbe639 100644
--- a/src/Panel/Profile/ProfilingSnapshot.php
+++ b/src/Panel/Profile/ProfilingSnapshot.php
@@ -11,11 +11,14 @@
 use function array_map;
 use function count;
 use function is_array;
+use function max;
 use function usort;
 
 /**
  * Canonical profiling snapshot holding the request metrics, the resolved profile blocks, and the memory samples that
  * feed the timeline chart.
+ *
+ * @phpstan-import-type LogMessage from \PHPForge\Debug\Panel\Log\LogSnapshot
  */
 final readonly class ProfilingSnapshot implements PanelSnapshot
 {
@@ -33,39 +36,32 @@ public function __construct(
     /**
      * Resolves the logger's begin/end pairs into typed blocks and collects the per-message memory samples.
      *
-     * @param array $messages Raw profile tuples in capture order.
+     * @param list $messages Profile tuples in capture order.
      */
     public static function capture(int $memory, float $time, array $messages): self
     {
-        $tuples = [];
         $samples = [];
 
         foreach ($messages as $message) {
-            if (!is_array($message)) {
-                continue;
-            }
+            $sampleMemory = $message[5] ?? null;
 
-            $tuples[] = $message;
-
-            $sampleTime = Coerce::floatOrNull($message[3] ?? null);
-            $sampleMemory = Coerce::intOrNull($message[5] ?? null);
-
-            if ($sampleTime !== null && $sampleMemory !== null) {
-                $samples[] = new MemorySample($sampleTime * 1000, $sampleMemory);
+            if ($sampleMemory !== null) {
+                $samples[] = new MemorySample($message[3] * 1000, $sampleMemory);
             }
         }
 
         $entries = [];
 
-        foreach (ProfileTimings::calculate($tuples) as $timing) {
-            $row = ProfileRow::fromTiming($timing, count($entries));
-
-            if ($row !== null) {
-                $entries[] = $row;
-            }
+        foreach (ProfileTimings::calculate($messages) as $timing) {
+            $entries[] = ProfileRow::fromTiming($timing, count($entries));
         }
 
-        return new self($memory, $time, $entries, $samples);
+        return new self(
+            $memory,
+            $time,
+            $entries,
+            $samples,
+        );
     }
 
     /**
@@ -101,24 +97,22 @@ public static function captureCompleted(int $memory, float $time, array $message
             $endMemory = Coerce::intOrNull($context['endMemory'] ?? $context['memory'] ?? null);
             $memoryDiff = Coerce::intOrNull($context['memoryDiff'] ?? null)
                 ?? (($beginMemory !== null && $endMemory !== null) ? $endMemory - $beginMemory : 0);
-            $row = ProfileRow::fromTiming(
+            $level = Coerce::intOrNull($context['nestedLevel'] ?? null);
+
+            $entries[] = ProfileRow::fromTiming(
                 [
                     'timestamp' => $beginTime,
                     'duration' => $duration,
-                    'category' => $context['category'] ?? $message['category'] ?? '',
-                    'info' => $message['token'] ?? '',
-                    'level' => $context['nestedLevel'] ?? 0,
+                    'category' => Coerce::stringOrNull($context['category'] ?? $message['category'] ?? null) ?? '',
+                    'info' => Coerce::stringOrNull($message['token'] ?? null) ?? '',
+                    'level' => $level === null ? 0 : max(0, $level),
                     'memory' => $endMemory ?? 0,
                     'memoryDiff' => $memoryDiff,
-                    'trace' => $context['trace'] ?? [],
+                    'trace' => Coerce::traceFrames($context['trace'] ?? []),
                 ],
                 count($entries),
             );
 
-            if ($row !== null) {
-                $entries[] = $row;
-            }
-
             if ($beginMemory !== null) {
                 $samples[] = new MemorySample($beginTime * 1000, $beginMemory);
             }
diff --git a/src/Panel/Queue/QueueCardRenderer.php b/src/Panel/Queue/QueueCardRenderer.php
index fa493b0..a9e3774 100644
--- a/src/Panel/Queue/QueueCardRenderer.php
+++ b/src/Panel/Queue/QueueCardRenderer.php
@@ -128,8 +128,12 @@ private static function metaItem(string $label, string $value): Span
             ->class('yii-debug-queue-meta-item')
             ->addDataAttribute('field', $label)
             ->html(
-                Span::tag()->class('yii-debug-queue-meta-label')->content($label),
-                Span::tag()->class('yii-debug-queue-meta-value')->content($value),
+                Span::tag()
+                    ->class('yii-debug-queue-meta-label')
+                    ->content($label),
+                Span::tag()
+                    ->class('yii-debug-queue-meta-value')
+                    ->content($value),
             );
     }
 
@@ -206,8 +210,12 @@ private static function renderArrayOrObjectRow(string $key, array $value): Detai
         return Details::tag()
             ->class('yii-debug-queue-tree-collapse')
             ->html(
-                Summary::tag()->class('yii-debug-queue-tree-summary')->html($summaryHtml),
-                Div::tag()->class('yii-debug-queue-tree-children')->html(...$children),
+                Summary::tag()
+                    ->class('yii-debug-queue-tree-summary')
+                    ->html($summaryHtml),
+                Div::tag()
+                    ->class('yii-debug-queue-tree-children')
+                    ->html(...$children),
             );
     }
 
@@ -306,8 +314,12 @@ private static function renderHead(JobRecord $record): Header
             ->class('yii-debug-queue-card-head')
             ->html(
                 self::renderAvatar($record),
-                Div::tag()->class('yii-debug-queue-headline')->html(...$title),
-                Div::tag()->class('yii-debug-queue-meta-pills')->html(...$pills),
+                Div::tag()
+                    ->class('yii-debug-queue-headline')
+                    ->html(...$title),
+                Div::tag()
+                    ->class('yii-debug-queue-meta-pills')
+                    ->html(...$pills),
             );
     }
 
@@ -384,9 +396,15 @@ private static function renderScalarRow(string $key, string $type, string $value
         return Div::tag()
             ->class('yii-debug-queue-tree-row')
             ->html(
-                Span::tag()->class('yii-debug-queue-tree-key')->content($key),
-                Span::tag()->class('yii-debug-queue-tree-type')->content($type),
-                Span::tag()->class("yii-debug-queue-tree-value yii-debug-queue-tree-value-{$variant}")->content($value),
+                Span::tag()
+                    ->class('yii-debug-queue-tree-key')
+                    ->content($key),
+                Span::tag()
+                    ->class('yii-debug-queue-tree-type')
+                    ->content($type),
+                Span::tag()
+                    ->class("yii-debug-queue-tree-value yii-debug-queue-tree-value-{$variant}")
+                    ->content($value),
             );
     }
 
diff --git a/src/Panel/Request/RequestDataNormalizer.php b/src/Panel/Request/RequestDataNormalizer.php
index 219f8dd..e6ab2b8 100644
--- a/src/Panel/Request/RequestDataNormalizer.php
+++ b/src/Panel/Request/RequestDataNormalizer.php
@@ -64,9 +64,9 @@ private static function buildHero(array $data, RequestSummary|null $summary): Re
 
         $method = Coerce::stringOrNull($general['method'] ?? null)
             ?? ($summary === null ? '' : $summary->method);
+
         $url = $summary === null ? '' : $summary->url;
         $ip = $summary === null ? '' : $summary->ip;
-
         $capturedAt = $summary === null ? 0.0 : $summary->time;
 
         $time = $capturedAt > 0 ? date('H:i:s', (int) $capturedAt) : '';
@@ -241,6 +241,8 @@ private static function sessionSections(array $data): array
      */
     private static function statusVariant(int $statusCode): string
     {
-        return Vocabulary::statusClass($statusCode);
+        return Vocabulary::statusClass(
+            $statusCode,
+        );
     }
 }
diff --git a/src/Panel/Request/RequestSectionRenderer.php b/src/Panel/Request/RequestSectionRenderer.php
index b93c9db..c9effe4 100644
--- a/src/Panel/Request/RequestSectionRenderer.php
+++ b/src/Panel/Request/RequestSectionRenderer.php
@@ -128,7 +128,9 @@ private static function renderRow(int|string $name, mixed $value): Tr
 
         return Tr::tag()
             ->html(
-                Th::tag()->scope('row')->content((string) $name),
+                Th::tag()
+                    ->scope('row')
+                    ->content((string) $name),
                 Td::tag()->html($escaped),
             );
     }
@@ -178,12 +180,18 @@ private static function renderSectionTable(RequestSection $section): string
                     ->class('yii-debug-table yii-debug-table-mono')
                     ->style(['table-layout' => 'fixed'])
                     ->html(
-                        Thead::tag()->html(
-                            Tr::tag()->html(
-                                Th::tag()->scope('col')->content('Name'),
-                                Th::tag()->scope('col')->content('Value'),
+                        Thead::tag()
+                            ->html(
+                                Tr::tag()
+                                    ->html(
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Name'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Value'),
+                                    ),
                             ),
-                        ),
                         Tbody::tag()->html(...$rows),
                     ),
             )
diff --git a/src/Panel/Request/RequestSnapshot.php b/src/Panel/Request/RequestSnapshot.php
index 7344b10..1146737 100644
--- a/src/Panel/Request/RequestSnapshot.php
+++ b/src/Panel/Request/RequestSnapshot.php
@@ -47,7 +47,10 @@ public static function fromArray(mixed $data, string $path): self
         $values = $snapshotData->values();
 
         if (($values['statusCode'] ?? null) !== $statusCode) {
-            throw HydrationException::at("{$path}.statusCode", 'the status code stored in data');
+            throw HydrationException::at(
+                "{$path}.statusCode",
+                'the status code stored in data',
+            );
         }
 
         return new self($snapshotData, $statusCode);
diff --git a/src/Panel/Router/CurrentRouteLogRow.php b/src/Panel/Router/CurrentRouteLogRow.php
index e14be77..396d872 100644
--- a/src/Panel/Router/CurrentRouteLogRow.php
+++ b/src/Panel/Router/CurrentRouteLogRow.php
@@ -41,7 +41,11 @@ public static function fromArray(mixed $data, string $path): self
                 ],
             );
 
-        return new self($payload->string('rule'), $payload->string('parent'), $payload->bool('match'));
+        return new self(
+            $payload->string('rule'),
+            $payload->string('parent'),
+            $payload->bool('match'),
+        );
     }
 
     /**
@@ -62,7 +66,11 @@ public static function fromLogMessage(mixed $message): self|null
 
         $parent = $message['parent'] ?? null;
 
-        return new self($message['rule'], is_string($parent) ? $parent : '', $message['match']);
+        return new self(
+            $message['rule'],
+            is_string($parent) ? $parent : '',
+            $message['match'],
+        );
     }
 
     /**
diff --git a/src/Panel/Router/RouterSectionRenderer.php b/src/Panel/Router/RouterSectionRenderer.php
index 37f5414..aaf64dc 100644
--- a/src/Panel/Router/RouterSectionRenderer.php
+++ b/src/Panel/Router/RouterSectionRenderer.php
@@ -17,10 +17,6 @@
 
 /**
  * Renders the Router panel detail view from framework-neutral row models.
- *
- * Stateless static helpers: the public entry point takes the typed Current Route view plus pre-built rule and
- * action rows, and returns a fully-rendered HTML string. Concentrates tab-strip wiring, badge tinting, the three
- * section tables (Current Route logs / Router Rules / Action Routes), and the callout block in one testable place.
  */
 final class RouterSectionRenderer
 {
@@ -28,11 +24,6 @@ final class RouterSectionRenderer
      * Renders the entire Router panel detail: the router-wide flags strip, the tab strip (Current Route / Router
      * Rules / Action Routes), and the per-tab content panels.
      *
-     * Usage example:
-     * ```php
-     * $html = \PHPForge\Debug\Panel\Router\RouterSectionRenderer::renderTabs($current, $rules, $actions, $badges);
-     * ```
-     *
      * @param RouterCurrentView $current Current-route resolver view.
      * @param list $ruleRows Router rules in display order.
      * @param list $actionRows Discovered action routes in display order.
@@ -91,13 +82,24 @@ private static function renderActionRoutesPanel(array $actionRows): string
                     ->html(
                         Thead::tag()
                             ->html(
-                                Tr::tag()->html(
-                                    Th::tag()->scope('col')->content('#'),
-                                    Th::tag()->scope('col')->content('Action'),
-                                    Th::tag()->scope('col')->content('Route'),
-                                    Th::tag()->scope('col')->content('First Matching Rule'),
-                                    Th::tag()->scope('col')->content('Rules Tested'),
-                                ),
+                                Tr::tag()
+                                    ->html(
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('#'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Action'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Route'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('First Matching Rule'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Rules Tested'),
+                                    ),
                             ),
                         Tbody::tag()->html(...$rows),
                     ),
@@ -226,11 +228,18 @@ private static function renderLogsTable(RouterCurrentView $current): string
                     ->html(
                         Thead::tag()
                             ->html(
-                                Tr::tag()->html(
-                                    Th::tag()->scope('col')->content('#'),
-                                    Th::tag()->scope('col')->content('Rule'),
-                                    Th::tag()->scope('col')->content('Parent'),
-                                ),
+                                Tr::tag()
+                                    ->html(
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('#'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Rule'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Parent'),
+                                    ),
                             ),
                         Tbody::tag()->html(...$rows),
                     ),
@@ -274,15 +283,30 @@ private static function renderRouterRulesPanel(array $ruleRows): string
                     ->html(
                         Thead::tag()
                             ->html(
-                                Tr::tag()->html(
-                                    Th::tag()->scope('col')->content('#'),
-                                    Th::tag()->scope('col')->content('Rule'),
-                                    Th::tag()->scope('col')->content('Target'),
-                                    Th::tag()->scope('col')->content('Verb'),
-                                    Th::tag()->scope('col')->content('Suffix'),
-                                    Th::tag()->scope('col')->content('Mode'),
-                                    Th::tag()->scope('col')->content('Type'),
-                                ),
+                                Tr::tag()
+                                    ->html(
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('#'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Rule'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Target'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Verb'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Suffix'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Mode'),
+                                        Th::tag()
+                                            ->scope('col')
+                                            ->content('Type'),
+                                    ),
                             ),
                         Tbody::tag()->html(...$rows),
                     ),
@@ -305,17 +329,13 @@ private static function renderRouteSummary(RouterCurrentView $current): string
         $items = [];
 
         if ($current->route !== '') {
-            $items[] = Dt::tag()
-                ->content('Resolved route');
-            $items[] = Dd::tag()
-                ->html(Code::tag()->content($current->route));
+            $items[] = Dt::tag()->content('Resolved route');
+            $items[] = Dd::tag()->html(Code::tag()->content($current->route));
         }
 
         if ($current->action !== '') {
-            $items[] = Dt::tag()
-                ->content('Dispatched action');
-            $items[] = Dd::tag()
-                ->html(Code::tag()->content($current->action));
+            $items[] = Dt::tag()->content('Dispatched action');
+            $items[] = Dd::tag()->html(Code::tag()->content($current->action));
         }
 
         return Dl::tag()
diff --git a/src/Panel/Router/RouterSnapshot.php b/src/Panel/Router/RouterSnapshot.php
index 403546e..e858711 100644
--- a/src/Panel/Router/RouterSnapshot.php
+++ b/src/Panel/Router/RouterSnapshot.php
@@ -57,7 +57,12 @@ public static function capture(string|null $action, array $messages, string $rou
             $last = $row;
         }
 
-        return new self($action, $route, $message, $entries);
+        return new self(
+            $action,
+            $route,
+            $message,
+            $entries,
+        );
     }
 
     /**
diff --git a/src/Panel/Timeline/TimelineGeometry.php b/src/Panel/Timeline/TimelineGeometry.php
index 467eaa2..61146c8 100644
--- a/src/Panel/Timeline/TimelineGeometry.php
+++ b/src/Panel/Timeline/TimelineGeometry.php
@@ -43,12 +43,11 @@ public static function rulers(float $duration, int $line = 6): array
         $ticks = [0 => 0.0];
         $limit = $duration - $step / 4;
 
-        if ($step > $limit) {
-            return $ticks;
-        }
+        $tickCount = (int) floor($limit / $step);
+        $millisecondsList = $tickCount > 0 ? range($step, $tickCount * $step, $step) : [];
 
-        foreach (range($step, $limit, $step) as $milliseconds) {
-            $ticks[(int) $milliseconds] = $milliseconds / $duration * 100;
+        foreach ($millisecondsList as $milliseconds) {
+            $ticks[$milliseconds] = $milliseconds / $duration * 100;
         }
 
         return $ticks;
diff --git a/src/Panel/Timeline/TimelineMemoryRenderer.php b/src/Panel/Timeline/TimelineMemoryRenderer.php
index ab86cfa..856df5c 100644
--- a/src/Panel/Timeline/TimelineMemoryRenderer.php
+++ b/src/Panel/Timeline/TimelineMemoryRenderer.php
@@ -55,16 +55,17 @@ public static function render(
             ->height($height)
             ->html(
                 Defs::tag()->html(self::gradient()),
-                G::tag()->html(
-                    Polygon::tag()
-                        ->points(self::polygonPoints($points, $width, $height))
-                        ->fill('url(#yii-debug-tl-memory-gradient)'),
-                    Polyline::tag()
-                        ->points(self::polylinePoints($points, $width, $height))
-                        ->fill('none')
-                        ->stroke('currentColor')
-                        ->strokeWidth('1.5'),
-                ),
+                G::tag()
+                    ->html(
+                        Polygon::tag()
+                            ->points(self::polygonPoints($points, $width, $height))
+                            ->fill('url(#yii-debug-tl-memory-gradient)'),
+                        Polyline::tag()
+                            ->points(self::polylinePoints($points, $width, $height))
+                            ->fill('none')
+                            ->stroke('currentColor')
+                            ->strokeWidth('1.5'),
+                    ),
             )
             ->preserveAspectRatio('none')
             ->viewBox("0 0 {$width} {$height}")
@@ -107,7 +108,7 @@ private static function polygonPoints(array $points, int $width, int $height): s
     {
         $rendered = "0 {$height}";
 
-        $lastY = (float) $height;
+        $lastY = $height;
 
         foreach ($points as [$x, $y]) {
             $rendered .= ' ' . self::number($x) . ' ' . self::number($y);
@@ -126,11 +127,10 @@ private static function polylinePoints(array $points, int $width, int $height):
     {
         $rendered = "0 {$height}";
 
-        $lastY = (float) $height;
+        $lastY = $height;
 
         foreach ($points as [$x, $y]) {
             $rendered .= ' ' . self::number($x) . ' ' . self::number($y);
-
             $lastY = $y;
         }
 
diff --git a/src/Panel/Timeline/TimelineRenderer.php b/src/Panel/Timeline/TimelineRenderer.php
index cd75eb0..2950797 100644
--- a/src/Panel/Timeline/TimelineRenderer.php
+++ b/src/Panel/Timeline/TimelineRenderer.php
@@ -13,6 +13,7 @@
 use UIAwesome\Html\Sectioning\Section;
 
 use function count;
+use function in_array;
 use function number_format;
 use function rtrim;
 use function sprintf;
@@ -84,8 +85,7 @@ public static function renderEmptyHint(bool $hasRows, string $profilingUrl): str
                     ->class('yii-debug-tl-hint-body')
                     ->html(
                         'The timeline is most useful for requests that take hundreds of milliseconds, where you can ',
-                        Em::tag()
-                            ->content('see'),
+                        Em::tag()->content('see'),
                         ' which operations dominate. For quick requests the ',
                         A::tag()
                             ->href($profilingUrl)
@@ -162,8 +162,7 @@ public static function renderSummary(float $duration, int $memory, int $spanCoun
             ->html(
                 Span::tag()
                     ->html(
-                        Strong::tag()
-                            ->content(number_format($duration)),
+                        Strong::tag()->content(number_format($duration)),
                         ' ms total',
                     ),
                 Span::tag()
@@ -171,10 +170,7 @@ public static function renderSummary(float $duration, int $memory, int $spanCoun
                     ->content('·'),
                 Span::tag()
                     ->html(
-                        Strong::tag()
-                            ->content(
-                                Format::bytesToMb($memory)
-                            ),
+                        Strong::tag()->content(Format::bytesToMb($memory)),
                         ' peak memory',
                     ),
                 Span::tag()
@@ -182,10 +178,7 @@ public static function renderSummary(float $duration, int $memory, int $spanCoun
                     ->content('·'),
                 Span::tag()
                     ->html(
-                        Strong::tag()
-                    ->content(
-                        (string) $spanCount
-                    ),
+                        Strong::tag()->content((string) $spanCount),
                         ' spans',
                     ),
             )
@@ -230,7 +223,9 @@ private static function renderLegend(array $rows): array
         $present = [];
 
         foreach ($rows as $row) {
-            $present[$row->variant] = true;
+            if (!in_array($row->variant, $present, true)) {
+                $present[] = $row->variant;
+            }
         }
 
         if (count($present) < 2) {
@@ -240,7 +235,7 @@ private static function renderLegend(array $rows): array
         $items = [];
 
         foreach (self::LEGEND_LABELS as $variant => $label) {
-            if (!isset($present[$variant])) {
+            if (!in_array($variant, $present, true)) {
                 continue;
             }
 
@@ -299,10 +294,12 @@ private static function renderRow(TimelineSpanRow $row): Div
                     ->html(
                         Div::tag()
                             ->class('yii-debug-tl-bar')
-                            ->style([
-                                'left' => $row->cssLeft . '%',
-                                'width' => $row->cssWidth . '%',
-                            ])
+                            ->style(
+                                [
+                                    'left' => $row->cssLeft . '%',
+                                    'width' => $row->cssWidth . '%',
+                                ],
+                            )
                             ->html(
                                 Span::tag()
                                     ->class('yii-debug-tl-bar-duration')
diff --git a/src/Panel/User/UserIdentityRenderer.php b/src/Panel/User/UserIdentityRenderer.php
index b45653e..d5e3c06 100644
--- a/src/Panel/User/UserIdentityRenderer.php
+++ b/src/Panel/User/UserIdentityRenderer.php
@@ -116,8 +116,7 @@ private static function renderSection(UserIdentitySection $section): Article
                             ->addAttribute('aria-hidden', 'true')
                             ->class('yii-debug-user-section-icon')
                             ->html($section->icon),
-                        Span::tag()
-                            ->content($section->label),
+                        Span::tag()->content($section->label),
                     ),
                 Dl::tag()->html(...$rows),
             );
@@ -140,9 +139,15 @@ private static function renderValue(UserAttribute $attribute): Span|Button
                 ->addDataAttribute('yii-debug-reveal', true)
                 ->class('yii-debug-user-reveal')
                 ->html(
-                    Span::tag()->class('yii-debug-user-mask')->content('••••••••••••'),
-                    Span::tag()->class('yii-debug-user-real')->content($attribute->displayValue),
-                    Span::tag()->class('yii-debug-user-reveal-cta')->addAttribute('aria-hidden', 'true'),
+                    Span::tag()
+                        ->class('yii-debug-user-mask')
+                        ->content('••••••••••••'),
+                    Span::tag()
+                        ->class('yii-debug-user-real')
+                        ->content($attribute->displayValue),
+                    Span::tag()
+                        ->class('yii-debug-user-reveal-cta')
+                        ->addAttribute('aria-hidden', 'true'),
                 )
                 ->type('button');
         }
@@ -152,8 +157,12 @@ private static function renderValue(UserAttribute $attribute): Span|Button
                 ->class('yii-debug-user-time')
                 ->title($attribute->displayValue)
                 ->html(
-                    Span::tag()->class('yii-debug-user-time-rel')->content($attribute->timestampRel),
-                    Span::tag()->class('yii-debug-user-time-abs')->content($attribute->timestampAbs),
+                    Span::tag()
+                        ->class('yii-debug-user-time-rel')
+                        ->content($attribute->timestampRel),
+                    Span::tag()
+                        ->class('yii-debug-user-time-abs')
+                        ->content($attribute->timestampAbs),
                 );
         }
 
diff --git a/src/Panel/User/UserRbacRenderer.php b/src/Panel/User/UserRbacRenderer.php
index 81430dc..4b96945 100644
--- a/src/Panel/User/UserRbacRenderer.php
+++ b/src/Panel/User/UserRbacRenderer.php
@@ -6,6 +6,8 @@
 
 use UIAwesome\Html\Heading\H2;
 
+use function implode;
+
 /**
  * Composes the shared Roles and Permissions section around adapter-owned grid markup.
  */
@@ -22,20 +24,20 @@ final class UserRbacRenderer
      */
     public static function render(string|null $rolesGrid, string|null $permissionsGrid): string
     {
-        $html = '';
+        $sections = [];
 
         if ($rolesGrid !== null) {
-            $html .= H2::tag()
+            $sections[] = H2::tag()
                 ->content('Roles')
                 ->render() . $rolesGrid;
         }
 
         if ($permissionsGrid !== null) {
-            $html .= H2::tag()
+            $sections[] = H2::tag()
                 ->content('Permissions')
                 ->render() . $permissionsGrid;
         }
 
-        return $html;
+        return implode('', $sections);
     }
 }
diff --git a/src/Panel/User/UserRbacRow.php b/src/Panel/User/UserRbacRow.php
index fcb2961..07bc009 100644
--- a/src/Panel/User/UserRbacRow.php
+++ b/src/Panel/User/UserRbacRow.php
@@ -9,13 +9,6 @@
 
 /**
  * Represents one RBAC item row (role or permission) in the User panel detail view.
- *
- * Usage example:
- *
- * ```php
- * $row = \PHPForge\Debug\Panel\User\UserRbacRow::fromArray($rawItem);
- * echo $row->name;
- * ```
  */
 final readonly class UserRbacRow
 {
@@ -39,19 +32,6 @@ public function __construct(
     /**
      * Builds a row from the normalized array shape produced by RBAC adapters.
      *
-     * Usage example:
-     *
-     * ```php
-     * $row = \PHPForge\Debug\Panel\User\UserRbacRow::fromArray([
-     *     'name' => 'admin',
-     *     'description' => 'Administrator',
-     *     'ruleName' => '',
-     *     'data' => '',
-     *     'createdAt' => 1700000000,
-     *     'updatedAt' => 1700000001,
-     * ]);
-     * ```
-     *
      * @param array $row Associative array with keys `name`, `description`, `ruleName`, `data`,
      * `createdAt`, and `updatedAt`.
      */
diff --git a/src/PhpInfo/PhpInfoDataNormalizer.php b/src/PhpInfo/PhpInfoDataNormalizer.php
index 851fc18..b5e3508 100644
--- a/src/PhpInfo/PhpInfoDataNormalizer.php
+++ b/src/PhpInfo/PhpInfoDataNormalizer.php
@@ -113,12 +113,6 @@ final class PhpInfoDataNormalizer
     /**
      * Captures the {@see phpinfo()} report of the current process and narrows it into the typed {@see PhpInfoView}.
      *
-     * Usage example:
-     *
-     * ```php
-     * $view = \PHPForge\Debug\PhpInfo\PhpInfoDataNormalizer::capture();
-     * ```
-     *
      * @return PhpInfoView Typed view-model for the running PHP process.
      */
     public static function capture(): PhpInfoView
@@ -420,7 +414,12 @@ private static function buildTile(string $label, string $value, string $home): P
             );
         }
 
-        return new PhpInfoTile(label: $label, displayValue: $value, rawValue: $value, kind: PhpInfoTile::KIND_TEXT);
+        return new PhpInfoTile(
+            label: $label,
+            displayValue: $value,
+            rawValue: $value,
+            kind: PhpInfoTile::KIND_TEXT,
+        );
     }
 
     /**
@@ -584,7 +583,11 @@ private static function extractCompactModule(
             return null;
         }
 
-        return new PhpInfoCompactModule(title: $title, slug: $slug, tiles: $tiles);
+        return new PhpInfoCompactModule(
+            title: $title,
+            slug: $slug,
+            tiles: $tiles,
+        );
     }
 
     /**
diff --git a/src/PhpInfo/PhpInfoModuleGroup.php b/src/PhpInfo/PhpInfoModuleGroup.php
index dc5d073..32d28a3 100644
--- a/src/PhpInfo/PhpInfoModuleGroup.php
+++ b/src/PhpInfo/PhpInfoModuleGroup.php
@@ -11,15 +11,6 @@
 
 /**
  * Groups {@see phpinfo()} modules by the job they perform.
- *
- * Drives the TOC sidebar layout, the "Loaded extensions" buckets, and the extension/non-extension split that keeps
- * PHP Variables, PHP Credits, and the other reporting blocks out of the extension list.
- *
- * Usage example:
- * ```php
- * \PHPForge\Debug\PhpInfo\PhpInfoModuleGroup::resolve('pdo_mysql');    // 'Database'
- * \PHPForge\Debug\PhpInfo\PhpInfoModuleGroup::isExtension('PHP License'); // false
- * ```
  */
 final class PhpInfoModuleGroup
 {
diff --git a/src/PhpInfo/PhpInfoRenderer.php b/src/PhpInfo/PhpInfoRenderer.php
index f957db4..c10509d 100644
--- a/src/PhpInfo/PhpInfoRenderer.php
+++ b/src/PhpInfo/PhpInfoRenderer.php
@@ -21,11 +21,6 @@
 
 /**
  * Renders the phpinfo page.
- *
- * Stateless static helpers: the public entry point takes a typed {@see PhpInfoView} and emits the shell (TOC sidebar
- * + main column with the search input, the Overview hero, the Configure Command details disclosure, and the modules
- * HTML). Per-section / per-tile rendering branches live in private helpers, so the view template collapses to a single
- * `render()` call.
  */
 final class PhpInfoRenderer
 {
diff --git a/src/Storage/ArrayPayloadSnapshot.php b/src/Storage/ArrayPayloadSnapshot.php
index 6d5f972..a5ace4e 100644
--- a/src/Storage/ArrayPayloadSnapshot.php
+++ b/src/Storage/ArrayPayloadSnapshot.php
@@ -7,9 +7,6 @@
 /**
  * Provides the capture/hydrate/serialize cycle for panels whose payload stays genuinely dynamic (application
  * configuration, dumped values, and user-configured request globals).
- *
- * The using class returns the JSON key its payload is persisted under from {@see payloadKey()}, and exposes the payload
- * through a domain-named accessor delegating to {@see values()}.
  */
 trait ArrayPayloadSnapshot
 {
@@ -23,20 +20,6 @@ final public function __construct(private readonly DebugArray $payload) {}
     /**
      * Captures arbitrary array values in a JSON-safe snapshot.
      *
-     * Usage example:
-     *
-     * ```php
-     * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) {
-     *     use \PHPForge\Debug\Storage\ArrayPayloadSnapshot;
-     *
-     *     protected static function payloadKey(): string
-     *     {
-     *         return 'data';
-     *     }
-     * };
-     * $snapshot = $prototype::capture(['enabled' => true]);
-     * ```
-     *
      * @param array $values Raw payload captured for the request.
      *
      * @return self Snapshot containing tagged debug values.
@@ -51,21 +34,6 @@ public static function capture(array $values): self
     /**
      * Hydrates a dynamic payload from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) {
-     *     use \PHPForge\Debug\Storage\ArrayPayloadSnapshot;
-     *
-     *     protected static function payloadKey(): string
-     *     {
-     *         return 'data';
-     *     }
-     * };
-     * $data = $prototype::capture(['enabled' => true])->jsonSerialize();
-     * $snapshot = $prototype::fromArray($data, '$.panel');
-     * ```
-     *
      * @param mixed $data Decoded JSON payload.
      * @param string $path Payload path used in hydration errors.
      *
@@ -85,20 +53,6 @@ public static function fromArray(mixed $data, string $path): self
     /**
      * Returns the tagged payload for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) {
-     *     use \PHPForge\Debug\Storage\ArrayPayloadSnapshot;
-     *
-     *     protected static function payloadKey(): string
-     *     {
-     *         return 'data';
-     *     }
-     * };
-     * $data = $prototype::capture(['enabled' => true])->jsonSerialize();
-     * ```
-     *
      * @return array Tagged payload indexed by its persistence key.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/DebugArray.php b/src/Storage/DebugArray.php
index e9f3752..508e8e7 100644
--- a/src/Storage/DebugArray.php
+++ b/src/Storage/DebugArray.php
@@ -22,31 +22,20 @@ private function __construct(private DebugValue $value) {}
     /**
      * Captures an array as tagged debug data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $array = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true]);
-     * ```
-     *
      * @param array $value PHP values to capture.
      *
      * @return self Tagged array facade.
      */
     public static function capture(#[SensitiveParameter] array $value): self
     {
-        return new self(DebugValue::capture($value));
+        return new self(
+            DebugValue::capture($value),
+        );
     }
 
     /**
      * Hydrates a tagged debug array from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize();
-     * $array = \PHPForge\Debug\Storage\DebugArray::fromArray($data, '$.panel.data');
-     * ```
-     *
      * @param mixed $value Decoded tagged value.
      * @param string $path Payload path used in hydration errors.
      *
@@ -63,18 +52,14 @@ public static function fromArray(mixed $value, string $path): self
             );
         }
 
-        return new self($debugValue);
+        return new self(
+            $debugValue,
+        );
     }
 
     /**
      * Returns the tagged array for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize();
-     * ```
-     *
      * @return array Tagged array payload.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/DebugSnapshot.php b/src/Storage/DebugSnapshot.php
index e46b291..88f53ee 100644
--- a/src/Storage/DebugSnapshot.php
+++ b/src/Storage/DebugSnapshot.php
@@ -27,12 +27,6 @@ public function __construct(public RequestSummary $summary, public array $panels
     /**
      * Hydrates a versioned request envelope from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $snapshot = \PHPForge\Debug\Storage\DebugSnapshot::fromArray($data);
-     * ```
-     *
      * @param mixed $data Decoded snapshot envelope.
      *
      * @return self Hydrated request snapshot.
@@ -79,12 +73,6 @@ public static function fromArray(mixed $data): self
     /**
      * Returns the request envelope for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = $snapshot->jsonSerialize();
-     * ```
-     *
      * @return array Versioned request envelope.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php
index 2a934be..2e947cd 100644
--- a/src/Storage/DebugValue.php
+++ b/src/Storage/DebugValue.php
@@ -17,7 +17,6 @@
 use function array_key_first;
 use function base64_decode;
 use function base64_encode;
-use function count;
 use function get_object_vars;
 use function get_resource_type;
 use function in_array;
@@ -92,12 +91,6 @@ private function __construct(
     /**
      * Captures an arbitrary PHP value as JSON-safe tagged data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $value = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true]);
-     * ```
-     *
      * @param mixed $value PHP value to capture.
      *
      * @return self Tagged debug value.
@@ -114,12 +107,6 @@ public static function capture(#[SensitiveParameter] mixed $value): self
     /**
      * Hydrates a tagged debug value from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $value = \PHPForge\Debug\Storage\DebugValue::fromArray(['type' => 'int', 'value' => 42]);
-     * ```
-     *
      * @param mixed $data Decoded tagged value.
      * @param string $path Payload path used in hydration errors.
      *
@@ -135,12 +122,6 @@ public static function fromArray(mixed $data, string $path = '$'): self
     /**
      * Returns the tagged value for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->jsonSerialize();
-     * ```
-     *
      * @return array Tagged debug value payload.
      */
     public function jsonSerialize(): array
@@ -786,10 +767,6 @@ private static function validateShape(array $payload, array $shape, string $path
             }
         }
 
-        if (count($payload) === count($shape)) {
-            return;
-        }
-
         $unknown = array_diff_key($payload, $shape);
 
         if ($unknown !== []) {
diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php
index 07e25e4..af172b3 100644
--- a/src/Storage/ExceptionSnapshot.php
+++ b/src/Storage/ExceptionSnapshot.php
@@ -67,13 +67,6 @@ public function __toString(): string
     /**
      * Hydrates a throwable snapshot from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $captured = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('Failed.'));
-     * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromArray($captured->jsonSerialize());
-     * ```
-     *
      * @param mixed $data Decoded throwable payload.
      * @param string $path Payload path used in hydration errors.
      *
@@ -152,14 +145,6 @@ class: $payload->string('class'),
     /**
      * Captures a throwable and its previous-exception chain without executable state.
      *
-     * Usage example:
-     *
-     * ```php
-     * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(
-     *     new \RuntimeException('Capture failed.'),
-     * );
-     * ```
-     *
      * @param Throwable $throwable Throwable to capture.
      *
      * @return self Captured throwable snapshot.
@@ -207,12 +192,6 @@ class: Json::safeString($throwable::class),
     /**
      * Returns the captured throwable class.
      *
-     * Usage example:
-     *
-     * ```php
-     * $class = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getClass();
-     * ```
-     *
      * @return string Captured throwable class.
      */
     public function getClass(): string
@@ -223,12 +202,6 @@ public function getClass(): string
     /**
      * Returns the captured throwable code.
      *
-     * Usage example:
-     *
-     * ```php
-     * $code = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('', 42))->getCode();
-     * ```
-     *
      * @return int|string Captured throwable code.
      */
     public function getCode(): int|string
@@ -239,12 +212,6 @@ public function getCode(): int|string
     /**
      * Returns the file where the throwable originated.
      *
-     * Usage example:
-     *
-     * ```php
-     * $file = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getFile();
-     * ```
-     *
      * @return string Origin file path.
      */
     public function getFile(): string
@@ -255,12 +222,6 @@ public function getFile(): string
     /**
      * Returns the line where the throwable originated.
      *
-     * Usage example:
-     *
-     * ```php
-     * $line = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getLine();
-     * ```
-     *
      * @return int Origin line number.
      */
     public function getLine(): int
@@ -271,14 +232,6 @@ public function getLine(): int
     /**
      * Returns the captured throwable message.
      *
-     * Usage example:
-     *
-     * ```php
-     * $message = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(
-     *     new \RuntimeException('Capture failed.'),
-     * )->getMessage();
-     * ```
-     *
      * @return string Captured throwable message.
      */
     public function getMessage(): string
@@ -289,15 +242,6 @@ public function getMessage(): string
     /**
      * Returns the previous throwable snapshot or `null`.
      *
-     * Usage example:
-     *
-     * ```php
-     * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(
-     *     new \RuntimeException('Outer.', 0, new \LogicException('Inner.')),
-     * );
-     * $previous = $snapshot->getPrevious();
-     * ```
-     *
      * @return self|null Previous throwable snapshot or `null`.
      */
     public function getPrevious(): self|null
@@ -308,12 +252,6 @@ public function getPrevious(): self|null
     /**
      * Returns the trace frames with their arguments projected to plain display values.
      *
-     * Usage example:
-     *
-     * ```php
-     * $trace = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getTrace();
-     * ```
-     *
      * @return list> Display-safe trace frames.
      */
     public function getTrace(): array
@@ -327,14 +265,6 @@ public function getTrace(): array
     /**
      * Returns the throwable snapshot for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(
-     *     new \RuntimeException('Capture failed.'),
-     * )->jsonSerialize();
-     * ```
-     *
      * @return array Serialized throwable snapshot.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/HydrationException.php b/src/Storage/HydrationException.php
index 427dde7..afab278 100644
--- a/src/Storage/HydrationException.php
+++ b/src/Storage/HydrationException.php
@@ -14,12 +14,6 @@ final class HydrationException extends RuntimeException
     /**
      * Creates an exception that identifies an invalid payload path and its expected value.
      *
-     * Usage example:
-     *
-     * ```php
-     * throw \PHPForge\Debug\Storage\HydrationException::at('$.summary.statusCode', 'an integer');
-     * ```
-     *
      * @param string $path Path of the invalid payload value.
      * @param string $expected Description of the expected value.
      *
diff --git a/src/Storage/Json.php b/src/Storage/Json.php
index 44d8047..77dfef1 100644
--- a/src/Storage/Json.php
+++ b/src/Storage/Json.php
@@ -21,12 +21,6 @@ private function __construct() {}
     /**
      * Returns valid UTF-8, representing binary text as base64.
      *
-     * Usage example:
-     *
-     * ```php
-     * $label = \PHPForge\Debug\Storage\Json::safeString("\xB1\x31");
-     * ```
-     *
      * @param string $value String to normalize.
      *
      * @return string JSON-safe string.
diff --git a/src/Storage/Manifest.php b/src/Storage/Manifest.php
index a24871e..c8c0fd7 100644
--- a/src/Storage/Manifest.php
+++ b/src/Storage/Manifest.php
@@ -23,12 +23,6 @@ public function __construct(public array $entries) {}
     /**
      * Hydrates a versioned manifest from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $manifest = \PHPForge\Debug\Storage\Manifest::fromArray($data);
-     * ```
-     *
      * @param mixed $data Decoded manifest payload.
      *
      * @return self Hydrated request-summary index.
@@ -73,12 +67,6 @@ public static function fromArray(mixed $data): self
     /**
      * Returns the versioned manifest for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = (new \PHPForge\Debug\Storage\Manifest([]))->jsonSerialize();
-     * ```
-     *
      * @return array Versioned manifest payload.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/ManifestReadResult.php b/src/Storage/ManifestReadResult.php
index 0f3cd2d..daa6c5f 100644
--- a/src/Storage/ManifestReadResult.php
+++ b/src/Storage/ManifestReadResult.php
@@ -6,9 +6,6 @@
 
 /**
  * Exposes a manifest read together with an optional storage diagnostic.
- *
- * An empty {@see $entries} list with no {@see $error} represents a valid empty store. Consumers that only need the
- * legacy fail-closed behavior can continue to use {@see SnapshotStore::loadManifest()}.
  */
 final readonly class ManifestReadResult
 {
diff --git a/src/Storage/PanelFailure.php b/src/Storage/PanelFailure.php
index 46b6d61..36c3c50 100644
--- a/src/Storage/PanelFailure.php
+++ b/src/Storage/PanelFailure.php
@@ -26,12 +26,6 @@ public function __construct(public string $stage, public ExceptionSnapshot $exce
     /**
      * Hydrates a panel failure from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $failure = \PHPForge\Debug\Storage\PanelFailure::fromArray($data, '$.failures.log');
-     * ```
-     *
      * @param mixed $data Decoded panel failure payload.
      * @param string $path Payload path used in hydration errors.
      *
@@ -65,15 +59,6 @@ public static function fromArray(mixed $data, string $path): self
     /**
      * Captures a throwable raised during a panel lifecycle stage.
      *
-     * Usage example:
-     *
-     * ```php
-     * $failure = \PHPForge\Debug\Storage\PanelFailure::fromThrowable(
-     *     \PHPForge\Debug\Storage\PanelFailure::CAPTURE,
-     *     new \RuntimeException('Capture failed.'),
-     * );
-     * ```
-     *
      * @param 'capture'|'hydrate' $stage Lifecycle stage the panel failed in.
      * @param Throwable $throwable Panel exception to capture.
      *
@@ -90,15 +75,6 @@ public static function fromThrowable(string $stage, Throwable $throwable): self
     /**
      * Returns the failure record for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\PanelFailure::fromThrowable(
-     *     \PHPForge\Debug\Storage\PanelFailure::CAPTURE,
-     *     new \RuntimeException('Capture failed.'),
-     * )->jsonSerialize();
-     * ```
-     *
      * @return array Serialized failure stage and exception.
      */
     public function jsonSerialize(): array
diff --git a/src/Storage/PanelRow.php b/src/Storage/PanelRow.php
index aea5367..39a2ca4 100644
--- a/src/Storage/PanelRow.php
+++ b/src/Storage/PanelRow.php
@@ -8,21 +8,12 @@
 
 /**
  * Defines a typed row held by a panel snapshot.
- *
- * Rows are narrowed once, at capture time, and persisted in that typed form; hydration restores them through
- * {@see Payload} without coercion.
  */
 interface PanelRow extends JsonSerializable
 {
     /**
      * Returns the typed row for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = $row->jsonSerialize();
-     * ```
-     *
      * @return array Serialized row fields.
      */
     public function jsonSerialize(): array;
diff --git a/src/Storage/PanelSnapshot.php b/src/Storage/PanelSnapshot.php
index 7bcaeb2..d15d7d6 100644
--- a/src/Storage/PanelSnapshot.php
+++ b/src/Storage/PanelSnapshot.php
@@ -14,12 +14,6 @@ interface PanelSnapshot extends JsonSerializable
     /**
      * Returns the panel snapshot for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = $snapshot->jsonSerialize();
-     * ```
-     *
      * @return array Serialized panel fields.
      */
     public function jsonSerialize(): array;
diff --git a/src/Storage/Payload.php b/src/Storage/Payload.php
index e1f6728..16b516b 100644
--- a/src/Storage/Payload.php
+++ b/src/Storage/Payload.php
@@ -34,12 +34,6 @@ private function __construct(private array $data, private string $path) {}
     /**
      * Returns every decoded field without conversion.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->all();
-     * ```
-     *
      * @return array Decoded object fields.
      */
     public function all(): array
@@ -50,12 +44,6 @@ public function all(): array
     /**
      * Returns a required boolean field.
      *
-     * Usage example:
-     *
-     * ```php
-     * $enabled = \PHPForge\Debug\Storage\Payload::object(['enabled' => true])->bool('enabled');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return bool Boolean field value.
@@ -77,13 +65,6 @@ public function bool(string $key): bool
     /**
      * Reads a tagged array value, keeping the path of the enclosing payload for error reporting.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize();
-     * $array = \PHPForge\Debug\Storage\Payload::object(['data' => $data])->debugArray('data');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return DebugArray Hydrated tagged array.
@@ -96,12 +77,6 @@ public function debugArray(string $key): DebugArray
     /**
      * Returns a required integer field.
      *
-     * Usage example:
-     *
-     * ```php
-     * $count = \PHPForge\Debug\Storage\Payload::object(['count' => 3])->int('count');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return int Integer field value.
@@ -123,12 +98,6 @@ public function int(string $key): int
     /**
      * Returns a required list field.
      *
-     * Usage example:
-     *
-     * ```php
-     * $items = \PHPForge\Debug\Storage\Payload::object(['items' => ['one', 'two']])->list('items');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return list List field value.
@@ -150,12 +119,6 @@ public function list(string $key): array
     /**
      * Returns a required JSON object as a `string`-keyed array.
      *
-     * Usage example:
-     *
-     * ```php
-     * $options = \PHPForge\Debug\Storage\Payload::object(['options' => ['enabled' => true]])->map('options');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return array Object field value.
@@ -168,12 +131,6 @@ public function map(string $key): array
     /**
      * Returns an integer field or `null`.
      *
-     * Usage example:
-     *
-     * ```php
-     * $line = \PHPForge\Debug\Storage\Payload::object(['line' => null])->nullableInt('line');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return int|null Integer field value or `null`.
@@ -199,12 +156,6 @@ public function nullableInt(string $key): int|null
     /**
      * Returns a numeric field as a float or `null`.
      *
-     * Usage example:
-     *
-     * ```php
-     * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->nullableNumber('duration');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return float|null Numeric field value or `null`.
@@ -230,12 +181,6 @@ public function nullableNumber(string $key): float|null
     /**
      * Returns a string field or `null`.
      *
-     * Usage example:
-     *
-     * ```php
-     * $action = \PHPForge\Debug\Storage\Payload::object(['action' => null])->nullableString('action');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return string|null String field value or `null`.
@@ -261,12 +206,6 @@ public function nullableString(string $key): string|null
     /**
      * Returns a required numeric field as a float.
      *
-     * Usage example:
-     *
-     * ```php
-     * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->number('duration');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return float Numeric field value.
@@ -288,12 +227,6 @@ public function number(string $key): float
     /**
      * Creates a strict reader for a decoded JSON object.
      *
-     * Usage example:
-     *
-     * ```php
-     * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'], '$.panel');
-     * ```
-     *
      * @param mixed $value Decoded JSON value.
      * @param string $path Object path used in hydration errors.
      *
@@ -327,12 +260,6 @@ public static function object(mixed $value, string $path = '$'): self
     /**
      * Returns a required field without conversion.
      *
-     * Usage example:
-     *
-     * ```php
-     * $value = \PHPForge\Debug\Storage\Payload::object(['value' => ['nested']])->raw('value');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return mixed Unconverted field value.
@@ -345,12 +272,6 @@ public function raw(string $key): mixed
     /**
      * Reads a list of JSON objects, validating each element's shape but leaving its values untouched.
      *
-     * Usage example:
-     *
-     * ```php
-     * $rows = \PHPForge\Debug\Storage\Payload::object(['rows' => [['name' => 'debug']]])->rows('rows');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return list> Validated object rows.
@@ -371,12 +292,6 @@ public function rows(string $key): array
     /**
      * Validates required, optional, and undeclared fields.
      *
-     * Usage example:
-     *
-     * ```php
-     * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->shape(['name'], ['description']);
-     * ```
-     *
      * @param list $required Required field names.
      * @param list $optional Optional field names.
      *
@@ -410,12 +325,6 @@ public function shape(array $required, array $optional = []): self
     /**
      * Returns a required `string` field.
      *
-     * Usage example:
-     *
-     * ```php
-     * $name = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->string('name');
-     * ```
-     *
      * @param string $key Required field name.
      *
      * @return string String field value.
diff --git a/src/Storage/RequestSummary.php b/src/Storage/RequestSummary.php
index 7d25ce3..8caf961 100644
--- a/src/Storage/RequestSummary.php
+++ b/src/Storage/RequestSummary.php
@@ -49,12 +49,6 @@ public function __construct(
     /**
      * Hydrates request metadata from decoded JSON data.
      *
-     * Usage example:
-     *
-     * ```php
-     * $summary = \PHPForge\Debug\Storage\RequestSummary::fromArray($data);
-     * ```
-     *
      * @param mixed $data Decoded request metadata.
      * @param string $path Payload path used in hydration errors.
      *
@@ -114,12 +108,6 @@ public static function fromArray(mixed $data, string $path = '$.summary'): self
     /**
      * Returns the request metadata for JSON serialization.
      *
-     * Usage example:
-     *
-     * ```php
-     * $data = $summary->jsonSerialize();
-     * ```
-     *
      * @return array Serialized request metadata.
      */
     public function jsonSerialize(): array
@@ -144,12 +132,6 @@ public function jsonSerialize(): array
     /**
      * Returns a copy enriched with processing time and peak memory usage.
      *
-     * Usage example:
-     *
-     * ```php
-     * $profiledSummary = $summary->withProfiling(0.015, 2_097_152);
-     * ```
-     *
      * @param float $processingTime Processing duration in seconds.
      * @param int $peakMemory Peak memory in bytes.
      *
diff --git a/src/Storage/SnapshotReadResult.php b/src/Storage/SnapshotReadResult.php
index 7a78f6c..c7cf505 100644
--- a/src/Storage/SnapshotReadResult.php
+++ b/src/Storage/SnapshotReadResult.php
@@ -6,9 +6,6 @@
 
 /**
  * Exposes a snapshot read together with an optional storage diagnostic.
- *
- * A `null` {@see $snapshot} with no {@see $error} means the requested snapshot does not exist. Consumers that only
- * need the legacy fail-closed behavior can continue to use {@see SnapshotStore::readSnapshot()}.
  */
 final readonly class SnapshotReadResult
 {
diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php
index fc6551e..58e2882 100644
--- a/src/Storage/SnapshotStore.php
+++ b/src/Storage/SnapshotStore.php
@@ -54,13 +54,6 @@ public function __construct(
 
     /**
      * Removes stored manifests, snapshots, and temporary files.
-     *
-     * Usage example:
-     *
-     * ```php
-     * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null);
-     * $store->clear();
-     * ```
      */
     public function clear(): void
     {
@@ -93,13 +86,6 @@ public function clear(): void
     /**
      * Returns manifest entries ordered from newest to oldest.
      *
-     * Usage example:
-     *
-     * ```php
-     * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null);
-     * $entries = $store->loadManifest();
-     * ```
-     *
      * @return array Newest entries first.
      */
     public function loadManifest(): array
@@ -172,13 +158,6 @@ public function loadManifestResult(): ManifestReadResult
     /**
      * Returns a stored snapshot or `null` when the tag or persisted payload is invalid.
      *
-     * Usage example:
-     *
-     * ```php
-     * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null);
-     * $snapshot = $store->readSnapshot('request-1');
-     * ```
-     *
      * @param string $tag Snapshot tag.
      *
      * @return DebugSnapshot|null Hydrated snapshot or `null` when the stored value is unavailable or invalid.
@@ -267,12 +246,6 @@ public function readSnapshotResult(string $tag): SnapshotReadResult
     /**
      * Writes a snapshot, updates the manifest, and runs garbage collection under one exclusive lock.
      *
-     * Usage example:
-     *
-     * ```php
-     * $removed = $store->writeSnapshot($snapshot, 50);
-     * ```
-     *
      * @param DebugSnapshot $snapshot Snapshot to persist.
      * @param int $historySize Maximum number of retained entries.
      *
diff --git a/src/Theme/ThemeResolver.php b/src/Theme/ThemeResolver.php
index 53c15fd..6546ebd 100644
--- a/src/Theme/ThemeResolver.php
+++ b/src/Theme/ThemeResolver.php
@@ -9,14 +9,6 @@
 
 /**
  * Resolves the effective debugger theme from the request's cookie and query parameters.
- *
- * The persisted cookie (written by the client-side theme toggle) always outranks the `yii_debug_theme` query
- * parameter, which is only a link-time snapshot; anything other than `dark` resolves to `light`.
- *
- * Usage example:
- * ```php
- * $theme = \PHPForge\Debug\Theme\ThemeResolver::resolve($request->getCookieParams(), $request->getQueryParams());
- * ```
  */
 final class ThemeResolver
 {
diff --git a/src/Toolbar/ToolbarItem.php b/src/Toolbar/ToolbarItem.php
index 29a807b..11b024c 100644
--- a/src/Toolbar/ToolbarItem.php
+++ b/src/Toolbar/ToolbarItem.php
@@ -35,15 +35,8 @@ public function __construct(
     /**
      * Returns the metric payload consumed by the toolbar runtime.
      *
-     * Usage example:
-     *
-     * ```php
-     * $payload = (new \PHPForge\Debug\Toolbar\ToolbarItem('12 ms', label: 'Time'))->jsonSerialize();
-     * ```
-     *
      * @return array{value: string, status: string, label?: string, icon?: string, title?: string, url?: string,
-     * id?: string}
-     * Serialized metric payload.
+     * id?: string} Serialized metric payload.
      */
     public function jsonSerialize(): array
     {
diff --git a/src/Toolbar/ToolbarPanel.php b/src/Toolbar/ToolbarPanel.php
index 2e66f11..1e3bb43 100644
--- a/src/Toolbar/ToolbarPanel.php
+++ b/src/Toolbar/ToolbarPanel.php
@@ -32,12 +32,6 @@ public function __construct(
     /**
      * Returns the panel payload consumed by the toolbar runtime.
      *
-     * Usage example:
-     *
-     * ```php
-     * $payload = (new \PHPForge\Debug\Toolbar\ToolbarPanel('request', 'Request'))->jsonSerialize();
-     * ```
-     *
      * @return array{
      *     id: string,
      *     title: string,
diff --git a/src/View/Grid/ActiveFilterBanner.php b/src/View/Grid/ActiveFilterBanner.php
index 46d4514..b0ea00a 100644
--- a/src/View/Grid/ActiveFilterBanner.php
+++ b/src/View/Grid/ActiveFilterBanner.php
@@ -14,18 +14,6 @@
 
 /**
  * Renders the active-filter banner above a panel grid.
- *
- * The banner surfaces every active `Prefix[attribute]` filter as a removable pill, plus a "Clear all" action. URL
- * construction stays with the caller: the removal-URL builder receives the attribute names to drop and returns the
- * rebuilt link, so every other query parameter (sort, page size, theme) is preserved by the adapter's own routing.
- *
- * Usage example:
- * ```php
- * $html = \PHPForge\Debug\View\Grid\ActiveFilterBanner::render(
- *     ['statusCode' => '404'],
- *     static fn(array $without): string => '/debug?cleared=1',
- * );
- * ```
  */
 final class ActiveFilterBanner
 {
diff --git a/src/View/Grid/RowClass.php b/src/View/Grid/RowClass.php
index ee30b7b..d698151 100644
--- a/src/View/Grid/RowClass.php
+++ b/src/View/Grid/RowClass.php
@@ -14,14 +14,6 @@ final class RowClass
     /**
      * Returns the row-attributes array carrying the `yii-debug-row-` CSS class for the given status level.
      *
-     * Accepts `success`, `info`, `warning`, `danger`, and `error` (aliased to `danger`). Unknown or empty levels yield
-     * an empty array, so the caller can splat the result safely.
-     *
-     * Usage example:
-     * ```php
-     * $attributes = \PHPForge\Debug\View\Grid\RowClass::for('danger');
-     * ```
-     *
      * @param string|null $level Status keyword, or `null` to skip the class.
      *
      * @return array Row-attributes array with the `class` key set, or `[]` for unknown/`null` levels.
@@ -34,6 +26,6 @@ public static function for(string|null $level): array
             return [];
         }
 
-        return ['class' => 'yii-debug-row-' . $normalized];
+        return ['class' => "yii-debug-row-{$normalized}"];
     }
 }
diff --git a/src/View/History/HistoryCellRenderer.php b/src/View/History/HistoryCellRenderer.php
index f2f0b62..366d3e6 100644
--- a/src/View/History/HistoryCellRenderer.php
+++ b/src/View/History/HistoryCellRenderer.php
@@ -17,10 +17,6 @@
 /**
  * Renders the History index summary header + the per-cell HTML consumed by the grid columns and the typed
  * row-attributes builder.
- *
- * Stateless static helpers; every method takes a typed {@see HistoryRow} or {@see HistorySummary} and returns a
- * ready-to-echo HTML string (or, for the row-attributes builder, the attribute map the grid consumes for ``).
- * Link targets are pre-built URL strings supplied by the adapter, so the renderer stays framework-neutral.
  */
 final class HistoryCellRenderer
 {
@@ -194,10 +190,11 @@ public static function renderSummary(HistorySummary $summary, array $bucketUrls,
         $requestLabel = $summary->totalRequests === 1 ? 'captured request' : 'captured requests';
 
         $children = [
-            Span::tag()->html(
-                Strong::tag()->content((string) $summary->totalRequests),
-                " {$requestLabel}",
-            ),
+            Span::tag()
+                ->html(
+                    Strong::tag()->content((string) $summary->totalRequests),
+                    " {$requestLabel}",
+                ),
         ];
 
         foreach ($summary->statusBuckets as $bucket) {
diff --git a/src/View/History/HistoryRow.php b/src/View/History/HistoryRow.php
index 057faa7..80c320f 100644
--- a/src/View/History/HistoryRow.php
+++ b/src/View/History/HistoryRow.php
@@ -10,9 +10,6 @@
 
 /**
  * Typed view-model for one captured-request row in the History grid.
- *
- * Projects the manifest's {@see RequestSummary} into the shape the grid renders, adding only the pre-formatted clock
- * time; every value is already typed, so no narrowing happens per cell.
  */
 final readonly class HistoryRow
 {
diff --git a/src/View/History/HistoryScale.php b/src/View/History/HistoryScale.php
index b68fa23..d658585 100644
--- a/src/View/History/HistoryScale.php
+++ b/src/View/History/HistoryScale.php
@@ -8,9 +8,6 @@
 
 /**
  * Typed capture-wide maxima for the History grid's micro-gauges.
- *
- * Computed once per page from the visible rows, so every Duration/Memory cell scales its rail against the same
- * reference.
  */
 final readonly class HistoryScale
 {
diff --git a/src/View/History/HistorySummary.php b/src/View/History/HistorySummary.php
index b0bbc1f..f060577 100644
--- a/src/View/History/HistorySummary.php
+++ b/src/View/History/HistorySummary.php
@@ -11,9 +11,6 @@
 
 /**
  * Typed aggregate view-model for the History index summary header.
- *
- * Pre-computes the captured-request total, the per-status-bucket counts + sample codes, and the unique status-code list
- * consumed by the grid's status filter dropdown.
  */
 final readonly class HistorySummary
 {
diff --git a/src/View/Sidebar/SidebarNavItem.php b/src/View/Sidebar/SidebarNavItem.php
index 30b28dd..b449cda 100644
--- a/src/View/Sidebar/SidebarNavItem.php
+++ b/src/View/Sidebar/SidebarNavItem.php
@@ -6,9 +6,6 @@
 
 /**
  * Typed view-model for one entry in the debugger sidebar panel navigation.
- *
- * Encapsulates the per-panel resolution (icon SVG, link target, tooltip text, active-state flag) so the renderer
- * stays focused on emitting markup. Used for both the 'History' entry and every registered panel link.
  */
 final readonly class SidebarNavItem
 {
diff --git a/src/View/Sidebar/SidebarRenderer.php b/src/View/Sidebar/SidebarRenderer.php
index 6a2f382..88a1ccd 100644
--- a/src/View/Sidebar/SidebarRenderer.php
+++ b/src/View/Sidebar/SidebarRenderer.php
@@ -16,12 +16,6 @@
 
 /**
  * Renders the debugger sidebar partial.
- *
- * Stateless static helpers: the public entry point takes a typed {@see SidebarView} with pre-built link targets and
- * returns ready-to-echo HTML, so the renderer stays framework-neutral.
- *
- * The snapshot card (top section) and panel-list nav (bottom section) are built by private helpers and rendered
- * directly by the shared layout.
  */
 final class SidebarRenderer
 {
@@ -29,11 +23,6 @@ final class SidebarRenderer
 
     /**
      * Renders the full sidebar (`
+ unsafe attribute + HTML; + + self::assertSame( + << +
+ safe +
safe
- unsafe attribute - HTML; - - $html = DumpCardRenderer::renderMessageCell( - self::makeRow(message: $message), - self::traceLine(), - 0, - ); - - self::assertStringContainsString( - '
safe
', - $html, - 'The exact fixed markup emitted by PHP dump highlighters must remain available.', - ); - self::assertStringContainsString( - '<span onclick="alert(1)">unsafe attribute</span>', - $html, - 'Highlighter tags with arbitrary attributes must be escaped.', - ); - self::assertStringContainsString( - '<script>alert(1)</script>', - $html, - 'Executable tags from a callback or manipulated snapshot must be escaped.', - ); - self::assertStringNotContainsString('', level: 0)); - self::assertStringContainsString( - '<script>', - $html, - 'Info content must be HTML-escaped.', - ); - self::assertStringNotContainsString( - '"]); - $html = RequestSectionRenderer::renderSection($section); - - self::assertStringNotContainsString( - '', - $html, + self::assertSame( + << +

+ Headers +

+
+ + + + + + + + + + +
+ Name + + Value +
+ X-Custom + + '\'quoted\' <script>alert(1)</script>' +
+
+ HTML, + RequestSectionRenderer::renderSection($section), 'Raw payload must never reach the rendered HTML.', ); - self::assertStringContainsString( - '<script>', - $html, - 'Tag characters must be escaped.', - ); - self::assertStringContainsString( - ''', - $html, - 'Single quotes must be escaped by ENT_QUOTES.', - ); } public function testRenderSectionRendersOneRowPerEntry(): void { $section = new RequestSection(caption: 'Headers', entries: ['a' => 'A', 'b' => 'B', 'c' => 'C']); - $html = RequestSectionRenderer::renderSection($section); - self::assertSame( 3, - substr_count($html, ''), + substr_count(RequestSectionRenderer::renderSection($section), ''), 'Each entry must produce exactly one body row.', ); } @@ -198,23 +228,23 @@ public function testRenderTabsMarksFirstTabActive(): void new RequestTab(label: 'Headers', sections: []), ]; - $html = RequestSectionRenderer::renderTabs($tabs); - - self::assertStringContainsString( - 'is-active', - $html, + self::assertSame( + << + +
+
+
+
+ HTML, + RequestSectionRenderer::renderTabs($tabs), "First tab must carry the 'is-active' class.", ); - self::assertStringContainsString( - 'aria-selected="true"', - $html, - "First tab anchor must have 'aria-selected=true'.", - ); - self::assertStringContainsString( - 'aria-selected="false"', - $html, - "Subsequent tab anchors must have 'aria-selected=false'.", - ); } public function testRenderTabsRendersNestedSections(): void @@ -222,21 +252,75 @@ public function testRenderTabsRendersNestedSections(): void $tabs = [ new RequestTab( label: 'Parameters', - sections: [new RequestSection(caption: 'Query parameters', entries: ['page' => 1])], + sections: [ + new RequestSection(caption: 'Query parameters', entries: ['page' => 1]), + new RequestSection(caption: 'Body parameters', entries: ['name' => 'Ada']), + ], ), ]; - $html = RequestSectionRenderer::renderTabs($tabs); - self::assertSame( - 1, - substr_count($html, 'Query parameters'), - 'A nested section must render exactly once.', - ); - self::assertSame( - 1, - substr_count($html, 'page'), - 'A nested section row must render exactly once.', + <<<'HTML' +
+
+
+

+ Query parameters +

+
+ + + + + + + + + + +
+ Name + + Value +
+ page + + 1 +
+
+

+ Body parameters +

+
+ + + + + + + + + + +
+ Name + + Value +
+ name + + 'Ada' +
+
+
+
+ HTML, + RequestSectionRenderer::renderTabs($tabs), + 'Every nested section must be concatenated into the exact tab-panel HTML.', ); } @@ -247,23 +331,25 @@ public function testRenderTabsWiresPanelIdsAndAriaControls(): void new RequestTab(label: 'Headers', sections: []), ]; - $html = RequestSectionRenderer::renderTabs($tabs); - - self::assertStringContainsString( - 'href="#request-panel-0"', - $html, + self::assertSame( + << + +
+
+
+
+ HTML, + RequestSectionRenderer::renderTabs($tabs), "First tab 'href' must point to 'request-panel-0'.", ); - self::assertStringContainsString( - 'aria-controls="request-panel-1"', - $html, - "Second tab 'aria-controls' must match its panel id.", - ); - self::assertStringContainsString( - 'id="request-panel-0"', - $html, - "First panel 'id' must match its tab href.", - ); + + } public function testRequestSectionDefaultsToNonFilterable(): void diff --git a/tests/Panel/Router/RouterCurrentViewTest.php b/tests/Panel/Router/RouterCurrentViewTest.php index 05b52e6..8351f60 100644 --- a/tests/Panel/Router/RouterCurrentViewTest.php +++ b/tests/Panel/Router/RouterCurrentViewTest.php @@ -10,8 +10,6 @@ /** * Unit tests for {@see RouterCurrentView} covering the snapshot-to-view projection and the empty fallback. - * - * @since 0.1 */ #[Group('panel')] #[Group('router')] @@ -33,23 +31,68 @@ public function testFromSnapshotProjectsTheHydratedSnapshot(): void $view = RouterCurrentView::fromSnapshot($snapshot); - self::assertSame('app/controllers/SiteController::actionIndex', $view->action, 'Action must project.'); - self::assertSame('site/index', $view->route, 'Route must project.'); - self::assertSame('Route requested: site/index', $view->message, 'Message must project.'); - self::assertSame(1, $view->count, 'Log count must reflect the entries.'); - self::assertTrue($view->hasMatch, 'A matching entry must flag the view.'); - self::assertCount(1, $view->logs, 'Log rows must project.'); + self::assertSame( + 'app/controllers/SiteController::actionIndex', + $view->action, + 'Action must project.', + ); + self::assertSame( + 'site/index', + $view->route, + 'Route must project.', + ); + self::assertSame( + 'Route requested: site/index', + $view->message, + 'Message must project.', + ); + self::assertSame( + 1, + $view->count, + 'Log count must reflect the entries.', + ); + self::assertTrue( + $view->hasMatch, + 'A matching entry must flag the view.', + ); + self::assertCount( + 1, + $view->logs, + 'Log rows must project.', + ); } public function testFromSnapshotReturnsEmptyViewForNull(): void { $view = RouterCurrentView::fromSnapshot(null); - self::assertSame('', $view->action, 'Empty view must carry no action.'); - self::assertSame('', $view->route, 'Empty view must carry no route.'); - self::assertNull($view->message, 'Empty view must carry no message.'); - self::assertSame(0, $view->count, 'Empty view must count zero rules.'); - self::assertFalse($view->hasMatch, 'Empty view must report no match.'); - self::assertSame([], $view->logs, 'Empty view must carry no logs.'); + self::assertSame( + '', + $view->action, + 'Empty view must carry no action.', + ); + self::assertSame( + '', + $view->route, + 'Empty view must carry no route.', + ); + self::assertNull( + $view->message, + 'Empty view must carry no message.', + ); + self::assertSame( + 0, + $view->count, + 'Empty view must count zero rules.', + ); + self::assertFalse( + $view->hasMatch, + 'Empty view must report no match.', + ); + self::assertSame( + [], + $view->logs, + 'Empty view must carry no logs.', + ); } } diff --git a/tests/Panel/Router/RouterSectionRendererTest.php b/tests/Panel/Router/RouterSectionRendererTest.php index 20c001a..c6d0228 100644 --- a/tests/Panel/Router/RouterSectionRendererTest.php +++ b/tests/Panel/Router/RouterSectionRendererTest.php @@ -39,7 +39,12 @@ public function testRenderTabsComposesFlagsTabsAndSummary(): void $current, [new RouterRuleRow('home', '/', 'GET', '', '', 'App\Web\HomePage')], [new ActionRouteRow('App\Web\HomePage', 'home', '/', 0)], - [['label' => 'FastRoute Matcher', 'variant' => 'success']], + [ + [ + 'label' => 'FastRoute Matcher', + 'variant' => 'success', + ], + ], ); self::assertSame( diff --git a/tests/Panel/SnapshotHydrationTest.php b/tests/Panel/SnapshotHydrationTest.php index c1de224..1136645 100644 --- a/tests/Panel/SnapshotHydrationTest.php +++ b/tests/Panel/SnapshotHydrationTest.php @@ -51,6 +51,7 @@ public function testAssetSnapshotHydratesBundlesAndViteManifest(): void ]; $snapshot = AssetSnapshot::fromArray($payload, '$.panels.asset'); + $bundle = $snapshot->bundles()[0] ?? self::fail('Expected one hydrated asset bundle.'); self::assertSame( @@ -109,6 +110,7 @@ public function testDatabaseSnapshotHydratesQueryRows(): void ]; $snapshot = DbSnapshot::fromArray($payload, '$.panels.db'); + $query = $snapshot->entries()[0] ?? self::fail('Expected one hydrated query row.'); self::assertSame( @@ -138,6 +140,7 @@ public function testEventSnapshotHydratesEventRows(): void ]; $snapshot = EventSnapshot::fromArray($payload, '$.panels.event'); + $event = $snapshot->entries()[0] ?? self::fail('Expected one hydrated event row.'); self::assertSame( diff --git a/tests/Panel/Timeline/TimelineGeometryTest.php b/tests/Panel/Timeline/TimelineGeometryTest.php index 2b9bd46..a5c9305 100644 --- a/tests/Panel/Timeline/TimelineGeometryTest.php +++ b/tests/Panel/Timeline/TimelineGeometryTest.php @@ -19,7 +19,13 @@ final class TimelineGeometryTest extends TestCase public function testRulersUseAdaptiveRoundSteps(): void { self::assertSame( - [0 => 0.0, 20 => 20.0, 40 => 40.0, 60 => 60.0, 80 => 80.0], + [ + 0 => 0.0, + 20 => 20.0, + 40 => 40.0, + 60 => 60.0, + 80 => 80.0, + ], TimelineGeometry::rulers(100.0), 'A 100 ms request must use uncluttered 20 ms ticks.', ); @@ -34,12 +40,26 @@ public function testRulersUseAdaptiveRoundSteps(): void 'A disabled ruler must not emit ticks.', ); self::assertSame( - [0 => 0.0, 5 => 27.77777777777778, 10 => 55.55555555555556, 15 => 83.33333333333334], + [0 => 0.0], + TimelineGeometry::rulers(100.0, 1), + 'A one-line ruler target must remain valid and retain the origin.', + ); + self::assertSame( + [ + 0 => 0.0, + 5 => 27.77777777777778, + 10 => 55.55555555555556, + 15 => 83.33333333333334, + ], TimelineGeometry::rulers(18.0), 'A normalized duration up to five must use five-unit ticks.', ); self::assertSame( - [0 => 0.0, 10 => 32.25806451612903, 20 => 64.51612903225806], + [ + 0 => 0.0, + 10 => 32.25806451612903, + 20 => 64.51612903225806, + ], TimelineGeometry::rulers(31.0), 'A normalized duration above five must use ten-unit ticks.', ); @@ -48,6 +68,87 @@ public function testRulersUseAdaptiveRoundSteps(): void TimelineGeometry::rulers(1.0), 'A duration shorter than one complete step must keep only the origin.', ); + self::assertSame( + [ + 0 => 0.0, + 1 => 16.666666666666664, + 2 => 33.33333333333333, + 3 => 50.0, 4 => 66.66666666666666, + 5 => 83.33333333333334, + ], + TimelineGeometry::rulers(6.0), + 'The default six-line target and normalized-one boundary must use one-unit ticks.', + ); + self::assertSame( + [ + 0 => 0.0, + 2 => 16.666666666666664, + 4 => 33.33333333333333, + 6 => 50.0, + 8 => 66.66666666666666, + 10 => 83.33333333333334, + ], + TimelineGeometry::rulers(12.0), + 'The normalized-two boundary must use two-unit ticks.', + ); + self::assertSame( + [ + 0 => 0.0, + 5 => 16.666666666666664, + 10 => 33.33333333333333, + 15 => 50.0, + 20 => 66.66666666666666, + 25 => 83.33333333333334, + ], + TimelineGeometry::rulers(30.0), + 'The normalized-five boundary must use five-unit ticks.', + ); + self::assertSame( + [ + 0 => 0.0, + 5 => 20.833333333333336, + 10 => 41.66666666666667, + 15 => 62.5, + 20 => 83.33333333333334, + ], + TimelineGeometry::rulers(24.0), + 'Ruler magnitude selection must use floor rather than nearest rounding.', + ); + self::assertSame( + [0 => 0.0], + TimelineGeometry::rulers(1.2), + 'A tick below the one-quarter end margin must be omitted.', + ); + self::assertSame( + [ + 0 => 0.0, + 1 => 80.0, + ], + TimelineGeometry::rulers(1.25), + 'A tick exactly on the one-quarter end margin must be retained.', + ); + self::assertSame( + [ + 0 => 0.0, + 50 => 16.666666666666664, + 100 => 33.33333333333333, + 150 => 50.0, + 200 => 66.66666666666666, + 250 => 83.33333333333334, + ], + TimelineGeometry::rulers(300.0), + 'Five-step scaling must multiply by the magnitude.', + ); + self::assertSame( + [ + 0 => 0.0, + 100 => 27.77777777777778, + 200 => 55.55555555555556, + 300 => 83.33333333333334, + ], + TimelineGeometry::rulers(360.0), + 'Ten-step scaling must multiply by the magnitude.', + ); } public function testSpansUseTheSharedRequestGeometry(): void @@ -85,7 +186,11 @@ public function testSpansUseTheSharedRequestGeometry(): void ); self::assertSame( [], - TimelineGeometry::spans([], 0.0, 0.0), + TimelineGeometry::spans( + [new ProfileRow(0.0, 1.0, 'category', 'info', 0, 0, 0, 0, [])], + 0.0, + 0.0, + ), 'A zero duration must not produce spans.', ); } diff --git a/tests/Panel/Timeline/TimelineMemoryRendererTest.php b/tests/Panel/Timeline/TimelineMemoryRendererTest.php index b6ddbe0..3aeefbf 100644 --- a/tests/Panel/Timeline/TimelineMemoryRendererTest.php +++ b/tests/Panel/Timeline/TimelineMemoryRendererTest.php @@ -31,7 +31,7 @@ public function testRenderProducesExactSharedSvg(): void HTML, TimelineMemoryRenderer::render( - [new MemorySample(1_000.0, 50), new MemorySample(1_050.0, 75)], + [new MemorySample(1_050.0, 75), new MemorySample(1_000.0, 50)], 1_000.0, 100.0, 100, @@ -54,5 +54,65 @@ public function testRenderReturnsEmptyStringForInvalidGeometry(): void TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 0.0, 1), 'A zero duration must omit the SVG.', ); + self::assertSame( + '', + TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 1.0, 0, 100, 20), + 'Zero peak memory must omit the SVG even when dimensions are valid.', + ); + self::assertSame( + '', + TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 1.0, 1, 0, 20), + 'Zero width must omit the SVG even when memory and height are valid.', + ); + self::assertSame( + '', + TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 1.0, 1, 100, 0), + 'Zero height must omit the SVG even when memory and width are valid.', + ); + } + + public function testRenderSortsSamplesByTheHorizontalCoordinateOnly(): void + { + self::assertSame( + << + + + + + + + + + HTML, + TimelineMemoryRenderer::render( + [new MemorySample(1_050.0, 150), new MemorySample(1_000.0, 0)], + 1_000.0, + 100.0, + 100, + 100, + 100, + ), + 'Memory values must not influence chronological point sorting.', + ); + } + + public function testRenderUsesTheExactDefaultDimensions(): void + { + self::assertSame( + << + + + + + + + + + HTML, + TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 1.0, 1), + 'Default SVG width and height must remain part of the exact rendering contract.', + ); } } diff --git a/tests/Panel/Timeline/TimelineRendererTest.php b/tests/Panel/Timeline/TimelineRendererTest.php index 8892947..39f2f22 100644 --- a/tests/Panel/Timeline/TimelineRendererTest.php +++ b/tests/Panel/Timeline/TimelineRendererTest.php @@ -8,6 +8,7 @@ use PHPForge\Debug\Panel\Timeline\{TimelineGeometry, TimelineRenderer}; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use ReflectionMethod; /** * Unit tests for {@see TimelineRenderer} preserving the complete cross-adapter Timeline markup contract. @@ -16,6 +17,29 @@ #[Group('timeline')] final class TimelineRendererTest extends TestCase { + public function testRenderChartDeclaresExactScalarDefaults(): void + { + $parameters = (new ReflectionMethod(TimelineRenderer::class, 'renderChart'))->getParameters(); + $defaults = []; + + foreach ($parameters as $parameter) { + if ($parameter->isDefaultValueAvailable()) { + $defaults[$parameter->getName()] = $parameter->getDefaultValue(); + } + } + + self::assertSame( + 0, + $defaults['memory'] ?? null, + 'Peak memory must default to exactly zero bytes.', + ); + self::assertSame( + 40, + $defaults['memoryHeight'] ?? null, + 'Memory chart height must default to exactly 40 pixels.', + ); + } + public function testRenderChartFormatsSecondsAndOmitsSingleCategoryLegend(): void { $rows = TimelineGeometry::spans( @@ -39,6 +63,83 @@ public function testRenderChartFormatsSecondsAndOmitsSingleCategoryLegend(): voi 'A single category must not render a redundant legend.', ); } + + public function testRenderChartFormatsTickBoundariesExactly(): void + { + $rows = TimelineGeometry::spans( + [new ProfileRow(0.0, 10.0, 'Queue\\Job', 'job', 0, 0, 0, 0, [])], + 0.0, + 100.0, + ); + + self::assertSame( + << +
+ 1 s1 s1.1 s +
+
+
+ Queue\Job +
+
+ 10.0 ms +
+
+
+
+ + HTML, + TimelineRenderer::renderChart($rows, [1_000 => 10.0, 1_049 => 20.0, 1_050 => 30.0]), + 'Millisecond-to-second boundaries and decimal trimming must render exactly.', + ); + } + + public function testRenderChartIncludesNonAdjacentLegendVariants(): void + { + $rows = TimelineGeometry::spans( + [ + new ProfileRow(0.0, 10.0, 'Yii3\\Application::handle', 'app', 0, 0, 0, 0, []), + new ProfileRow(10.0, 10.0, 'Queue\\Job', 'queue', 0, 1, 0, 0, []), + ], + 0.0, + 100.0, + ); + + self::assertSame( + << +
+
+ ApplicationQueue +
+
+
+ Yii3\Application::handle +
+
+ 10.0 ms +
+
+
+
+ Queue\Job +
+
+ 10.0 ms +
+
+
+
+ + HTML, + TimelineRenderer::renderChart($rows, []), + 'Legend traversal must retain variants separated by absent canonical categories.', + ); + } public function testRenderChartProducesExactSharedMarkup(): void { $rows = TimelineGeometry::spans( @@ -51,7 +152,7 @@ public function testRenderChartProducesExactSharedMarkup(): void ); self::assertSame( - <<<'HTML' + <<
0 ms50 ms @@ -93,10 +194,45 @@ public function testRenderChartProducesExactSharedMarkup(): void 'A chart without spans must stay empty.', ); } + + public function testRenderChartUsesExactDefaultMemoryValues(): void + { + $rows = TimelineGeometry::spans( + [new ProfileRow(0.0, 10.0, 'Queue\\Job', 'job', 0, 0, 0, 0, [])], + 0.0, + 100.0, + ); + + self::assertSame( + << +
+
+
+
+ Queue\Job +
+
+ 10.0 ms +
+
+
+
+ Memory
+ +
0.00 MB +
+ + HTML, + TimelineRenderer::renderChart($rows, [], ''), + 'The default memory value and footer height must render exactly.', + ); + } public function testRenderFilterFormProducesExactSharedMarkup(): void { self::assertSame( - <<<'HTML' + <<
@@ -118,7 +254,7 @@ public function testRenderFilterFormProducesExactSharedMarkup(): void public function testRenderHintAndSummaryProduceExactSharedMarkup(): void { self::assertSame( - <<<'HTML' + << 123 ms total·2.00 MB peak memory·2 spans
diff --git a/tests/Panel/Timeline/TimelineSpanRowTest.php b/tests/Panel/Timeline/TimelineSpanRowTest.php index 29bfe6e..c5305b5 100644 --- a/tests/Panel/Timeline/TimelineSpanRowTest.php +++ b/tests/Panel/Timeline/TimelineSpanRowTest.php @@ -10,8 +10,8 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see TimelineSpanRow} covering the category → CSS-variant mapping, the minimum-width floor and - * the multi-line tooltip composition. + * Unit tests for {@see TimelineSpanRow} covering the category → CSS-variant mapping, the minimum-width floor and the + * multi-line tooltip composition. */ #[Group('panel')] #[Group('timeline')] diff --git a/tests/Panel/User/UserDataNormalizerTest.php b/tests/Panel/User/UserDataNormalizerTest.php index 0d1f489..8c5209f 100644 --- a/tests/Panel/User/UserDataNormalizerTest.php +++ b/tests/Panel/User/UserDataNormalizerTest.php @@ -12,9 +12,9 @@ use function array_map; /** - * Unit tests for {@see UserDataNormalizer} covering the narrowing of captured identity data - * into the typed view-model: hero composition (monogram + status variant), attribute bucketing (Identity / Security / - * Timestamps / Other), VarDumper-quote stripping, sensitive-key detection and timestamp humanization. + * Unit tests for {@see UserDataNormalizer} covering the narrowing of captured identity data into the typed view-model: + * hero composition (monogram + status variant), attribute bucketing (Identity / Security / Timestamps / Other), + * VarDumper-quote stripping, sensitive-key detection and timestamp humanization. */ #[Group('panel')] #[Group('user')] @@ -134,7 +134,10 @@ public function testFromIdentityBuildsDefaultLabelsFromDotsAndUnderscores(): voi $other = $view->sections[1] ?? null; - self::assertNotNull($other, 'Other attributes section must be present.'); + self::assertNotNull( + $other, + 'Other attributes section must be present.', + ); self::assertSame( ['Preferred Locale Key'], array_map(static fn(UserAttribute $attribute): string => $attribute->label, $other->attributes), @@ -248,14 +251,46 @@ public function testFromIdentityHumanizesTimestampsAcrossEveryRelativeBucket(): } } - self::assertSame('just now', $relatives['second_59_at'] ?? null, '59 seconds must remain just now.'); - self::assertSame('1 min ago', $relatives['second_60_at'] ?? null, '60 seconds must become one minute.'); - self::assertSame('1 min ago', $relatives['second_61_at'] ?? null, '61 seconds must round down to one minute.'); - self::assertSame('59 min ago', $relatives['second_3599_at'] ?? null, '3599 seconds must round down.'); - self::assertSame('1 h ago', $relatives['second_3600_at'] ?? null, '3600 seconds must become one hour.'); - self::assertSame('23 h ago', $relatives['second_86399_at'] ?? null, '86399 seconds must round down.'); - self::assertSame('1 d ago', $relatives['second_86400_at'] ?? null, '86400 seconds must become one day.'); - self::assertSame('29 d ago', $relatives['second_2591999_at'] ?? null, 'The last sub-month second must round down.'); + self::assertSame( + 'just now', + $relatives['second_59_at'] ?? null, + '59 seconds must remain just now.', + ); + self::assertSame( + '1 min ago', + $relatives['second_60_at'] ?? null, + '60 seconds must become one minute.', + ); + self::assertSame( + '1 min ago', + $relatives['second_61_at'] ?? null, + '61 seconds must round down to one minute.', + ); + self::assertSame( + '59 min ago', + $relatives['second_3599_at'] ?? null, + '3599 seconds must round down.', + ); + self::assertSame( + '1 h ago', + $relatives['second_3600_at'] ?? null, + '3600 seconds must become one hour.', + ); + self::assertSame( + '23 h ago', + $relatives['second_86399_at'] ?? null, + '86399 seconds must round down.', + ); + self::assertSame( + '1 d ago', + $relatives['second_86400_at'] ?? null, + '86400 seconds must become one day.', + ); + self::assertSame( + '29 d ago', + $relatives['second_2591999_at'] ?? null, + 'The last sub-month second must round down.', + ); self::assertSame( date('M j, Y · H:i', self::NOW - 2592000), $relatives['second_2592000_at'] ?? null, diff --git a/tests/Panel/User/UserIdentityRendererTest.php b/tests/Panel/User/UserIdentityRendererTest.php index 59f8485..cbe1cb6 100644 --- a/tests/Panel/User/UserIdentityRendererTest.php +++ b/tests/Panel/User/UserIdentityRendererTest.php @@ -47,21 +47,34 @@ public function testRenderEmitsTimestampRelativeAndAbsoluteParts(): void $html = UserIdentityRenderer::render($view); - self::assertStringContainsString( - '28 d ago', + self::assertSame( + << +
+
+

+ admin +

+
+
+
+
+ Timestamps +
+
+
+ Created At +
+ 28 d agoApr 13, 2026 · 14:19 +
+
+
+
+ + HTML, $html, 'Relative time must surface in the row.', ); - self::assertStringContainsString( - 'Apr 13, 2026', - $html, - 'Absolute time must surface in the row.', - ); - self::assertStringContainsString( - 'yii-debug-user-time', - $html, - 'Timestamp rows must carry the time CSS class.', - ); } public function testRenderEmitsTwoButtonsForSecurityAttributes(): void @@ -97,21 +110,40 @@ public function testRenderEmitsTwoButtonsForSecurityAttributes(): void substr_count($html, ' +
+
+

+ admin +

+
+
+
+
+ Security +
+
+
+ Auth Key +
+ +
+
+
+ Password Hash +
+ +
+
+
+
+ + HTML, $html, 'Reveal buttons must carry the JS hook attribute.', ); - self::assertStringContainsString( - 'aria-label="Reveal Auth Key"', - $html, - 'The reveal control must expose its complete accessible label.', - ); - self::assertStringContainsString( - 'data-yii-debug-reveal="true"', - $html, - 'The JS hook must retain its enabled boolean value.', - ); } public function testRenderHeroEmitsAvatarMonogramAndStatusVariant(): void @@ -130,31 +162,25 @@ public function testRenderHeroEmitsAvatarMonogramAndStatusVariant(): void $html = UserIdentityRenderer::render($view); - self::assertStringContainsString( - 'A', + self::assertSame( + << +
+
+

+ admin +

+ admin@example.com +

+ ActiveID #1 +
+
+
+ + HTML, $html, 'Monogram must render inside the avatar span.', ); - self::assertStringContainsString( - 'yii-debug-user-status-success', - $html, - 'Status variant must surface as the CSS modifier.', - ); - self::assertStringContainsString( - 'admin@example.com', - $html, - 'Email must surface in the handle paragraph.', - ); - self::assertStringContainsString( - 'yii-debug-user-handle', - $html, - 'Handle paragraph must use the handle CSS class.', - ); - self::assertStringContainsString( - 'ID #1', - $html, - 'ID pill must surface with the `#` prefix.', - ); } public function testRenderHeroOmitsEmailWhenMissing(): void @@ -173,8 +199,19 @@ public function testRenderHeroOmitsEmailWhenMissing(): void $html = UserIdentityRenderer::render($view); - self::assertStringNotContainsString( - 'yii-debug-user-handle', + self::assertSame( + <<<'HTML' +
+
+
+

+ admin +

+
+
+
+
+ HTML, $html, 'Empty email must drop the handle paragraph entirely.', ); @@ -196,8 +233,19 @@ public function testRenderHeroOmitsStatusPillWhenLabelEmpty(): void $html = UserIdentityRenderer::render($view); - self::assertStringNotContainsString( - 'yii-debug-user-status', + self::assertSame( + << +
+
+

+ admin +

+
+
+
+ + HTML, $html, 'Empty status label must drop the status pill.', ); @@ -225,16 +273,34 @@ public function testRenderSurfacesEmptyDashForEmptyAttribute(): void $html = UserIdentityRenderer::render($view); - self::assertStringContainsString( - 'yii-debug-user-empty', + self::assertSame( + << +
+
+

+ admin +

+
+
+
+
+ Security +
+
+
+ Token +
+ +
+
+
+
+ + HTML, $html, 'Empty rows must surface the dedicated CSS class.', ); - self::assertStringContainsString( - '—', - $html, - 'Empty rows must show the em-dash placeholder.', - ); } public function testRenderWiresFullPipelineThroughNormalizer(): void @@ -253,31 +319,73 @@ public function testRenderWiresFullPipelineThroughNormalizer(): void $html = UserIdentityRenderer::render($view); - self::assertStringContainsString( - 'yii-debug-user-name', + self::assertSame( + << +
+
+

+ admin +

+ admin@example.com +

+ ActiveID #1 +
+
+
+
+ Identity +
+
+
+ Id +
+ 1 +
+
+
+ Username +
+ admin +
+
+
+ Email +
+ admin@example.com +
+
+
+
+
+ Security +
+
+
+ Auth Key +
+ +
+
+
+
+
+ Timestamps +
+
+
+ Created At +
+ Dec 20, 2021 · 11:33Dec 20, 2021 · 11:33 +
+
+
+
+ + HTML, $html, 'End-to-end view must surface the user-name heading.', ); - self::assertStringContainsString( - 'admin', - $html, - 'End-to-end username must reach the rendered DOM.' - ); - self::assertStringContainsString( - 'yii-debug-user-status-success', - $html, - 'End-to-end status variant must reach the DOM.' - ); - self::assertStringContainsString( - 'data-yii-debug-reveal', - $html, - 'End-to-end auth_key must trigger the security reveal button.' - ); - self::assertStringContainsString( - 'yii-debug-user-time', - $html, - 'End-to-end created_at must trigger the timestamp formatter.' - ); } private function emptyHero(): UserIdentityHero diff --git a/tests/Panel/User/UserRbacRowTest.php b/tests/Panel/User/UserRbacRowTest.php index 4904d44..d2a8679 100644 --- a/tests/Panel/User/UserRbacRowTest.php +++ b/tests/Panel/User/UserRbacRowTest.php @@ -27,12 +27,36 @@ public function testConstructorExposesAllPropertiesVerbatim(): void updatedAt: 1_700_000_001, ); - self::assertSame('admin', $row->name, 'Name must be exposed verbatim.'); - self::assertSame('Administrator', $row->description, 'Description must be exposed verbatim.'); - self::assertSame('isAdmin', $row->ruleName, 'Rule name must be exposed verbatim.'); - self::assertSame('{"scope":"all"}', $row->data, 'Data must be exposed verbatim.'); - self::assertSame(1_700_000_000, $row->createdAt, 'Created-at timestamp must be exposed verbatim.'); - self::assertSame(1_700_000_001, $row->updatedAt, 'Updated-at timestamp must be exposed verbatim.'); + self::assertSame( + 'admin', + $row->name, + 'Name must be exposed verbatim.', + ); + self::assertSame( + 'Administrator', + $row->description, + 'Description must be exposed verbatim.', + ); + self::assertSame( + 'isAdmin', + $row->ruleName, + 'Rule name must be exposed verbatim.', + ); + self::assertSame( + '{"scope":"all"}', + $row->data, + 'Data must be exposed verbatim.', + ); + self::assertSame( + 1_700_000_000, + $row->createdAt, + 'Created-at timestamp must be exposed verbatim.', + ); + self::assertSame( + 1_700_000_001, + $row->updatedAt, + 'Updated-at timestamp must be exposed verbatim.', + ); } public function testFromArrayCastsNumericStringTimestampsToInt(): void @@ -45,8 +69,16 @@ public function testFromArrayCastsNumericStringTimestampsToInt(): void ], ); - self::assertSame(1_700_000_000, $row->createdAt, 'Numeric string must be cast to `int`.'); - self::assertSame(1_700_000_001, $row->updatedAt, 'Numeric string must be cast to `int`.'); + self::assertSame( + 1_700_000_000, + $row->createdAt, + 'Numeric string must be cast to `int`.', + ); + self::assertSame( + 1_700_000_001, + $row->updatedAt, + 'Numeric string must be cast to `int`.', + ); } public function testFromArrayCoercesNonStringTextualFieldsToEmptyStrings(): void @@ -62,22 +94,60 @@ public function testFromArrayCoercesNonStringTextualFieldsToEmptyStrings(): void ], ); - self::assertSame('', $row->name, 'Non-string name must collapse to an empty `string`.'); - self::assertSame('', $row->description, 'Non-string description must collapse to an empty `string`.'); - self::assertSame('', $row->ruleName, 'Non-string rule name must collapse to an empty `string`.'); - self::assertSame('', $row->data, 'Non-string data must collapse to an empty `string`.'); + self::assertSame( + '', + $row->name, + 'Non-string name must collapse to an empty `string`.', + ); + self::assertSame( + '', + $row->description, + 'Non-string description must collapse to an empty `string`.', + ); + self::assertSame( + '', + $row->ruleName, + 'Non-string rule name must collapse to an empty `string`.', + ); + self::assertSame( + '', + $row->data, + 'Non-string data must collapse to an empty `string`.', + ); } public function testFromArrayDefaultsMissingKeysToEmptyStringsAndNullTimestamps(): void { $row = UserRbacRow::fromArray([]); - self::assertSame('', $row->name, 'Missing name must default to an empty `string`.'); - self::assertSame('', $row->description, 'Missing description must default to an empty `string`.'); - self::assertSame('', $row->ruleName, 'Missing rule name must default to an empty `string`.'); - self::assertSame('', $row->data, 'Missing data must default to an empty `string`.'); - self::assertNull($row->createdAt, 'Missing created-at must default to `null`.'); - self::assertNull($row->updatedAt, 'Missing updated-at must default to `null`.'); + self::assertSame( + '', + $row->name, + 'Missing name must default to an empty `string`.', + ); + self::assertSame( + '', + $row->description, + 'Missing description must default to an empty `string`.', + ); + self::assertSame( + '', + $row->ruleName, + 'Missing rule name must default to an empty `string`.', + ); + self::assertSame( + '', + $row->data, + 'Missing data must default to an empty `string`.', + ); + self::assertNull( + $row->createdAt, + 'Missing created-at must default to `null`.', + ); + self::assertNull( + $row->updatedAt, + 'Missing updated-at must default to `null`.', + ); } public function testFromArrayHydratesAllFieldsFromCompleteRow(): void @@ -93,12 +163,36 @@ public function testFromArrayHydratesAllFieldsFromCompleteRow(): void ], ); - self::assertSame('admin', $row->name, 'Name must be hydrated.'); - self::assertSame('Administrator', $row->description, 'Description must be hydrated.'); - self::assertSame('isAdmin', $row->ruleName, 'Rule name must be hydrated.'); - self::assertSame('{"scope":"all"}', $row->data, 'Data must be hydrated.'); - self::assertSame(1_700_000_000, $row->createdAt, 'Integer created-at must pass through unchanged.'); - self::assertSame(1_700_000_001, $row->updatedAt, 'Integer updated-at must pass through unchanged.'); + self::assertSame( + 'admin', + $row->name, + 'Name must be hydrated.', + ); + self::assertSame( + 'Administrator', + $row->description, + 'Description must be hydrated.', + ); + self::assertSame( + 'isAdmin', + $row->ruleName, + 'Rule name must be hydrated.', + ); + self::assertSame( + '{"scope":"all"}', + $row->data, + 'Data must be hydrated.', + ); + self::assertSame( + 1_700_000_000, + $row->createdAt, + 'Integer created-at must pass through unchanged.', + ); + self::assertSame( + 1_700_000_001, + $row->updatedAt, + 'Integer updated-at must pass through unchanged.', + ); } public function testFromArrayRejectsNonNumericTimestamps(): void @@ -110,8 +204,14 @@ public function testFromArrayRejectsNonNumericTimestamps(): void ], ); - self::assertNull($row->createdAt, 'Non-numeric created-at must collapse to `null`.'); - self::assertNull($row->updatedAt, 'Non-numeric updated-at must collapse to `null`.'); + self::assertNull( + $row->createdAt, + "Non-numeric created-at must collapse to 'null'.", + ); + self::assertNull( + $row->updatedAt, + "Non-numeric updated-at must collapse to 'null'.", + ); } public function testFromArrayTruncatesFloatTimestampsToInt(): void @@ -123,7 +223,15 @@ public function testFromArrayTruncatesFloatTimestampsToInt(): void ], ); - self::assertSame(1_700_000_000, $row->createdAt, 'Float created-at must be truncated to `int`.'); - self::assertSame(1_700_000_001, $row->updatedAt, 'Float updated-at must be truncated to `int`.'); + self::assertSame( + 1_700_000_000, + $row->createdAt, + "Float created-at must be truncated to 'int'.", + ); + self::assertSame( + 1_700_000_001, + $row->updatedAt, + "Float updated-at must be truncated to 'int'.", + ); } } diff --git a/tests/PhpInfo/PhpInfoDataNormalizerTest.php b/tests/PhpInfo/PhpInfoDataNormalizerTest.php index 3d28d06..bf2e3f0 100644 --- a/tests/PhpInfo/PhpInfoDataNormalizerTest.php +++ b/tests/PhpInfo/PhpInfoDataNormalizerTest.php @@ -21,8 +21,6 @@ * tile-kind classification (pill / path / token list) and the wrapping of module blocks into deep-linkable sections. * * {@see PhpInfoDataNormalizerProvider} for test case data providers. - * - * @since 0.1 */ #[Group('phpinfo')] final class PhpInfoDataNormalizerTest extends TestCase @@ -91,7 +89,10 @@ public function testCaptureBuildsViewFromBufferedPhpInfoOutput(): void $osTile = $this->findTileByLabel($view, 'OS'); - self::assertNotNull($osTile, 'Capture must surface the runtime OS tile.'); + self::assertNotNull( + $osTile, + 'Capture must surface the runtime OS tile.', + ); self::assertSame( php_uname('s') . ' ' . php_uname('r'), $osTile->displayValue, @@ -178,7 +179,7 @@ public function testFromOutputBuildsHeroSectionWithVersionHeadline(): void public function testFromOutputClassifiesAndEnhancesModuleTables(): void { - $body = <<<'HTML' + $body = <<curl

@@ -193,36 +194,22 @@ public function testFromOutputClassifiesAndEnhancesModuleTables(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'yii-debug-phpinfo-table-section is-facts', + self::assertSame( + <<

curl

+
Module information2 values
cURL supportenabled
+ + + +
cURL supportenabled
Features
HTTP2Yes
+
Configuration directives1 directive
+ + +
DirectiveLocal ValueMaster Value
curl.cainfono valueno value
+ HTML, $view->modulesHtml, 'Two-column module metadata must use the compact facts presentation.', ); - self::assertStringContainsString( - 'Module information2 values', - $view->modulesHtml, - 'Facts must expose a descriptive heading and exclude subsection rows from the value count.', - ); - self::assertStringContainsString( - 'class="yii-debug-phpinfo-status-pill" data-variant="success">enabled', - $view->modulesHtml, - 'Enabled module capabilities must render as success pills.', - ); - self::assertStringContainsString( - 'Features', - $view->modulesHtml, - 'Single-cell labels inside fact tables must become full-width subsection headings.', - ); - self::assertStringContainsString( - 'yii-debug-phpinfo-table-section is-directives', - $view->modulesHtml, - 'Local/master configuration tables must retain a dense directives presentation.', - ); - self::assertStringContainsString( - 'Configuration directives1 directive', - $view->modulesHtml, - 'Directive tables must surface an accurate directive count.', - ); } public function testFromOutputClassifiesDisabledAsMutedPill(): void @@ -423,7 +410,7 @@ public function testFromOutputCompactsExactlyThreeTrimmedTokens(): void public function testFromOutputDoesNotRedactOrdinaryModuleDirectives(): void { - $body = <<<'HTML' + $body = <<session @@ -433,21 +420,23 @@ public function testFromOutputDoesNotRedactOrdinaryModuleDirectives(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringNotContainsString( - 'yii-debug-phpinfo-redacted', + self::assertSame( + <<

session

+
Configuration directives1 directive
DirectiveLocal ValueMaster Value
+ + +
DirectiveLocal ValueMaster Value
session.cookie_path//
+ HTML, $view->modulesHtml, 'Sensitive-name detection must not hide ordinary lowercase PHP directives.', ); - self::assertStringContainsString( - '//', - $view->modulesHtml, - 'Ordinary directive values must remain intact.', - ); + } public function testFromOutputDoesNotRedactTokenizerSupport(): void { - $body = <<<'HTML' + $body = <<tokenizer
Tokenizer Supportenabled
HTML; @@ -556,8 +545,11 @@ public function testFromOutputGroupsPhpVariablesBySource(): void substr_count($view->modulesHtml, 'data-yii-debug-phpinfo-default-open="true"'), 'Only the first populated variable group must be expanded initially.', ); - self::assertStringContainsString( - '

PHP Variables

+
Request1 row
VariableValue
$_REQUEST['page']1
Cookies1 row
VariableValue
$_COOKIE['theme']redacted
Server1 row
VariableValue
$_SERVER['REQUEST_METHOD']GET
Environment1 row
VariableValue
APP_ENVdev
+ HTML, $view->modulesHtml, 'The first variable group must use an open details disclosure.', ); @@ -576,7 +568,7 @@ public function testFromOutputGroupsPhpVariablesBySource(): void public function testFromOutputIgnoresColspanSubheadingsWhenSummarizingAModule(): void { // `php_info_print_table_colspan_header()` emits a single-cell row inside an otherwise two-column facts table. - $body = <<<'HTML' + $body = <<ftp @@ -629,7 +621,7 @@ public function testFromOutputKeepsAbsolutePathVerbatimWhenHomeNotResolved(): vo public function testFromOutputKeepsLongValueListsInStandaloneModules(): void { - $body = <<<'HTML' + $body = <<PDO
FTP supportenabled
@@ -644,8 +636,14 @@ public function testFromOutputKeepsLongValueListsInStandaloneModules(): void $view->compactModules, 'Capability lists must not be compressed into a small card.', ); - self::assertStringContainsString( - 'id="phpinfo-pdo"', + self::assertSame( + <<

PDO

+
Module information2 values
PDO supportenabled
+ + +
PDO supportenabled
PDO driversmysql, pgsql, sqlite, oci, sqlsrv
+ HTML, $view->modulesHtml, 'PDO driver information must retain its own panel.', ); @@ -662,8 +660,10 @@ public function testFromOutputKeepsModuleStandaloneWhenAFactValueIsEmpty(): void $view->compactModules, 'A blank value must block the Overview summary.', ); - self::assertStringContainsString( - 'id="phpinfo-example"', + self::assertSame( + <<<'HTML' +

example

Module information1 value
Statistics
+ HTML, $view->modulesHtml, 'The module must keep its own section instead.', ); @@ -671,7 +671,7 @@ public function testFromOutputKeepsModuleStandaloneWhenAFactValueIsEmpty(): void public function testFromOutputKeepsOverviewBoundariesAndDropsEmptySections(): void { - $body = <<<'HTML' + $body = << Build Date overview build

example

@@ -697,7 +697,7 @@ public function testFromOutputKeepsOverviewBoundariesAndDropsEmptySections(): vo public function testFromOutputKeepsOverviewHeadingOutOfModuleParsing(): void { - $body = <<<'HTML' + $body = <<Overview
@@ -721,8 +721,16 @@ public function testFromOutputKeepsOverviewHeadingOutOfModuleParsing(): void array_map(static fn(PhpInfoTocEntry $entry): string => $entry->title, $view->tocEntries), 'The overview heading must not create a duplicate module navigation entry.', ); - self::assertStringNotContainsString( - 'id="phpinfo-overview"', + self::assertSame( + <<

example

+
Module information4 values
Build Dateoverview build
+ + + + +
Firstone
Secondtwo
Thirdthree
Fourthfour
+ HTML, $view->modulesHtml, 'The overview prefix must not be rendered again as a detailed module.', ); @@ -746,10 +754,16 @@ public function testFromOutputLabelsDataTablesByTheirLeadingHeader(string $heade $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - "{$expectedLabel}", + self::assertSame( + <<

example

+
{$expectedLabel}1 row
+ {$headers} + +
firstsecond
+ HTML, $view->modulesHtml, - 'Head bar must name the data table.', + 'Data tables must render the exact labeled module markup.', ); } @@ -759,8 +773,10 @@ public function testFromOutputLabelsGenericSingleColumnTablesAsNotes(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'Notes1 note', + self::assertSame( + <<

example

Notes1 note
License text
+ HTML, $view->modulesHtml, 'A single-column table without a native caption must use the Notes label.', ); @@ -769,8 +785,10 @@ public function testFromOutputLabelsGenericSingleColumnTablesAsNotes(): void public function testFromOutputLeavesConfigureCommandUntouchedWithoutHome(): void { unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); + MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', [], false, true); $view = PhpInfoDataNormalizer::fromOutput( @@ -791,6 +809,7 @@ public function testFromOutputLeavesConfigureCommandUntouchedWithoutHome(): void public function testFromOutputMarksLongFactValuesAsWide(): void { $long = str_repeat('a', 73); + // A fourth fact keeps the module out of the Overview summary, so the rows reach the fact-row normalizer. $body = <<example @@ -804,22 +823,27 @@ public function testFromOutputMarksLongFactValuesAsWide(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'class="yii-debug-phpinfo-fact yii-debug-phpinfo-fact-wide"', + self::assertSame( + <<

example

+
Module information4 values
+ + + + +
Shortbrief
Anothervalue
Thirdvalue
Longaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ HTML, $view->modulesHtml, 'Values beyond 72 characters must claim the full row.', ); - self::assertStringContainsString( - '', - $view->modulesHtml, - 'Short values must keep the compact row.', - ); + } public function testFromOutputNormalizesFactRowWhitespaceAttributesAndUnicodeWidth(): void { $unicodeValue = str_repeat('é', 40); $boundaryValue = str_repeat('a', 72); + $body = <<example @@ -834,41 +858,32 @@ public function testFromOutputNormalizesFactRowWhitespaceAttributesAndUnicodeWid $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - '', + self::assertSame( + <<

example

+
Module information5 values
Heading
+ + + + + + +
Heading
Firstone
Secondenabled
StatusENABLED
Unicodeéééééééééééééééééééééééééééééééééééééééé
Boundaryaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ HTML, $view->modulesHtml, 'Fact subheadings must trim content and normalize row attributes without extra whitespace.', ); - self::assertStringNotContainsString( - 'yii-debug-phpinfo-fact-wide', - $view->modulesHtml, - 'Unicode values and values of exactly 72 characters must remain compact facts.', - ); + self::assertSame( 1, substr_count($view->modulesHtml, 'yii-debug-phpinfo-status-pill'), 'Only class-v status cells may become status pills.', ); - self::assertStringContainsString( - 'data-variant="success">ENABLED', - $view->modulesHtml, - 'Status matching must ignore case and trim visible pill content.', - ); - self::assertStringContainsString( - 'enabled', - $view->modulesHtml, - 'Non-status table cells must round-trip unchanged.', - ); - self::assertStringContainsString( - '
modulesHtml, - 'Ordinary module tables must use non-collapsible div and header chrome.', - ); } public function testFromOutputOmitsModulesWithoutContentRows(): void { - $body = <<<'HTML' + $body = <<Additional Modules
Module Name

Core

@@ -886,8 +901,15 @@ public function testFromOutputOmitsModulesWithoutContentRows(): void array_map(static fn(PhpInfoTocEntry $entry): string => $entry->title, $view->tocEntries), 'A title-only phpinfo table must not create an empty navigation destination.', ); - self::assertStringNotContainsString( - 'phpinfo-additional-modules', + self::assertSame( + <<

Core

+
Module information3 values
+ + + +
Version8.5
Debugdisabled
Thread Safetydisabled
+ HTML, $view->modulesHtml, 'Empty modules must be omitted from the rendered content.', ); @@ -899,7 +921,11 @@ public function testFromOutputOmitsWhitespaceOnlyModuleTables(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertSame('', $view->modulesHtml, 'Whitespace-only table cells must not create an empty module.'); + self::assertSame( + '', + $view->modulesHtml, + 'Whitespace-only table cells must not create an empty module.', + ); self::assertSame( ['Overview'], array_map(static fn(PhpInfoTocEntry $entry): string => $entry->title, $view->tocEntries), @@ -909,7 +935,7 @@ public function testFromOutputOmitsWhitespaceOnlyModuleTables(): void public function testFromOutputPreservesRedactedRowAttributes(): void { - $body = <<<'HTML' + $body = <<Environment
DB_PASSWORDsecret
HTML; @@ -925,7 +951,7 @@ public function testFromOutputPreservesRedactedRowAttributes(): void public function testFromOutputProducesTocEntryPerDetailedModuleH2(): void { - $body = <<<'HTML' + $body = <<apcu
Version5.1.0
@@ -967,7 +993,7 @@ public function testFromOutputProducesTocEntryPerDetailedModuleH2(): void public function testFromOutputProducesUniqueSlugsForTocEntries(): void { - $body = <<<'HTML' + $body = <<apcu
@@ -1036,7 +1062,7 @@ public function testFromOutputQuotesHomePathAndTrimsModuleSlug(): void public function testFromOutputReadsMultilineCaseInsensitiveHeadersAndCountsOnlyDataRows(): void { - $body = <<<'HTML' + $body = <<example
Version5.1
@@ -1049,8 +1075,13 @@ public function testFromOutputReadsMultilineCaseInsensitiveHeadersAndCountsOnlyD $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'Other2 rows', + self::assertSame( + <<

example

+
Other2 rows
+ +
VariableValue
firstone
secondtwo
+ HTML, $view->modulesHtml, 'Multiline uppercase variable headers must enable grouping and stay out of the data count.', ); @@ -1076,61 +1107,19 @@ public function testFromOutputRedactsSensitiveEnvironmentAndRuntimeVariables(): $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringNotContainsString( - 'sensitive-cookie-value', + self::assertSame( + <<<'HTML' +

PHP Variables

+
Cookies1 row
VariableValue
$_COOKIE['XSRF-TOKEN']redacted
Server1 row
VariableValue
$_SERVER['PHP_AUTH_PW']redacted
Environment6 rows
VariableValue
APP_KEYredacted
WEBHOOK_SIGNATUREredacted
PWD/srv/app
OLDPWD/srv
CHPWD_STATUSunchanged
APP_NAMEYii application
Other1 row
VariableValue
database_urlredacted
+ HTML, $view->modulesHtml, 'Cookie values must never reach the rendered phpinfo HTML.', ); - self::assertStringNotContainsString( - 'sensitive-app-key', - $view->modulesHtml, - 'Credential-like environment values must never reach the rendered phpinfo HTML.', - ); - self::assertStringNotContainsString( - 'sensitive-basic-auth-value', - $view->modulesHtml, - 'PHP basic-auth credentials must never reach the rendered phpinfo HTML.', - ); - self::assertStringNotContainsString( - 'sensitive-signature-value', - $view->modulesHtml, - 'Signature values must never reach the rendered phpinfo HTML.', - ); - self::assertStringNotContainsString( - 'sensitive-database-url', - $view->modulesHtml, - 'Lowercase credential-like variable values must never reach the rendered phpinfo HTML.', - ); - self::assertStringContainsString( - 'aria-label="Sensitive value hidden">redacted', - $view->modulesHtml, - 'Redacted variables must expose an accessible non-secret placeholder.', - ); - self::assertStringContainsString( - '>Yii application', - $view->modulesHtml, - 'Ordinary environment values must remain available.', - ); - self::assertStringContainsString( - '>/srv/app', - $view->modulesHtml, - 'The PWD working-directory variable must not be mistaken for a password.', - ); - self::assertStringContainsString( - '>/srv', - $view->modulesHtml, - 'The OLDPWD working-directory variable must remain visible.', - ); - self::assertStringContainsString( - '>unchanged', - $view->modulesHtml, - 'Environment names containing CHPWD must not be treated as credentials.', - ); } public function testFromOutputRedactsSensitiveVariablesWhenTableHasNoVariableHeader(): void { - $body = <<<'HTML' + $body = <<Environment @@ -1140,30 +1129,38 @@ public function testFromOutputRedactsSensitiveVariablesWhenTableHasNoVariableHea $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringNotContainsString( - 'sensitive-database-password', + self::assertSame( + <<

Environment

+
Module information2 values
DB_PASSWORDsensitive-database-password
+ + +
DB_PASSWORDredacted
APP_NAMEYii application
+ HTML, $view->modulesHtml, 'Redaction must survive the fallback taken when grouping is impossible.', ); - self::assertStringContainsString( - 'aria-label="Sensitive value hidden">redacted', - $view->modulesHtml, - 'Placeholder must replace the credential.', - ); - self::assertStringContainsString( - '>Yii application', - $view->modulesHtml, - 'Ordinary values must stay visible.', - ); } public function testFromOutputRequiresBothPosixFunctionsForHomeFallback(): void { unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getpwuid'], true); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getuid'], false); + + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getpwuid'], + true, + ); + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getuid'], + false, + ); PhpInfoDataNormalizer::fromOutput('', 'x', 'cli', 'Linux', ''); @@ -1177,10 +1174,22 @@ public function testFromOutputRequiresBothPosixFunctionsForHomeFallback(): void public function testFromOutputRequiresPosixPasswordLookupForHomeFallback(): void { unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getpwuid'], false); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getuid'], true); + + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getpwuid'], + false, + ); + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getuid'], + true, + ); PhpInfoDataNormalizer::fromOutput('', 'x', 'cli', 'Linux', ''); @@ -1196,6 +1205,7 @@ public function testFromOutputResolvesHomeDirectoryFromPosixWhenEnvUnset(): void $body = '
Loaded Configuration File/tmp/php.ini
'; unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); @@ -1223,7 +1233,7 @@ public function testFromOutputResolvesHomeDirectoryFromPosixWhenEnvUnset(): void public function testFromOutputSeparatesPhpCreditsFromPhpVariables(): void { - $body = <<<'HTML' + $body = <<PHP Variables
VariableValue

PHP Credits

PHP GroupContributors

PHP License

License text
@@ -1241,8 +1251,12 @@ public function testFromOutputSeparatesPhpCreditsFromPhpVariables(): void $titles, 'The h1-based PHP Credits block must become an independent module instead of extending PHP Variables.', ); - self::assertStringContainsString( - 'id="phpinfo-php-credits"', + self::assertSame( + <<

PHP Variables

Module information1 value
VariableValue
+

PHP Credits

Module information1 value
PHP GroupContributors
+

PHP License

Notes1 note
License text
+ HTML, $view->modulesHtml, 'PHP Credits must expose its own deep-linkable section.', ); @@ -1285,7 +1299,7 @@ public function testFromOutputShortensHomeDirectoryInsideConfigureCommand(): voi { $_SERVER['HOME'] = '/home/dev'; - $body = <<<'HTML' + $body = <<Configure Command './configure' '--prefix=/home/dev/.local/php' '--with-config=/opt/home/dev/etc' HTML; @@ -1318,7 +1332,10 @@ public function testFromOutputSkipsPhpLogoRows(): void $section = $view->sections[0] ?? null; - self::assertNotNull($section, 'Normalized output must expose at least one hero section.'); + self::assertNotNull( + $section, + 'Normalized output must expose at least one hero section.', + ); $heroLabels = []; @@ -1335,7 +1352,7 @@ public function testFromOutputSkipsPhpLogoRows(): void public function testFromOutputSummarizesSmallFactsOnlyModules(): void { - $body = <<<'HTML' + $body = <<calendar
Calendar supportenabled

fileinfo

@@ -1363,16 +1380,19 @@ public function testFromOutputSummarizesSmallFactsOnlyModules(): void array_map(static fn(PhpInfoTocEntry $entry): string => $entry->title, $view->tocEntries), 'Modules with directives must retain a standalone TOC entry.', ); - self::assertStringNotContainsString( - 'id="phpinfo-calendar"', + self::assertSame( + <<

bcmath

+
Module information1 value
BCMath supportenabled
+
Configuration directives1 directive
+ + +
DirectiveLocal ValueMaster Value
bcmath.scale00
+ HTML, $view->modulesHtml, 'Compact modules must not retain a duplicate standalone section.', ); - self::assertStringContainsString( - 'id="phpinfo-bcmath"', - $view->modulesHtml, - 'Modules with configuration must remain in the detailed modules HTML.', - ); + } public function testFromOutputSurfacesPathTokensForStandaloneAbsolutePath(): void @@ -1406,8 +1426,10 @@ public function testFromOutputTreatsAHeaderOnlyTwoColumnTableAsFacts(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'Module information1 value', + self::assertSame( + <<

PHP Credits

Module information1 value
UnknownOther
+ HTML, $view->modulesHtml, 'A two-column header itself carries a fact value when no known data heading is present.', ); @@ -1417,6 +1439,7 @@ public function testFromOutputTrimsRuntimeTilesAndKeepsTokenBoundariesUnicodeAwa { $unicodeToken = str_repeat('é', 20); $boundaryToken = str_repeat('a', 32); + $body = << Registered PHP Streamssingle, @@ -1435,7 +1458,11 @@ public function testFromOutputTrimsRuntimeTilesAndKeepsTokenBoundariesUnicodeAwa } } - self::assertSame(['cli', PhpInfoTile::KIND_TEXT], $tiles['SAPI'] ?? null, 'Runtime values must be trimmed.'); + self::assertSame( + ['cli', PhpInfoTile::KIND_TEXT], + $tiles['SAPI'] ?? null, + 'Runtime values must be trimmed.', + ); self::assertSame( ['128M', PhpInfoTile::KIND_TEXT], $tiles['Memory limit'] ?? null, @@ -1489,11 +1516,28 @@ public function testFromOutputTrimsTrailingHomeSeparators(): void public function testFromOutputUsesAndTrimsPosixHomeFallback(): void { unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getpwuid'], true); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'function_exists', ['posix_getuid'], true); - MockerState::addCondition('PHPForge\Debug\PhpInfo', 'posix_getuid', [], 1000); + + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getpwuid'], + true, + ); + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'function_exists', + ['posix_getuid'], + true, + ); + MockerState::addCondition( + 'PHPForge\Debug\PhpInfo', + 'posix_getuid', + [], + 1000, + ); MockerState::addCondition( 'PHPForge\Debug\PhpInfo', 'posix_getpwuid', @@ -1518,7 +1562,7 @@ public function testFromOutputUsesAndTrimsPosixHomeFallback(): void public function testFromOutputUsesNativePhpCreditsTableTitles(): void { - $body = <<<'HTML' + $body = <<PHP Variables

PHP Credits

PHP Group
Contributors
@@ -1530,36 +1574,22 @@ public function testFromOutputUsesNativePhpCreditsTableTitles(): void $view = PhpInfoDataNormalizer::fromOutput($body, 'x', 'cli', 'Linux', ''); - self::assertStringContainsString( - 'PHP Group', + self::assertSame( + <<

PHP Credits

+
PHP Group1 note
Contributors
+
PHP Authors1 value
+ +
Zend EngineAuthors
+ HTML, $view->modulesHtml, 'A one-cell phpinfo heading must become the table card title.', ); - self::assertStringContainsString( - 'PHP Authors', - $view->modulesHtml, - 'PHP Credits fact tables must retain their native titles.', - ); - self::assertStringNotContainsString( - 'Notes', - $view->modulesHtml, - 'Native PHP Credits headings must replace the generic Notes label.', - ); - self::assertStringNotContainsString( - 'Module information', - $view->modulesHtml, - 'Native PHP Credits headings must replace the generic module-information label.', - ); - self::assertStringContainsString( - '1 note', - $view->modulesHtml, - 'The title row must not inflate a note table count.', - ); } public function testFromOutputWrapsModulesHtmlWithSectionChrome(): void { - $body = <<<'HTML' + $body = <<apcu @@ -1576,26 +1606,24 @@ public function testFromOutputWrapsModulesHtmlWithSectionChrome(): void '', ); - self::assertStringContainsString( - 'yii-debug-phpinfo-module', + self::assertSame( + <<

apcu

+
Module information3 values
Version5.1
+ + + +
Version5.1
Debugdisabled
MMAPenabled
+ HTML, $view->modulesHtml, 'Modules HTML must wrap blocks with the module class.', ); - self::assertStringContainsString( - 'yii-debug-table-wrap', - $view->modulesHtml, - 'Modules HTML must wrap tables in the panel chrome.', - ); - self::assertStringContainsString( - 'id="phpinfo-apcu"', - $view->modulesHtml, - 'Modules HTML must carry the slug id for TOC anchors.', - ); } public function testResolveHomeDirectoryReturnsEmptyWhenEnvAndPosixUnavailable(): void { unset($_SERVER['HOME'], $_SERVER['USERPROFILE']); + putenv('HOME'); putenv('USERPROFILE'); diff --git a/tests/PhpInfo/PhpInfoRendererTest.php b/tests/PhpInfo/PhpInfoRendererTest.php index 72f207b..ce28914 100644 --- a/tests/PhpInfo/PhpInfoRendererTest.php +++ b/tests/PhpInfo/PhpInfoRendererTest.php @@ -21,15 +21,17 @@ /** * Unit tests for {@see PhpInfoRenderer} covering the TOC sidebar, the per-section composition (eyebrow + headline + * tiles), the tile-kind rendering branches and the Configure Command details disclosure. - * - * @since 0.1 */ #[Group('phpinfo')] final class PhpInfoRendererTest extends TestCase { public function testModuleGroupBucketPreservesEveryGroupAndPublicResolution(): void { - self::assertSame('Database', PhpInfoModuleGroup::resolve('PDO'), 'Public resolution must remain callable.'); + self::assertSame( + 'Database', + PhpInfoModuleGroup::resolve('PDO'), + 'Public resolution must remain callable.', + ); self::assertSame( [ 'Core & Runtime', @@ -50,28 +52,59 @@ public function testRenderEmitsTocLinkPerEntry(): void { $view = $this->emptyView( [ - new PhpInfoTocEntry(title: 'Overview', slug: 'phpinfo-overview'), - new PhpInfoTocEntry(title: 'apcu', slug: 'phpinfo-apcu'), + new PhpInfoTocEntry( + title: 'Overview', + slug: 'phpinfo-overview', + ), + new PhpInfoTocEntry( + title: 'apcu', + slug: 'phpinfo-apcu', + ), ], ); $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - 'href="#phpinfo-overview"', + self::assertSame( + << +
+
+
+
+
+
+
+
+ + HTML, $html, 'TOC must link to the Overview slug.', ); - self::assertStringContainsString( - 'href="#phpinfo-apcu"', - $html, - 'TOC must link to every module slug.', - ); - self::assertStringContainsString( - 'data-toc-target="phpinfo-apcu"', - $html, - 'TOC entries must carry the data-toc-target attribute.', - ); } public function testRenderGroupsModulesAndFallsBackToOther(): void @@ -79,11 +112,26 @@ public function testRenderGroupsModulesAndFallsBackToOther(): void $html = PhpInfoRenderer::render( $this->emptyView( [ - new PhpInfoTocEntry(title: 'Overview', slug: 'phpinfo-overview'), - new PhpInfoTocEntry(title: 'Core', slug: 'phpinfo-core'), - new PhpInfoTocEntry(title: 'date', slug: 'phpinfo-date'), - new PhpInfoTocEntry(title: 'PDO', slug: 'phpinfo-pdo'), - new PhpInfoTocEntry(title: 'vendor_extension', slug: 'phpinfo-vendor-extension'), + new PhpInfoTocEntry( + title: 'Overview', + slug: 'phpinfo-overview', + ), + new PhpInfoTocEntry( + title: 'Core', + slug: 'phpinfo-core', + ), + new PhpInfoTocEntry( + title: 'date', + slug: 'phpinfo-date', + ), + new PhpInfoTocEntry( + title: 'PDO', + slug: 'phpinfo-pdo', + ), + new PhpInfoTocEntry( + title: 'vendor_extension', + slug: 'phpinfo-vendor-extension', + ), ], ), ); @@ -103,21 +151,64 @@ public function testRenderGroupsModulesAndFallsBackToOther(): void $html, 'Unknown extensions must remain accessible in the Other group.', ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-toc-group="true"', + self::assertSame( + << +
+
+
+
+
+
+
+
+ + HTML, $html, 'Every module group must expose the JavaScript synchronization hook.', ); - self::assertStringContainsString( - 'aria-label="2 modules"', - $html, - 'A group with two entries must use the pluralized accessible count.', - ); - self::assertStringContainsString( - 'aria-label="1 module"', - $html, - 'A group with one entry must use the singular accessible count.', - ); } public function testRenderMarksLongOverviewValuesAsWide(): void @@ -144,8 +235,43 @@ public function testRenderMarksLongOverviewValuesAsWide(): void ), ); - self::assertStringContainsString( - 'class="yii-debug-phpinfo-overview-hero-metric is-wide"', + self::assertSame( + << +
+
+
+
+
+
+ Build +
+
+
+ Build System +
+ A deliberately long build-system value that needs the complete card width +
+
+
+
+
+
+
+
+ + HTML, $html, 'Long technical values must span the overview card instead of wrapping inside a narrow grid cell.', ); @@ -156,42 +282,58 @@ public function testRenderMarksOverviewAsInitialTocSelection(): void $html = PhpInfoRenderer::render( $this->emptyView( [ - new PhpInfoTocEntry(title: 'Overview', slug: 'phpinfo-overview'), - new PhpInfoTocEntry(title: 'Core', slug: 'phpinfo-core'), + new PhpInfoTocEntry( + title: 'Overview', + slug: 'phpinfo-overview', + ), + new PhpInfoTocEntry( + title: 'Core', + slug: 'phpinfo-core', + ), ], ), ); - self::assertStringContainsString( - 'class="yii-debug-phpinfo-toc-link is-active"', + self::assertSame( + << +
+
+
+
+
+
+
+
+ + HTML, $html, 'Overview must render as the initial selected view before JavaScript initializes.', ); - self::assertStringContainsString( - 'aria-current="page"', - $html, - 'The initial TOC selection must be exposed to assistive technology.', - ); - self::assertStringContainsString( - '1modules', - $html, - 'The TOC counter must exclude the Overview entry.', - ); - self::assertStringContainsString( - 'Overview', - $html, - 'Only Overview must carry active styling and aria-current.', - ); - self::assertStringContainsString( - 'Core', - $html, - 'Ordinary module links must remain inactive.', - ); - self::assertStringNotContainsString( - 'in Overview', - $html, - 'The TOC must omit the Overview note when no modules were summarized.', - ); } public function testRenderModulesHtmlPassesThroughVerbatim(): void @@ -206,8 +348,30 @@ public function testRenderModulesHtmlPassesThroughVerbatim(): void $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - '
module-body
', + self::assertSame( + << +
+
+
+
+
+
module-body
+
+
+ + HTML, $html, 'Modules HTML must round-trip verbatim into the main column.', ); @@ -225,62 +389,90 @@ public function testRenderRendersConfigureCommandWhenPresent(): void $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - 'Configure Command', + self::assertSame( + << +
+
+
+
+
+ + Configure Command +
+
+            ./configure --foo
+            
+
+
+
+
+
+ + HTML, $html, 'Configure Command details must surface.', ); - self::assertStringContainsString( - './configure --foo', - $html, - 'Configure command body must surface inside the disclosure.', - ); + } public function testRenderSearchInputCarriesFilterHooks(): void { $html = PhpInfoRenderer::render($this->emptyView([])); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-search="true"', + self::assertSame( + << +
+
+
+
+
+
+
+
+ + HTML, $html, 'Search input must enable the filter JS hook explicitly.', ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-empty="true"', - $html, - 'Empty-state hint must enable the JS hook explicitly.', - ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-clear="true"', - $html, - 'Search must expose an explicit clear action.', - ); + + self::assertMatchesRegularExpression( '~ +
+ +
+
+
+
+
+
+ Runtime +
+
+
+
+
+
+ + + HTML, $html, 'Every overview section must retain its eyebrow header.', ); @@ -321,16 +541,47 @@ public function testRenderSectionWithMutedPillTile(): void ); $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - 'yii-debug-phpinfo-overview-pill', + self::assertSame( + << +
+
+
+
+
+
+ Capabilities +
+
+
+ Debug Build +
+ no +
+
+
+
+
+
+
+
+ + HTML, $html, 'Muted pill must carry the pill CSS class.', ); - self::assertStringContainsString( - 'data-variant="muted"', - $html, - 'Muted pill must carry the muted variant attribute.', - ); + } public function testRenderSectionWithPathListTokens(): void @@ -345,8 +596,10 @@ public function testRenderSectionWithPathListTokens(): void new PhpInfoToken(label: 'b.ini', title: '/etc/b.ini'), ], ); - - $section = new PhpInfoSection(eyebrow: 'Configuration', tiles: [$tile]); + $section = new PhpInfoSection( + eyebrow: 'Configuration', + tiles: [$tile], + ); $view = new PhpInfoView( sections: [$section], tocEntries: [], @@ -356,21 +609,48 @@ public function testRenderSectionWithPathListTokens(): void ); $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - '>a.ini<', + self::assertSame( + << +
+
+
+
+
+
+ Configuration +
+
+
+ Additional .ini files parsed +
+ a.inib.ini +
+
+
+
+
+
+
+
+ + HTML, $html, 'First token basename must render inside a code chip.', ); - self::assertStringContainsString( - 'title="/etc/a.ini"', - $html, - 'First token full path must surface in the title attribute.', - ); - self::assertStringContainsString( - 'yii-debug-phpinfo-overview-token', - $html, - 'Tokens must carry the token CSS class.', - ); + + } public function testRenderSectionWithPathTileRendersCodeWithFullPathTitle(): void @@ -381,8 +661,10 @@ public function testRenderSectionWithPathTileRendersCodeWithFullPathTitle(): voi rawValue: '/etc/php/8.5/cli/php.ini', kind: PhpInfoTile::KIND_PATH, ); - - $section = new PhpInfoSection(eyebrow: 'Configuration', tiles: [$tile]); + $section = new PhpInfoSection( + eyebrow: 'Configuration', + tiles: [$tile], + ); $view = new PhpInfoView( sections: [$section], tocEntries: [], @@ -390,23 +672,47 @@ public function testRenderSectionWithPathTileRendersCodeWithFullPathTitle(): voi modulesHtml: '', configureCommand: '', ); - $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - ' +
+
+
+
+
+
+ Configuration +
+
+
+ Loaded Configuration File +
+ php.ini +
+
+
+
+
+
+
+
+ + HTML, + PhpInfoRenderer::render($view), 'KIND_PATH must render inside a `` element.', ); - self::assertStringContainsString( - 'title="/etc/php/8.5/cli/php.ini"', - $html, - 'KIND_PATH must surface the raw path in the title attribute.', - ); - self::assertStringContainsString( - '>php.ini<', - $html, - 'KIND_PATH must show the basename in the visible content.', - ); } public function testRenderSectionWithSuccessPillTile(): void @@ -422,7 +728,6 @@ public function testRenderSectionWithSuccessPillTile(): void ), ], ); - $view = new PhpInfoView( sections: [$section], tocEntries: [], @@ -430,22 +735,77 @@ public function testRenderSectionWithSuccessPillTile(): void modulesHtml: '', configureCommand: '', ); - $html = PhpInfoRenderer::render($view); - self::assertStringContainsString( - 'data-variant="success"', - $html, + + self::assertSame( + << +
+
+
+
+
+
+ Capabilities +
+
+
+ IPv6 Support +
+ enabled +
+
+
+
+
+
+
+
+ + HTML, + PhpInfoRenderer::render($view), 'Success pill must carry the success variant attribute.', ); } public function testRenderSkipsConfigureCommandWhenEmpty(): void { - $html = PhpInfoRenderer::render($this->emptyView([])); - - self::assertStringNotContainsString( - 'Configure Command', - $html, + self::assertSame( + << +
+
+
+
+
+
+
+
+ + HTML, + PhpInfoRenderer::render($this->emptyView([])), 'Empty Configure Command must drop the disclosure.', ); } @@ -476,63 +836,59 @@ public function testRenderSummarizesCompactModulesInOverview(): void configureCommand: '', ); - $html = PhpInfoRenderer::render($view); - - self::assertStringContainsString( - 'Loaded extensions', - $html, + self::assertSame( + << +
+
+
+
+
+ + Loaded extensions +
+
+
+ System & Compression1 +
+ calendaronCalendar support: enabled +
+
+
+
+
+
Core
+
+
+ + HTML, + PhpInfoRenderer::render($view), 'Facts-only modules must surface in the Overview.', ); - self::assertStringContainsString( - 'id="phpinfo-calendar"', - $html, - 'Summarized modules must retain their original deep-link anchor.', - ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-compact-module="true"', - $html, - 'Summarized modules must expose the search hook.', - ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-extensions="true"', - $html, - 'Summarized modules must live in an identifiable disclosure.', - ); - self::assertStringContainsString( - 'data-yii-debug-phpinfo-extension-group-count="true"', - $html, - 'Extension group counts must expose the enabled synchronization marker.', - ); - self::assertStringContainsString( - 'class="yii-debug-ext-pill is-on"', - $html, - 'Summaries must reuse the Config panel pill, enabled variant.', - ); - self::assertStringContainsString( - 'on', - $html, - 'A module without a version must fall back to the on/off state.', - ); - self::assertStringContainsString( - 'Calendar support: enabled', - $html, - 'Facts the pill cannot show must stay reachable and searchable.', - ); - self::assertStringNotContainsString( - 'href="#phpinfo-calendar"', - $html, - 'Summarized modules must not keep an almost-empty sidebar destination.', - ); - self::assertStringContainsString( - '2modules', - $html, - 'The sidebar total must include detailed and summarized modules.', - ); - self::assertStringContainsString( - '1 in Overview', - $html, - 'The sidebar must explain where summarized modules moved.', - ); } public function testRenderSurfacesVersionAndDisabledStateInCompactPills(): void @@ -582,28 +938,56 @@ public function testRenderSurfacesVersionAndDisabledStateInCompactPills(): void configureCommand: '', ); - $html = PhpInfoRenderer::render($view); - - self::assertStringContainsString( - '3.53.3', - $html, + self::assertSame( + << +
+
+
+
+
+ + Loaded extensions +
+
+
+ Database1 +
+ pdo_sqlite3.53.3PDO Driver for SQLite 3.x: enabled · SQLite Library: 3.53.3 +
+
+
+ System & Compression1 +
+ sysvshm1.2.3sysvshm support: disabled · Version: 1.2.3 +
+
+
+
+
+
+
+
+ + HTML, + PhpInfoRenderer::render($view), 'The state slot must prefer the version over a redundant on.', ); - self::assertStringContainsString( - 'class="yii-debug-ext-pill is-off"', - $html, - 'A module reporting only a muted fact must render as `is-off`.', - ); - self::assertStringContainsString( - '1.2.3', - $html, - 'A disabled module must still surface a version reported after its muted status.', - ); - self::assertStringContainsString( - 'title="PDO Driver for SQLite 3.x: enabled · SQLite Library: 3.53.3"', - $html, - 'Every fact must survive in the tooltip.', - ); } public function testRenderUsesUnicodeAwareStrictWideTileBoundary(): void @@ -617,6 +1001,7 @@ public function testRenderUsesUnicodeAwareStrictWideTileBoundary(): void new PhpInfoTile('Path', '/x', '/x', PhpInfoTile::KIND_PATH), ], ); + $html = PhpInfoRenderer::render(new PhpInfoView([$section], [], [], '', '')); foreach (['Boundary', 'Unicode', 'Short'] as $label) { @@ -635,7 +1020,7 @@ public function testRenderUsesUnicodeAwareStrictWideTileBoundary(): void public function testRenderViaNormalizerSnapshotProducesExpectedAnchors(): void { - $body = <<<'HTML' + $body = <<apcu @@ -644,24 +1029,88 @@ public function testRenderViaNormalizerSnapshotProducesExpectedAnchors(): void
Version5.1
HTML; - $view = PhpInfoDataNormalizer::fromOutput($body, '8.5.3', 'cli', 'Linux', '128M'); - $html = PhpInfoRenderer::render($view); + $view = PhpInfoDataNormalizer::fromOutput( + $body, + '8.5.3', + 'cli', + 'Linux', + '128M', + ); - self::assertStringContainsString( - 'id="phpinfo-overview"', - $html, + self::assertSame( + << +
+
+
+
+
+
+ PHP version +
+ 8.5.3 +
+
+
+ SAPI +
+ cli +
+
+
+ OS +
+ Linux +
+
+
+ Memory limit +
+ 128M +
+
+
+
+
+

apcu

+
Module information3 values
+ + + +
Version5.1
Debugdisabled
MMAPenabled
+
+
+ + HTML, + PhpInfoRenderer::render($view), 'Overview anchor must surface in the rendered shell.', ); - self::assertStringContainsString( - 'id="phpinfo-apcu"', - $html, - 'Module anchor must surface in the rendered shell.', - ); - self::assertStringContainsString( - 'href="#phpinfo-apcu"', - $html, - 'TOC must link to the module anchor.', - ); + + } /** diff --git a/tests/Provider/PhpInfoDataNormalizerProvider.php b/tests/Provider/PhpInfoDataNormalizerProvider.php index 50c1fb4..be0b2ff 100644 --- a/tests/Provider/PhpInfoDataNormalizerProvider.php +++ b/tests/Provider/PhpInfoDataNormalizerProvider.php @@ -6,10 +6,6 @@ /** * Data provider for {@see \PHPForge\Debug\Tests\PhpInfo\PhpInfoDataNormalizerTest} test cases. - * - * Provides the leading header row of a data table paired with the label the head bar must show. - * - * @since 0.1 */ final class PhpInfoDataNormalizerProvider { diff --git a/tests/Storage/DebugArrayTest.php b/tests/Storage/DebugArrayTest.php index 64dcf1b..544233e 100644 --- a/tests/Storage/DebugArrayTest.php +++ b/tests/Storage/DebugArrayTest.php @@ -10,8 +10,6 @@ /** * Unit tests for {@see DebugArray} covering the array-typed facade over {@see DebugValue}. - * - * @since 0.1 */ #[Group('storage')] final class DebugArrayTest extends TestCase diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php index 1d2625d..1baa200 100644 --- a/tests/Storage/DebugValueTest.php +++ b/tests/Storage/DebugValueTest.php @@ -465,6 +465,37 @@ public function testCaptureUsesTheCanonicalClosureLabel(): void ); } + public function testHydrationAcceptsAnEmptyDecodedObjectBeforeValidatingItsShape(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.type', + ); + + DebugValue::fromArray([]); + } + + public function testHydrationRejectsTheFirstLevelBeyondTheDepthBudget(): void + { + $payload = ['type' => 'null']; + + for ($depth = 0; $depth < 11; $depth++) { + $payload = [ + 'type' => 'array', + 'entries' => [ + ['keyType' => 'int', 'key' => 0, 'value' => $payload], + ], + ]; + } + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + 'at most 10 nested levels', + ); + + DebugValue::fromArray($payload); + } + public function testRoundTripPreservesJsonSafeValuesAndLabelsUnsafeValues(): void { $object = new stdClass(); @@ -586,6 +617,42 @@ public function testThrowHydrationExceptionForAnUnsupportedBinaryEncoding(): voi DebugValue::fromArray(['type' => 'binary', 'encoding' => 'hex', 'data' => 'ff']); } + public function testThrowHydrationExceptionForEveryInvalidTaggedFieldKind(): void + { + $cases = [ + [['type' => 'bool', 'value' => 1], '$.value'], + [ + [ + 'type' => 'array', + 'entries' => [['keyType' => 1, 'key' => 0, 'value' => ['type' => 'null']]], + ], + '$.entries[0].keyType', + ], + [['type' => 'int', 'value' => '1'], '$.value'], + [['type' => 'array', 'entries' => ['entry' => []]], '$.entries'], + [['type' => 'object', 'value' => 1, 'entries' => [], 'class' => 'Fixture'], '$.value'], + [['type' => 'float', 'value' => '1.0'], '$.value'], + ['invalid', '$'], + [['type' => 'null', 0 => 'unexpected'], '$'], + [['type' => 'string', 'value' => 1], '$.value'], + [['type' => 1], '$.type'], + [['type' => 'bool'], '$.value'], + ]; + + foreach ($cases as [$payload, $path]) { + try { + DebugValue::fromArray($payload); + self::fail("Expected invalid tagged payload at {$path} to fail hydration."); + } catch (HydrationException $exception) { + self::assertStringContainsString( + $path, + $exception->getMessage(), + "Invalid tagged payload must identify {$path}.", + ); + } + } + } + public function testThrowHydrationExceptionForFieldsThatDoNotBelongToTheTaggedType(): void { $this->expectException(HydrationException::class); diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php index 5b5692a..757f9e1 100644 --- a/tests/Storage/ExceptionSnapshotTest.php +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -19,7 +19,9 @@ final class ExceptionSnapshotTest extends TestCase public function testThrowableCaptureRedactsMessagesAndOmitsTraceArguments(): void { $throwable = $this->exceptionContainingSecret('do-not-persist'); + $snapshot = ExceptionSnapshot::fromThrowable($throwable); + $serialized = json_encode($snapshot, JSON_THROW_ON_ERROR); self::assertStringNotContainsString( diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index 4e225c4..9defb27 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -88,7 +88,12 @@ public function testClearThrowsWhenAStoredFileCannotBeRemoved(): void public function testCommittedTransactionJournalKeepsCommittedData(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); + file_put_contents( "{$this->path}/.debug-transaction.json", json_encode( @@ -103,7 +108,10 @@ public function testCommittedTransactionJournalKeepsCommittedData(): void ), ); - self::assertNotNull($store->readSnapshot('current'), 'Committed transaction data must remain visible.'); + self::assertNotNull( + $store->readSnapshot('current'), + 'Committed transaction data must remain visible.', + ); self::assertFileDoesNotExist( "{$this->path}/.debug-transaction.json", 'Committed journal must be cleaned during recovery.', @@ -113,17 +121,28 @@ public function testCommittedTransactionJournalKeepsCommittedData(): void public function testEmptyManifestRebuildsValidSnapshotHistory(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('older', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('older', 1.0), [], []), + 10, + ); + file_put_contents("{$this->path}/index.json", ''); - $store->writeSnapshot(new DebugSnapshot($this->summary('newer', 2.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('newer', 2.0), [], []), + 10, + ); self::assertSame( ['newer', 'older'], array_keys($store->loadManifest()), 'An empty index file must rebuild from valid snapshot envelopes instead of erasing history.', ); - self::assertNotNull($store->readSnapshot('older'), 'A valid snapshot must survive empty-index recovery.'); + self::assertNotNull( + $store->readSnapshot('older'), + 'A valid snapshot must survive empty-index recovery.', + ); } @@ -224,18 +243,32 @@ public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void public function testInvalidManifestRebuildsValidSnapshotHistory(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('oldest', 1.0), [], []), 10); - $store->writeSnapshot(new DebugSnapshot($this->summary('older', 2.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('oldest', 1.0), [], []), + 10, + ); + $store->writeSnapshot( + new DebugSnapshot($this->summary('older', 2.0), [], []), + 10, + ); + file_put_contents("{$this->path}/index.json", '{invalid'); - $store->writeSnapshot(new DebugSnapshot($this->summary('newer', 3.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('newer', 3.0), [], []), + 10, + ); self::assertSame( ['newer', 'older', 'oldest'], array_keys($store->loadManifest()), 'A corrupt index must be rebuilt from valid snapshot envelopes before appending new history.', ); - self::assertNotNull($store->readSnapshot('older'), 'A valid snapshot must survive index recovery.'); + self::assertNotNull( + $store->readSnapshot('older'), + 'A valid snapshot must survive index recovery.', + ); } public function testInvalidManifestResetsStaleSnapshots(): void @@ -270,26 +303,59 @@ public function testInvalidManifestResetsStaleSnapshots(): void public function testInvalidManifestSkipsInvalidAndEmptySnapshotFiles(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('valid', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('valid', 1.0), [], []), + 10, + ); + file_put_contents("{$this->path}/index.json", '{invalid'); file_put_contents("{$this->path}/7.json", '{}'); file_put_contents("{$this->path}/empty.json", ''); - $store->writeSnapshot(new DebugSnapshot($this->summary('newer', 2.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('newer', 2.0), [], []), + 10, + ); - self::assertSame(['newer', 'valid'], array_keys($store->loadManifest()), 'Only valid envelopes may rebuild.'); - self::assertFileDoesNotExist("{$this->path}/7.json", 'Invalid tag file must be removed during reconciliation.'); - self::assertFileDoesNotExist("{$this->path}/empty.json", 'Empty snapshot must be removed during reconciliation.'); + self::assertSame( + [ + 'newer', + 'valid', + ], + array_keys($store->loadManifest()), + 'Only valid envelopes may rebuild.', + ); + self::assertFileDoesNotExist( + "{$this->path}/7.json", + 'Invalid tag file must be removed during reconciliation.', + ); + self::assertFileDoesNotExist( + "{$this->path}/empty.json", + 'Empty snapshot must be removed during reconciliation.', + ); } public function testInvalidTransactionJournalMakesReadsFailClosed(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); + file_put_contents("{$this->path}/.debug-transaction.json", '{invalid'); - self::assertNull($store->readSnapshot('current'), 'Malformed transaction state must fail closed.'); - self::assertSame([], $store->loadManifest(), 'Malformed transaction state must not expose a partial manifest.'); + self::assertNull( + $store->readSnapshot('current'), + 'Malformed transaction state must fail closed.', + ); + self::assertSame( + [], + $store->loadManifest(), + 'Malformed transaction state must not expose a partial manifest.', + ); } public function testInvalidTransactionJournalShapeAndStateFailClosed(): void @@ -301,7 +367,12 @@ public function testInvalidTransactionJournalShapeAndStateFailClosed(): void "{$this->path}/.debug-transaction.json", json_encode(['version' => 99], JSON_THROW_ON_ERROR), ); - self::assertSame([], $this->store()->loadManifest(), 'Invalid journal shape must fail closed.'); + + self::assertSame( + [], + $this->store()->loadManifest(), + 'Invalid journal shape must fail closed.', + ); file_put_contents( "{$this->path}/.debug-transaction.json", @@ -316,7 +387,12 @@ public function testInvalidTransactionJournalShapeAndStateFailClosed(): void JSON_THROW_ON_ERROR, ), ); - self::assertSame([], $this->store()->loadManifest(), 'Unknown journal state must fail closed.'); + + self::assertSame( + [], + $this->store()->loadManifest(), + 'Unknown journal state must fail closed.', + ); } public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): void @@ -377,17 +453,34 @@ public function testManifestReadResultDistinguishesEmptyStoreFromCorruptManifest $empty = $store->loadManifestResult(); - self::assertSame([], $empty->entries, 'A store that does not exist yet must have no entries.'); - self::assertNull($empty->error, 'A store that does not exist yet must not be reported as a read failure.'); + self::assertSame( + [], + $empty->entries, + 'A store that does not exist yet must have no entries.', + ); + self::assertNull( + $empty->error, + 'A store that does not exist yet must not be reported as a read failure.', + ); mkdir($this->path, recursive: true); file_put_contents("{$this->path}/index.json", '{invalid'); $corrupt = $store->loadManifestResult(); - self::assertSame([], $corrupt->entries, 'A corrupt manifest must remain fail-closed.'); - self::assertNotNull($corrupt->error, 'A corrupt manifest must expose a diagnostic through the additive API.'); - self::assertNotNull($corrupt->error->getPrevious(), 'The decoding failure must remain available for logging.'); + self::assertSame( + [], + $corrupt->entries, + 'A corrupt manifest must remain fail-closed.', + ); + self::assertNotNull( + $corrupt->error, + 'A corrupt manifest must expose a diagnostic through the additive API.', + ); + self::assertNotNull( + $corrupt->error->getPrevious(), + 'The decoding failure must remain available for logging.', + ); } public function testManifestReadResultReportsEmptyAndUnreadableManifestFiles(): void @@ -452,6 +545,7 @@ public function testPreparedFirstWriteTransactionRemovesPartialFiles(): void { mkdir($this->path, recursive: true); touch("{$this->path}/index.lock"); + file_put_contents( "{$this->path}/current.json", json_encode(new DebugSnapshot($this->summary('current', 1.0), [], []), JSON_THROW_ON_ERROR), @@ -470,16 +564,31 @@ public function testPreparedFirstWriteTransactionRemovesPartialFiles(): void ), ); - self::assertNull($this->store()->readSnapshot('current'), 'Interrupted first write must roll back to no data.'); - self::assertFileDoesNotExist("{$this->path}/current.json", 'Partial first snapshot must be removed.'); - self::assertFileDoesNotExist("{$this->path}/index.json", 'Partial first manifest must be removed.'); + self::assertNull( + $this->store()->readSnapshot('current'), + 'Interrupted first write must roll back to no data.', + ); + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'Partial first snapshot must be removed.', + ); + self::assertFileDoesNotExist( + "{$this->path}/index.json", + 'Partial first manifest must be removed.', + ); } public function testPreparedTransactionIsRolledBackBeforeRead(): void { $store = $this->store(); + $oldSnapshot = new DebugSnapshot($this->summary('current', 1.0), ['panel' => ['value' => 'old']], []); - $store->writeSnapshot($oldSnapshot, 10); + + $store->writeSnapshot( + $oldSnapshot, + 10, + ); + $snapshotBefore = file_get_contents("{$this->path}/current.json"); $manifestBefore = file_get_contents("{$this->path}/index.json"); @@ -509,8 +618,15 @@ public function testPreparedTransactionIsRolledBackBeforeRead(): void $recovered = $store->readSnapshot('current'); - self::assertNotNull($recovered, 'Prepared transaction must recover the previous snapshot.'); - self::assertSame(['value' => 'old'], $recovered->panels['panel'] ?? null, 'Recovery must restore old detail.'); + self::assertNotNull( + $recovered, + 'Prepared transaction must recover the previous snapshot.', + ); + self::assertSame( + ['value' => 'old'], + $recovered->panels['panel'] ?? null, + 'Recovery must restore old detail.', + ); self::assertFileDoesNotExist( "{$this->path}/.debug-transaction.json", 'Successful recovery must remove the prepared journal.', @@ -521,6 +637,7 @@ public function testPreparedTransactionKeepsJournalWhenRollbackDeletionFails(): { mkdir($this->path, recursive: true); touch("{$this->path}/index.lock"); + file_put_contents( "{$this->path}/current.json", json_encode(new DebugSnapshot($this->summary('current', 1.0), [], []), JSON_THROW_ON_ERROR), @@ -563,7 +680,12 @@ public function testPreparedTransactionKeepsJournalWhenRollbackDeletionFails(): public function testReadRejectsSnapshotWhoseEnvelopeTagDoesNotMatchFilename(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('source', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('source', 1.0), [], []), + 10, + ); + copy("{$this->path}/source.json", "{$this->path}/renamed.json"); self::assertNull( @@ -637,10 +759,18 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void public function testRetriesOrphanSnapshotCleanupAfterEverySuccessfulCommit(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('kept', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('kept', 1.0), [], []), + 10, + ); + file_put_contents("{$this->path}/orphan.json", '{}'); - $store->writeSnapshot(new DebugSnapshot($this->summary('newer', 2.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('newer', 2.0), [], []), + 10, + ); self::assertFileDoesNotExist( "{$this->path}/orphan.json", @@ -727,12 +857,21 @@ public function testSnapshotReadResultDistinguishesMissingInvalidAndCorruptSnaps $missing = $store->readSnapshotResult('missing'); - self::assertNull($missing->snapshot, 'A missing snapshot must have no value.'); - self::assertNull($missing->error, 'A missing snapshot in an empty store must not be a read error.'); + self::assertNull( + $missing->snapshot, + 'A missing snapshot must have no value.', + ); + self::assertNull( + $missing->error, + 'A missing snapshot in an empty store must not be a read error.', + ); $invalid = $store->readSnapshotResult('../outside'); - self::assertNull($invalid->snapshot, 'An invalid tag must have no value.'); + self::assertNull( + $invalid->snapshot, + 'An invalid tag must have no value.', + ); self::assertSame( 'Invalid debug snapshot tag: ../outside', $invalid->error?->getMessage(), @@ -744,9 +883,18 @@ public function testSnapshotReadResultDistinguishesMissingInvalidAndCorruptSnaps $corrupt = $store->readSnapshotResult('corrupt'); - self::assertNull($corrupt->snapshot, 'A corrupt snapshot must remain fail-closed.'); - self::assertNotNull($corrupt->error, 'A corrupt snapshot must expose a diagnostic.'); - self::assertNotNull($corrupt->error->getPrevious(), 'The hydration failure must remain available for logging.'); + self::assertNull( + $corrupt->snapshot, + 'A corrupt snapshot must remain fail-closed.', + ); + self::assertNotNull( + $corrupt->error, + 'A corrupt snapshot must expose a diagnostic.', + ); + self::assertNotNull( + $corrupt->error->getPrevious(), + 'The hydration failure must remain available for logging.', + ); } public function testSnapshotReadResultReportsEmptyMismatchAndUnreadableFiles(): void @@ -765,6 +913,7 @@ public function testSnapshotReadResultReportsEmptyMismatchAndUnreadableFiles(): ); $snapshot = new DebugSnapshot($this->summary('source', 1.0), [], []); + file_put_contents("{$this->path}/renamed.json", json_encode($snapshot, JSON_THROW_ON_ERROR)); self::assertSame( @@ -818,12 +967,23 @@ public function testSnapshotReadResultReportsInvalidPathAndLockFailure(): void public function testSnapshotReadResultReturnsPersistedSnapshotWithoutAnError(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); $result = $store->readSnapshotResult('current'); - self::assertSame('current', $result->snapshot?->summary->tag, 'A valid snapshot must remain available.'); - self::assertNull($result->error, 'A valid snapshot must not produce a read diagnostic.'); + self::assertSame( + 'current', + $result->snapshot?->summary->tag, + 'A valid snapshot must remain available.', + ); + self::assertNull( + $result->error, + 'A valid snapshot must not produce a read diagnostic.', + ); } /** @@ -965,7 +1125,9 @@ public function testThrowStorageExceptionWhenDirectoryModeCannotBeApplied(): voi ); $this->expectException(StorageException::class); - $this->expectExceptionMessage('Unable to apply debug data directory mode'); + $this->expectExceptionMessage( + 'Unable to apply debug data directory mode', + ); (new SnapshotStore($this->path, 0o700, 0o600))->writeSnapshot( new DebugSnapshot($this->summary('current', 1.0), [], []), @@ -986,7 +1148,9 @@ public function testThrowStorageExceptionWhenFileModeCannotBeApplied(): void ); $this->expectException(StorageException::class); - $this->expectExceptionMessage('Unable to apply debug data file mode'); + $this->expectExceptionMessage( + 'Unable to apply debug data file mode', + ); (new SnapshotStore($this->path, 0o700, 0o600))->writeSnapshot( new DebugSnapshot($this->summary('current', 1.0), [], []), @@ -1243,7 +1407,12 @@ public function testWriteAppliesConfiguredFileMode(): void public function testWritePreservesPrimaryFailureWhenImmediateRollbackAlsoFails(): void { $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); + $temporaryFileCalls = 0; MockerState::addCondition( @@ -1258,9 +1427,14 @@ static function (string $directory, string $prefix) use (&$temporaryFileCalls): ); $this->expectException(StorageException::class); - $this->expectExceptionMessage('Unable to write temporary debug data file'); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 2.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 2.0), [], []), + 10, + ); } public function testWriteRejectsUnreadableExistingTransactionTarget(): void @@ -1270,27 +1444,46 @@ public function testWriteRejectsUnreadableExistingTransactionTarget(): void } $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); + chmod("{$this->path}/current.json", 0o000); $this->expectException(StorageException::class); - $this->expectExceptionMessage('Unable to read debug data file'); + $this->expectExceptionMessage( + 'Unable to read debug data file', + ); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 2.0), [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 2.0), [], []), + 10, + ); } public function testWriteRejectsUnreadableManifest(): void { if (PHP_OS_FAMILY === 'Windows') { - self::markTestSkipped('POSIX read permissions are not portable to Windows.'); + self::markTestSkipped( + 'POSIX read permissions are not portable to Windows.', + ); } $store = $this->store(); - $store->writeSnapshot(new DebugSnapshot($this->summary('current', 1.0), [], []), 10); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('current', 1.0), [], []), + 10, + ); + chmod("{$this->path}/index.json", 0o000); $this->expectException(StorageException::class); - $this->expectExceptionMessage('Unable to read debug manifest'); + $this->expectExceptionMessage( + 'Unable to read debug manifest', + ); try { $store->writeSnapshot(new DebugSnapshot($this->summary('newer', 2.0), [], []), 10); diff --git a/tests/Support/CoverageGate.php b/tests/Support/CoverageGate.php deleted file mode 100644 index c2b854f..0000000 --- a/tests/Support/CoverageGate.php +++ /dev/null @@ -1,169 +0,0 @@ -.\n"); - - return 2; - } - - $command = [PHP_BINARY, dirname(__DIR__, 2) . '/vendor/bin/phpunit', ...array_slice($arguments, 1)]; - - passthru(implode(' ', array_map(escapeshellarg(...), $command)), $exitCode); - - if ($exitCode !== 0) { - return $exitCode; - } - - return self::verify($coverageFile); - } - - /** - * @return list|null - */ - private static function cliArguments(mixed $arguments): array|null - { - if (!is_array($arguments)) { - return null; - } - - $result = []; - - foreach ($arguments as $argument) { - if (!is_string($argument)) { - return null; - } - - $result[] = $argument; - } - - return $result; - } - - /** - * @param list $arguments - * @return non-empty-string|null - */ - private static function coverageFile(array $arguments): string|null - { - foreach ($arguments as $argument) { - if (str_starts_with($argument, self::COVERAGE_OPTION)) { - $coverageFile = substr($argument, strlen(self::COVERAGE_OPTION)); - - return $coverageFile !== '' ? $coverageFile : null; - } - } - - return null; - } - - /** - * @return array{classes: int, methods: int, coveredMethods: int, lines: int, coveredLines: int} - */ - private static function metrics(DOMElement $metrics): array - { - return [ - 'classes' => (int) $metrics->getAttribute('classes'), - 'methods' => (int) $metrics->getAttribute('methods'), - 'coveredMethods' => (int) $metrics->getAttribute('coveredmethods'), - 'lines' => (int) $metrics->getAttribute('statements'), - 'coveredLines' => (int) $metrics->getAttribute('coveredstatements'), - ]; - } - - /** - * @param non-empty-string $coverageFile - */ - private static function verify(string $coverageFile): int - { - if (!file_exists($coverageFile)) { - fprintf(STDERR, "Coverage report %s was not generated.\n", $coverageFile); - - return 2; - } - - $document = new DOMDocument(); - - if (!$document->load($coverageFile, LIBXML_NONET)) { - fprintf(STDERR, "Coverage report %s is not valid XML.\n", $coverageFile); - - return 2; - } - - $nodes = (new DOMXPath($document))->query('/coverage/project/metrics'); - $metrics = $nodes === false ? null : $nodes->item(0); - - if (!$metrics instanceof DOMElement) { - fprintf(STDERR, "Coverage report %s has no project metrics.\n", $coverageFile); - - return 2; - } - - $totals = self::metrics($metrics); - $complete = $totals['classes'] > 0 - && $totals['methods'] > 0 - && $totals['lines'] > 0 - && $totals['methods'] === $totals['coveredMethods'] - && $totals['lines'] === $totals['coveredLines']; - $coveredClasses = $complete ? $totals['classes'] : 0; - - fwrite( - $complete ? STDOUT : STDERR, - sprintf( - "Coverage gate: %d/%d classes, %d/%d methods, %d/%d lines.\n", - $coveredClasses, - $totals['classes'], - $totals['coveredMethods'], - $totals['methods'], - $totals['coveredLines'], - $totals['lines'], - ), - ); - - return $complete ? 0 : 1; - } -} diff --git a/tests/Theme/ThemeResolverTest.php b/tests/Theme/ThemeResolverTest.php index c09a20b..a92c964 100644 --- a/tests/Theme/ThemeResolverTest.php +++ b/tests/Theme/ThemeResolverTest.php @@ -10,15 +10,17 @@ /** * Unit tests for {@see ThemeResolver} covering the cookie-over-query precedence and the light fallback. - * - * @since 0.1 */ #[Group('theme')] final class ThemeResolverTest extends TestCase { public function testResolveDefaultsToLightWithoutAnySignal(): void { - self::assertSame('light', ThemeResolver::resolve([], []), 'No signal must resolve to light.'); + self::assertSame( + 'light', + ThemeResolver::resolve([], []), + 'No signal must resolve to light.', + ); } public function testResolveIgnoresNonStringAndUnknownValues(): void diff --git a/tests/Toolbar/ToolbarDataTest.php b/tests/Toolbar/ToolbarDataTest.php index de7b475..1e01906 100644 --- a/tests/Toolbar/ToolbarDataTest.php +++ b/tests/Toolbar/ToolbarDataTest.php @@ -12,8 +12,6 @@ /** * Unit tests for {@see ToolbarData} serializing portable toolbar panels and metrics. - * - * @since 0.1 */ #[Group('toolbar')] final class ToolbarDataTest extends TestCase diff --git a/tests/View/Grid/ActiveFilterBannerTest.php b/tests/View/Grid/ActiveFilterBannerTest.php index 8149c60..c6a7c00 100644 --- a/tests/View/Grid/ActiveFilterBannerTest.php +++ b/tests/View/Grid/ActiveFilterBannerTest.php @@ -14,8 +14,6 @@ /** * Unit tests for {@see ActiveFilterBanner} covering the removable filter pills, the "Clear all" action, and the * empty-state short-circuit. - * - * @since 0.1 */ #[Group('view')] #[Group('grid')] @@ -28,16 +26,16 @@ public function testRenderBuildsRemovalUrlsThroughTheCallback(): void static fn(array $without): string => '/debug?without=' . implode(',', $without), ); - self::assertStringContainsString( - 'href="/debug?without=statusCode"', + self::assertSame( + << + 2 filters activestatusCode:404url:adminClear all + + HTML, $html, 'Pill link must drop only its own attribute.', ); - self::assertStringContainsString( - 'href="/debug?without=statusCode,url"', - $html, - 'Clear-all link must drop every active attribute.', - ); + } public function testRenderEmitsOnePillPerActiveFilter(): void @@ -47,12 +45,20 @@ public function testRenderEmitsOnePillPerActiveFilter(): void static fn(array $without): string => '/debug', ); - self::assertSame(2, substr_count($html, 'yii-debug-active-filter-pill'), 'One pill per active filter.'); - self::assertStringContainsString('2 filters active', $html, 'Plural count label must surface.'); - self::assertStringContainsString('statusCode', $html, 'Attribute names must surface inside the pills.'); - self::assertStringContainsString('404', $html, 'Filter values must surface inside the pills.'); - self::assertStringContainsString('Clear all', $html, 'The clear-all action must render.'); - self::assertStringContainsString('aria-label="Active filters"', $html, 'Group must carry its accessible name.'); + self::assertSame( + 2, + substr_count($html, 'yii-debug-active-filter-pill'), + 'One pill per active filter.', + ); + self::assertSame( + << + 2 filters activestatusCode:404url:adminClear all + + HTML, + $html, + 'Plural count label must surface.', + ); } public function testRenderReturnsEmptyStringWhenNoFiltersAreActive(): void @@ -66,8 +72,12 @@ public function testRenderReturnsEmptyStringWhenNoFiltersAreActive(): void public function testRenderUsesSingularLabelForOneFilter(): void { - self::assertStringContainsString( - '1 filter active', + self::assertSame( + << + 1 filter activeurl:adminClear all + + HTML, ActiveFilterBanner::render(['url' => 'admin'], static fn(array $without): string => '/debug'), 'Single filter must use the singular label.', ); diff --git a/tests/View/Grid/RowClassTest.php b/tests/View/Grid/RowClassTest.php index be1a965..1ddd5f3 100644 --- a/tests/View/Grid/RowClassTest.php +++ b/tests/View/Grid/RowClassTest.php @@ -10,8 +10,6 @@ /** * Unit tests for {@see RowClass} covering the status-level to row-class mapping used by the debug grids. - * - * @since 0.1 */ #[Group('view')] #[Group('grid')] @@ -28,16 +26,44 @@ public function testForAliasesErrorToDanger(): void public function testForMapsKnownLevelsToRowClasses(): void { - self::assertSame(['class' => 'yii-debug-row-success'], RowClass::for('success'), 'Success must map.'); - self::assertSame(['class' => 'yii-debug-row-info'], RowClass::for('info'), 'Info must map.'); - self::assertSame(['class' => 'yii-debug-row-warning'], RowClass::for('warning'), 'Warning must map.'); - self::assertSame(['class' => 'yii-debug-row-danger'], RowClass::for('danger'), 'Danger must map.'); + self::assertSame( + ['class' => 'yii-debug-row-success'], + RowClass::for('success'), + 'Success must map.', + ); + self::assertSame( + ['class' => 'yii-debug-row-info'], + RowClass::for('info'), + 'Info must map.' + ); + self::assertSame( + ['class' => 'yii-debug-row-warning'], + RowClass::for('warning'), + 'Warning must map.', + ); + self::assertSame( + ['class' => 'yii-debug-row-danger'], + RowClass::for('danger'), + 'Danger must map.', + ); } public function testForReturnsEmptyArrayForUnknownOrNullLevels(): void { - self::assertSame([], RowClass::for(null), '`null` must yield no class.'); - self::assertSame([], RowClass::for(''), 'Empty string must yield no class.'); - self::assertSame([], RowClass::for('primary'), 'Unknown levels must yield no class.'); + self::assertSame( + [], + RowClass::for(null), + "'null' must yield no class.", + ); + self::assertSame( + [], + RowClass::for(''), + 'Empty string must yield no class.', + ); + self::assertSame( + [], + RowClass::for('primary'), + 'Unknown levels must yield no class.', + ); } } diff --git a/tests/View/History/HistoryCellRendererTest.php b/tests/View/History/HistoryCellRendererTest.php index 5769083..cb7be95 100644 --- a/tests/View/History/HistoryCellRendererTest.php +++ b/tests/View/History/HistoryCellRendererTest.php @@ -14,8 +14,6 @@ /** * Unit tests for {@see HistoryCellRenderer} covering the per-column rendering helpers, the row-attributes builder * (`data-yii-debug-*` attributes for the sidebar cursor JS) and the summary header composition. - * - * @since 0.1 */ #[Group('view')] #[Group('history')] @@ -53,14 +51,21 @@ public function testBuildRowAttributesAddsDataAttributesForCursorJs(): void ], 'Row data-yii-debug-* attributes must mirror the typed row.', ); - self::assertArrayNotHasKey('class', $options, 'Non-critical rows must not carry a row class.'); + self::assertArrayNotHasKey( + 'class', + $options, + 'Non-critical rows must not carry a row class.', + ); } public function testBuildRowAttributesFlagsCriticalStatusCodesWithDangerHighlight(): void { $options = HistoryCellRenderer::buildRowAttributes(self::row(['statusCode' => 500]), true); - self::assertIsString($options['class'] ?? null, 'class entry must be a string.'); + self::assertIsString( + $options['class'] ?? null, + 'class entry must be a string.', + ); self::assertStringContainsString( 'yii-debug-row-danger', $options['class'], @@ -101,20 +106,23 @@ public function testRenderDurationCellScalesGaugeAgainstPageMaximum(): void $html = HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.125]), 0.25); self::assertSame( - '' - . '125 ms' - . '' - . '', + <<125 ms + HTML, $html, 'Rail must sit at half the page maximum.', ); - self::assertStringContainsString( - '--yii-debug-gauge: 100%;', + self::assertSame( + <<250 ms + HTML, HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.25]), 0.25), 'The slowest row must fill its rail.', ); - self::assertStringContainsString( - '--yii-debug-gauge: 0%;', + self::assertSame( + <<0 ms + HTML, HistoryCellRenderer::renderDurationCell(self::row(['processingTime' => 0.0]), 0.25), 'A zero measurement must show an empty rail.', ); @@ -122,10 +130,14 @@ public function testRenderDurationCellScalesGaugeAgainstPageMaximum(): void public function testRenderDurationCellShowsNotSetWhenMissing(): void { - $html = HistoryCellRenderer::renderDurationCell(self::row([]), 0.25); + self::assertSame( + <<(not set) + HTML, + HistoryCellRenderer::renderDurationCell(self::row([]), 0.25), + 'Missing duration must surface the muted placeholder.', + ); - self::assertStringContainsString('(not set)', $html, 'Missing duration must surface the muted placeholder.'); - self::assertStringNotContainsString('yii-debug-gauge', $html, 'Missing duration must not draw a rail.'); } public function testRenderMemoryCellFormatsMb(): void @@ -139,18 +151,26 @@ public function testRenderMemoryCellFormatsMb(): void public function testRenderMemoryCellScalesGaugeAgainstPageMaximum(): void { - $html = HistoryCellRenderer::renderMemoryCell(self::row(['peakMemory' => 2097152]), 4194304); + self::assertSame( + <<2.000 MB + HTML, + HistoryCellRenderer::renderMemoryCell(self::row(['peakMemory' => 2097152]), 4194304), + 'Rail must sit at half the page maximum.', + ); - self::assertStringContainsString('--yii-debug-gauge: 50%;', $html, 'Rail must sit at half the page maximum.'); - self::assertStringContainsString('2.000 MB', $html, 'Readout must keep its formatted value.'); } public function testRenderMemoryCellShowsNotSetWhenMissing(): void { - $html = HistoryCellRenderer::renderMemoryCell(self::row([]), 4194304); + self::assertSame( + <<(not set) + HTML, + HistoryCellRenderer::renderMemoryCell(self::row([]), 4194304), + 'Missing peak memory must surface the muted placeholder.', + ); - self::assertStringContainsString('(not set)', $html, 'Missing peak memory must surface the muted placeholder.'); - self::assertStringNotContainsString('yii-debug-gauge', $html, 'Missing peak memory must not draw a rail.'); } public function testRenderMethodCellRendersVocabularyColoredText(): void @@ -160,13 +180,17 @@ public function testRenderMethodCellRendersVocabularyColoredText(): void HistoryCellRenderer::renderMethodCell(self::row(['method' => 'GET'])), "GET must wear the 'get' verb class.", ); - self::assertStringContainsString( - 'yii-debug-verb-put', + self::assertSame( + <<PATCH + HTML, HistoryCellRenderer::renderMethodCell(self::row(['method' => 'PATCH'])), "PATCH must share the 'put' verb hue.", ); - self::assertStringContainsString( - 'yii-debug-verb-other', + self::assertSame( + <<COMMAND + HTML, HistoryCellRenderer::renderMethodCell(self::row(['method' => 'COMMAND'])), "COMMAND must fall back to the 'other' verb.", ); @@ -185,23 +209,25 @@ public function testRenderSqlCountCellEmitsWarningGlyphWhenCountIsCritical(): vo { $row = self::row(['tag' => 'flood', 'sqlCount' => 500, 'excessiveCallersCount' => 0]); - $html = HistoryCellRenderer::renderSqlCountCell($row, '/debug/view?panel=db&tag=flood', true, 100); - - self::assertStringContainsString('⚠', $html, 'Critical counts must surface the warning glyph.'); - self::assertStringContainsString('Too many queries', $html, 'Warning tooltip must explain the breach.'); - self::assertStringContainsString( - 'panel=db&tag=flood', - $html, - 'SQL count must link to the request database panel.', + self::assertSame( + <<500 + HTML, + HistoryCellRenderer::renderSqlCountCell($row, '/debug/view?panel=db&tag=flood', true, 100), + 'Critical counts must surface the warning glyph.', ); + + } public function testRenderSqlCountCellPluralizesExcessiveCallersCount(): void { $row = self::row(['tag' => 'flood', 'sqlCount' => 10, 'excessiveCallersCount' => 4]); - self::assertStringContainsString( - '4 callers are making too many calls.', + self::assertSame( + <<10 + HTML, HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100), 'Multiple excessive callers must surface the plural tooltip form.', ); @@ -211,18 +237,24 @@ public function testRenderSqlCountCellRendersPlainCountWhenNotCritical(): void { $row = self::row(['tag' => 'low', 'sqlCount' => 3, 'excessiveCallersCount' => 0]); - $html = HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100); + self::assertSame( + <<3 + HTML, + HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100), + 'Plain SQL count must surface as the bare integer.', + ); - self::assertStringContainsString('>3<', $html, 'Plain SQL count must surface as the bare integer.'); - self::assertStringNotContainsString('⚠', $html, 'Non-critical counts must NOT carry the warning glyph.'); } public function testRenderSqlCountCellSingularizesSingleExcessiveCaller(): void { $row = self::row(['tag' => 'flood', 'sqlCount' => 10, 'excessiveCallersCount' => 1]); - self::assertStringContainsString( - '1 caller is making too many calls.', + self::assertSame( + <<10 + HTML, HistoryCellRenderer::renderSqlCountCell($row, '/db', false, 100), 'A single excessive caller must surface the singular tooltip form.', ); @@ -239,23 +271,31 @@ public function testRenderStatusCellMapsCommandWithZeroToSuccess(): void public function testRenderStatusCellMapsRangeToStatusClass(): void { - self::assertStringContainsString( - 'yii-debug-badge yii-debug-status-2xx', + self::assertSame( + <<200 + HTML, HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 200])), "Status code '200' must map to '2xx'.", ); - self::assertStringContainsString( - 'yii-debug-status-3xx', + self::assertSame( + <<301 + HTML, HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 301])), "Status code '301' must map to '3xx'.", ); - self::assertStringContainsString( - 'yii-debug-status-4xx', + self::assertSame( + <<404 + HTML, HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 404])), "Status code '404' must map to '4xx'.", ); - self::assertStringContainsString( - 'yii-debug-status-5xx', + self::assertSame( + <<500 + HTML, HistoryCellRenderer::renderStatusCell(self::row(['statusCode' => 500])), "Status code '500' must map to '5xx'.", ); @@ -274,35 +314,21 @@ public function testRenderSummaryEchoesBucketPills(): void $html = HistoryCellRenderer::renderSummary( $summary, - ['2xx' => '/debug?Debug%5BstatusCode%5D=200', '4xx' => '/debug?Debug%5BstatusCode%5D=404'], + [ + '2xx' => '/debug?Debug%5BstatusCode%5D=200', + '4xx' => '/debug?Debug%5BstatusCode%5D=404', + ], '', ); - self::assertStringContainsString('captured requests', $html, 'Multiple requests must use the plural label.'); - self::assertStringContainsString( - 'yii-debug-grid-summary-stat-2xx', - $html, - "'2xx' pill must carry the '2xx' status class.", - ); - self::assertStringContainsString( - 'yii-debug-grid-summary-stat-4xx', - $html, - "'4xx' pill must carry the '4xx' status class.", - ); - self::assertStringContainsString( - 'Debug%5BstatusCode%5D=200', - $html, - "The '2xx' bucket must link to its sample status filter.", - ); - self::assertStringContainsString( - 'Debug%5BstatusCode%5D=404', - $html, - "The '4xx' bucket must link to its sample status filter.", - ); - self::assertStringContainsString( - 'yii-debug-grid-pagesize', + self::assertSame( + << + 5 captured requests·4 2xx·1 4xx + + HTML, $html, - 'History summary must include the shared page-size selector.', + 'Multiple requests must use the plural label.', ); } @@ -321,42 +347,45 @@ public function testRenderSummaryUsesSingularLabelForOneRequest(): void { $summary = new HistorySummary(totalRequests: 1, statusBuckets: [], statusCodeFilter: null); - $html = HistoryCellRenderer::renderSummary($summary, [], ''); - - self::assertStringContainsString('captured request', $html, 'One request must use the singular label.'); - self::assertStringNotContainsString('captured requests', $html, 'One request must not use the plural label.'); + self::assertSame( + << + 1 captured request + + HTML, + HistoryCellRenderer::renderSummary($summary, [], ''), + 'One request must use the singular label.', + ); } public function testRenderTagCellLinksToPanelView(): void { - $html = HistoryCellRenderer::renderTagCell(self::row(['tag' => 'abc']), '/debug/view?tag=abc'); - - self::assertStringContainsString('yii-debug-tag-link', $html, 'Tag link must carry the tag-link CSS class.'); - self::assertStringContainsString('abc', $html, 'Tag value must surface inside the link.'); - self::assertStringContainsString('tag=abc', $html, 'Tag cell must link to the matching request view.'); + self::assertSame( + <<abc + HTML, + HistoryCellRenderer::renderTagCell(self::row(['tag' => 'abc']), '/debug/view?tag=abc'), + 'Tag link must carry the tag-link CSS class.', + ); } public function testRenderTimeCellRendersCompactClockWithFullTooltip(): void { - $html = HistoryCellRenderer::renderTimeCell(self::row(['time' => 1_700_000_000])); - - self::assertStringContainsString('yii-debug-nowrap', $html, 'Time cell must carry the nowrap CSS class.'); - self::assertStringContainsString( - 'title="' . date('Y-m-d H:i:s', 1_700_000_000) . '"', - $html, - 'Time cell must carry the full datetime tooltip.', - ); - self::assertStringContainsString( - '>' . date('H:i:s', 1_700_000_000) . '<', - $html, - 'Time cell must render the compact clock string.', + self::assertSame( + <<22:13:20 + HTML, + HistoryCellRenderer::renderTimeCell(self::row(['time' => 1_700_000_000])), + 'Time cell must carry the nowrap CSS class.', ); } public function testRenderTimeCellShowsNotSetForZeroTimestamp(): void { - self::assertStringContainsString( - '(not set)', + self::assertSame( + <<(not set) + HTML, HistoryCellRenderer::renderTimeCell(self::row(['time' => 0])), 'Zero timestamps must surface the muted placeholder.', ); @@ -364,10 +393,14 @@ public function testRenderTimeCellShowsNotSetForZeroTimestamp(): void public function testRenderUrlCellWrapsUrlInTitleSpan(): void { - $html = HistoryCellRenderer::renderUrlCell(self::row(['url' => 'http://example.test/path'])); + self::assertSame( + <<http://example.test/path + HTML, + HistoryCellRenderer::renderUrlCell(self::row(['url' => 'http://example.test/path'])), + 'URL cell must carry the dedicated class.', + ); - self::assertStringContainsString('yii-debug-url-cell', $html, 'URL cell must carry the dedicated class.'); - self::assertStringContainsString('http://example.test/path', $html, 'URL value must render inside the cell.'); } /** diff --git a/tests/View/History/HistoryRowTest.php b/tests/View/History/HistoryRowTest.php index 2b9437d..ea15d09 100644 --- a/tests/View/History/HistoryRowTest.php +++ b/tests/View/History/HistoryRowTest.php @@ -12,10 +12,8 @@ use function date; /** - * Unit tests for {@see HistoryRow} covering the projection of a manifest {@see RequestSummary} into the row the - * History grid renders. - * - * @since 0.1 + * Unit tests for {@see HistoryRow} covering the projection of a manifest {@see RequestSummary} into the row the History + * grid renders. */ #[Group('view')] #[Group('history')] @@ -61,17 +59,60 @@ public function testFromSummaryPassesEveryFieldThroughUntouched(): void ), ); - self::assertSame('tag-9', $row->tag, 'Tag must pass through.'); - self::assertSame('https://example.test/orders', $row->url, 'URL must pass through.'); - self::assertTrue($row->ajax, 'AJAX flag must pass through.'); - self::assertSame('POST', $row->method, 'Method must pass through.'); - self::assertSame('10.0.0.1', $row->ip, 'IP must pass through.'); - self::assertSame(404, $row->statusCode, 'Status code must pass through.'); - self::assertSame(7, $row->sqlCount, 'SQL count must pass through.'); - self::assertSame(2, $row->excessiveCallersCount, 'Excessive-caller count must pass through.'); - self::assertSame(1, $row->mailCount, 'Mail count must pass through.'); - self::assertSame(0.125, $row->processingTime, 'Processing time must pass through.'); - self::assertSame(1_048_576, $row->peakMemory, 'Peak memory must pass through.'); + self::assertSame( + 'tag-9', + $row->tag, + 'Tag must pass through.', + ); + self::assertSame( + 'https://example.test/orders', + $row->url, + 'URL must pass through.', + ); + self::assertTrue( + $row->ajax, + 'AJAX flag must pass through.', + ); + self::assertSame( + 'POST', + $row->method, + 'Method must pass through.', + ); + self::assertSame( + '10.0.0.1', + $row->ip, + 'IP must pass through.', + ); + self::assertSame( + 404, + $row->statusCode, + 'Status code must pass through.', + ); + self::assertSame( + 7, + $row->sqlCount, + 'SQL count must pass through.', + ); + self::assertSame( + 2, + $row->excessiveCallersCount, + 'Excessive-caller count must pass through.', + ); + self::assertSame( + 1, + $row->mailCount, + 'Mail count must pass through.', + ); + self::assertSame( + 0.125, + $row->processingTime, + 'Processing time must pass through.', + ); + self::assertSame( + 1_048_576, + $row->peakMemory, + 'Peak memory must pass through.', + ); } /** diff --git a/tests/View/History/HistoryScaleTest.php b/tests/View/History/HistoryScaleTest.php index 8f55e3f..d3a1272 100644 --- a/tests/View/History/HistoryScaleTest.php +++ b/tests/View/History/HistoryScaleTest.php @@ -11,8 +11,6 @@ /** * Unit tests for {@see HistoryScale} covering the page-maxima scan behind the History micro-gauges. - * - * @since 0.1 */ #[Group('view')] #[Group('history')] @@ -29,26 +27,53 @@ public function testFromModelsIgnoresRowsWithoutCapturedValues(): void ], ); - self::assertSame(0.5, $scale->maxProcessingTime, 'Largest captured duration must win.'); - self::assertSame(2_097_152, $scale->maxPeakMemory, 'Largest captured memory must win.'); + self::assertSame( + 0.5, + $scale->maxProcessingTime, + 'Largest captured duration must win.', + ); + self::assertSame( + 2_097_152, + $scale->maxPeakMemory, + 'Largest captured memory must win.', + ); } public function testFromModelsReturnsZeroMaximaForEmptyList(): void { $scale = HistoryScale::fromModels([]); - self::assertSame(0.0, $scale->maxProcessingTime, 'Empty pages must report a `0.0` duration scale.'); - self::assertSame(0, $scale->maxPeakMemory, 'Empty pages must report a `0` memory scale.'); + self::assertSame( + 0.0, + $scale->maxProcessingTime, + "Empty pages must report a '0.0' duration scale.", + ); + self::assertSame( + 0, + $scale->maxPeakMemory, + "Empty pages must report a '0' memory scale.", + ); } public function testFromModelsReturnsZeroMaximaWhenNoRowCarriesValues(): void { $scale = HistoryScale::fromModels( - [self::row(null, null), self::row(null, null)], + [ + self::row(null, null), + self::row(null, null), + ], ); - self::assertSame(0.0, $scale->maxProcessingTime, 'All-`null` durations must collapse the scale to `0.0`.'); - self::assertSame(0, $scale->maxPeakMemory, 'All-`null` memory must collapse the scale to `0`.'); + self::assertSame( + 0.0, + $scale->maxProcessingTime, + "All-'null' durations must collapse the scale to '0.0'.", + ); + self::assertSame( + 0, + $scale->maxPeakMemory, + "All-'null' memory must collapse the scale to '0'.", + ); } private static function row(float|null $processingTime, int|null $peakMemory): HistoryRow diff --git a/tests/View/History/HistorySummaryTest.php b/tests/View/History/HistorySummaryTest.php index f4575a7..74c604d 100644 --- a/tests/View/History/HistorySummaryTest.php +++ b/tests/View/History/HistorySummaryTest.php @@ -12,8 +12,6 @@ /** * Unit tests for {@see HistorySummary} covering the manifest aggregation that feeds the History index summary header * total requests, per-bucket counts/sample codes/variants and the unique status-code filter map. - * - * @since 0.1 */ #[Group('view')] #[Group('history')] @@ -58,8 +56,16 @@ public function testFromManifestCountsTypedEntries(): void ], ); - self::assertSame(2, $summary->totalRequests, 'Total count must reflect every typed manifest entry.'); - self::assertCount(2, $summary->statusBuckets, 'Each status family must contribute one bucket.'); + self::assertSame( + 2, + $summary->totalRequests, + 'Total count must reflect every typed manifest entry.', + ); + self::assertCount( + 2, + $summary->statusBuckets, + 'Each status family must contribute one bucket.', + ); } public function testFromManifestExposesEmptyFilterWhenNoStatusCaptured(): void @@ -83,8 +89,15 @@ public function testFromManifestExposesFirstSeenSampleCode(): void $first = $summary->statusBuckets[0] ?? null; - self::assertNotNull($first, 'Bucket list must be non-empty.'); - self::assertSame(201, $first->sampleCode, 'Sample code must be the first observed in the bucket.'); + self::assertNotNull( + $first, + 'Bucket list must be non-empty.', + ); + self::assertSame( + 201, + $first->sampleCode, + 'Sample code must be the first observed in the bucket.', + ); } public function testFromManifestKeepsStatusFamilyBoundariesExclusive(): void @@ -172,9 +185,20 @@ public function testFromManifestReturnsEmptyForEmptyManifest(): void { $summary = HistorySummary::fromManifest([]); - self::assertSame(0, $summary->totalRequests, 'Empty manifest must yield zero total requests.'); - self::assertSame([], $summary->statusBuckets, 'Empty manifest must yield no buckets.'); - self::assertNull($summary->statusCodeFilter, 'Empty manifest must yield a null filter dropdown.'); + self::assertSame( + 0, + $summary->totalRequests, + 'Empty manifest must yield zero total requests.', + ); + self::assertSame( + [], + $summary->statusBuckets, + 'Empty manifest must yield no buckets.', + ); + self::assertNull( + $summary->statusCodeFilter, + 'Empty manifest must yield a null filter dropdown.', + ); } public function testFromManifestSkipsRequestsWithStatusBelow200(): void @@ -188,8 +212,15 @@ public function testFromManifestSkipsRequestsWithStatusBelow200(): void $first = $summary->statusBuckets[0] ?? null; - self::assertNotNull($first, "Bucket list must surface the '200' entry."); - self::assertSame(1, $first->count, "Status '100' must not contribute to any bucket."); + self::assertNotNull( + $first, + "Bucket list must surface the '200' entry.", + ); + self::assertSame( + 1, + $first->count, + "Status '100' must not contribute to any bucket.", + ); } public function testFromManifestSortsUniqueStatusCodes(): void diff --git a/tests/View/Sidebar/SidebarRendererTest.php b/tests/View/Sidebar/SidebarRendererTest.php index 51c306c..b20b5da 100644 --- a/tests/View/Sidebar/SidebarRendererTest.php +++ b/tests/View/Sidebar/SidebarRendererTest.php @@ -13,8 +13,6 @@ /** * Unit tests for {@see SidebarRenderer} covering the snapshot card composition (method/URL/status/time/AJAX), the * cursor-mode vs navigation-mode branching of the navigator row, and the panel nav entry rendering. - * - * @since 0.1 */ #[Group('view')] #[Group('sidebar')] @@ -44,9 +42,38 @@ public function testRenderEmitsAriaCurrentOnActiveNavLink(): void $html = SidebarRenderer::render($view); - self::assertStringContainsString('aria-current="page"', $html, 'Active nav entry must carry aria-current.'); - self::assertStringContainsString('is-active', $html, 'Active nav entry must carry the is-active modifier.'); - self::assertSame(1, substr_count($html, 'is-active'), 'Only the active entry carries the modifier.'); + self::assertSame( + << + + + HTML, + $html, + 'Active nav entry must carry aria-current.', + ); + + self::assertSame( + 1, + substr_count($html, 'is-active'), + 'Only the active entry carries the modifier.', + ); self::assertMatchesRegularExpression( '/]*title="History"[^>]*aria-current="page">/', $html, @@ -58,37 +85,55 @@ public function testRenderEmitsCursorButtonsWhenSnapshotIsCursor(): void { $view = new SidebarView(snapshot: $this->snapshot(isCursor: true), navItems: []); - $html = SidebarRenderer::render($view); - - self::assertStringContainsString( - 'data-yii-debug-cursor="newest"', - $html, + self::assertSame( + << +
+
+ Newest request +
+
+ GET/index.php +
+ 20012:34:56AJAX +
+ +
+
+
+ + HTML, + SidebarRenderer::render($view), 'Cursor mode must emit the Newest cursor button.', ); - self::assertStringContainsString( - 'data-yii-debug-cursor="older"', - $html, - 'Cursor mode must emit the Older cursor button.', - ); - self::assertStringContainsString('snapshot(isCursor: true, cursorInitTag: 'init-tag'), navItems: []); - $html = SidebarRenderer::render($view); - - self::assertStringContainsString( - 'data-yii-debug-history-cursor="true"', - $html, + self::assertSame( + << +
+
+ Newest request +
+
+ GET/index.php +
+ 20012:34:56AJAX +
+ +
+
+
+ + HTML, + SidebarRenderer::render($view), 'Cursor mode must emit a true history-cursor marker.', ); - self::assertStringContainsString( - 'data-yii-debug-cursor-init="init-tag"', - $html, - 'Cursor init tag must surface as data attribute.', - ); + } public function testRenderEmitsIconSpanWhenNavItemDeclaresIconSvg(): void @@ -106,23 +151,30 @@ public function testRenderEmitsIconSpanWhenNavItemDeclaresIconSvg(): void ], ); - $html = SidebarRenderer::render($view); - - self::assertStringContainsString( - 'yii-debug-nav-link-icon', - $html, + self::assertSame( + << +
+ + HTML, + SidebarRenderer::render($view), 'Nav item with iconSvg must wrap the markup in the icon span.', ); - self::assertStringContainsString( - 'data-test="request-icon"', - $html, - 'Icon SVG payload must surface inside the nav link.', - ); - self::assertStringContainsString( - 'aria-hidden="true"', - $html, - 'Decorative panel icons must remain hidden from assistive technology.', - ); + + } public function testRenderHidesAjaxTagWhenNotAjax(): void @@ -151,8 +203,24 @@ public function testRenderShowsDashWhenStatusCodeIsZero(): void { $view = new SidebarView(snapshot: $this->snapshot(statusCode: 0), navItems: []); - self::assertStringContainsString( - '>–<', + self::assertSame( + << +
+
+ Current request +
+
+ GET/index.php +
+ 12:34:56AJAX +
+ +
+
+
+ + HTML, SidebarRenderer::render($view), 'Status 0 must surface as an en-dash placeholder.', ); @@ -162,8 +230,11 @@ public function testRenderSkipsSnapshotSectionWhenSnapshotIsNull(): void { $view = new SidebarView(snapshot: null, navItems: []); - self::assertStringNotContainsString( - 'yii-debug-side-section', + self::assertSame( + << + + HTML, SidebarRenderer::render($view), 'Null snapshot must skip the section entirely.', ); @@ -173,18 +244,46 @@ public function testRenderTintsSnapshotMethodAndStatusWithVocabularyClasses(): v { $html = SidebarRenderer::render(new SidebarView(snapshot: $this->snapshot(), navItems: [])); - self::assertStringContainsString( - 'class="yii-debug-snapshot-method yii-debug-verb-get"', + self::assertSame( + << +
+
+ Current request +
+
+ GET/index.php +
+ 20012:34:56AJAX +
+ +
+
+
+ + HTML, $html, "GET must wear the 'get' verb class.", ); - self::assertStringContainsString( - 'class="yii-debug-snapshot-status yii-debug-status-2xx"', - $html, - "Status '200' must wear the '2xx' status class.", - ); - self::assertStringContainsString( - 'class="yii-debug-snapshot-status yii-debug-status-5xx"', + + self::assertSame( + << +
+
+ Current request +
+
+ GET/index.php +
+ 50012:34:56AJAX +
+ +
+
+
+ + HTML, SidebarRenderer::render(new SidebarView(snapshot: $this->snapshot(statusCode: 500), navItems: [])), "Status '500' must wear the '5xx' status class.", ); @@ -196,16 +295,27 @@ public function testRenderWiresNavigationAnchorsInViewMode(): void $html = SidebarRenderer::render($view); - self::assertStringContainsString( - 'aria-label="Newest captured request"', + self::assertSame( + << +
+
+ Current request +
+
+ GET/index.php +
+ 20012:34:56AJAX +
+ +
+
+
+ + HTML, $html, "Navigation mode must use the long 'aria-label' for Newest.", ); - self::assertStringContainsString( - 'title="GET http://example.test/index.php"', - $html, - 'Snapshot tooltip must prefix the URL with the request method.', - ); self::assertMatchesRegularExpression( '/